diff --git a/docs/36字花-useGameBoardVm-数据层实施说明.md b/docs/36字花-useGameBoardVm-数据层实施说明.md new file mode 100644 index 0000000..bca81b1 --- /dev/null +++ b/docs/36字花-useGameBoardVm-数据层实施说明.md @@ -0,0 +1,248 @@ +# 36字花 useGameBoardVm 数据层实施说明 + +## 1. 目标 + +本说明只服务下一步开发:实现 `useGameBoardVm.ts`。 + +当前目标很单一: + +- 让桌面选号盘从 store 读取真实业务数据 +- 让点击格子时真正触发下注动作 +- 不在 [desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) 内写业务逻辑 +- 不同时改造 mobile + +本阶段不做: + +- 不改造 mobile +- 不新增 `game-ui-store` +- 不重写 `DesktopAnimal` 整个展示结构 +- 不处理 auto-spin / modal + +--- + +## 2. 相关文件 + +本次只围绕以下文件展开: + +- [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) +- [src/features/game/entry/pc-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/pc-entry.tsx) +- [src/store/game/game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts) +- [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts) + +将新增: + +- `src/features/game/hooks/use-game-board-vm.ts` + +--- + +## 3. 当前问题 + +当前 [pc-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/pc-entry.tsx) 里虽然已经挂载了 `DesktopAnimal`,但还没有把桌面主玩法接到业务链路。 + +当前状态: + +- `DesktopAnimal` 只是展示组件 +- `DesktopAnimal` 支持 `activeId` 和 `onSelect` +- `PcEntry` 直接渲染 `` +- 点击格子不会写入真实下注状态 + +结果是: + +- 控制栏虽然已经接入了部分业务数据 +- 状态栏、历史区也开始接入 store +- 但桌面最核心的“点击动物下注”链路还没有打通 + +--- + +## 4. useGameBoardVm 的职责 + +`useGameBoardVm` 只做 3 件事: + +1. 从 `game-round-store` 读取选号盘需要的业务数据 +2. 组织出桌面选号盘可以直接消费的 view-model +3. 暴露点击格子的业务动作 + +它不负责: + +- 直接渲染 UI +- 做 hover / 动画状态 +- 控制 modal +- 处理移动端布局 + +--- + +## 5. 数据来源 + +`useGameBoardVm` 第一版只从 [game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts) 读取: + +- `cells` +- `round` +- `selections` +- `trends` +- `placeBet` + +可复用的派生逻辑来自 [selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts): + +- `buildGameCellViewModels` + +--- + +## 6. 第一版输出字段 + +第一版不追求一步到位,输出保持最小可用。 + +建议 `useGameBoardVm` 返回: + +```ts +{ + cells: GameCellViewModel[] + activeId: number | null + canPlaceBets: boolean + onCellPress: (cellId: number) => void +} +``` + +### 字段说明 + +#### `cells` + +来源: + +- `buildGameCellViewModels({ cells, round, selections, trends })` + +作用: + +- 给未来第二版 board 组件升级时使用 +- 即使第一版 `DesktopAnimal` 还没完全吃它,也应该先在 hook 里产出来 + +#### `activeId` + +第一版定义: + +- 当前有下注的最后一个格子 id +- 如果没有任何下注,则为 `null` + +作用: + +- 兼容当前 [desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) 现有接口 +- 因为这个组件当前只支持单个 `activeId`,还不支持多个已选格子 + +#### `canPlaceBets` + +定义: + +- `round.phase === 'betting'` + +作用: + +- 控制点击是否真正触发下注 +- 也为后续 UI 禁用态预留 + +#### `onCellPress` + +定义: + +- 当 `canPlaceBets === true` 时,调用 `placeBet(cellId)` +- 否则不执行 + +--- + +## 7. 第一版实现规则 + +### 7.1 不在 DesktopAnimal 内直接读 store + +[desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) 必须继续保持展示组件定位。 + +不应该在里面直接写: + +- `useGameRoundStore` +- `placeBet` +- `buildGameCellViewModels` +- `round.phase` 判断 + +### 7.2 业务写在 hook,UI 只接 props + +推荐接线方式: + +在 [pc-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/pc-entry.tsx) 中: + +```tsx +const { activeId, onCellPress } = useGameBoardVm() + + +``` + +### 7.3 第一版先兼容现有 DesktopAnimal 接口 + +当前 `DesktopAnimal` 只支持: + +- `activeId?: number | null` +- `onSelect?: (animalId: number) => void` + +所以第一版不要强行重做它的 props 结构。 + +先兼容现状,把业务链路打通即可。 + +--- + +## 8. 第一版文件改动范围 + +本次改动建议控制在 2 到 3 个文件: + +1. 新增 `src/features/game/hooks/use-game-board-vm.ts` +2. 修改 [pc-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/pc-entry.tsx) +3. 如有必要,微调 [desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) + +其中第 3 项不是必须,优先争取只改前两个文件。 + +--- + +## 9. 第一版完成标准 + +完成后应满足: + +- `PcEntry` 不再裸挂 `` +- `PcEntry` 通过 `useGameBoardVm` 把选号盘接到 store +- 点击格子会真实调用下注动作 +- 控制栏中的总下注额会随点击变化 +- `DesktopAnimal` 仍然保持展示组件定位 + +--- + +## 10. 第二版演进方向 + +第一版做完后,下一版再考虑升级 `DesktopAnimal` 接口。 + +当前限制: + +- `DesktopAnimal` 只能高亮一个 `activeId` + +第二版建议升级为: + +```ts +{ + items: GameCellViewModel[] + onSelect: (cellId: number) => void +} +``` + +这样就可以支持: + +- 多个已下注格子同时高亮 +- 依据 `status` 区分 `betting / selected / won / lost` +- PC 和 Mobile 共用同一份 board view-model + +但这些都不属于当前这一步。 + +--- + +## 11. 结论 + +当前最正确的操作顺序是: + +1. 先写 `useGameBoardVm.ts` +2. 再在 `pc-entry.tsx` 中接入 +3. 暂时不把业务写进 `desktop-animal.tsx` + +这样可以在最小改动范围内,把桌面端最核心的“点击格子下注”链路打通,并继续保持“数据层在外、UI 层在内”的改造方向。 + diff --git a/docs/36字花-核心玩法与前端规则摘要.md b/docs/36字花-核心玩法与前端规则摘要.md new file mode 100644 index 0000000..c8fe684 --- /dev/null +++ b/docs/36字花-核心玩法与前端规则摘要.md @@ -0,0 +1,396 @@ +# 36字花核心玩法与前端规则摘要 + +## 1. 项目定位 + +36字花是一个**单期开奖结果、单期号循环运行**的实时开奖类游戏。 + +前端的核心界面是: + +- 36 宫格下注盘 +- 倒计时与当前期状态栏 +- 筹码与确认下注区 +- 开奖历史与走势信息 +- 公告、规则、自动托管、充值提现等外围模块 + +产品运行原则: + +- 全平台共享同一局数据 +- `PC` 与 `Mobile` 只分界面,不分玩法、不分对局 +- 前端必须以服务端状态为准,不能靠本地时间自行判断开奖或封盘 + +--- + +## 2. 核心玩法 + +### 2.1 基本盘面 + +- 游戏共有 **36 个号码格子** +- 每个格子代表一个“字花编号” +- 编号范围为 **1-36** +- 当前前端盘面布局为 **6 x 6** + +相关代码与约束: + +- [src/features/game/shared/constants.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/constants.ts) +- `GAME_GRID_ROWS = 6` +- `GAME_GRID_COLUMNS = 6` +- `GAME_TOTAL_CELLS = 36` + +### 2.2 开奖规则 + +- 每一期只会开出 **1 个中奖号码** +- 开奖号码属于 1-36 中的一个 +- 用户只要下注号码集合中包含该号码,即视为中奖 + +接口口径见: + +- [docs/36字花-移动端接口设计草案.md](/Users/jiaunun/Desktop/36-character-flower/docs/36字花-移动端接口设计草案.md) +- `POST /api/game/placeBet` + +文档原意: + +- 玩家提交的是“号码集合 + 单注金额” +- 系统按“单注金额 × 号码数量”计算本笔总扣款 +- 开奖后只出一个号码 +- 若该号码命中玩家所选集合,则该笔下注中奖 + +--- + +## 3. 下注模型 + +### 3.1 单注结构 + +一笔下注至少包含: + +- `period_no`:下注目标期号 +- `numbers`:本次下注号码集合 +- `single_bet_amount`:单个号码的下注金额 + +说明: + +- `numbers` 是一个号码集合,而不是单个号码 +- 多选号码时,总扣款 = `single_bet_amount × numbers数量` +- 重复号码应去重 + +### 3.2 前端当前数据模型 + +当前前端 store 中的单笔选择数据为: + +- `cellId` +- `chipId` +- `amount` +- `placedAt` +- `source` + +定义见: + +- [src/features/game/shared/types.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/types.ts) +- `BetSelection` + +当前本地 mock 与 store 落地方式是: + +- 每点击一个格子,会追加一条 `selection` +- 每条 `selection` 对应一个格子和一个筹码金额 + +这与接口文档中的“号码集合提交”并不完全一致。 + +因此当前前端可以先采用如下理解: + +- UI 交互阶段:按“逐格选择”记录本地状态 +- 提交到后端阶段:再把本地多个格子聚合成 `numbers` + +这是后续 `confirm bet` 需要承担的转换逻辑。 + +--- + +## 4. 回合状态机 + +### 4.1 当前状态枚举 + +当前项目中定义的回合阶段为: + +- `waiting` +- `betting` +- `locked` +- `revealing` +- `settled` + +定义见: + +- [src/features/game/shared/constants.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/constants.ts) + +接口文档中的状态口径包括: + +- `betting` +- `locked` +- `settling` +- `finished` +- `void` + +说明: + +- 当前前端内部状态和接口文档状态还未完全统一 +- 第一阶段前端可继续沿用内部状态机 +- 后续真实联调时,要补一层接口状态 -> 前端状态的映射 + +### 4.2 各阶段前端规则 + +#### `BETTING` + +- 允许选格子 +- 允许切换筹码 +- 允许清除当前选择 +- 允许重复上一注 +- 允许确认下注 +- 允许开启自动托管 + +#### `LOCKED` + +- 已封盘 +- 前端必须立即停止下注交互 +- 不应等待后端返回后才禁用点击 + +#### `REVEALING / SETTLING` + +- 等待开奖与派彩 +- 展示开奖结果、跑马灯、中奖态 + +#### `SETTLED / FINISHED` + +- 本期结束 +- 准备进入下一轮 +- 历史、走势、余额等应刷新 + +#### `VOID` + +- 本期作废 +- 需要退款待开奖本金 +- 前端要清理本期下注态并正确提示 + +--- + +## 5. 封盘与倒计时规则 + +### 5.1 核心原则 + +- 前端必须以服务端返回的时间和阶段为准 +- 到达封盘时间点时,前端应立即锁盘 +- 即使网络延迟,也不能继续允许下注交互 + +这点在需求文档中是明确要求: + +- [docs/frontend-baseline-requirements.md](/Users/jiaunun/Desktop/36-character-flower/docs/frontend-baseline-requirements.md) + +### 5.2 当前前端倒计时模型 + +当前前端使用: + +- `round.bettingClosesAt` +- `round.revealingAt` +- `round.settledAt` + +并通过: + +- [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts) +- `getRoundCountdownMs(round)` + +来计算当前倒计时毫秒数。 + +规则如下: + +- `waiting / betting`:倒计时到 `bettingClosesAt` +- `locked / revealing`:倒计时到 `revealingAt` +- 其余:倒计时到 `settledAt` + +### 5.3 Mock 数据口径 + +当前 mock 数据中: + +- 开始后约 18 秒封盘 +- 约 24 秒开奖 +- 约 30 秒结算 + +来源: + +- [src/features/game/shared/mock-data.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/mock-data.ts) + +这只是前端演示节奏,不代表最终真实服务端配置。 + +--- + +## 6. 赔率、金额与筹码规则 + +### 6.1 当前赔率 + +当前 mock 中每个格子赔率固定为: + +- `36` + +来源: + +- [src/features/game/shared/mock-data.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/mock-data.ts) +- `createGameCells()` + +### 6.2 当前默认筹码 + +当前默认筹码面额为: + +- `10` +- `25` +- `50` +- `100` +- `200` +- `500` + +来源: + +- [src/features/game/shared/constants.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/constants.ts) +- `DEFAULT_GAME_CHIP_AMOUNTS` + +### 6.3 下注限制 + +从接口文档与基线需求中可得出以下前端规则: + +- 单次下注号码数量不得超过 `pick_max_number_count` +- 单号码下注金额不得超过 `single_number_max_bet` +- 总下注额不得超过余额 +- 封盘后不能继续下注 + +文档来源: + +- [docs/36字花-移动端接口设计草案.md](/Users/jiaunun/Desktop/36-character-flower/docs/36字花-移动端接口设计草案.md) +- [docs/frontend-baseline-requirements.md](/Users/jiaunun/Desktop/36-character-flower/docs/frontend-baseline-requirements.md) + +--- + +## 7. 中奖、历史与走势 + +### 7.1 历史记录 + +每期开奖后应产生一条历史记录,至少包括: + +- `roundId` +- `winningCellId` +- `settledAt` +- `payoutMultiplier` +- `totalPoolAmount` + +定义见: + +- [src/features/game/shared/types.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/types.ts) +- `HistoryEntry` + +### 7.2 走势 + +前端会基于历史数据派生走势信息,包括: + +- `currentStreak` +- `hitCount` +- `missCount` +- `direction` +- `lastHitRoundId` + +派生逻辑见: + +- [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts) +- `deriveTrendEntries(history)` + +说明: + +- 走势属于纯派生展示数据 +- 不应由 UI 组件自己重复计算 + +--- + +## 8. 公告、维护与运行开关 + +### 8.1 运行开关 + +接口文档中存在: + +- `runtime_enabled` + +含义: + +- `true`:游戏正常运行 +- `false`:后台维护中,禁止下注 + +规则: + +- 维护中不允许新下注 +- 当前已开盘的一局仍可正常开奖和派彩 +- 前端应禁用下注入口并提示维护状态 + +### 8.2 公告 + +公告是前端大厅的一部分,但不属于主玩法。 + +当前前端已存在公告模型: + +- `AnnouncementState` +- `AnnouncementItem` + +它们属于会话层状态,不应混入下注与回合逻辑。 + +--- + +## 9. 自动托管 + +接口文档中已定义自动托管能力: + +- `POST /api/game/autoSpin` + +入参包括: + +- `action` +- `period_no` +- `numbers` +- `single_bet_amount` +- `rounds` + +说明: + +- 自动托管属于建立在主玩法之上的扩展能力 +- 它依赖同一套选号与下注规则 +- 当前前端可以先保留 UI 壳层,不需要在主玩法没走通前抢先落业务 + +--- + +## 10. 前端当前最应该优先走通的主玩法链路 + +基于上述规则,当前前端最核心、最应该优先走通的是: + +1. 状态栏拿到当前期状态与倒计时 +2. 控制栏拿到当前筹码与总下注额 +3. 选号盘点击格子后写入本地下注选择 +4. 总下注额、选中数量、选号高亮联动刷新 +5. 后续再补确认下注请求与开奖结果回写 + +这也是为什么当前下一步应优先实现: + +- `useGameBoardVm` + +而不是优先改 `mobile` 或外围弹窗。 + +--- + +## 11. 总结 + +36字花这个项目的核心,不是“36 张图摆出来”,而是下面这条实时对局链路: + +- 同一局 +- 同一倒计时 +- 同一开奖结果 +- 36 格可选号码 +- 用户用统一筹码模型下注 +- 封盘、开奖、派奖按服务端状态推进 + +前端实现时必须坚持两点: + +1. **玩法规则统一** + - PC 和 Mobile 只能换壳,不能换规则 + +2. **服务端状态优先** + - 前端可以先做本地交互反馈 + - 但回合状态、封盘、开奖、派彩都必须最终以服务端为准 + diff --git a/docs/36字花-游戏模块数据与界面分层第一阶段实施稿.md b/docs/36字花-游戏模块数据与界面分层第一阶段实施稿.md new file mode 100644 index 0000000..98caabb --- /dev/null +++ b/docs/36字花-游戏模块数据与界面分层第一阶段实施稿.md @@ -0,0 +1,846 @@ +# 36字花游戏模块数据与界面分层第一阶段实施稿 + +## 1. 目标 + +第一阶段的目标不是做一次彻底重构,而是先把当前项目中的“业务数据”和“界面渲染”拆出清晰边界,让同一套数据和交互逻辑可以同时服务 `PC` 界面与 `Mobile` 界面。 + +本阶段只解决以下问题: + +- 一套业务数据,供 `PC` 与 `Mobile` 共同消费 +- 统一业务交互逻辑,避免双端各自维护一份 +- 保持 `PC` 与 `Mobile` 布局、样式、视觉独立 +- 不大范围推翻现有组件,优先在现有代码上增量改造 + +本阶段暂不追求: + +- 所有组件完全抽象成跨端共享组件 +- 一次性去掉所有本地 `useState` +- 一次性重写所有弹窗和表单 +- 大规模目录迁移导致整仓成本过高 + + +## 2. 当前代码现状 + +当前仓库中已经存在一部分良好的基础设施: + +- 数据请求与 DTO 归一化: + - [src/features/game/api/game-api.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/api/game-api.ts) +- 共享领域类型与派生逻辑: + - [src/features/game/shared/types.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/types.ts) + - [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts) +- 回合与会话状态: + - [src/store/game/game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts) + - [src/store/game/game-session-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-session-store.ts) +- 页面入口已统一在: + - [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/entry-page.tsx) + +当前主要问题有 4 个: + +1. `Mobile` 界面直接复用了 `Desktop` 组件 + 例如 [src/features/game/entry/mobile-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/mobile-entry.tsx) 当前直接引用了 `DesktopAnimal`、`DesktopGameHistory`、`DesktopTitle`。这会导致移动端无法形成独立布局层。 + +2. 业务状态和界面状态混杂 + 一些状态是业务性的,例如当前选中筹码、是否允许下注;另一些只是表现性的,例如按钮点击动画、过渡效果。当前这两种状态没有被清晰拆开。 + +3. 组件层直接拼 store 数据或自己维护业务数据 + 例如 [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx) 内部自己维护了部分核心控制状态,导致未来 `Mobile` 难以复用。 + +4. 弹窗与 UI 业务状态分散在各组件本地 `useState` + 比如 `auto setting`、`notice`、`user info`、`procedures` 等弹窗,当前都在各自文件内部自管 `open` 或 tab 状态,不利于双端共享和统一调度。 + +--- + +## 3. 第一阶段总体原则 + +第一阶段采用四层结构: + +1. `api / dto / normalize` +2. `store / domain actions` +3. `view-model hooks` +4. `pc ui` 与 `mobile ui` + +每层职责如下: + +### 3.1 API 层 + +职责: + +- 调用后端接口 +- DTO 转前端领域模型 +- 不参与 UI 展示逻辑 + +保留现状: + +- [src/features/game/api/game-api.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/api/game-api.ts) + +### 3.2 Store 层 + +职责: + +- 保存当前页面共享的真实业务状态 +- 提供业务动作 +- 不直接处理视觉布局 + +### 3.3 View-model hooks 层 + +职责: + +- 从 store 中读取数据 +- 聚合派生字段 +- 封装业务事件 +- 返回给 `PC` 与 `Mobile` 可直接渲染的数据结构 + +这层是本次改造的核心。 + +### 3.4 UI 层 + +职责: + +- 只负责布局、视觉、交互表现 +- `PC` 与 `Mobile` 各自独立 +- 不直接拼底层 store + +--- + +## 4. 第一阶段目录改造清单 + +建议目录结构如下。第一阶段以“新增”为主,不要求立刻大规模迁移原文件。 + +```text +src/features/game/ + api/ + game-api.ts + index.ts + types.ts + + shared/ + constants.ts + mock-data.ts + selectors.ts + types.ts + + hooks/ + use-game-header-vm.ts + use-game-status-vm.ts + use-game-board-vm.ts + use-game-history-vm.ts + use-game-control-vm.ts + use-game-announcement-vm.ts + use-game-auto-spin-vm.ts + index.ts + + views/ + pc/ + pc-entry.tsx + mobile/ + mobile-entry.tsx + + components/ + shared/ + game-board.tsx + game-history-list.tsx + game-status-bar.tsx + game-control-panel.tsx + desktop/ + mobile/ + + modal/ + desktop/ + mobile/ +``` + +Store 目录建议补齐成: + +```text +src/store/game/ + game-round-store.ts + game-session-store.ts + game-ui-store.ts + index.ts +``` + +说明: + +- 第一阶段可以继续保留现有 `components/desktop` 与 `entry/` 目录 +- `views/pc` 与 `views/mobile` 可以先作为新目录开始承接之后的容器入口 +- `components/shared` 第一阶段只抽少量共用展示结构,不强行全部收拢 + +--- + +## 5. 第一阶段 Store 设计 + +### 5.1 `game-round-store` + +文件: + +- [src/store/game/game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts) + +该 store 保留为“回合与下注核心业务状态”。 + +#### 现有字段 + +- `cells` +- `chips` +- `history` +- `round` +- `selections` +- `trends` +- `activeChipId` + +#### 现有动作 + +- `hydrateRound` +- `selectChip` +- `placeBet` +- `removeSelectionsForCell` +- `clearSelections` +- `setPhase` +- `syncRound` +- `upsertSelections` + +#### 第一阶段建议新增动作 + +- `setActiveChip(chipId: string)` +- `replaceHistory(history: HistoryEntry[])` +- `replaceTrends(trends: TrendEntry[])` +- `replaceCells(cells: GameCell[])` + +说明: + +- 如果不想增加重复动作,也可以只统一命名 `selectChip` +- 新增 `replaceHistory / replaceTrends / replaceCells` 是为了后续轮询、增量同步更清晰 + +#### 不应该放在这里的内容 + +- 各类 modal 的打开关闭 +- 用户信息 tab +- auto-spin 面板设置 +- 登录注册表单 +- 按钮 hover / 点击动画态 + +--- + +### 5.2 `game-session-store` + +文件: + +- [src/store/game/game-session-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-session-store.ts) + +该 store 保留为“会话、连接、公告、面板摘要信息”。 + +#### 现有字段 + +- `announcements` +- `connection` +- `dashboard` + +#### 现有动作 + +- `hydrateSession` +- `dismissAnnouncement` +- `markAnnouncementRead` +- `setConnectionLatency` +- `setConnectionStatus` +- `syncConnection` +- `syncDashboard` + +#### 第一阶段建议新增字段 + +- `serverTimeIso: string | null` +- `lastBootstrapAt: string | null` + +#### 第一阶段建议新增动作 + +- `setServerTime(serverTimeIso: string)` +- `setLastBootstrapAt(iso: string)` + +说明: + +- [src/features/game/components/desktop/desktop-header.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-header.tsx) 当前展示系统时间,后续建议统一从这里拿 +- `lastBootstrapAt` 方便后续做重连、刷新、数据同步判断 + +--- + +### 5.3 新增 `game-ui-store` + +新文件建议: + +- `src/store/game/game-ui-store.ts` + +该 store 专门管理“共享的 UI 业务状态”。 + +#### 建议字段 + +- `activeModal: GameModalType | null` +- `modalPayload: Record | null` +- `selectedUserInfoTab: 'profile' | 'message'` +- `isBgmEnabled: boolean` +- `isMuted: boolean` +- `autoSpinEnabled: boolean` +- `autoSpinSettings` + - `stopIfBalanceLowerThan: string` + - `stopIfSingleWinExceeds: string` + - `stopOnAnyJackpot: boolean` +- `authForm` + - `loginAccount: string` + - `loginPassword: string` + - `registerAccount: string` + - `registerPassword: string` + - `registerConfirmPassword: string` +- `proceduresType: 'withdraw' | 'topup' | null` + +#### 建议动作 + +- `openModal(type, payload?)` +- `closeModal()` +- `setSelectedUserInfoTab(tab)` +- `toggleBgm()` +- `setAutoSpinEnabled(enabled)` +- `setAutoSpinSetting(key, value)` +- `setAuthField(field, value)` +- `setProceduresType(type)` +- `resetAuthForm()` +- `resetAutoSpinSettings()` + +#### 第一阶段要接管的现有状态来源 + +- [src/features/game/modal/desktop/desktop-auto-setting-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx) +- [src/features/game/modal/desktop/desktop-notice-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-notice-modal.tsx) +- [src/features/game/modal/desktop/desktop-userInfo-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-userInfo-modal.tsx) +- [src/features/game/modal/desktop/desktop-procedures-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-procedures-modal.tsx) +- [src/features/game/modal/desktop/desktop-login-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-login-modal.tsx) +- [src/features/game/modal/desktop/desktop-register-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-register-modal.tsx) +- [src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx) + +#### 不要放到 `game-ui-store` 的状态 + +- `confirmClicked` +- `clickedId` +- `hidingId` + +这些状态目前位于 [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx),属于纯动画与表现状态,继续留在本地组件即可。 + +--- + +## 6. 第一阶段 Hook 设计 + +本阶段重点不是“先抽 shared 组件”,而是先抽统一的 `view-model hooks`。 + +这些 hook 的目标: + +- 统一封装 store 读取 +- 统一组织派生字段 +- 统一输出 PC/Mobile 都能消费的 props + +--- + +### 6.1 `useGameHeaderVm` + +建议文件: + +- `src/features/game/hooks/use-game-header-vm.ts` + +服务组件: + +- [src/features/game/components/desktop/desktop-header.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-header.tsx) +- 未来的 `mobile-header.tsx` + +#### 建议暴露字段 + +- `latencyMs: number | null` +- `latencyLabel: string` +- `connectionStatus: 'connected' | 'reconnecting' | 'offline'` +- `systemTimeLabel: string` +- `username: string` +- `balanceLabel: string` +- `avatarUrl: string | null` +- `unreadMessageCount: number` +- `isBgmEnabled: boolean` + +#### 建议暴露动作 + +- `onOpenRules()` +- `onOpenMessages()` +- `onToggleBgm()` +- `onOpenProfile()` + +#### 说明 + +- `DesktopHeader` 未来不再直接读取 store 或写死文案来源 +- header 只消费“最终展示值”与“用户点击后的动作” + +--- + +### 6.2 `useGameStatusVm` + +建议文件: + +- `src/features/game/hooks/use-game-status-vm.ts` + +服务组件: + +- [src/features/game/components/desktop/desktop-status.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-status.tsx) + +#### 建议暴露字段 + +- `roundId: string` +- `oddsLabel: string` +- `streakLabel: string` +- `limitLabel: string` +- `countdownSeconds: number` +- `countdownMs: number` +- `phase: RoundPhase` +- `phaseLabel: string` +- `phaseTone: 'open' | 'locked' | 'settled'` +- `acceptingBets: boolean` + +#### 说明 + +- 倒计时展示逻辑、回合阶段文案逻辑统一在这里处理 +- `PC` 和 `Mobile` 不各自重复拼 countdown 与 round phase + +--- + +### 6.3 `useGameBoardVm` + +建议文件: + +- `src/features/game/hooks/use-game-board-vm.ts` + +服务组件: + +- [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) +- 未来 `mobile-board.tsx` + +#### 建议暴露字段 + +- `cells` + - `id` + - `imageUrl` + - `status` + - `isSelected` + - `isWinningCell` + - `selectionAmount` + - `selectionCount` + - `hitCount` + - `currentStreak` +- `activeCellId: number | null` +- `canPlaceBets: boolean` +- `totalSelectedCount: number` +- `totalSelectedAmount: number` + +#### 建议暴露动作 + +- `onCellPress(cellId: number)` +- `onCellLongPress?(cellId: number)` +- `onCellClear(cellId: number)` + +#### 说明 + +- 优先基于 [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts) 中的: + - `buildGameCellViewModels` + - `groupSelectionsByCell` + - `getSelectionTotal` +- `DesktopAnimal` 不应该自己管理业务选择逻辑,只负责渲染 cell grid + +--- + +### 6.4 `useGameHistoryVm` + +建议文件: + +- `src/features/game/hooks/use-game-history-vm.ts` + +服务组件: + +- [src/features/game/components/desktop/desktop-game-history.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-game-history.tsx) +- 未来 `mobile-history.tsx` + +#### 建议暴露字段 + +- `items` + - `orderNo` + - `roundId` + - `winningCellId` + - `betAmountLabel` + - `totalAmountLabel` + - `winAmountLabel` + - `statusLabel` + - `createdAtLabel` +- `isEmpty: boolean` +- `emptyText: string` +- `recentWinningCellIds: number[]` + +#### 说明 + +- 当前 [src/features/game/components/desktop/desktop-game-history.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-game-history.tsx) 里还是本地 fake data,第一阶段应切换为 VM 数据 +- `PC` 与 `Mobile` 历史列表的数据格式统一,只是布局不同 + +--- + +### 6.5 `useGameControlVm` + +建议文件: + +- `src/features/game/hooks/use-game-control-vm.ts` + +服务组件: + +- [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx) +- 未来 `mobile-control.tsx` + +#### 建议暴露字段 + +- `chips` + - `id` + - `value` + - `amount` + - `color` + - `isSelected` + - `isDefault` +- `selectedChipId: string | null` +- `selectedChipAmountLabel: string` +- `selectionTotalLabel: string` +- `selectedCountLabel: string` +- `actionButtons` + - `id` + - `label` + - `disabled` +- `canConfirm: boolean` +- `canClear: boolean` +- `canRepeat: boolean` +- `canOpenAutoSpin: boolean` + +#### 建议暴露动作 + +- `onChipSelect(chipId: string)` +- `onAddChip()` +- `onReduceChip()` +- `onClearSelections()` +- `onRepeatLastRound()` +- `onOpenAutoSpin()` +- `onConfirmBet()` + +#### 说明 + +- [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx) 内部仍可保留点击动画和短暂视觉反馈 +- 但“当前选中筹码、是否可确认、操作是否禁用”等必须来自 VM,而不是继续散在组件内部 + +--- + +### 6.6 `useGameAnnouncementVm` + +建议文件: + +- `src/features/game/hooks/use-game-announcement-vm.ts` + +服务组件: + +- [src/features/game/components/shared/game-announcement-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/game-announcement-modal.tsx) +- header 消息入口 +- notice modal + +#### 建议暴露字段 + +- `activeAnnouncement: AnnouncementItem | null` +- `visibleAnnouncements: AnnouncementItem[]` +- `unreadCount: number` +- `hasUnread: boolean` +- `isOpen: boolean` + +#### 建议暴露动作 + +- `onOpenAnnouncement(id?: string)` +- `onDismissAnnouncement(id: string)` +- `onMarkRead(id: string)` +- `onCloseAnnouncement()` + +#### 说明 + +- 这样公告逻辑不会散落在 [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/entry-page.tsx) 和多个具体 modal 中 + +--- + +### 6.7 `useGameAutoSpinVm` + +建议文件: + +- `src/features/game/hooks/use-game-auto-spin-vm.ts` + +服务组件: + +- [src/features/game/modal/desktop/desktop-auto-setting-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx) +- 未来 `mobile-auto-setting-modal.tsx` + +#### 建议暴露字段 + +- `open: boolean` +- `enabled: boolean` +- `stopIfBalanceLowerThan: string` +- `stopIfSingleWinExceeds: string` +- `stopOnAnyJackpot: boolean` +- `canSubmit: boolean` + +#### 建议暴露动作 + +- `onOpen()` +- `onClose()` +- `onToggleEnabled(enabled: boolean)` +- `onChangeBalanceThreshold(value: string)` +- `onChangeSingleWinThreshold(value: string)` +- `onToggleStopOnJackpot(enabled: boolean)` +- `onSubmit()` + +#### 说明 + +- 这是最适合第一阶段打样的一类场景:数据结构固定、PC/Mobile 表现不同、交互规则相同 + +--- + +## 7. 第一阶段共享展示组件建议 + +本阶段不要一口气把所有 `Desktop` 组件变成跨端共享。 + +建议只优先抽 2 到 3 个 shared 展示组件: + +- `src/features/game/components/shared/game-board.tsx` +- `src/features/game/components/shared/game-history-list.tsx` +- `src/features/game/components/shared/game-status-bar.tsx` + +### 7.1 `game-board` + +职责: + +- 只接收 `cells` +- 只接收 `onCellPress` +- 不直接读 store + +这样: + +- `PC` 可以包一层桌面 grid 样式 +- `Mobile` 可以包一层移动端布局样式 + +### 7.2 `game-history-list` + +职责: + +- 只负责历史列表渲染 +- 不关心历史数据怎么来 + +### 7.3 `game-status-bar` + +职责: + +- 渲染赔率、回合、倒计时、当前状态 +- 由 `useGameStatusVm` 驱动 + +不建议第一阶段就抽 shared 的内容: + +- header +- control panel +- 大多数 modal + +因为这些区域 `PC/Mobile` 差异通常更大,先共用 hook 比先共用视觉组件更稳。 + +--- + +## 8. 第一阶段 UI 层改造边界 + +本阶段只定 3 条规则: + +### 8.1 `EntryPage` 可以直接处理 hydration + +文件: + +- [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/entry-page.tsx) + +保留它作为: + +- bootstrap 获取入口 +- store hydrate 入口 +- PC/Mobile 分流入口 + +### 8.2 容器组件可以调用 VM hooks + +例如: + +- `PcEntry` +- `MobileEntry` +- 各 section 容器 +- modal host + +这些组件可以: + +- `useGameBoardVm()` +- `useGameControlVm()` +- `useGameStatusVm()` + +### 8.3 纯展示组件不直接碰 store + +比如: + +- [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) +- [src/features/game/components/desktop/desktop-game-history.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-game-history.tsx) +- [src/features/game/components/desktop/desktop-status.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-status.tsx) +- [src/features/game/components/desktop/desktop-header.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-header.tsx) + +未来都应该尽量演进为: + +- 只吃 props +- 不主动拼底层 store + +--- + +## 9. 第一阶段实施顺序 + +建议严格按这个顺序执行,避免改动面过大。 + +### Step 1:新增 UI Store 与 Hooks 目录 + +新增: + +- `src/store/game/game-ui-store.ts` +- `src/features/game/hooks/` + +这一步只建壳子,不急着接业务。 + +### Step 2:实现 `useGameBoardVm` 与 `useGameControlVm` + +这两块是主玩法交互核心,优先收益最大。 + +优先接的现有文件: + +- [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) +- [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx) + +### Step 3:让 `DesktopAnimal` 只吃 props + +目标: + +- 不直接读 store +- 不自己处理业务选择逻辑 + +保留字段形态: + +- `cells` +- `activeId` +- `onSelect` + +### Step 4:让 `DesktopControl` 业务数据来自 VM + +目标: + +- 当前筹码列表、已选筹码、总下注额等来自 `useGameControlVm` +- 点击动画状态仍然保留本地 + +### Step 5:停止 `Mobile` 直接复用 `Desktop` 组件 + +文件: + +- [src/features/game/entry/mobile-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/mobile-entry.tsx) + +当前问题: + +- 直接 import `DesktopAnimal` +- 直接 import `DesktopGameHistory` +- 直接 import `DesktopTitle` + +第一阶段目标: + +- 改成 `MobileBoardSection` +- 改成 `MobileHistorySection` +- 共享 hook,不共享 `Desktop` 布局组件 + +### Step 6:实现 `useGameStatusVm` 与 `useGameHeaderVm` + +接入文件: + +- [src/features/game/components/desktop/desktop-status.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-status.tsx) +- [src/features/game/components/desktop/desktop-header.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-header.tsx) + +### Step 7:实现 `game-ui-store` + `useGameAutoSpinVm` + +先接一个最合适的 modal: + +- [src/features/game/modal/desktop/desktop-auto-setting-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx) + +跑通后,再接: + +- notice +- user info +- procedures +- login/register + +--- + +## 10. 第一阶段优先改造文件清单 + +第一阶段最值得优先改的 6 个文件如下: + +1. [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/entry-page.tsx) +2. [src/features/game/entry/mobile-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/mobile-entry.tsx) +3. [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) +4. [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx) +5. [src/features/game/components/desktop/desktop-status.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-status.tsx) +6. [src/features/game/modal/desktop/desktop-auto-setting-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx) + +原因: + +- 它们覆盖了主玩法、主状态、移动端入口和共享 modal 模式 +- 先把这 6 个改通,数据与界面分层的骨架就建立起来了 + +--- + +## 11. 第一阶段完成后的验收标准 + +做到以下几点,即可视为第一阶段完成: + +- `PC` 与 `Mobile` 都不直接拼 API / store 的底层结构 +- `Mobile` 不再直接 import `DesktopXxx` +- 至少 `board / control / status` 这 3 类区域中的 2 类已经改为 “VM 驱动 + 展示组件消费 props” +- modal 开关与 tab 这类共享 UI 业务状态已进入 `game-ui-store` +- 视觉动画状态与业务状态分层清晰 + +--- + +## 12. 风险与边界说明 + +### 12.1 不要在第一阶段做的事情 + +- 不要一次性把所有桌面组件都改成共享组件 +- 不要同时把全部 modal 状态都收进 store +- 不要同时重做 API 调用方式 +- 不要试图在第一阶段解决所有命名和目录历史包袱 + +### 12.2 第一阶段最容易犯的错误 + +- 把所有状态都塞进 store + 结果动画、hover、点击反馈这些纯表现状态也被全局化,反而变复杂 + +- 先抽 shared UI,而不是先抽 VM + 这样容易做出一堆仍然耦合 store 的“伪共享组件” + +- Mobile 继续复用 Desktop 组件 + 这会让后面任何布局调整都越来越痛苦 + +--- + +## 13. 结论 + +本项目第一阶段的正确方向不是: + +- “PC 一套状态,Mobile 一套状态” + +而是: + +- 一套 `domain state` +- 一套 `domain actions` +- 一套 `view-model hooks` +- 两套 `presentation` + +按本实施稿推进后,后续才适合继续做: + +- 第二阶段:shared UI 组件整理 +- 第三阶段:modal host 统一 +- 第四阶段:实时数据同步、轮询或 websocket 接入 + diff --git a/figma/img.png b/figma/img.png deleted file mode 100644 index 49edc27..0000000 Binary files a/figma/img.png and /dev/null differ diff --git a/figma/原型图.jpg b/figma/原型图.jpg new file mode 100644 index 0000000..efc53ad Binary files /dev/null and b/figma/原型图.jpg differ diff --git a/figma/设计图.png b/figma/设计图.png new file mode 100644 index 0000000..1b7b5a7 Binary files /dev/null and b/figma/设计图.png differ diff --git a/package.json b/package.json index 8e59739..b9f4788 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@fontsource-variable/geist": "^5.2.8", + "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "^5.99.0", "@tanstack/react-query-devtools": "^5.99.0", "@tanstack/react-router": "^1.168.22", @@ -40,10 +41,12 @@ "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-hook-form": "^7.75.0", "react-i18next": "^17.0.3", "shadcn": "^4.7.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "zod": "^4.4.3", "zustand": "^5.0.12" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8df190c..30f73a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@fontsource-variable/geist': specifier: ^5.2.8 version: 5.2.8 + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.75.0(react@19.2.5)) '@tanstack/react-query': specifier: ^5.99.0 version: 5.99.0(react@19.2.5) @@ -53,6 +56,9 @@ importers: react-dom: specifier: ^19.2.4 version: 19.2.5(react@19.2.5) + react-hook-form: + specifier: ^7.75.0 + version: 7.75.0(react@19.2.5) 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) @@ -65,6 +71,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 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)) @@ -608,6 +617,11 @@ packages: peerDependencies: hono: ^4 + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} + peerDependencies: + react-hook-form: ^7.55.0 + '@inquirer/ansi@2.0.5': resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} @@ -1541,6 +1555,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tailwindcss/node@4.2.2': resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} @@ -3180,6 +3197,12 @@ packages: peerDependencies: react: ^19.2.5 + react-hook-form@7.75.0: + resolution: {integrity: sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-i18next@17.0.3: resolution: {integrity: sha512-x4xjvUNZ56T+zfXWNedNnCET9Xq1IBYWX7IsWo5cCQ/RT+Rm7GWqt0h9PShFi4IhyMnsdiu1C6Jc4DE+/S3PFQ==} peerDependencies: @@ -3748,6 +3771,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.12: resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} engines: {node: '>=12.20.0'} @@ -4247,6 +4273,11 @@ snapshots: dependencies: hono: 4.12.18 + '@hookform/resolvers@5.2.2(react-hook-form@7.75.0(react@19.2.5))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.75.0(react@19.2.5) + '@inquirer/ansi@2.0.5': {} '@inquirer/confirm@6.0.12(@types/node@24.12.2)': @@ -5183,6 +5214,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/utils@0.3.0': {} + '@tailwindcss/node@4.2.2': dependencies: '@jridgewell/remapping': 2.3.5 @@ -6796,6 +6829,10 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 + react-hook-form@7.75.0(react@19.2.5): + dependencies: + react: 19.2.5 + react-i18next@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): dependencies: '@babel/runtime': 7.29.2 @@ -7342,6 +7379,8 @@ snapshots: zod@3.25.76: {} + zod@4.4.3: {} + zustand@5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): optionalDependencies: '@types/react': 19.2.14 diff --git a/src/assets/system/length-blue-btn.webp b/src/assets/system/length-blue-btn.webp index 9039bd9..7fb504f 100644 Binary files a/src/assets/system/length-blue-btn.webp and b/src/assets/system/length-blue-btn.webp differ diff --git a/src/assets/system/length-green-btn.webp b/src/assets/system/length-green-btn.webp index 8af8d1d..f9d194b 100644 Binary files a/src/assets/system/length-green-btn.webp and b/src/assets/system/length-green-btn.webp differ diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..36f73b9 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,18 @@ +import type * as React from 'react' +import { cn } from '@/lib/utils' + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ) +} + +export { Input } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..6147b66 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,192 @@ +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react' +import { Select as SelectPrimitive } from 'radix-ui' +import type * as React from 'react' +import { cn } from '@/lib/utils' + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = 'default', + children, + ...props +}: React.ComponentProps & { + size?: 'sm' | 'default' +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = 'item-aligned', + align = 'center', + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/src/features/game/components/desktop/desktop-control.tsx b/src/features/game/components/desktop/desktop-control.tsx index 04547c2..44a7437 100644 --- a/src/features/game/components/desktop/desktop-control.tsx +++ b/src/features/game/components/desktop/desktop-control.tsx @@ -11,48 +11,47 @@ import reduce from '@/assets/game/reduce.webp' import totalBg from '@/assets/game/total-bg.webp' import { SmartBackground } from '@/components/smart-background.tsx' import { SmartImage } from '@/components/smart-image.tsx' -import { ACTION_OPTIONS, CHIP_OPTIONS } from '@/constants' +import { ACTION_OPTIONS } from '@/constants' +import { useGameControlVm } from '@/features/game/hooks/use-game-control-vm.ts' import { cn } from '@/lib/utils' export function DesktopControl() { - const [chips, setChips] = useState(CHIP_OPTIONS) - const [selectedChipId, setSelectedChipId] = useState( - CHIP_OPTIONS[CHIP_OPTIONS.length - 1]?.id ?? '', - ) + const { + canClear, + chips, + onChipSelect, + onClearSelections, + selectedChipAmountLabel, + selectedChipId, + selectedCountLabel, + totalBetAmountLabel, + } = useGameControlVm() + const [clickedId, setClickedId] = useState(null) const [hidingId, setHidingId] = useState(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 - }) + onChipSelect(chipId) } - const handleActionClick = useCallback((id: string) => { - setClickedId(id) - setTimeout(() => { - setClickedId(null) - setHidingId(id) + const handleActionClick = useCallback( + (id: string) => { + if (id === 'clear' && canClear) { + onClearSelections() + } + + setClickedId(id) setTimeout(() => { - setHidingId(null) - }, 180) - }, 200) - }, []) + setClickedId(null) + setHidingId(id) + setTimeout(() => { + setHidingId(null) + }, 180) + }, 200) + }, + [canClear, onClearSelections], + ) const handleConfirmClick = useCallback(() => { setConfirmClicked(true) @@ -202,7 +201,7 @@ export function DesktopControl() { > @@ -225,7 +224,7 @@ export function DesktopControl() {
- {selectedChip.value} + {selectedChipAmountLabel}
-
SELECTED:3/5
-
Total Bet:150
+
SELECTED:{selectedCountLabel}
+
Total Bet:{totalBetAmountLabel}
- {data.map((item) => { - return ( -
+ {isEmpty ? ( +
+ {emptyText} +
+ ) : ( + items.map((item) => { + return (
- {item.status} -
-
-
- Round ID: - {item.order_no} +
+ {item.statusLabel}
-
- Animals Bet: - {item.numbers.join(', ')} -
-
- Total Bet Amount: - {item.bet_amount} -
-
- Winning Result: - - {' '} - {item.result_number === null ? '--' : item.result_number} - +
+
+ Round ID: + {item.roundId} +
+
+ Settled At: + {item.settledAtLabel} +
+
+ + Total Pool Amount:{' '} + + + {item.totalPoolAmountLabel} + +
+
+ Winning Result: + + {item.winningCellIdLabel} + +
+
+ Payout: + {item.payoutMultiplierLabel} +
-
- ) - })} + ) + }) + )}
) diff --git a/src/features/game/components/desktop/desktop-status.tsx b/src/features/game/components/desktop/desktop-status.tsx index d0d32f0..2be2eb0 100644 --- a/src/features/game/components/desktop/desktop-status.tsx +++ b/src/features/game/components/desktop/desktop-status.tsx @@ -3,8 +3,20 @@ import statusLine from '@/assets/system/status-line.webp' import { SmartBackground } from '@/components/smart-background.tsx' import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx' import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx' +import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts' export function DesktopStatusLine() { + const { + countdownMs, + limitLabel, + oddsLabel, + phaseDescription, + phaseLabel, + phaseToneClassName, + roundId, + streakLabel, + } = useGameStatusVm() + return (
-
-
Odds: 1:33
-
Streak: X2
-
Limit: 100
+
+
Odds: {oddsLabel}
+
Streak: {streakLabel}
+
Limit: {limitLabel}
{ console.log('countdown finished') }} />
-
Round ID:20241026120
+
Round ID:{roundId}
-
OPEN
+
{phaseLabel}
-
(Menerima Taruhan)
+
{phaseDescription}
diff --git a/src/features/game/components/desktop/desktop-withdraw.tsx b/src/features/game/components/desktop/desktop-withdraw.tsx index ae18492..4e0c7c7 100644 --- a/src/features/game/components/desktop/desktop-withdraw.tsx +++ b/src/features/game/components/desktop/desktop-withdraw.tsx @@ -1,5 +1,615 @@ +import { Minus, Plus } from 'lucide-react' +import { type ReactNode, useState } from 'react' +import lengthBlueBtn from '@/assets/system/length-blue-btn.webp' +import lengthGreenBtn from '@/assets/system/length-green-btn.webp' +import { SmartBackground } from '@/components/smart-background.tsx' +import { Input } from '@/components/ui/input.tsx' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select.tsx' +import { cn } from '@/lib/utils' + +const AVAILABLE_BALANCE = 6628 +const MYR_PER_100_DIAMONDS = 1 +const USDT_TO_MYR_RATE = 4.049 +const VND_PER_DIAMOND = 10 + +const QUICK_AMOUNTS = [ + { diamonds: 210, preview: 'MYR 3' }, + { diamonds: 2250, preview: 'MYR 30' }, + { diamonds: 4000, preview: 'MYR 50' }, + { diamonds: 8000, preview: 'MYR 100' }, + { diamonds: 17000, preview: 'MYR 200' }, + { diamonds: 45000, preview: 'MYR 500' }, +] as const + +const CURRENCY_OPTIONS = ['MYR'] as const + +const PAYMENT_CHANNELS = [ + { + id: 'alipay-primary', + label: 'Alipay', + glyph: '支', + }, + { + id: 'alipay-secondary', + label: 'Alipay', + glyph: '支', + }, + { + id: 'alipay-third', + label: 'Alipay', + glyph: '支', + }, +] as const + +const BANK_OPTIONS = [ + { + id: 'bca', + label: 'BCA', + brand: 'BCA', + subtitle: 'Bank Central Asia', + surface: + 'bg-[linear-gradient(180deg,rgba(251,252,255,0.98),rgba(224,239,255,0.96))] text-[#1E53A4]', + }, + { + id: 'mandiri', + label: 'Mandiri', + brand: 'mandiri', + subtitle: 'Mandiri', + surface: + 'bg-[linear-gradient(180deg,rgba(26,53,93,0.98),rgba(9,22,43,0.96))] text-[#F5C247]', + }, + { + id: 'bni', + label: 'BNI', + brand: 'BNI', + subtitle: 'BNI', + surface: + 'bg-[linear-gradient(180deg,rgba(254,253,252,0.98),rgba(239,242,247,0.96))] text-[#E1742B]', + }, + { + id: 'bri', + label: 'BRI', + brand: 'BRI', + subtitle: 'BRI', + surface: + 'bg-[linear-gradient(180deg,rgba(253,254,255,0.98),rgba(234,243,255,0.96))] text-[#0E56A5]', + }, +] as const + +type PaymentChannelId = (typeof PAYMENT_CHANNELS)[number]['id'] +type BankId = (typeof BANK_OPTIONS)[number]['id'] + +const numberFormatter = new Intl.NumberFormat('en-US') +const fixedTwoFormatter = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}) +const fixedSixFormatter = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 6, + maximumFractionDigits: 6, +}) + +const PANEL_CLASS = + 'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(88,225,238,0.08),0_0_calc(var(--design-unit)*10)_rgba(32,163,186,0.12)]' + +const SELECTABLE_CARD_CLASS = + 'flex shrink-0 cursor-pointer flex-col items-center justify-between rounded-[calc(var(--design-unit)*6)] border px-design-8 py-design-8 transition' + +const SELECTABLE_CARD_ACTIVE_CLASS = + 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(65,45,28,0.92),rgba(39,26,16,0.9))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]' + +const SELECTABLE_CARD_IDLE_CLASS = + 'border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(8,34,48,0.92),rgba(5,19,29,0.94))] hover:border-[rgba(170,247,255,0.7)]' + +function formatNumber(value: number) { + return numberFormatter.format(value) +} + +function formatFixedTwo(value: number) { + return fixedTwoFormatter.format(value) +} + +function formatFixedSix(value: number) { + return fixedSixFormatter.format(value) +} + +function WithdrawField({ + label, + children, + alignStart = true, +}: { + label: string + children: ReactNode + alignStart?: boolean +}) { + return ( +
+
+ {label} + : +
+
+ {children} +
+
+ ) +} + +function AmountShell({ + amount, + onMinus, + onPlus, +}: { + amount: number + onMinus: () => void + onPlus: () => void +}) { + return ( +
+
+ + +
+ {formatNumber(amount)} +
+ + +
+ +
+ Saldo Tersedia: {formatNumber(AVAILABLE_BALANCE)} +
+
+ ) +} + +function QuickAmountCard({ + amount, + preview, + active, + onClick, +}: { + amount: number + preview: string + active: boolean + onClick: () => void +}) { + return ( + + ) +} + +function PaymentCard({ + active, + label, + glyph, + onClick, +}: { + active: boolean + label: string + glyph: string + onClick: () => void +}) { + return ( + + ) +} + +function BankCard({ + active, + brand, + subtitle, + surface, + onClick, +}: { + active: boolean + brand: string + subtitle: string + surface: string + onClick: () => void +}) { + return ( + + ) +} + +function InputShell({ + value, + onChange, + placeholder, + error, + errorMessage, + uppercase = false, +}: { + value: string + onChange: (value: string) => void + placeholder: string + error?: boolean + errorMessage?: string + uppercase?: boolean +}) { + return ( +
+ onChange(event.target.value)} + placeholder={placeholder} + className={cn( + 'h-design-42 rounded-[calc(var(--design-unit)*5)] border px-design-14 text-design-16', + uppercase && 'uppercase', + error + ? 'border-[#B93F44] bg-[rgba(34,13,16,0.78)] text-[#FCEEEE]' + : 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(10,47,57,0.84),rgba(5,23,32,0.92))] text-[#ACF1F6]', + )} + /> + {error && errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+ ) +} + +function PreviewRow({ + label, + value, + highlight = false, +}: { + label: string + value: ReactNode + highlight?: boolean +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} + function DesktopWithdraw() { - return
DesktopWithdraw
+ const [amount, setAmount] = useState(6626) + const [currency, setCurrency] = + useState<(typeof CURRENCY_OPTIONS)[number]>('MYR') + const [paymentChannel, setPaymentChannel] = + useState('alipay-primary') + const [bank, setBank] = useState('bca') + const [holderName, setHolderName] = useState('') + const [bankAccount, setBankAccount] = useState('') + const [receiverEmail, setReceiverEmail] = useState('') + const [receiverPhone, setReceiverPhone] = useState('') + + const withdrawMyr = amount / 100 + const withdrawVnd = amount * VND_PER_DIAMOND + const withdrawUsdt = withdrawMyr / USDT_TO_MYR_RATE + + const selectedBank = BANK_OPTIONS.find((item) => item.id === bank) + const holderNameError = holderName.trim().length === 0 + const bankAccountError = bankAccount.trim().length === 0 + + function handleAmountChange(nextAmount: number) { + setAmount(Math.max(0, nextAmount)) + } + + return ( +
+
+
+
+ + handleAmountChange(amount - 1)} + onPlus={() => handleAmountChange(amount + 1)} + /> + + + + + + +
+
+
+ {QUICK_AMOUNTS.map((option) => ( + handleAmountChange(option.diamonds)} + /> + ))} +
+
+ + +
+ {PAYMENT_CHANNELS.map((channel) => ( + setPaymentChannel(channel.id)} + /> + ))} +
+
+ + +
+
+ {`014${selectedBank?.label ?? 'BCA'} (${selectedBank?.subtitle ?? 'BANK CENTRAL ASIA'}): 014`} +
+
+ {BANK_OPTIONS.map((option) => ( + setBank(option.id)} + /> + ))} +
+
+
+ + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+ Pratinjau Penukaran +
+ +
+
+ + + + + + + + +
+ +
+ Nilai tukar berfungsi sebagai harga acuan; nilai tukar aktual yang + berlaku ditentukan pada saat penarikan. +
+ +
+
+ Dompet Elektronik:{' '} + Minimal RM10 +
+
+ Bank: Minimal RM10 +
+
+ Waktu Pengerjaan:{' '} + + Dana Tiba Hanya Dalam 9 Detik. + +
+
+ Melihat: Transaksi antara RM10 dan RM99,99 akan dikenakan biaya + penarikan minimum sebesar RM1. +
+
+ +
+ + Membatalkan + + + Konfirmasi +
+ Penarikan +
+
+
+
+
+
+ ) } export default DesktopWithdraw diff --git a/src/features/game/entry/mobile-entry.tsx b/src/features/game/entry/mobile-entry.tsx index 51695a7..b129fa4 100644 --- a/src/features/game/entry/mobile-entry.tsx +++ b/src/features/game/entry/mobile-entry.tsx @@ -1,24 +1,3 @@ -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 ( - <> -
- -
- -
- - -
- - ) + return
mobile component entry
} diff --git a/src/features/game/entry/pc-entry.tsx b/src/features/game/entry/pc-entry.tsx index 099d12b..cbba741 100644 --- a/src/features/game/entry/pc-entry.tsx +++ b/src/features/game/entry/pc-entry.tsx @@ -4,9 +4,7 @@ import { DesktopControl } from '@/features/game/components/desktop/desktop-contr import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx' import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx' import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx' -import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx' import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx' -import DesktopRegisterModal from '../modal/desktop/desktop-register-modal' export function PcEntry() { return ( @@ -49,9 +47,9 @@ export function PcEntry() { {/*公告弹窗*/} {/**/} {/*自动托管弹窗*/} - {/* */} + {/* 充值提现前置选择弹窗*/} - + {/**/} {/* 充值和提现弹窗 */} {/**/} diff --git a/src/features/game/hooks/use-game-control-vm.ts b/src/features/game/hooks/use-game-control-vm.ts new file mode 100644 index 0000000..b45a23a --- /dev/null +++ b/src/features/game/hooks/use-game-control-vm.ts @@ -0,0 +1,42 @@ +import { useMemo } from 'react' +import { CHIP_OPTIONS } from '@/constants' +import { selectSelectionTotal, useGameRoundStore } from '@/store/game' + +const CHIP_IMAGE_MAP = new Map( + CHIP_OPTIONS.map((chip) => [chip.value, chip.src] as const), +) + +export function useGameControlVm() { + const chips = useGameRoundStore((state) => state.chips) + const activeChipId = useGameRoundStore((state) => state.activeChipId) + const selections = useGameRoundStore((state) => state.selections) + const clearSelections = useGameRoundStore((state) => state.clearSelections) + const selectChip = useGameRoundStore((state) => state.selectChip) + const totalBetAmount = useGameRoundStore(selectSelectionTotal) + + const chipItems = useMemo( + () => + chips.map((chip) => ({ + amount: chip.amount, + id: chip.id, + isSelected: chip.id === activeChipId, + src: CHIP_IMAGE_MAP.get(chip.amount) ?? CHIP_OPTIONS[0]?.src ?? '', + valueLabel: String(chip.amount), + })), + [activeChipId, chips], + ) + + const selectedChip = + chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null + + return { + canClear: selections.length > 0, + onChipSelect: selectChip, + onClearSelections: clearSelections, + selectedChipAmountLabel: selectedChip?.valueLabel ?? '--', + selectedChipId: activeChipId, + selectedCountLabel: `${selections.length}/5`, + totalBetAmountLabel: String(totalBetAmount), + chips: chipItems, + } +} diff --git a/src/features/game/hooks/use-game-history-vm.ts b/src/features/game/hooks/use-game-history-vm.ts new file mode 100644 index 0000000..ac74c0e --- /dev/null +++ b/src/features/game/hooks/use-game-history-vm.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react' +import { useGameRoundStore } from '@/store/game' + +function formatSettledTime(iso: string) { + const date = new Date(iso) + + if (Number.isNaN(date.getTime())) { + return '--' + } + + return date.toLocaleString('zh-CN', { + hour12: false, + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +export function useGameHistoryVm() { + const history = useGameRoundStore((state) => state.history) + + const items = useMemo( + () => + history.map((entry) => ({ + id: entry.roundId, + payoutMultiplierLabel: `${entry.payoutMultiplier}x`, + roundId: entry.roundId, + settledAtLabel: formatSettledTime(entry.settledAt), + statusLabel: 'settled', + totalPoolAmountLabel: entry.totalPoolAmount.toFixed(2), + winningCellIdLabel: String(entry.winningCellId), + })), + [history], + ) + + return { + emptyText: 'No history yet', + isEmpty: items.length === 0, + items, + } +} diff --git a/src/features/game/hooks/use-game-status-vm.ts b/src/features/game/hooks/use-game-status-vm.ts new file mode 100644 index 0000000..f7c65b2 --- /dev/null +++ b/src/features/game/hooks/use-game-status-vm.ts @@ -0,0 +1,59 @@ +import { useMemo } from 'react' +import { getRoundCountdownMs } from '@/features/game/shared/selectors' +import { useGameRoundStore, useGameSessionStore } from '@/store/game' + +const PHASE_META = { + betting: { + description: '(Menerima Taruhan)', + label: 'OPEN', + toneClassName: 'text-[#78FF7F]', + }, + locked: { + description: '(Taruhan Ditutup)', + label: 'LOCKED', + toneClassName: 'text-[#FFE375]', + }, + revealing: { + description: '(Mengundi Hasil)', + label: 'DRAWING', + toneClassName: 'text-[#57E8FF]', + }, + settled: { + description: '(Putaran Selesai)', + label: 'SETTLED', + toneClassName: 'text-[#FF9C6B]', + }, + waiting: { + description: '(Menunggu Putaran Berikutnya)', + label: 'WAITING', + toneClassName: 'text-[#A7B6C7]', + }, +} as const + +export function useGameStatusVm() { + const cells = useGameRoundStore((state) => state.cells) + const round = useGameRoundStore((state) => state.round) + const trends = useGameRoundStore((state) => state.trends) + const dashboard = useGameSessionStore((state) => state.dashboard) + + return useMemo(() => { + const oddsValue = cells[0]?.odds ?? '--' + const featuredTrend = trends.find( + (entry) => entry.cellId === dashboard.featuredCellId, + ) + const phaseMeta = PHASE_META[round.phase] + + return { + acceptingBets: round.phase === 'betting', + countdownMs: getRoundCountdownMs(round), + limitLabel: `${dashboard.tableLimitMin}-${dashboard.tableLimitMax}`, + oddsLabel: `1:${oddsValue}`, + phase: round.phase, + phaseDescription: phaseMeta.description, + phaseLabel: phaseMeta.label, + phaseToneClassName: phaseMeta.toneClassName, + roundId: round.id, + streakLabel: featuredTrend ? `X${featuredTrend.currentStreak}` : '--', + } + }, [cells, dashboard, round, trends]) +} diff --git a/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx b/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx index 7df392e..8322bed 100644 --- a/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx +++ b/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import lengthBlueBtn from '@/assets/system/length-blue-btn.webp' import { CenterModal } from '@/components/center-modal.tsx' import { SmartBackground } from '@/components/smart-background.tsx' +import { Input } from '@/components/ui/input.tsx' import { Switch } from '@/components/ui/switch.tsx' const AUTO_STOP_ROWS = [ @@ -17,6 +18,7 @@ const AUTO_STOP_ROWS = [ }, { label: 'Stop on any Jackpot', + // value: '50000', checked: false, }, ] as const @@ -66,7 +68,7 @@ function DesktopAutoSettingModal() { 'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10' } > - Akun/TEL:
- @@ -52,7 +53,7 @@ function DesktopLoginModal() { > Kata Sandi:
- diff --git a/src/features/game/modal/desktop/desktop-register-modal.tsx b/src/features/game/modal/desktop/desktop-register-modal.tsx index 2801deb..78e9b7d 100644 --- a/src/features/game/modal/desktop/desktop-register-modal.tsx +++ b/src/features/game/modal/desktop/desktop-register-modal.tsx @@ -5,6 +5,7 @@ import rightImg from '@/assets/system/right.webp' import { CenterModal } from '@/components/center-modal.tsx' import { SmartBackground } from '@/components/smart-background.tsx' import { SmartImage } from '@/components/smart-image.tsx' +import { Input } from '@/components/ui/input.tsx' function DesktopRegisterModal() { const [open, setOpen] = useState(true) @@ -37,7 +38,7 @@ function DesktopRegisterModal() { > Akun/TEL:
- @@ -50,7 +51,7 @@ function DesktopRegisterModal() { > Kata Sandi:
- @@ -63,7 +64,7 @@ function DesktopRegisterModal() { > Kata Sandi:
- @@ -76,7 +77,7 @@ function DesktopRegisterModal() { > Kata Sandi: - diff --git a/src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx b/src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx index b9a7964..1af504e 100644 --- a/src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx +++ b/src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx @@ -7,7 +7,7 @@ type WithdrawType = 'withdraw' | 'topup' function DesktopWithdrawTopupModal() { const [open, setOpen] = useState(true) - const [type, setType] = useState('withdraw') + const [type] = useState('withdraw') function handleSubmit() { setOpen(false) } @@ -18,15 +18,16 @@ function DesktopWithdrawTopupModal() { onClose={handleSubmit} title={
- {type} + {type === 'withdraw' ? '申请提现' : '申请充值'}
} - isShowClose={false} isNormalBg={true} titleAlign="left" - className={'w-design-835 h-design-500'} + className={'w-design-1200 h-design-700'} > -
{type ? : }
+
+ {type === 'withdraw' ? : } +
) } diff --git a/src/features/game/shared/constants.ts b/src/features/game/shared/constants.ts index fd6eeff..d2ad1fc 100644 --- a/src/features/game/shared/constants.ts +++ b/src/features/game/shared/constants.ts @@ -44,8 +44,6 @@ export const BET_SOURCES = ['local', 'server'] as const export const TREND_DIRECTIONS = ['rising', 'steady', 'falling'] as const -export const DEFAULT_GAME_CHIP_AMOUNTS = [10, 25, 50, 100, 200, 500] as const - export const DEFAULT_GAME_CHIP_COLORS = [ '#1D4ED8', '#0F766E', @@ -55,7 +53,7 @@ export const DEFAULT_GAME_CHIP_COLORS = [ '#111827', ] as const -export const DEFAULT_ACTIVE_CHIP_ID = 'chip-50' +export const DEFAULT_ACTIVE_CHIP_ID = 'chip-5' export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000 export const GAME_RECENT_HISTORY_LIMIT = 12 export const GAME_BOARD_COLUMNS = GAME_GRID_COLUMNS diff --git a/src/features/game/shared/mock-data.ts b/src/features/game/shared/mock-data.ts index dd1f09b..f396036 100644 --- a/src/features/game/shared/mock-data.ts +++ b/src/features/game/shared/mock-data.ts @@ -1,7 +1,7 @@ +import { CHIP_OPTIONS } from '@/constants' import { DEFAULT_ACTIVE_CHIP_ID, DEFAULT_ANNOUNCEMENT_TTL_MS, - DEFAULT_GAME_CHIP_AMOUNTS, DEFAULT_GAME_CHIP_COLORS, GAME_GRID_COLUMNS, GAME_TOTAL_CELLS, @@ -41,12 +41,12 @@ export function createGameCells() { } export function createDefaultChips() { - return DEFAULT_GAME_CHIP_AMOUNTS.map((amount, index) => ({ - amount, + return CHIP_OPTIONS.map((chip, index) => ({ + amount: chip.value, color: DEFAULT_GAME_CHIP_COLORS[index], - id: `chip-${amount}`, - isDefault: `chip-${amount}` === DEFAULT_ACTIVE_CHIP_ID, - label: amount >= 100 ? `${amount / 100}x` : String(amount), + id: chip.id, + isDefault: chip.id === DEFAULT_ACTIVE_CHIP_ID, + label: chip.value >= 100 ? `${chip.value / 100}x` : String(chip.value), })) satisfies Chip[] } diff --git a/src/style/index.css b/src/style/index.css index ba119d6..470ebe0 100644 --- a/src/style/index.css +++ b/src/style/index.css @@ -190,25 +190,6 @@ linear-gradient(180deg, #07111f 0%, #040812 100%); color: #f8fafc; } - - input { - @apply border border-transparent bg-[#135E65]/60 text-[#D9FFFF] py-design-15 px-design-30 text-design-20 rounded-md outline-none transition; - } - - input::placeholder { - color: rgba(116, 173, 175, 0.72); - } - - input:focus, - input:focus-visible { - border-color: rgba(110, 255, 255, 0.72); - outline: none; - box-shadow: - 0 0 0 calc(var(--design-unit) * 1.5) rgba(110, 255, 255, 0.16), - 0 0 calc(var(--design-unit) * 8) rgba(48, 214, 255, 0.36), - 0 0 calc(var(--design-unit) * 18) rgba(18, 162, 255, 0.22), - inset 0 0 calc(var(--design-unit) * 6) rgba(110, 255, 255, 0.08); - } } @layer utilities {