feat: 实现桌面控制组件
@@ -168,7 +168,7 @@
|
||||
### 2.3 获取当前用户信息
|
||||
- **POST** `/api/user/profile`
|
||||
|
||||
返回参数(金额类字段统一 2 位小数字符串,与 `/api/wallet/balanceSummary` 对齐):
|
||||
返回参数(金额类字段统一 2 位小数字符串,与钱包展示口径一致):
|
||||
|
||||
**基础档案**
|
||||
- `uuid`:string(含义:用户对外唯一标识,10 位)
|
||||
@@ -184,13 +184,13 @@
|
||||
- `create_time`:int(含义:注册时间戳)
|
||||
|
||||
**资金与提现配额**
|
||||
- `coin` / `coin_balance`:string(含义:当前余额;两字段同值,便于与 `/api/wallet/balanceSummary` 平滑切换)
|
||||
- `coin` / `coin_balance`:string(含义:当前余额;两字段同值)
|
||||
- `frozen_balance`:string(含义:冻结余额;无冻结场景,固定 `0.00`)
|
||||
- `total_deposit_coin`:string(含义:累计充值)
|
||||
- `total_withdraw_coin`:string(含义:累计提现;受理后累加)
|
||||
- `bet_flow_coin`:string(含义:打码量/流水;开奖结算后按每注 `total_amount` 1:1 累加)
|
||||
- `max_withdrawable`:string(含义:**当前允许发起的单笔最大提现金额** = `min(coin_balance, max_withdraw_by_flow)`)
|
||||
- `withdraw_flow`:object(含义:打码量 / 提现配额诊断快照,结构与 `/api/wallet/balanceSummary.withdraw_flow` 一致,此处额外附 `pending_withdraw`)
|
||||
- `withdraw_flow`:object(含义:打码量 / 提现配额诊断快照,此处额外附 `pending_withdraw`)
|
||||
- `ratio`:string(打码量倍数;`0` 表示不限打码)
|
||||
- `net_deposit`:string(净充值 = max(0, 累计充值 − 累计提现))
|
||||
- `required_bet_flow`:string(按门槛口径所需打码量,纯展示)
|
||||
@@ -247,20 +247,6 @@
|
||||
- `version`:string(含义:字典版本号,前端可用于缓存比对)
|
||||
- `items`:同 `dictionary`(含义:36字花字典明细)
|
||||
|
||||
### 3.3 获取最近开奖记录(近30期)
|
||||
- **POST** `/api/game/periodHistory`
|
||||
|
||||
请求参数:
|
||||
- `limit`:int(可选,默认 30,含义:返回最近几期)
|
||||
|
||||
返回参数:
|
||||
- `list`:array<object>
|
||||
- `period_no`:string(含义:历史期号)
|
||||
- `result_number`:int(含义:该期开奖结果)
|
||||
- `open_time`:int(含义:开奖时间)
|
||||
|
||||
---
|
||||
|
||||
## 4. 下注与对局模块(game/bet)
|
||||
|
||||
### 4.1 获取当前期详情
|
||||
@@ -276,19 +262,22 @@
|
||||
- `result_number`:int/null(未开奖为 null,含义:开奖号码)
|
||||
|
||||
### 4.2 提交下注
|
||||
- **POST** `/api/game/betPlace`
|
||||
- 用途:单期手动下注;玩家只需选择**压注号码**与**本笔压注总金额**。开奖只出一个号码,若该号码 ∈ 所选号码集合即视为**中奖**,派彩按整笔 `bet_amount`(落库为 `total_amount`)× 赔率计算(赔率与连胜倍率见服务端实现)。
|
||||
- **POST** `/api/game/placeBet`(兼容旧路径 `/api/game/betPlace`)
|
||||
- 用途:单期手动下注;玩家传入**压注号码**与**单注金额 `single_bet_amount`**。服务端按 `single_bet_amount × numbers数量` 计算本笔总扣款(落库 `total_amount`),开奖只出一个号码,若该号码 ∈ 所选号码集合即视为中奖。
|
||||
|
||||
请求参数:
|
||||
- `period_no`:string(含义:下注目标期号)
|
||||
- `numbers`:string(含义:本次压注号码集合,**英文逗号分隔**,如 `1,8,16`;每个号码为 1–36 的整数,数量不超过 `pick_max_number_count`(同 `lobbyInit.bet_config`),重复号码会去重)
|
||||
- `bet_amount`:string(含义:**本笔整笔压注金额**,> 0;服务端按此金额从余额扣款并写入注单 `total_amount`,**不再**按「单号金额 × 号码个数」计算)
|
||||
- `single_bet_amount`:string(含义:**单注金额**,> 0)
|
||||
- `bet_amount`:string(兼容字段,含义同 `single_bet_amount`)
|
||||
- `idempotency_key`:string(必填,含义:防止重复下单)
|
||||
|
||||
返回参数:
|
||||
- `order_no`:string(含义:下注订单号)
|
||||
- `period_no`:string(含义:实际落单期号)
|
||||
- `status`:string(`accepted`/`rejected`,含义:受理结果)
|
||||
- `single_bet_amount`:string(含义:本次单注金额)
|
||||
- `numbers_count`:int(含义:本次号码数量)
|
||||
- `locked_balance`:string(可选,含义:冻结金额)
|
||||
- `balance_after`:string(含义:下单后余额)
|
||||
- `current_streak`:int(含义:下单后连胜快照)
|
||||
@@ -298,9 +287,22 @@
|
||||
- `3001`:游戏已暂停(`runtime_enabled=false`,后台「游戏实时对局」关闭运行开关或作废本局后未重新开启;与非法流程类错误同段)
|
||||
- `5000`:系统繁忙;或 **用户 Redis 互斥锁**未获取(与后台钱包/并发写同一用户串行,文案与后台一致:「该用户正在被其他管理员操作(钱包/并发保存),请稍后再试」);或 **`coin` 条件更新**未命中(并发下注/派彩/后台已改余额:「扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试」)。
|
||||
|
||||
> 说明:一键重复上一注、自动托管开启/停止均由前端控制,客户端在相应时机调用 `/api/game/betPlace` 即可完成,不再提供独立接口。
|
||||
### 4.3 自动托管
|
||||
- **POST** `/api/game/autoSpin`
|
||||
|
||||
### 4.3 查询我的下注记录(最近1个月)
|
||||
请求参数:
|
||||
- `action`:string(`start`/`stop`)
|
||||
- `period_no`:string(`action=start` 时必填)
|
||||
- `numbers`:string(`action=start` 时必填,英文逗号分隔)
|
||||
- `single_bet_amount`:string(`action=start` 时必填,支持兼容字段 `bet_amount`)
|
||||
- `rounds`:int(`action=start` 时必填,>=1)
|
||||
|
||||
返回参数:
|
||||
- `status`:string(`scheduled`/`stopped`)
|
||||
- `auto_mode`:bool
|
||||
- `remaining_rounds`:int(仅 `start` 返回)
|
||||
|
||||
### 4.4 查询我的下注记录(最近1个月)
|
||||
- **POST** `/api/game/betMyOrders`
|
||||
|
||||
请求参数:
|
||||
@@ -324,29 +326,12 @@
|
||||
|
||||
## 5. 钱包与资金模块(wallet/finance)
|
||||
|
||||
### 5.1 获取钱包摘要
|
||||
- **POST** `/api/wallet/balanceSummary`
|
||||
|
||||
返回参数:
|
||||
- `coin_balance`:string(含义:可用余额,等同于 `user.coin`)
|
||||
- `frozen_balance`:string(含义:冻结余额;当前系统无冻结场景,固定返回 `0.00`)
|
||||
- `withdrawable_balance`:string(含义:可提现余额;等同于 `coin_balance`,**单笔实际上限以 `max_withdrawable` 为准**)
|
||||
- `max_withdrawable`:string(含义:**当前允许发起的单笔最大提现金额** = min(`coin_balance`, 打码配额余量);客户端直接用于"最大可提 XXX"提示与金额输入上限校验)
|
||||
- `total_deposit_coin`:string(含义:累计充值币额)
|
||||
- `total_withdraw_coin`:string(含义:累计提现币额;提现受理时累加)
|
||||
- `bet_flow_coin`:string(含义:打码量/流水;开奖结算后按每注 `total_amount` 1:1 累加)
|
||||
- `withdraw_flow`:object(含义:打码量 / 提现配额诊断快照,供前端展示说明与上限推导)
|
||||
- `ratio`:string(含义:打码量倍数,来自 `game_config.withdraw_bet_flow_ratio`,默认 `1.00`;`0` 表示"不限打码")
|
||||
- `net_deposit`:string(含义:净充值 = max(0, 累计充值 - 累计提现))
|
||||
- `required_bet_flow`:string(含义:按门槛口径所需的打码量 = 净充值 × 倍数,纯展示)
|
||||
- `remaining_bet_flow`:string(含义:按门槛口径还差多少打码量,纯展示;已达标为 `0.00`)
|
||||
- `eligible`:bool(含义:是否满足整体门槛,纯展示,实际放行以 `max_withdrawable` 为准)
|
||||
- `max_withdraw_by_flow`:string/null(含义:仅按打码量折算的单笔可提上限 = max(0, `bet_flow_coin` / `ratio` - `total_withdraw_coin`);`ratio=0` 不限打码时返回 `null`)
|
||||
- `flow_unlimited`:bool(含义:是否处于"不限打码"状态,`ratio=0` 时为 `true`)
|
||||
|
||||
说明(打码量即提现配额模型):
|
||||
- 每笔提现按 `withdraw_coin × ratio` 消耗打码配额;`total_withdraw_coin` 已累积历史消耗。
|
||||
- **单笔最大可提现 `max_withdrawable = min(coin_balance, max_withdraw_by_flow)`**;`ratio=0` 时退化为仅受余额约束。
|
||||
### 5.1 余额同步口径(已移除独立摘要接口)
|
||||
- 已移除 `/api/wallet/balanceSummary`。
|
||||
- 余额同步来源调整为:
|
||||
- 下注返回 `placeBet.balance_after`
|
||||
- WebSocket 推送 `wallet.changed`
|
||||
- 充值/提现详情接口(如 `depositDetail` / `withdrawDetail`)作为业务单据维度核对
|
||||
- 倍数 `ratio` 由后台「游戏配置 → `withdraw_bet_flow_ratio`」维护,修改后对新请求立即生效。
|
||||
- 历史累计类字段(`total_deposit_coin` / `total_withdraw_coin` / `bet_flow_coin`)均为累加语义;若后续审核驳回,回冲逻辑由后台审核流程负责。
|
||||
|
||||
@@ -655,114 +640,78 @@
|
||||
|
||||
---
|
||||
|
||||
## 7. 推送模块(webman/push)
|
||||
## 7. WebSocket(H5)与状态同步
|
||||
|
||||
> 用于移动端实时监听对局状态、开奖结果、余额变更与强公告事件。
|
||||
> 协议与客户端行为对齐 [Pusher Channels](https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol/)(webman/push 内置兼容客户端 `push.js`)。
|
||||
> 参考:[webman/push 官方文档](https://www.workerman.net/doc/webman/plugin/push.html)
|
||||
> 本版本已移除 webman/push 频道模式;H5 前端使用原生 WebSocket 直连,HTTP 轮询仅作为弱网兜底。
|
||||
|
||||
### 7.1 频道命名与职责(优化版)
|
||||
### 7.1 WebSocket 连接与消息
|
||||
|
||||
| 频道名 | 类型 | 订阅方 | 典型事件 |
|
||||
|--------|------|--------|----------|
|
||||
| `private-user-{user.uuid}` | 私有(`private-` 前缀) | 当前登录用户;`{user.uuid}` 与登录态/档案中的 **10 位 `uuid`** 一致 | `bet.accepted`、`wallet.changed`、`withdraw.review_required`、定向 `notice.popout` 等 |
|
||||
| `public-game-period` | 公共 | 所有在线客户端 | `period.tick`、`period.locked`、`period.opened` |
|
||||
| `public-operation-notice` | 公共 | 所有在线客户端 | 全站/渠道级 `notice.popout`(与私有公告二选一或并存,由实现约定) |
|
||||
- **连接地址**:由服务端配置下发(后台测试页读取 `H5_WEBSOCKET_URL`)
|
||||
- **客户端**:浏览器原生 `WebSocket`(`ws://` / `wss://`)
|
||||
- **连接时携带参数(建议)**:
|
||||
- URL Query:`token`(用户登录态 user-token)、`auth_token`(接口鉴权)、`device_id`(设备标识)、`lang`(`zh/en`)
|
||||
- 示例:`wss://ws.example.com/game?token=xxx&auth_token=xxx&device_id=ios_001&lang=zh`
|
||||
- **连接成功返回(服务端首帧建议)**:
|
||||
- `event`:`ws.connected`
|
||||
- `connection_id`:连接唯一标识
|
||||
- `server_time`:服务器时间戳(秒)
|
||||
- `heartbeat_interval`:心跳间隔(秒)
|
||||
- **连接失败返回(建议)**:
|
||||
- `event`:`ws.error`
|
||||
- `code`:错误码(如 `1101` 未登录、`1103` 鉴权失败)
|
||||
- `message`:错误描述
|
||||
- **建议消息**:
|
||||
- 心跳:`{"action":"ping"}`
|
||||
- 订阅状态流:`{"action":"subscribe","topics":["period.tick","period.opened"]}`
|
||||
- 订阅资金流:`{"action":"subscribe","topics":["bet.accepted","wallet.changed"]}`
|
||||
- 订阅托管流:`{"action":"subscribe","topics":["auto.spin.progress","wallet.changed"]}`
|
||||
|
||||
约定说明:
|
||||
#### 7.1.1 消息协议字段定义(联调口径)
|
||||
|
||||
- **用户私有频道一律使用对外标识 `uuid`,不使用数据库主键 `user_id`**,避免与后台、日志、多端展示口径不一致,并降低枚举内网 ID 的风险。
|
||||
- 名称以 `private-` 开头的频道必须通过 **私有频道鉴权**(见 7.2)成功后才能收到服务端推送。
|
||||
- `public-*` 可直接订阅,无需鉴权 HTTP 步骤。
|
||||
- 客户端 -> 服务端:
|
||||
- `action`:动作名(当前约定 `ping` / `subscribe`)
|
||||
- `topics`:仅 `subscribe` 时必填,表示要订阅的主题列表(数组)
|
||||
- 服务端 -> 客户端:
|
||||
- `event`:事件名(如 `period.tick`、`wallet.changed`、`jackpot.hit`)
|
||||
- `topic`:所属主题(通常与 `event` 一致;用于前端按主题路由)
|
||||
- `data`:业务载荷(对象)
|
||||
- `server_time`:服务端时间戳(秒,倒计时与对时基准)
|
||||
|
||||
### 7.2 连接地址与鉴权流程
|
||||
#### 7.1.2 订阅行为说明
|
||||
|
||||
**WebSocket 连接 URL(与官方 `push.js` 一致)**
|
||||
- **仅建立连接不会自动下发全部业务消息**;客户端需要发送 `subscribe` 明确订阅主题。
|
||||
- 成功订阅后服务端返回:`{"event":"ws.subscribed","topics":[...]}`。
|
||||
- 若未订阅主题,通常只能收到握手首帧(`ws.connected`)和心跳回包(`pong`)。
|
||||
|
||||
- 形如:`{websocket_base}/app/{app_key}`
|
||||
- 示例(本地默认配置见 `config/plugin/webman/push/app.php`):`ws://127.0.0.1:3131/app/{app_key}`
|
||||
- 生产环境请改为 `wss://` 与对外域名,并与网关/证书一致。
|
||||
#### 7.1.3 推送频率与触发规则(当前实现)
|
||||
|
||||
**连接建立后的协议步骤(简述)**
|
||||
- `period.tick`:**每秒一次**(用于倒计时、状态同步)。
|
||||
- `admin.live.snapshot`:**每秒一次**(后台实时对局页全量快照)。
|
||||
- `period.opened` / `period.payout` / `admin.live.opened`:按开奖流程阶段触发(事件触发型,非固定频率)。
|
||||
- `wallet.changed`:仅在余额发生变更时推送(如下注扣款、充值入账、派彩入账)。
|
||||
- `jackpot.hit`:**仅在本期存在中大奖命中用户时推送**;无命中不推送。
|
||||
|
||||
1. 客户端建立 WebSocket,服务端下发 `pusher:connection_established`,payload 内含 **`socket_id`**(后续鉴权必填)。
|
||||
2. 订阅 **公共** 频道:发送 `pusher:subscribe`,`data` 仅含 `channel` 名即可。
|
||||
3. 订阅 **私有** 频道:
|
||||
- 客户端向 **鉴权接口** 发起 `POST`(`Content-Type: application/x-www-form-urlencoded`),表单字段:`channel_name`、`socket_id`。
|
||||
- 默认鉴权路径为 **`/plugin/webman/push/auth`**(与 `config/plugin/webman/push/app.php` 中 `auth` 一致,可随部署调整)。
|
||||
- 服务端校验「当前登录用户是否允许订阅该 `channel_name`」——对 `private-user-{uuid}` 应校验 **`uuid` 与当前用户一致**,否则返回 `403`。
|
||||
- 鉴权成功返回的 JSON 由 `push.js` 原样作为 `pusher:subscribe` 的 `data` 发送(含 `auth` 等字段)。
|
||||
### 7.1A 后台连接方式(管理端联调)
|
||||
|
||||
**与移动端登录态的关系**
|
||||
- 后台菜单:仅保留一个菜单 `连接服务器websocket`,用于统一联调 WebSocket
|
||||
- 后台连接入口:
|
||||
- `/admin/test.GameCurrentStatus/wsConfig`
|
||||
- 后台页面能力:
|
||||
- 读取 `ws_url`、`connect_tip`、`sample_messages`
|
||||
- 手动连接/断开 WebSocket
|
||||
- 手动发送订阅与心跳报文
|
||||
- 实时查看服务端返回帧内容(用于联调事件格式)
|
||||
|
||||
- 客户端在调用鉴权接口时,除 `channel_name` / `socket_id` 外,需携带与 REST API 一致的 **`user-token`(及业务所需的 `auth-token`)**,由服务端解析用户身份后再比对 `private-user-{uuid}`。
|
||||
- **不建议**依赖浏览器 Cookie Session 作为唯一依据(H5 外还有 App 内嵌、小程序等);若仅沿用框架示例中的 Session,需在落地实现中改为 **无状态 token 校验**。
|
||||
### 7.2 HTTP 兜底接口
|
||||
|
||||
### 7.3 事件定义(初设)
|
||||
- 本版本已移除以下兜底接口:`/api/game/currentStatus`、`/api/game/periodHistory`、`/api/wallet/balanceSummary`。
|
||||
- 状态与余额统一以 WebSocket 推送为主,HTTP 仅保留业务动作/详情查询接口(如 `placeBet`、`depositDetail`、`withdrawDetail`)。
|
||||
|
||||
| 事件名 | 建议频道 | 说明 |
|
||||
|--------|----------|------|
|
||||
| `period.tick` | `public-game-period` | 倒计时广播 |
|
||||
| `period.locked` | `public-game-period` | 封盘 |
|
||||
| `period.opened` | `public-game-period` | 开奖完成(中奖号码) |
|
||||
| `bet.accepted` | `private-user-{uuid}` | 下注成功回执 |
|
||||
| `bet.settled` | `private-user-{uuid}` | **每期每用户一条**:该局开奖对该用户全部注单的汇总(`total_win_amount`、`order_count`、`hit_order_count`、`result_number`、`balance_after`;不再按单笔注单重复推送) |
|
||||
| `wallet.changed` | `private-user-{uuid}` | 余额变化(中奖派彩入账等;`reason=payout` 等) |
|
||||
| `notice.popout` | `public-operation-notice` 或 `private-user-{uuid}` | 强公告(按业务选择广播或定向) |
|
||||
| `withdraw.review_required` | `private-user-{uuid}` | 提现进入审核 |
|
||||
### 7.3 一致性规则
|
||||
|
||||
### 7.4 消息形态(客户端解析)
|
||||
|
||||
连接上收到的单帧一般为 JSON,常见两类:
|
||||
|
||||
- 协议类:`event` 为 `pusher:connection_established`、`pusher_internal:subscription_succeeded` 等。
|
||||
- 业务类:`event` 为业务事件名,`channel` 为频道名,`data` 为负载(可能为字符串化的 JSON,客户端需 `JSON.parse` 一次)。
|
||||
|
||||
业务负载示例(与初设一致,字段以实际实现为准):
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "period.opened",
|
||||
"channel": "public-game-period",
|
||||
"data": {
|
||||
"period_no": "20260416001",
|
||||
"result_number": 18,
|
||||
"open_time": 1776326400
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.5 降级与一致性
|
||||
|
||||
- 推送仅作 **体验增强**:断线、弱网时客户端仍应以 **HTTP 轮询/用户主动刷新**(如 `/api/game/periodCurrent`、`/api/wallet/balanceSummary`)为准。
|
||||
- 同一业务状态以 **服务端落库与接口查询** 为最终一致;推送到达顺序不保证与业务因果严格一致,需客户端幂等与去重(可带 `period_no` / `order_no` / 时间戳)。
|
||||
|
||||
### 7.6 使用 Apipost 调试 WebSocket 与私有频道
|
||||
|
||||
Apipost(v7+)支持 **WebSocket**:新建请求 → 选择 **WebSocket** → 类型选 **Raw**。私有频道遵循「先拿 `socket_id` → 再 HTTP 鉴权 → 再发 `pusher:subscribe`」,与 `vendor/webman/push/src/push.js` 行为一致。
|
||||
|
||||
**A. 仅调试公共频道(如 `public-game-period`)**
|
||||
|
||||
1. 启动 webman 与 push 进程,确认 `config/plugin/webman/push/app.php` 中 `websocket`、`app_key`。
|
||||
2. 在 Apipost 中 WebSocket URL 填:`ws://127.0.0.1:3131/app/{app_key}`(将 `{app_key}` 换成配置中的真实值)。
|
||||
3. 点击连接,在消息面板应收到一帧 `pusher:connection_established`,从中取出 `socket_id`(公共订阅可不依赖后续步骤,但便于对照协议)。
|
||||
4. 在发送框填入一行 JSON(勿带代码块标记)并发送:
|
||||
`{"event":"pusher:subscribe","data":{"channel":"public-game-period"}}`
|
||||
5. 成功时随后会收到 `pusher_internal:subscription_succeeded`;之后服务端向该频道 `trigger` 的事件会出现在消息列表中。
|
||||
|
||||
**B. 调试用户私有频道 `private-user-{uuid}`**
|
||||
|
||||
1. 同上先连接,从首帧解析出 **`socket_id`**。
|
||||
2. 新建 **HTTP** 请求:`POST http://{你的HTTP入口}/plugin/webman/push/auth`
|
||||
- Header:`Content-Type: application/x-www-form-urlencoded`
|
||||
- Body(x-www-form-urlencoded):`channel_name=private-user-{替换为真实uuid}&socket_id={上一步的socket_id}`
|
||||
- 若鉴权已接入 `user-token`,请在 Header 中一并带上与移动端一致的 **`user-token`**(及 `auth-token` 等),否则会得到 `403` 或无效签名。
|
||||
3. 将接口返回的 **JSON 正文**(整段)作为 `pusher:subscribe` 的 `data`:在 Apipost WebSocket 发送
|
||||
`{"event":"pusher:subscribe","data": <上一步响应 JSON 对象>}`
|
||||
注意:`push.js` 会把鉴权返回与 `channel` 字段合并后再发送;若手搓 JSON,需保证与官方协议一致(含 `auth` 字段)。
|
||||
4. 订阅成功后即可在消息面板等待该私有频道上的业务事件。
|
||||
|
||||
**说明**:若仅做协议连通性验证,可暂时使用服务端对鉴权接口的占位实现;**上线前**必须落实「`channel_name` 与当前用户 `uuid` 匹配」校验,避免越权订阅。
|
||||
- 倒计时以服务端下发时间为准,不信任本地时钟累计。
|
||||
- 下注成功后以 `placeBet` 返回的 `balance_after` 为准,并等待 `wallet.changed` 同步。
|
||||
- WebSocket 断线后立即重连并重新订阅主题,不再依赖 `currentStatus/periodHistory/balanceSummary` 回补。
|
||||
|
||||
---
|
||||
|
||||
@@ -772,13 +721,10 @@ Apipost(v7+)支持 **WebSocket**:新建请求 → 选择 **WebSocket** →
|
||||
1. `GET /api/v1/authToken?secret=xxx×tamp=xxx&device_id=xxx&signature=xxx` 获取 `auth-token`
|
||||
2. `POST /api/user/login` 登录(请求头带 `auth-token`)
|
||||
3. `POST /api/game/lobbyInit` 拉首页初始化(请求头带 `auth-token`)
|
||||
3. 建立 webman/push 连接并订阅:
|
||||
- `public-game-period`
|
||||
- `private-user-{user.uuid}`(`uuid` 取自登录/档案接口,与 7.1 一致)
|
||||
4. 收到 `period.tick` 实时刷新倒计时
|
||||
5. 用户下注调用 `POST /api/game/betPlace`
|
||||
6. 监听 `bet.accepted` + `wallet.changed` 更新下注结果和余额
|
||||
7. 监听 `period.opened` 渲染开奖动画并刷新开奖记录
|
||||
4. 建立 WebSocket(H5)连接,发送订阅消息监听状态流
|
||||
5. 用户下注调用 `POST /api/game/placeBet`
|
||||
6. 下单后以 `placeBet.balance_after` 与 `wallet.changed` 同步余额
|
||||
7. 断线或页面回前台时,重连 WebSocket 并重新订阅主题回补实时状态
|
||||
|
||||
## 8.2 充值到下注到提现闭环
|
||||
1. 拉取档位:`POST /api/finance/depositTierList`(玩家选择一档,并记下该档 `channels[].code`)
|
||||
@@ -786,8 +732,8 @@ Apipost(v7+)支持 **WebSocket**:新建请求 → 选择 **WebSocket** →
|
||||
- 返回 `paid=false`、`status=pending`、**非空 `pay_url`**:客户端在 WebView/浏览器中打开 `pay_url`(`GET /api/finance/depositMockPayPage`);用户在模拟页点击确认后,由 `POST /api/finance/depositMockNotify` 完成入账,或轮询 `depositDetail` / 等 `wallet.changed` 再刷新余额
|
||||
- 未来接真实第三方:将 `pay_url` 换为真网关,入账仅在支付平台 **异步通知** 中调用 `DepositSettlement::settle`(与当前 `depositMockNotify` 路径一致)
|
||||
3. 客户端可选轮询 `POST /api/finance/depositDetail` 兜底确认状态;入账成功后会收到 `wallet.changed`
|
||||
4. 下注:`POST /api/game/betPlace`
|
||||
5. 派彩后收到 `wallet.changed`
|
||||
4. 下注:`POST /api/game/placeBet`
|
||||
5. 监听余额:`wallet.changed`(或按订单详情接口核对)
|
||||
6. 查询流水:`POST /api/wallet/recordList`
|
||||
7. 提现:`POST /api/finance/withdrawCreate`(即时冻结 `user.coin` 与写出 `withdraw` 流水) -> `POST /api/finance/withdrawDetail`
|
||||
|
||||
@@ -799,22 +745,20 @@ Apipost(v7+)支持 **WebSocket**:新建请求 → 选择 **WebSocket** →
|
||||
|
||||
---
|
||||
|
||||
## 9. 游戏时序流程图(接口 + 推送)
|
||||
## 9. 游戏时序流程图(WebSocket + HTTP兜底)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户登录 /api/user/login] --> B[拉初始化 /api/game/lobbyInit]
|
||||
B --> C[连接webman/push并订阅频道]
|
||||
C --> D[收到 period.tick 倒计时]
|
||||
D --> E{0-20秒下注期?}
|
||||
E -- 是 --> F[提交下注 /api/game/betPlace]
|
||||
F --> G[推送 bet.accepted + wallet.changed]
|
||||
E -- 否 --> H[进入封盘状态 period.locked]
|
||||
H --> I[服务端算票与开奖]
|
||||
I --> J[推送 period.opened]
|
||||
J --> K[客户端开奖动画与结果展示]
|
||||
K --> L[客户端刷新开奖记录 /api/game/periodHistory]
|
||||
L --> D
|
||||
B --> C[连接 WebSocket 并订阅主题]
|
||||
C --> D{0-20秒下注期?}
|
||||
D -- 是 --> E[提交下注 /api/game/placeBet]
|
||||
E --> F[等待 wallet.changed 同步余额]
|
||||
D -- 否 --> G[进入封盘与开奖阶段]
|
||||
G --> H[服务端算票与开奖]
|
||||
H --> I[WebSocket 推送状态变化]
|
||||
I --> J[断线重连并重新订阅]
|
||||
J --> C
|
||||
```
|
||||
|
||||
---
|
||||
@@ -874,7 +818,7 @@ flowchart TD
|
||||
1. **登录方式**:仅账号密码,还是要短信/邮箱验证码?
|
||||
2. **提现收款类型**:首版只做银行卡,还是同时支持电子钱包/加密地址?
|
||||
3. **自动托管**:是否首期上线;若不上线可先隐藏 `auto-bet` 接口。
|
||||
4. **push事件最小集**:是否先只上 `period.tick`、`period.opened`、`wallet.changed` 三类。
|
||||
4. **WebSocket 主题定义**:状态流、资金流、托管流的 topic 与消息体是否按本文固定。
|
||||
5. **错误码规范**:是否已有公司统一错误码表;若有需对齐替换本草案码段。
|
||||
|
||||
确认后可进入下一步:按该文档落地 controller + validate + service + 路由。
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
- 各种纯函数派生逻辑
|
||||
- 包括格子 view model、倒计时、公告筛选、趋势计算、下注汇总等
|
||||
|
||||
#### [src/features/game/shared/index.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/index.ts)
|
||||
#### [src/features/game/shared/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/index.ts)
|
||||
新增内容:
|
||||
- 对 `shared` 子模块统一导出
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
- 并进一步按模块拆为子目录:
|
||||
- `src/store/auth`
|
||||
- `src/store/game`
|
||||
- `src/features/game/model/index.ts` 现在仅作为过渡导出层,用于维持 `features/game` 对外接口不变
|
||||
- `src/features/game/model/untils.ts` 现在仅作为过渡导出层,用于维持 `features/game` 对外接口不变
|
||||
|
||||
#### [src/store/game/game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts)
|
||||
新增内容:
|
||||
@@ -138,20 +138,20 @@
|
||||
- 认证相关 store
|
||||
- 原有 `auth-store` 已归入 `auth` 子目录
|
||||
|
||||
#### [src/store/auth/index.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/auth/index.ts)
|
||||
#### [src/store/auth/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/auth/index.ts)
|
||||
说明:
|
||||
- 统一导出认证模块 store
|
||||
|
||||
#### [src/store/game/index.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/index.ts)
|
||||
#### [src/store/game/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/index.ts)
|
||||
说明:
|
||||
- 统一导出游戏模块 store
|
||||
|
||||
#### [src/store/index.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/index.ts)
|
||||
#### [src/store/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/index.ts)
|
||||
新增内容:
|
||||
- 统一导出 `src/store` 下各模块
|
||||
- 当前包括 `auth` 和 `game`
|
||||
|
||||
#### [src/features/game/model/index.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/model/index.ts)
|
||||
#### [src/features/game/model/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/model/index.ts)
|
||||
新增内容:
|
||||
- 作为过渡导出层,继续对 `features/game` 暴露游戏 store
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
- 包括 bootstrap、round feed、announcement 的响应映射
|
||||
- 提供 `getMockGameBootstrap()` 用于当前阶段 UI 接线
|
||||
|
||||
#### [src/features/game/api/index.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/api/index.ts)
|
||||
#### [src/features/game/api/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/api/index.ts)
|
||||
新增内容:
|
||||
- 对 `api` 子模块统一导出
|
||||
|
||||
@@ -230,13 +230,13 @@
|
||||
- 桌面端游戏页壳层
|
||||
- 负责多栏编排
|
||||
|
||||
#### [src/features/game/components/index.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/index.ts)
|
||||
#### [src/features/game/components/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/index.ts)
|
||||
新增内容:
|
||||
- 对组件模块统一导出
|
||||
|
||||
### 3.8 游戏入口与总导出
|
||||
|
||||
#### [src/features/game/entry/game-route-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/game-route-page.tsx)
|
||||
#### [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/game-route-page.tsx)
|
||||
新增内容:
|
||||
- 游戏路由页适配层
|
||||
- 负责:
|
||||
@@ -247,7 +247,7 @@
|
||||
- 接公告弹窗、自动托管遮罩
|
||||
- 使用 i18n 文案
|
||||
|
||||
#### [src/features/game/index.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/index.ts)
|
||||
#### [src/features/game/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/index.ts)
|
||||
新增内容:
|
||||
- 对 `game` 功能模块统一导出
|
||||
|
||||
@@ -262,7 +262,7 @@
|
||||
|
||||
### 4.1 基础常量与全局信息
|
||||
|
||||
#### [src/constants/index.ts](/Users/jiaunun/Desktop/36-character-flower/src/constants/index.ts)
|
||||
#### [src/constants/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/constants/index.ts)
|
||||
修改内容:
|
||||
- 将应用名称从通用模板名改为 `36字花`
|
||||
- 更新默认描述文案
|
||||
@@ -325,7 +325,7 @@
|
||||
### 5.1 修复 `/$lang/game` 的循环更新错误
|
||||
|
||||
涉及文件:
|
||||
- [src/features/game/entry/game-route-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/game-route-page.tsx)
|
||||
- [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/game-route-page.tsx)
|
||||
|
||||
修复内容:
|
||||
- 之前直接把会返回新引用的派生 selector 传给 Zustand hook,导致 React 触发无限更新
|
||||
@@ -336,7 +336,7 @@
|
||||
### 5.2 清理页面里的硬编码双语判断
|
||||
|
||||
涉及文件:
|
||||
- [src/features/game/entry/game-route-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/game-route-page.tsx)
|
||||
- [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/game-route-page.tsx)
|
||||
- [src/routes/$lang/route.tsx](/Users/jiaunun/Desktop/36-character-flower/src/routes/%24lang/route.tsx)
|
||||
- [src/locales/zh-CN/common.ts](/Users/jiaunun/Desktop/36-character-flower/src/locales/zh-CN/common.ts)
|
||||
- [src/locales/en-US/common.ts](/Users/jiaunun/Desktop/36-character-flower/src/locales/en-US/common.ts)
|
||||
|
||||
BIN
figma/img.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
figma/img_1.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
@@ -28,12 +28,16 @@
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tanstack/react-query-devtools": "^5.99.0",
|
||||
"@tanstack/react-router": "^1.168.22",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"i18next": "^26.0.5",
|
||||
"ky": "^2.0.1",
|
||||
"lucide-react": "^1.9.0",
|
||||
"motion": "^12.38.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
85
pnpm-lock.yaml
generated
@@ -17,6 +17,12 @@ importers:
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.168.22
|
||||
version: 1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
dayjs:
|
||||
specifier: ^1.11.20
|
||||
version: 1.11.20
|
||||
i18next:
|
||||
specifier: ^26.0.5
|
||||
version: 26.0.5(typescript@6.0.2)
|
||||
@@ -26,6 +32,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0(react@19.2.5)
|
||||
motion:
|
||||
specifier: ^12.38.0
|
||||
version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
react:
|
||||
specifier: ^19.2.4
|
||||
version: 19.2.5
|
||||
@@ -35,6 +44,9 @@ importers:
|
||||
react-i18next:
|
||||
specifier: ^17.0.3
|
||||
version: 17.0.3(i18next@26.0.5(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2)
|
||||
tailwind-merge:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
zustand:
|
||||
specifier: ^5.0.12
|
||||
version: 5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
|
||||
@@ -1011,6 +1023,10 @@ packages:
|
||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
color-convert@1.9.3:
|
||||
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
||||
|
||||
@@ -1088,6 +1104,9 @@ packages:
|
||||
resolution: {integrity: sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
dayjs@1.11.20:
|
||||
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -1204,6 +1223,20 @@ packages:
|
||||
resolution: {integrity: sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
framer-motion@12.38.0:
|
||||
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
fs-extra@9.1.0:
|
||||
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1571,6 +1604,26 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
motion-dom@12.38.0:
|
||||
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
|
||||
|
||||
motion-utils@12.36.0:
|
||||
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
|
||||
|
||||
motion@12.38.0:
|
||||
resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -1816,6 +1869,9 @@ packages:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
tailwind-merge@3.5.0:
|
||||
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||
|
||||
tailwindcss@4.2.2:
|
||||
resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
|
||||
|
||||
@@ -2823,6 +2879,8 @@ snapshots:
|
||||
|
||||
clone@1.0.4: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@1.9.3:
|
||||
dependencies:
|
||||
color-name: 1.1.3
|
||||
@@ -2917,6 +2975,8 @@ snapshots:
|
||||
- '@types/node'
|
||||
- typescript
|
||||
|
||||
dayjs@1.11.20: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -3033,6 +3093,15 @@ snapshots:
|
||||
micromatch: 4.0.8
|
||||
resolve-dir: 1.0.1
|
||||
|
||||
framer-motion@12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
dependencies:
|
||||
motion-dom: 12.38.0
|
||||
motion-utils: 12.36.0
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
|
||||
fs-extra@9.1.0:
|
||||
dependencies:
|
||||
at-least-node: 1.0.0
|
||||
@@ -3351,6 +3420,20 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
motion-dom@12.38.0:
|
||||
dependencies:
|
||||
motion-utils: 12.36.0
|
||||
|
||||
motion-utils@12.36.0: {}
|
||||
|
||||
motion@12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
dependencies:
|
||||
framer-motion: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mute-stream@0.0.8: {}
|
||||
@@ -3575,6 +3658,8 @@ snapshots:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
tailwind-merge@3.5.0: {}
|
||||
|
||||
tailwindcss@4.2.2: {}
|
||||
|
||||
tapable@2.3.2: {}
|
||||
|
||||
BIN
src/assets/fonts/countdown.TTF
Normal file
BIN
src/assets/fonts/countdown.woff2
Normal file
BIN
src/assets/fonts/框内可滑动@2x.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
src/assets/game/add.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src/assets/game/animal-card.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src/assets/game/arrow.webp
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src/assets/game/chip-bg.webp
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
src/assets/game/chip-line-bg.webp
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
src/assets/game/chip1.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/assets/game/chip2.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/game/chip3.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/game/chip4.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/game/chip5.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/assets/game/chip6.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src/assets/game/confirm-bg.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
src/assets/game/control-bg.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
src/assets/game/control-left.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
src/assets/game/control-mid.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/game/control-right.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/game/left-bg.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
src/assets/game/reduce.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src/assets/game/total-bg.webp
Normal file
|
After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
BIN
src/assets/system/fire.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/system/history-bg.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
src/assets/system/lock.webp
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
src/assets/system/modal-bg.webp
Normal file
|
After Width: | Height: | Size: 802 KiB |
BIN
src/assets/system/modal-close.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src/assets/system/status-center.webp
Normal file
|
After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 268 KiB |
@@ -7,9 +7,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
const cx = (...classes: Array<string | false | null | undefined>) =>
|
||||
classes.filter(Boolean).join(' ')
|
||||
import { cn } from '@/lib/untils'
|
||||
|
||||
export interface SmartImageProps
|
||||
extends Omit<
|
||||
@@ -89,7 +87,7 @@ export const SmartImage = forwardRef<HTMLImageElement, SmartImageProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('relative overflow-hidden', className)}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
style={aspectRatio ? { aspectRatio } : undefined}
|
||||
>
|
||||
{placeholderSrc ? (
|
||||
@@ -97,7 +95,7 @@ export const SmartImage = forwardRef<HTMLImageElement, SmartImageProps>(
|
||||
src={placeholderSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 h-full w-full object-cover transition duration-300',
|
||||
shouldShowVisualPlaceholder
|
||||
? 'scale-105 opacity-100 blur-xl'
|
||||
@@ -109,7 +107,7 @@ export const SmartImage = forwardRef<HTMLImageElement, SmartImageProps>(
|
||||
{showSkeleton && shouldShowVisualPlaceholder ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
className={cn(
|
||||
'absolute inset-0 animate-pulse bg-gradient-to-br from-slate-800 via-slate-700/70 to-slate-800',
|
||||
skeletonClassName,
|
||||
)}
|
||||
@@ -125,8 +123,8 @@ export const SmartImage = forwardRef<HTMLImageElement, SmartImageProps>(
|
||||
decoding={decoding ?? 'async'}
|
||||
draggable={draggable}
|
||||
fetchPriority={resolvedFetchPriority}
|
||||
className={cx(
|
||||
'relative z-[1] h-full w-full object-cover transition duration-300',
|
||||
className={cn(
|
||||
'relative z-10 h-full w-full object-cover transition duration-300',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||
imgClassName,
|
||||
)}
|
||||
@@ -162,7 +160,7 @@ export const SmartImage = forwardRef<HTMLImageElement, SmartImageProps>(
|
||||
{showFallback
|
||||
? (fallback ?? (
|
||||
<div
|
||||
className={cx(
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center bg-slate-950/80 px-4 text-center text-xs font-medium tracking-[0.12em] text-slate-300 uppercase',
|
||||
fallbackClassName,
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import { Repeat2, Settings, Trash2 } from 'lucide-react'
|
||||
import chip1 from '@/assets/game/chip1.webp'
|
||||
import chip2 from '@/assets/game/chip2.webp'
|
||||
import chip3 from '@/assets/game/chip3.webp'
|
||||
import chip4 from '@/assets/game/chip4.webp'
|
||||
import chip5 from '@/assets/game/chip5.webp'
|
||||
import chip6 from '@/assets/game/chip6.webp'
|
||||
import controlLeft from '@/assets/game/control-left.webp'
|
||||
import controlMid from '@/assets/game/control-mid.webp'
|
||||
import controlRight from '@/assets/game/control-right.webp'
|
||||
|
||||
/** @description 应用启动阶段使用的根节点常量。 */
|
||||
export const APP_ROOT_ELEMENT_ID = 'root'
|
||||
|
||||
@@ -42,3 +53,18 @@ export const I18N_LANGUAGE_STORAGE_KEY = 'app-language'
|
||||
|
||||
/** @description 桌面端布局切换起始断点。 */
|
||||
export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024
|
||||
|
||||
export const CHIP_OPTIONS = [
|
||||
{ id: 'chip-1', value: 1, src: chip1 },
|
||||
{ id: 'chip-2', value: 5, src: chip2 },
|
||||
{ id: 'chip-3', value: 10, src: chip3 },
|
||||
{ id: 'chip-4', value: 25, src: chip4 },
|
||||
{ id: 'chip-5', value: 50, src: chip5 },
|
||||
{ id: 'chip-6', value: 100, src: chip6 },
|
||||
]
|
||||
|
||||
export const ACTION_OPTIONS = [
|
||||
{ id: 'clear', label: 'Clear', Icon: Trash2, bg: controlLeft },
|
||||
{ id: 'repeat', label: 'Repeat', Icon: Repeat2, bg: controlMid },
|
||||
{ id: 'auto-spin', label: 'Auto-Spin', Icon: Settings, bg: controlRight },
|
||||
]
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { SmartImage } from '@/components/smart-image'
|
||||
|
||||
const cx = (...classes: Array<string | false | null | undefined>) =>
|
||||
classes.filter(Boolean).join(' ')
|
||||
import { cn } from '@/lib/untils'
|
||||
|
||||
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
|
||||
eager: true,
|
||||
@@ -36,7 +34,12 @@ export function DesktopAnimal({
|
||||
onSelect,
|
||||
}: DesktopAnimalProps) {
|
||||
return (
|
||||
<section className={cx('w-full grid grid-cols-6 gap-[5px]', className)}>
|
||||
<section
|
||||
className={cn(
|
||||
'grid w-full grid-cols-6 gap-design-5 common-neon-inset',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{animalImageList.map((item) => {
|
||||
const isActive = item.id === activeId
|
||||
|
||||
@@ -45,7 +48,7 @@ export function DesktopAnimal({
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(item.id)}
|
||||
className={cx(
|
||||
className={cn(
|
||||
'flex flex-col items-center transition',
|
||||
'cursor-pointer',
|
||||
isActive &&
|
||||
@@ -56,8 +59,8 @@ export function DesktopAnimal({
|
||||
<SmartImage
|
||||
src={item.url}
|
||||
alt={`animal-${item.id}`}
|
||||
className={cx(
|
||||
'h-[112px] w-[223px] object-contain rounded-2xl',
|
||||
className={cn(
|
||||
'h-design-112 w-design-223 rounded-2xl object-contain',
|
||||
imageClassName,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,371 @@
|
||||
import { motion } from 'motion/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import add from '@/assets/game/add.webp'
|
||||
import arrow from '@/assets/game/arrow.webp'
|
||||
import chipBg from '@/assets/game/chip-bg.webp'
|
||||
import chipLineBg from '@/assets/game/chip-line-bg.webp'
|
||||
import confirmBg from '@/assets/game/confirm-bg.png'
|
||||
import controlBg from '@/assets/game/control-bg.png'
|
||||
import leftBottomBg from '@/assets/game/left-bg.webp'
|
||||
import reduce from '@/assets/game/reduce.webp'
|
||||
import totalBg from '@/assets/game/total-bg.webp'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { ACTION_OPTIONS, CHIP_OPTIONS } from '@/constants'
|
||||
import { cn } from '@/lib/untils.ts'
|
||||
|
||||
export function DesktopControl() {
|
||||
return <div className={'w-full'}>DesktopControl</div>
|
||||
const [chips, setChips] = useState(CHIP_OPTIONS)
|
||||
const [selectedChipId, setSelectedChipId] = useState(
|
||||
CHIP_OPTIONS[CHIP_OPTIONS.length - 1]?.id ?? '',
|
||||
)
|
||||
const [clickedId, setClickedId] = useState<string | null>(null)
|
||||
const [hidingId, setHidingId] = useState<string | null>(null)
|
||||
const [confirmClicked, setConfirmClicked] = useState(false)
|
||||
|
||||
const selectedChip =
|
||||
chips.find((chip) => chip.id === selectedChipId) ?? CHIP_OPTIONS[0]
|
||||
|
||||
const handleChipClick = (chipId: string) => {
|
||||
setSelectedChipId(chipId)
|
||||
setChips((current) => {
|
||||
const next = [...current]
|
||||
const index = next.findIndex((chip) => chip.id === chipId)
|
||||
|
||||
if (index === -1 || index === next.length - 1) {
|
||||
return next
|
||||
}
|
||||
|
||||
const [selected] = next.splice(index, 1)
|
||||
next.push(selected)
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleActionClick = useCallback((id: string) => {
|
||||
setClickedId(id)
|
||||
setTimeout(() => {
|
||||
setClickedId(null)
|
||||
setHidingId(id)
|
||||
setTimeout(() => {
|
||||
setHidingId(null)
|
||||
}, 180)
|
||||
}, 200)
|
||||
}, [])
|
||||
|
||||
const handleConfirmClick = useCallback(() => {
|
||||
setConfirmClicked(true)
|
||||
setTimeout(() => {
|
||||
setConfirmClicked(false)
|
||||
}, 200)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex h-design-100 w-full items-center gap-design-16 overflow-hidden'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center gap-1 justify-center h-full w-design-110 shrink-0 bg-center bg-no-repeat'
|
||||
}
|
||||
style={{
|
||||
backgroundImage: `url(${leftBottomBg})`,
|
||||
backgroundSize: '100% 100%',
|
||||
}}
|
||||
>
|
||||
<div className={'flex flex-col items-center justify-center'}>
|
||||
<div>TREBD</div>
|
||||
<div>MAP</div>
|
||||
</div>
|
||||
<SmartImage
|
||||
src={arrow}
|
||||
alt={`animal`}
|
||||
className={'w-design-20 h-design-10'}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'desktop-control-chip relative z-10 flex h-full w-design-710 shrink-0 items-center gap-design-10 pl-design-20 pr-design-40 bg-center bg-no-repeat'
|
||||
}
|
||||
style={{
|
||||
backgroundImage: `url(${chipBg})`,
|
||||
backgroundSize: '100% 100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(${chipLineBg})`,
|
||||
backgroundSize: '100% 100%',
|
||||
}}
|
||||
className={
|
||||
'flex min-w-0 flex-1 items-center gap-design-10 overflow-visible'
|
||||
}
|
||||
>
|
||||
{chips.map((chip) => {
|
||||
const isSelected = chip.id === selectedChipId
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={chip.id}
|
||||
layout
|
||||
type="button"
|
||||
onClick={() => handleChipClick(chip.id)}
|
||||
whileTap={{ scale: 0.94 }}
|
||||
transition={{
|
||||
layout: {
|
||||
type: 'spring',
|
||||
stiffness: 420,
|
||||
damping: 32,
|
||||
},
|
||||
duration: 0.18,
|
||||
}}
|
||||
className={
|
||||
'relative flex h-design-70 w-design-70 shrink-0 cursor-pointer items-center justify-center rounded-full'
|
||||
}
|
||||
>
|
||||
<motion.span
|
||||
animate={
|
||||
isSelected
|
||||
? {
|
||||
opacity: [0.22, 0.5, 0.22],
|
||||
scaleX: [0.82, 1.02, 0.82],
|
||||
scaleY: [0.42, 0.56, 0.42],
|
||||
}
|
||||
: {
|
||||
opacity: 0.32,
|
||||
scaleX: 0.88,
|
||||
scaleY: 0.48,
|
||||
}
|
||||
}
|
||||
transition={
|
||||
isSelected
|
||||
? {
|
||||
duration: 1.5,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'easeInOut',
|
||||
}
|
||||
: { duration: 0.2, ease: 'easeOut' }
|
||||
}
|
||||
className={
|
||||
'pointer-events-none absolute bottom-[calc(var(--design-unit)*-4)] left-1/2 h-design-16 w-design-46 -translate-x-1/2 rounded-full bg-[rgba(0,0,0,0.48)] blur-[6px]'
|
||||
}
|
||||
/>
|
||||
<motion.span
|
||||
animate={
|
||||
isSelected
|
||||
? {
|
||||
opacity: [0.72, 1, 0.72],
|
||||
scale: [0.96, 1.04, 0.96],
|
||||
boxShadow: [
|
||||
'0 0 0 1px rgba(245, 200, 107, 0.68), 0 0 8px rgba(245, 200, 107, 0.44), 0 0 18px rgba(245, 200, 107, 0.22), inset 0 0 8px rgba(255, 214, 110, 0.18)',
|
||||
'0 0 0 1px rgba(245, 200, 107, 0.96), 0 0 14px rgba(245, 200, 107, 0.78), 0 0 28px rgba(245, 200, 107, 0.42), inset 0 0 12px rgba(255, 214, 110, 0.32)',
|
||||
'0 0 0 1px rgba(245, 200, 107, 0.68), 0 0 8px rgba(245, 200, 107, 0.44), 0 0 18px rgba(245, 200, 107, 0.22), inset 0 0 8px rgba(255, 214, 110, 0.18)',
|
||||
],
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.92,
|
||||
boxShadow: '0 0 0 0 rgba(0,0,0,0)',
|
||||
}
|
||||
}
|
||||
transition={
|
||||
isSelected
|
||||
? {
|
||||
duration: 1.6,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'easeInOut',
|
||||
}
|
||||
: { duration: 0.22, ease: 'easeOut' }
|
||||
}
|
||||
className={
|
||||
'pointer-events-none absolute inset-0 rounded-full'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
animate={
|
||||
isSelected
|
||||
? {
|
||||
y: [-1, -3, -1],
|
||||
scale: [1.02, 1.06, 1.02],
|
||||
filter: [
|
||||
'drop-shadow(0 8px 10px rgba(0,0,0,0.18))',
|
||||
'drop-shadow(0 10px 14px rgba(245, 200, 107, 0.22))',
|
||||
'drop-shadow(0 8px 10px rgba(0,0,0,0.18))',
|
||||
],
|
||||
}
|
||||
: {
|
||||
y: 0,
|
||||
scale: 1,
|
||||
filter:
|
||||
'drop-shadow(0 6px 10px rgba(0,0,0,0.34)) drop-shadow(0 2px 4px rgba(255,255,255,0.08))',
|
||||
}
|
||||
}
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 24 }}
|
||||
className={'relative z-[1]'}
|
||||
>
|
||||
<motion.img
|
||||
src={chip.src}
|
||||
alt={`chip-${chip.value}`}
|
||||
draggable={false}
|
||||
className={'h-design-70 w-design-70 object-contain'}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex h-design-50 shrink-0 items-center rounded-md bg-[#091118] box-border px-design-2 py-design-3'
|
||||
}
|
||||
>
|
||||
<SmartImage
|
||||
src={add}
|
||||
alt={`add`}
|
||||
className={'w-design-40 h-design-40'}
|
||||
/>
|
||||
<div
|
||||
className={'w-design-80 h-full flex items-center justify-center'}
|
||||
>
|
||||
{selectedChip.value}
|
||||
</div>
|
||||
<SmartImage
|
||||
src={reduce}
|
||||
alt={`reduce`}
|
||||
className={'w-design-40 h-design-40'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'desktop-control-total relative flex flex-col items-center justify-center z-10 h-full w-design-435 shrink-0 bg-center bg-no-repeat'
|
||||
}
|
||||
style={{
|
||||
backgroundImage: `url(${totalBg})`,
|
||||
backgroundSize: '100% 100%',
|
||||
}}
|
||||
>
|
||||
<div>SELECTED:3/5</div>
|
||||
<div>Total Bet:150</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'desktop-control-actions relative z-10 flex h-full w-design-385 shrink-0 items-center bg-center bg-no-repeat pl-design-15',
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `url(${controlBg})`,
|
||||
backgroundSize: '100% 100%',
|
||||
}}
|
||||
>
|
||||
{ACTION_OPTIONS.map(({ id, label, Icon, bg }) => {
|
||||
const isClicked = clickedId === id
|
||||
const isHiding = hidingId === id
|
||||
const showBg = isClicked || isHiding
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => handleActionClick(id)}
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
className={cn(
|
||||
'relative flex h-full flex-1 cursor-pointer items-center justify-center overflow-hidden',
|
||||
{ '-translate-x-1.5': id === 'auto-spin' },
|
||||
)}
|
||||
>
|
||||
{showBg && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={isClicked ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{
|
||||
duration: isClicked ? 0.15 : 0.18,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
className={cn(
|
||||
'pointer-events-none absolute bg-center inset-0 bg-no-repeat h-design-100',
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `url(${bg})`,
|
||||
backgroundSize:
|
||||
id === 'clear'
|
||||
? '110% 90%'
|
||||
: id === 'repeat'
|
||||
? '98% 80%'
|
||||
: '96% 80%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<motion.div
|
||||
animate={
|
||||
showBg
|
||||
? {
|
||||
y: -1,
|
||||
scale: 1.01,
|
||||
}
|
||||
: {
|
||||
y: 0,
|
||||
scale: 1,
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
duration: showBg ? 0.22 : 0.18,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
className={
|
||||
'relative z-10 flex flex-col items-center justify-center'
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
size={20}
|
||||
strokeWidth={2.2}
|
||||
className={showBg ? 'text-[#D9FEFF]' : 'text-[#37D5CB]'}
|
||||
/>
|
||||
<div className={'mt-design-6 text-design-14 leading-none'}>
|
||||
{label}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={handleConfirmClick}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
className={cn(
|
||||
'relative z-10 h-full w-design-260 shrink-0 cursor-pointer bg-center bg-no-repeat flex items-center justify-center text-design-32 font-bold',
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `url(${confirmBg})`,
|
||||
backgroundSize: '100% 100%',
|
||||
}}
|
||||
>
|
||||
{confirmClicked && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="pointer-events-none absolute inset-0 bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${confirmBg})`,
|
||||
backgroundSize: '100% 100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<motion.span
|
||||
animate={confirmClicked ? { opacity: 0, y: 2 } : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="relative"
|
||||
>
|
||||
confirm
|
||||
</motion.span>
|
||||
</motion.button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
83
src/features/game/components/desktop/desktop-countdown.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import dayjs from 'dayjs'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { cn } from '@/lib/untils'
|
||||
|
||||
dayjs.extend(duration)
|
||||
|
||||
function formatCountdown(remainingMs: number) {
|
||||
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000))
|
||||
const countdown = dayjs.duration(totalSeconds, 'seconds')
|
||||
const minutes = String(Math.floor(countdown.asMinutes())).padStart(2, '0')
|
||||
const seconds = String(countdown.seconds()).padStart(2, '0')
|
||||
|
||||
return `${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
interface DesktopCountdownProps {
|
||||
className?: string
|
||||
initialMs?: number
|
||||
initialSeconds?: number
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
export function DesktopCountdown({
|
||||
className,
|
||||
initialMs,
|
||||
initialSeconds,
|
||||
onComplete,
|
||||
}: DesktopCountdownProps) {
|
||||
const initialCountdownMs = useMemo(() => {
|
||||
if (typeof initialMs === 'number') {
|
||||
return Math.max(0, initialMs)
|
||||
}
|
||||
|
||||
if (typeof initialSeconds === 'number') {
|
||||
return Math.max(0, initialSeconds * 1000)
|
||||
}
|
||||
|
||||
return 30_000
|
||||
}, [initialMs, initialSeconds])
|
||||
|
||||
const [remainingMs, setRemainingMs] = useState(initialCountdownMs)
|
||||
|
||||
useEffect(() => {
|
||||
setRemainingMs(initialCountdownMs)
|
||||
}, [initialCountdownMs])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCountdownMs <= 0) {
|
||||
setRemainingMs(0)
|
||||
onComplete?.()
|
||||
return
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
const timer = window.setInterval(() => {
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
const nextRemainingMs = Math.max(0, initialCountdownMs - elapsedMs)
|
||||
|
||||
setRemainingMs(nextRemainingMs)
|
||||
|
||||
if (nextRemainingMs === 0) {
|
||||
window.clearInterval(timer)
|
||||
onComplete?.()
|
||||
}
|
||||
}, 250)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [initialCountdownMs, onComplete])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'font-countdown text-design-48 leading-none tracking-[0.08em] text-[#4BFFFE]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{formatCountdown(remainingMs)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,183 @@
|
||||
import historyBg from '@/assets/system/history-bg.png'
|
||||
|
||||
export function DesktopGameHistory() {
|
||||
return <div className={'w-full'}>DesktopGameHistory</div>
|
||||
const data = [
|
||||
{
|
||||
order_no: 'BET202604290001',
|
||||
period_no: '202604290101',
|
||||
numbers: [3, 8, 12],
|
||||
bet_amount: '100.00',
|
||||
total_amount: '100.00',
|
||||
result_number: 8,
|
||||
win_amount: '330.00',
|
||||
status: 'won',
|
||||
create_time: 1745881200,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290002',
|
||||
period_no: '202604290102',
|
||||
numbers: [5],
|
||||
bet_amount: '50.00',
|
||||
total_amount: '50.00',
|
||||
result_number: 11,
|
||||
win_amount: '0.00',
|
||||
status: 'lost',
|
||||
create_time: 1745882100,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290003',
|
||||
period_no: '202604290103',
|
||||
numbers: [1, 7],
|
||||
bet_amount: '88.00',
|
||||
total_amount: '88.00',
|
||||
result_number: 7,
|
||||
win_amount: '176.00',
|
||||
status: 'won',
|
||||
create_time: 1745883000,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290004',
|
||||
period_no: '202604290104',
|
||||
numbers: [9, 10, 15],
|
||||
bet_amount: '120.00',
|
||||
total_amount: '120.00',
|
||||
result_number: 4,
|
||||
win_amount: '0.00',
|
||||
status: 'settled',
|
||||
create_time: 1745883900,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290005',
|
||||
period_no: '202604290105',
|
||||
numbers: [6],
|
||||
bet_amount: '66.00',
|
||||
total_amount: '66.00',
|
||||
result_number: null,
|
||||
win_amount: '0.00',
|
||||
status: 'pending',
|
||||
create_time: 1745884800,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290006',
|
||||
period_no: '202604290106',
|
||||
numbers: [2, 14],
|
||||
bet_amount: '200.00',
|
||||
total_amount: '200.00',
|
||||
result_number: 14,
|
||||
win_amount: '400.00',
|
||||
status: 'won',
|
||||
create_time: 1745885700,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290007',
|
||||
period_no: '202604290107',
|
||||
numbers: [13],
|
||||
bet_amount: '30.00',
|
||||
total_amount: '30.00',
|
||||
result_number: 13,
|
||||
win_amount: '99.00',
|
||||
status: 'won',
|
||||
create_time: 1745886600,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290008',
|
||||
period_no: '202604290108',
|
||||
numbers: [4, 16],
|
||||
bet_amount: '150.00',
|
||||
total_amount: '150.00',
|
||||
result_number: 1,
|
||||
win_amount: '0.00',
|
||||
status: 'lost',
|
||||
create_time: 1745887500,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290009',
|
||||
period_no: '202604290109',
|
||||
numbers: [11, 18, 20],
|
||||
bet_amount: '300.00',
|
||||
total_amount: '300.00',
|
||||
result_number: null,
|
||||
win_amount: '0.00',
|
||||
status: 'pending',
|
||||
create_time: 1745888400,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290010',
|
||||
period_no: '202604290110',
|
||||
numbers: [17],
|
||||
bet_amount: '80.00',
|
||||
total_amount: '80.00',
|
||||
result_number: 17,
|
||||
win_amount: '264.00',
|
||||
status: 'won',
|
||||
create_time: 1745889300,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="desktop-game-history-glow flex h-full min-h-0 w-full flex-col items-center overflow-hidden bg-center bg-no-repeat py-2"
|
||||
style={{
|
||||
backgroundImage: `url(${historyBg})`,
|
||||
backgroundSize: '100% 100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'relative z-20 flex h-design-50 shrink-0 items-center justify-center text-design-30 text-[#D5FBFF]'
|
||||
}
|
||||
>
|
||||
History
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
|
||||
}
|
||||
>
|
||||
{data.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.order_no}
|
||||
className={
|
||||
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
|
||||
}
|
||||
>
|
||||
{item.status}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>Round ID: </span>
|
||||
<span className={'text-[#C0E7EB]'}>{item.order_no}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>Animals Bet: </span>
|
||||
<span>{item.numbers.join(', ')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>Total Bet Amount: </span>
|
||||
<span className={'text-[#FFE375]'}>{item.bet_amount}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}> Winning Result:</span>
|
||||
<span className={'text-[#FF7575]'}>
|
||||
{' '}
|
||||
{item.result_number === null ? '--' : item.result_number}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,64 +7,82 @@ import { SmartImage } from '@/components/smart-image.tsx'
|
||||
export function DesktopHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
|
||||
<div className="h-[70px] w-full flex items-center px-[12px]">
|
||||
<div className="h-full flex px-[10px] justify-center items-center border-r border-[rgba(128,223,231,0.65)]">
|
||||
<div className="flex h-design-70 w-full items-center px-design-12">
|
||||
<div className="flex h-full w-design-375 items-center justify-center border-r border-[rgba(128,223,231,0.65)] px-design-10">
|
||||
<SmartImage
|
||||
src={logo}
|
||||
alt="logo"
|
||||
priority
|
||||
className="w-[320px] h-[40px]"
|
||||
className="h-design-40 w-design-320"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-[120px] flex justify-center items-center gap-[10px] h-full border-r border-[rgba(128,223,231,0.65)]">
|
||||
<div className="flex h-full w-design-130 items-center justify-center gap-design-10 border-r border-[rgba(128,223,231,0.65)]">
|
||||
<SmartImage
|
||||
src={wifi}
|
||||
alt="wifi"
|
||||
priority
|
||||
className="w-[28px] h-[20px]"
|
||||
className="h-design-20 w-design-28"
|
||||
/>
|
||||
<div className={'text-[#74FF69] text-[20px]'}>
|
||||
24 <span className={'text-[16px]'}>ms</span>
|
||||
<div className={'text-[#74FF69] text-design-20'}>
|
||||
24 <span className={'text-design-16'}>ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[150px] flex flex-col justify-center items-center gap-[5px] h-full border-r border-[rgba(128,223,231,0.65)]">
|
||||
<div className="flex h-full w-design-175 flex-col items-center justify-center gap-design-5 border-r border-[rgba(128,223,231,0.65)]">
|
||||
<div>System Time</div>
|
||||
<div>20:05:12 GMT+08</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-around gap-[10px] h-full px-[10px] text-[#D5FBFF]">
|
||||
<div className={'flex items-center gap-[5px] common-neon-inset'}>
|
||||
<div className="flex h-full flex-1 items-center justify-around gap-design-10 px-design-40 text-[#D5FBFF] border-r border-[rgba(128,223,231,0.65)]">
|
||||
<div
|
||||
className={
|
||||
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
|
||||
}
|
||||
>
|
||||
<CircleAlert color={'#57B8BF'} size={16} />
|
||||
<div>Rules & Ddds</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center gap-[5px] common-neon-inset'}>
|
||||
<div
|
||||
className={
|
||||
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
|
||||
}
|
||||
>
|
||||
<Mail color={'#57B8BF'} size={16} />
|
||||
<div>Pesan</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center gap-[5px] common-neon-inset'}>
|
||||
<div
|
||||
className={
|
||||
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
|
||||
}
|
||||
>
|
||||
<Volume2 color={'#57B8BF'} size={16} />
|
||||
<div>BGM</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center gap-[5px] common-neon-inset'}>
|
||||
<div
|
||||
className={
|
||||
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
|
||||
}
|
||||
>
|
||||
<CircleAlert color={'#57B8BF'} size={16} />
|
||||
<div>ID</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center justify-center px-design-35'}>
|
||||
<div className={'relative flex items-center justify-center'}>
|
||||
<SmartImage
|
||||
src={avatar}
|
||||
alt="avatar"
|
||||
priority
|
||||
className="absolute left-[20px] top-0 z-20 w-[50px] h-[50px]"
|
||||
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'common-neon-inset !py-[20px] flex justify-end items-center w-[160px] h-[36px]'
|
||||
'common-neon-inset !py-design-20 flex h-design-36 w-design-160 items-center justify-end'
|
||||
}
|
||||
>
|
||||
Biomond Balance
|
||||
@@ -75,11 +93,11 @@ export function DesktopHeader() {
|
||||
src={avatar}
|
||||
alt="avatar"
|
||||
priority
|
||||
className="absolute left-[20px] top-0 z-20 w-[50px] h-[50px]"
|
||||
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'common-neon-inset !py-[20px] flex items-center justify-end box-border w-[160px] h-[36px]'
|
||||
'common-neon-inset !py-design-20 box-border flex h-design-36 w-design-160 items-center justify-end'
|
||||
}
|
||||
>
|
||||
Biomond Balance
|
||||
|
||||
54
src/features/game/components/desktop/desktop-status.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import statusCenter from '@/assets/system/status-center.webp'
|
||||
import statusLine from '@/assets/system/status-line.webp'
|
||||
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
|
||||
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
|
||||
|
||||
export function DesktopStatusLine() {
|
||||
return (
|
||||
<div className={'relative w-full flex flex-col text-design-22'}>
|
||||
<div
|
||||
className="w-full h-design-60 bg-no-repeat bg-center flex items-center justify-center"
|
||||
style={{
|
||||
backgroundImage: `url(${statusLine})`,
|
||||
backgroundSize: '100% 100%',
|
||||
}}
|
||||
>
|
||||
<div className={'flex-1 flex items-center justify-center'}>
|
||||
<div>Odds: 1:33</div>
|
||||
<div>Streak: X2</div>
|
||||
<div>Limit: 100</div>
|
||||
</div>
|
||||
<div
|
||||
className="w-design-360 h-[105px] font-countdown bg-no-repeat bg-center bg-contain flex items-center justify-center"
|
||||
style={{ backgroundImage: `url(${statusCenter})` }}
|
||||
>
|
||||
<DesktopCountdown
|
||||
initialSeconds={30}
|
||||
onComplete={() => {
|
||||
console.log('countdown finished')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={'flex-1 flex items-center justify-center gap-10'}>
|
||||
<div>Round ID:20241026120</div>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div
|
||||
className={'w-design-20 h-design-20 bg-[#78FF7F] rounded-[50%]'}
|
||||
></div>
|
||||
<div className={'text-[#78FF7F]'}>OPEN</div>
|
||||
</div>
|
||||
<div>(Menerima Taruhan)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'absolute top-design-60 left-1/2 -translate-x-1/2 -z-10 w-full px-design-16'
|
||||
}
|
||||
>
|
||||
<DesktopTitle />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Megaphone } from 'lucide-react'
|
||||
export function DesktopTitle() {
|
||||
return (
|
||||
<section className="common-neon-inset flex items-center text-[#FF970F] gap-[10px] !px-[20px] h-[40px]">
|
||||
<section className="common-neon-inset text-design-16 w-full flex h-design-50 items-end gap-design-10 !px-design-20 text-[#FF970F]">
|
||||
<Megaphone color={'#57B8BF'} />
|
||||
<div>
|
||||
Selamat kepada pemain Wu Yanzu yang telah memenangkan hadiah utama
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const cx = (...classes: Array<string | false | null | undefined>) =>
|
||||
classes.filter(Boolean).join(' ')
|
||||
import { cn } from '@/lib/untils'
|
||||
|
||||
type GameTone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger'
|
||||
|
||||
@@ -45,7 +44,7 @@ function ModalAction({ label, onClick, tone = 'brand' }: GameOverlayAction) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cx(
|
||||
className={cn(
|
||||
'inline-flex min-h-12 items-center justify-center rounded-full border px-5 text-sm font-semibold tracking-[0.18em] uppercase transition duration-200',
|
||||
actionToneClasses[tone],
|
||||
)}
|
||||
@@ -75,7 +74,7 @@ export function GameAnnouncementModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="game-announcement-title"
|
||||
className={cx(
|
||||
className={cn(
|
||||
'w-full max-w-xl rounded-[32px] border p-6 shadow-[0_40px_120px_-40px_rgba(15,23,42,0.95)] sm:p-7',
|
||||
toneClasses[tone],
|
||||
)}
|
||||
|
||||
@@ -3,14 +3,15 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getMockGameBootstrap, getVisibleAnnouncements } from '@/features/game'
|
||||
import { GameAnnouncementModal } from '@/features/game/components'
|
||||
import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx'
|
||||
import { PcEntry } from '@/features/game/entry/pc-entry.tsx'
|
||||
import { useDocumentMetadata } from '@/lib/head/document-metadata'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
|
||||
const ENABLE_ANNOUNCEMENT_MODAL = false
|
||||
|
||||
export function GameRoutePage() {
|
||||
export function EntryPage() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const announcements = useGameSessionStore((state) => state.announcements)
|
||||
const dismissAnnouncement = useGameSessionStore(
|
||||
(state) => state.dismissAnnouncement,
|
||||
@@ -22,6 +23,13 @@ export function GameRoutePage() {
|
||||
)
|
||||
|
||||
const [isHydrating, setIsHydrating] = useState(true)
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
return window.matchMedia('(max-width: 768px)').matches
|
||||
})
|
||||
|
||||
const activeAnnouncement = useMemo(
|
||||
() =>
|
||||
@@ -69,12 +77,32 @@ export function GameRoutePage() {
|
||||
}
|
||||
}, [hydrateRound, hydrateSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(max-width: 768px)')
|
||||
const syncLayout = (event?: MediaQueryListEvent) => {
|
||||
setIsMobile(event?.matches ?? mediaQuery.matches)
|
||||
}
|
||||
|
||||
syncLayout()
|
||||
mediaQuery.addEventListener('change', syncLayout)
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', syncLayout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-busy={isHydrating}
|
||||
aria-label={t('game.lobbyTitle')}
|
||||
className="flex min-h-0 flex-1"
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{isMobile ? <MobileEntry /> : <PcEntry />}
|
||||
|
||||
<GameAnnouncementModal
|
||||
open={ENABLE_ANNOUNCEMENT_MODAL && Boolean(activeAnnouncement)}
|
||||
eyebrow={t('game.modal.eyebrow')}
|
||||
24
src/features/game/entry/mobile-entry.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
|
||||
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
||||
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
|
||||
|
||||
export function MobileEntry() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={'mx-auto my-design-10 w-[calc(100%-24*var(--design-unit))]'}
|
||||
>
|
||||
<DesktopTitle />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'mx-auto flex w-[calc(100%-24*var(--design-unit))] flex-col gap-design-10'
|
||||
}
|
||||
>
|
||||
<DesktopGameHistory />
|
||||
<DesktopAnimal />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
41
src/features/game/entry/pc-entry.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { DesktopHeader } from '@/features/game/components'
|
||||
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
|
||||
import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx'
|
||||
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
||||
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
|
||||
|
||||
export function PcEntry() {
|
||||
return (
|
||||
<>
|
||||
<DesktopHeader />
|
||||
<div
|
||||
className={
|
||||
'mx-auto mt-design-30 mb-design-60 w-[calc(100%-40*var(--design-unit))]'
|
||||
}
|
||||
>
|
||||
<DesktopStatusLine />
|
||||
</div>
|
||||
|
||||
<div className={'mx-auto w-[calc(100%-72*var(--design-unit))]'}>
|
||||
<div className={'flex w-full items-start gap-design-10'}>
|
||||
<div className={'flex-1'}>
|
||||
<DesktopAnimal />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'flex h-[calc(var(--design-unit)*715)] min-h-0 w-design-370'
|
||||
}
|
||||
>
|
||||
<DesktopGameHistory />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={'mx-auto mt-design-10 w-[calc(100%-40*var(--design-unit))]'}
|
||||
>
|
||||
<DesktopControl />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
6
src/lib/untils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { GameRoutePage } from '@/features/game/entry/game-route-page'
|
||||
import { EntryPage } from '@/features/game/entry/entry-page.tsx'
|
||||
|
||||
export const Route = createFileRoute('/$lang/')({
|
||||
component: GameRoutePage,
|
||||
component: EntryPage,
|
||||
})
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { createFileRoute, Navigate, Outlet } from '@tanstack/react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
|
||||
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
||||
import { DesktopHeader } from '@/features/game/components/desktop/desktop-header.tsx'
|
||||
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
|
||||
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
|
||||
|
||||
export const Route = createFileRoute('/$lang')({
|
||||
@@ -26,28 +22,6 @@ function LanguageLayout() {
|
||||
if (!isValidLanguage) {
|
||||
return <Navigate to="/$lang" params={{ lang: preferredLanguage }} replace />
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-full flex-col w-full">
|
||||
<DesktopHeader />
|
||||
|
||||
<div className={'mx-auto w-[calc(100%-40px)] my-[10px]'}>
|
||||
<DesktopTitle />
|
||||
</div>
|
||||
|
||||
<div className={'mx-auto w-[calc(100%-72px)]'}>
|
||||
<div className={'w-full flex gap-[10px]'}>
|
||||
<div className={'flex-1'}>
|
||||
<DesktopAnimal />
|
||||
</div>
|
||||
<div className={'w-[200px]'}>
|
||||
<DesktopGameHistory />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
11
src/style/font.css
Normal file
@@ -0,0 +1,11 @@
|
||||
@font-face {
|
||||
font-family: "Countdown";
|
||||
src: url("../assets/fonts/countdown.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.font-countdown {
|
||||
font-family: "Countdown", var(--font-sans), sans-serif;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@import "./font.css";
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
@@ -5,6 +6,7 @@
|
||||
"Inter", "SF Pro Display", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
--font-mono:
|
||||
"JetBrains Mono", "SFMono-Regular", "SF Mono", Consolas, monospace;
|
||||
--font-countdown: "Countdown", "Inter", "SF Pro Display", sans-serif;
|
||||
--color-game-bg: #07111f;
|
||||
--color-game-surface: #0d1b2d;
|
||||
--color-game-surface-strong: #12253d;
|
||||
@@ -18,9 +20,97 @@
|
||||
0 20px 60px rgba(2, 6, 23, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
:root {
|
||||
--design-unit: calc(100vw / 1920);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--design-unit: calc(100vw / 375);
|
||||
}
|
||||
}
|
||||
|
||||
@utility w-design-* {
|
||||
width: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility h-design-* {
|
||||
height: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility min-w-design-* {
|
||||
min-width: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility max-w-design-* {
|
||||
max-width: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility text-design-* {
|
||||
font-size: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility gap-design-* {
|
||||
gap: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility px-design-* {
|
||||
padding-inline: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility p-design-* {
|
||||
padding: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility mt-design-* {
|
||||
margin-top: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility mb-design-* {
|
||||
margin-bottom: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility ml-design-* {
|
||||
margin-left: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility mr-design-* {
|
||||
margin-right: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility mx-design-* {
|
||||
margin-left: calc(var(--design-unit) * --value(integer));
|
||||
margin-right: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility py-design-* {
|
||||
padding-top: calc(var(--design-unit) * --value(integer));
|
||||
padding-bottom: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility pl-design-* {
|
||||
padding-left: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility pr-design-* {
|
||||
padding-right: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility my-design-* {
|
||||
margin-block: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility left-design-* {
|
||||
left: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@utility top-design-* {
|
||||
top: calc(var(--design-unit) * --value(integer));
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply h-full w-full;
|
||||
@apply h-full w-full text-design-16;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -48,10 +138,36 @@
|
||||
.common-neon-inset {
|
||||
border: 1px solid rgba(128, 223, 231, 0.65);
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
padding: calc(var(--design-unit) * 8) calc(var(--design-unit) * 10);
|
||||
box-shadow: inset 0 0 8px rgba(128, 223, 231, 0.65);
|
||||
}
|
||||
|
||||
.common-neon-inset-glow {
|
||||
border: 1px solid rgba(128, 223, 231, 0.65);
|
||||
border-radius: 5px;
|
||||
padding: calc(var(--design-unit) * 8) calc(var(--design-unit) * 10);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(18, 44, 58, 0.92), rgba(8, 22, 34, 0.9)),
|
||||
radial-gradient(circle at top, rgba(128, 223, 231, 0.16), transparent 62%);
|
||||
box-shadow:
|
||||
inset 0 0 calc(var(--design-unit) * 8) rgba(128, 223, 231, 0.72),
|
||||
inset 0 0 calc(var(--design-unit) * 18) rgba(128, 223, 231, 0.18),
|
||||
0 0 calc(var(--design-unit) * 6) rgba(128, 223, 231, 0.35),
|
||||
0 0 calc(var(--design-unit) * 18) rgba(128, 223, 231, 0.24);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.common-neon-inset-glow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 1px;
|
||||
border-radius: 4px;
|
||||
border-top: 1px solid rgba(214, 249, 252, 0.8);
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-panel {
|
||||
border: 1px solid rgba(124, 232, 255, 0.12);
|
||||
background: linear-gradient(
|
||||
@@ -74,4 +190,41 @@
|
||||
0 0 0 1px rgba(245, 200, 107, 0.2),
|
||||
0 18px 40px rgba(245, 200, 107, 0.14);
|
||||
}
|
||||
|
||||
.desktop-game-history-glow {
|
||||
filter: drop-shadow(
|
||||
0 0 calc(var(--design-unit) * 4) rgba(49, 208, 255, 0.65)
|
||||
)
|
||||
drop-shadow(0 0 calc(var(--design-unit) * 14) rgba(49, 208, 255, 0.28));
|
||||
}
|
||||
|
||||
.desktop-control-chip {
|
||||
margin-left: calc(var(--design-unit) * -4);
|
||||
margin-right: calc(var(--design-unit) * -18);
|
||||
}
|
||||
|
||||
.desktop-control-total {
|
||||
margin-left: calc(var(--design-unit) * -26);
|
||||
margin-right: calc(var(--design-unit) * -26);
|
||||
}
|
||||
|
||||
.desktop-control-actions {
|
||||
margin-left: calc(var(--design-unit) * -18);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.desktop-control-confirm {
|
||||
margin-left: calc(var(--design-unit) * -10);
|
||||
}
|
||||
|
||||
.history-scroll-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.history-scroll-hidden::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||