# UI 原语 API 设计 > Phase 0.5:动手写代码之前先把组件 API、game.ui 字段结构、数据流定义清楚。本文是设计稿,可能在实际实现时微调。 --- ## 1. 设计原则 1. **声明式**:业务代码只描述"是什么"(zone 类型、卡牌归属),不描述"怎么画"(transform、opacity 动画)。 2. **数据驱动**:所有可见状态都来自 `G`(boardgame.io 的 game state),本地 UI 状态仅限"拖拽中""已选中"这种瞬态。 3. **服务器权威**:所有 game-affecting 操作都通过 `onMove(name, args)` 触发 reducer,不在客户端直接修改 `G`。 4. **玩家视角过滤交给 boardgame.io**:UI 接收的 `G` 已经是该玩家视角下的版本(master 会用 `playerView` 过滤)。UI 不需要再写"对面手牌不可见"这种逻辑。 5. **可扩展**:每个游戏可以注入自定义 UI 组件(覆盖默认渲染),但默认实现对 80% 的卡牌/棋类游戏够用。 --- ## 2. 组件清单(v1) | 组件 | 角色 | 备注 | | --- | --- | --- | | `` | 顶层容器,坐标系 + 缩放 + 背景 | 一个游戏实例一个 `` | | `` | 区域容器(手牌/牌堆/弃牌堆/棋盘格/任意容器) | 通过 `type` 切换布局策略 | | `` | 手牌专用 zone(自动扇形 + 隐私) | 语法糖,等价于 `` | | `` | 牌堆专用 zone(仅显示顶牌,可点击翻牌) | 等价于 `` + 翻牌交互 | | `` | 单张牌 | SVG 矢量 | | `` | 单个 token(棋子、标记) | SVG / 图片 | | `` | 触发 move | 根据 `when` 规则自动启用/禁用 | | `` | 投骰子(可选) | v2 再说 | --- ## 3. 组件 API ### 3.1 `` ```tsx interface BoardProps { /** boardgame.io 的 Game 定义(含自定义 ui 字段) */ game: Game & { ui?: UISchema }; /** 当前状态(G = game state, ctx = turn context) */ state: { G: any; ctx: Ctx }; /** 当前玩家 ID */ playerID: string; /** 触发一个 move */ onMove: (moveName: string, args?: any[]) => void; /** 自定义 UI 覆盖层(按钮、菜单等),相对 Board 坐标系 */ children?: React.ReactNode; /** 视图缩放范围,默认 [0.5, 2] */ zoomRange?: [number, number]; /** 调试模式:显示 zone 边界、token ID */ debug?: boolean; } ``` **职责**: - 渲染 `game.ui.background`(如果有) - 根据 `game.ui.zones` 实例化所有 `` - 处理滚轮缩放、拖拽空白处平移 - 把 `state`、`onMove` 透传给子组件(Context) ### 3.2 `` ```tsx interface ZoneProps { /** 与 game.ui.zones[i].id 对应 */ id: string; /** 布局类型,决定子元素的排列方式 */ type: 'hand' | 'pile' | 'discard' | 'grid' | 'area' | 'free'; /** 绝对坐标 */ position: { x: number; y: number }; /** 占位大小(影响 hit area,不强制等于实际内容大小) */ size?: { w: number; h: number }; /** 扇形/网格布局参数 */ layout?: { /** hand: 扇形展开角度(度);grid: 列数 */ fan?: number; /** 子元素间距 */ spacing?: number; /** 子元素最大尺寸(hand 在屏幕小时自动缩小) */ maxWidth?: number; }; /** 所有卡牌默认面朝下(仅显示牌背) */ faceDown?: boolean; /** 所有者:'self' = 当前玩家,'P0' = 玩家 0,null = 公共区域 */ owner?: string | null; /** 接收拖入时的回调,return false 拒绝 */ onDrop?: (cardIds: string[]) => boolean | void; /** 接收规则(游戏级别控制可放置的牌) */ accepts?: (card: any, ctx: Ctx) => boolean; } ``` **type 行为差异**: | type | 渲染 | 交互 | | --- | --- | --- | | `hand` | 扇形展开(曲别针样式) | 拖拽到自己其他 zone 时自动调整顺序 | | `pile` | 只显示顶牌 + 数量徽章 | 点击顶牌 = 翻一张(触发 `draw` move) | | `discard` | 只显示顶牌 + 数量徽章 | 不可交互(只读) | | `grid` | 网格排列(如棋盘格) | 点击格子 = 选中该位置 | | `area` | 自由位置(不规则区域) | 拖拽可放置 | | `free` | 无视觉边界,只作为逻辑分组 | 拖拽可放置 | ### 3.3 `` ```tsx interface CardProps { /** G.cards[cardId],由 Zone 注入 */ card: any; /** 渲染模板(来自 game.ui.cards) */ template: CardTemplate; /** 选中态(多选用) */ selected?: boolean; /** 拖拽中(显示 ghost 样式) */ ghost?: boolean; /** 不可拖拽 */ locked?: boolean; /** 拖拽开始 */ onDragStart?: (e: DragEvent) => void; /** 点击(用于查看/选中) */ onClick?: (e: MouseEvent) => void; /** 右键菜单 */ onContextMenu?: (e: MouseEvent) => void; /** 当前位置(hand 自动计算时可省略) */ position?: { x: number; y: number }; /** 旋转角度 */ rotation?: number; } interface CardTemplate { width: number; height: number; back: string; // URL 或 DataURL 或 SVG 字符串 front: string | ((card: any) => string); // 模板字符串或函数 } ``` **front 模板字符串示例**(用 handlebars-like 替换): ```typescript front: ` {{value}} {{suitSymbol}} {{value}} ` ``` **front 函数形式**(用于动态生成): ```typescript front: (card) => { if (card.joker) return `...joker svg...`; return `...regular card...`; } ``` ### 3.4 `` ```tsx interface TokenProps { token: any; // G.tokens[tokenId] image: string; // URL / DataURL / SVG string size: { w: number; h: number }; position: { x: number; y: number }; rotation?: number; selected?: boolean; onClick?: (e: MouseEvent) => void; onDragStart?: (e: DragEvent) => void; onContextMenu?: (e: MouseEvent) => void; } ``` ### 3.5 `` ```tsx interface ActionButtonProps { /** 对应 game.moves 的 key */ move: string; /** 传给 move 的参数(也可以是函数,返回动态参数) */ args?: any[] | ((ctx: Ctx) => any[]); /** 启用条件(JSONLogic 字符串或函数) */ when?: string | ((ctx: Ctx) => boolean); /** 按钮文字 */ children: React.ReactNode; /** 位置(默认右下浮动栏) */ position?: 'floating' | { x: number; y: number }; /** 危险操作样式(红色 + 二次确认) */ destructive?: boolean; } ``` **when 字符串示例**(JSONLogic 风格): ```typescript when: 'phase == "play" and currentPlayer == "self" and hand.length > 0' when: 'ctx.turn >= 3' ``` ### 3.6 ``(v2,先标记) ```tsx interface DiceProps { sides: number; // 6, 20, ... count?: number; // 几个骰子 onRoll?: (results: number[]) => void; } ``` --- ## 4. `game.ui` 字段 schema ```typescript interface UISchema { /** 背景图(URL / DataURL / SVG string) */ background?: string; /** 所有 zone 的静态定义 */ zones: ZoneDef[]; /** 卡牌渲染模板 */ cards?: CardTemplate; /** Token 静态定义(也可以由游戏动态往 G.tokens 里加) */ tokens?: TokenDef[]; /** 顶部/底部按钮栏(行动按钮) */ actions?: ActionDef[]; /** 自定义 React 组件覆盖(高级用法) */ overrides?: { Zone?: React.ComponentType; Card?: React.ComponentType; Token?: React.ComponentType; }; } interface ZoneDef { id: string; type: 'hand' | 'pile' | 'discard' | 'grid' | 'area' | 'free'; position: { x: number; y: number }; size?: { w: number; h: number }; layout?: { fan?: number; spacing?: number; maxWidth?: number }; faceDown?: boolean; owner?: string | null; /** 接收规则(字符串是 JSONLogic,函数是 (card, ctx) => bool) */ accepts?: string | ((card: any, ctx: Ctx) => boolean); } interface TokenDef { id: string; image: string; size: { w: number; h: number }; } interface ActionDef { label: string; move: string; args?: any[]; when?: string; destructive?: boolean; } ``` ### 4.1 完整示例(Blackjack) ```typescript export const blackjack = { name: 'Blackjack', minPlayers: 2, maxPlayers: 4, setup: () => ({ deck: shuffle(makeDeck()), hands: {}, // { [playerID]: Card[] } dealer: [], // 庄家的牌 bets: {}, }), moves: { deal: (G, ctx) => { /* ... */ }, hit: (G, ctx) => { /* ... */ }, stand: (G, ctx) => { /* ... */ }, }, phases: { betting: { /* ... */ }, play: { start: true, next: 'reveal', }, reveal: { /* ... */ }, }, // ====== 自定义 UI 字段 ====== ui: { background: '/games/blackjack/table.svg', zones: [ { id: 'dealer', type: 'area', position: { x: 400, y: 60 }, size: { w: 200, h: 120 } }, { id: 'discard', type: 'discard', position: { x: 750, y: 60 } }, // 每个玩家动态创建:手牌区归属 self // (运行时根据 playerID 实例化) ], cards: { width: 63, height: 88, back: '/games/blackjack/card-back.svg', front: (card) => renderPlayingCard(card), }, actions: [ { label: '要牌', move: 'hit' }, { label: '停牌', move: 'stand' }, { label: '加倍', move: 'double', when: 'turn == 1' }, ], }, }; ``` --- ## 5. 数据流 ``` boardgame.io Master ↓ SYNC message Client receives { G, ctx } ↓ │ ├─ 遍历 game.ui.zones │ └─ │ ├─ 根据 type 决定布局 │ ├─ 拉取 G.zones[z.id] 里的 cardIds │ └─ 对每个 cardId 渲染 │ ├─ │ └─ 根据 when 评估 enabled │ └─ children(自定义覆盖层) ↓ User drags from Zone A to Zone B ↓ Zone B 的 onDrop 被调用 ↓ Game 的 UI 处理器调用 props.onMove('moveName', args) ↓ boardgame.io client → Master: MAKE_MOVE ↓ Master apply reducer → 广播 SYNC ↓ 所有 client 重渲染 ``` --- ## 6. 玩家视角过滤 **关键事实**:boardgame.io 的 master 已经实现了 `playerView(G, ctx, playerID)`,会把 `G` 过滤到该玩家可见的版本。客户端拿到的 `G` 已经是过滤后的。 **UI 层需要做的**: - 不需要写"对面手牌不可见"逻辑——`G.hands['P1']` 在 P0 的客户端里就是 `undefined` 或空数组(master 滤掉了)。 - zone 定义本身是公开的(`game.ui.zones`),所有玩家都能看到 zone 的存在,但里面装的牌数可能是"?"或根据 G 推断。 **特殊 case**:自家手牌是否完全可见? - 默认:可见(自己看自己的牌)。 - 战场迷雾游戏:游戏自己在 `playerView` 里把自家手牌也滤掉即可,UI 不感知。 **测试方式**:写一个 2 人游戏,A 和 B 各自客户端看到的 `G` 应不同,UI 不需要任何特殊处理就能正确渲染。 --- ## 7. 交互流程详解 ### 7.1 拖拽一张牌 ``` 1. User mousedown on → Card 的 onMouseDown 设置 local state: draggingCardId = card.id → 显示 ghost(在鼠标位置跟随) 2. User mousemove → ghost 跟随光标 → 用 document.elementFromPoint 找到光标下的 zone → 该 zone 高亮(如果 accepts(card) === true) 3. User mouseup → 如果在合法 zone 上: 触发 zone.onDrop([card.id]) zone.onDrop 返回 true 或调用 props.onMove(...) → 如果不在 zone 上: 取消拖拽,ghost 消失 → 清除 local state ``` ### 7.2 多选移动(万智牌 / 扑克 / 麻将场景) ``` 1. User 按住 Shift 点击 → selected 集合加/减 2. 拖动任一选中牌 → 整个 selected 集合一起 ghost 3. 落下 → zone.onDrop([id1, id2, ...]) ``` ### 7.3 点击牌查看 ``` 1. User click (非拖拽) 2. Card 的 onClick → 显示大图 / 弹窗 / 高亮 3. 点击空白处关闭 ``` ### 7.4 ActionButton ``` 1. Board 根据 game.ui.actions 渲染按钮 2. 评估 when 条件: - 字符串:用 JSONLogic 库(如 json-logic-js)求值 - 函数:直接调用 3. enabled=false 时按钮置灰 4. User click → props.onMove(action.move, action.args) ``` --- ## 8. 边缘情况与决策记录 ### 8.1 已决定 ✅ | 问题 | 决定 | 日期 | | --- | --- | --- | | SVG vs Canvas | 全 SVG(卡牌天然矢量;复杂动画后续单独评估) | 2026-08-16 | | 状态管理 | 全部走 boardgame.io 的 G/ctx,UI 层只有瞬态 local state | 2026-08-16 | | 自定义 UI | 通过 `game.ui.overrides` 提供组件级覆盖 | 2026-08-16 | | 卡牌图源 | SVG 内嵌字符串优先,fallback 到 DataURL/URL | 2026-08-16 | | zone 坐标系统 | 固定像素坐标系 + Board 层处理 viewport 缩放 | 2026-08-16 | | 动画策略 | 默认 CSS transition + 游戏可关闭/覆盖 | 2026-08-16 | | 可访问性 | v1 仅鼠标;v2 加键盘;v3 加屏幕阅读器 | 2026-08-16 | | 触屏/移动端 | v1 仅桌面;触屏在 v2 单独评估 | 2026-08-16 | | 多选 UI | v1 仅 Shift+click;框选 v2 | 2026-08-16 | | 牌堆数量显示 | 角标显示数量(来源 `G.zones[zoneId].length`) | 2026-08-16 | ### 8.3 风险 - **拖拽中 G 变化**:用户在拖拽时,其他玩家发了 move 改了这个 zone 的内容。 - 解决方案:拖拽开始时锁定 cardId 对应的 G 快照,mouseup 时校验该 cardId 是否仍在原 zone。若不在,取消拖拽。 - **大量实体性能**:100+ 个 token 同时渲染可能卡。 - v1 不优化,先跑通;v2 用 React.memo + 虚拟化(只渲染视口内)。 - **SVG 大小**:内嵌 SVG 字符串随 G 增大而增大(G 是字符串)。 - 优化:卡牌图只存一次(game.ui.cards.front),G 里存 cardId,不存 SVG。 --- ## 9. 与 boardgame.io 的对接点 | UI 概念 | 对应 boardgame.io 概念 | | --- | --- | | `` | 不直接对应,是 wrapper | | `` | 不直接对应,是 UI 抽象 | | `G.zones['hand-0']` | G 的某个字段(结构由游戏自己定义) | | `game.ui.zones[i]` | 静态 zone 元数据(game definition 的 ui 字段) | | `onMove('hit')` | `props.moves.hit(...)`(boardgame.io client 提供) | | `when` 规则 | 在 `ctx` 上求值(JSONLogic) | | 玩家视角 | `playerView(G, ctx, playerID)`(master 内置) | --- ## 10. 下一步 1. 用户审阅本文档。 2. 待定项 8.2 至少挑出 3 个给出明确答案。 3. 然后进入 Task #4:实现 UI 原语(先纯展示,无交互)。 4. 实现完组件后写一个 "storybook"(在 apps/web 里一个 `/dev` 路由),手动渲染所有组件做视觉验证。 5. 之后再加拖拽(Task #5)。 --- **附:v1 最小可用 API 集合**(如果想先小后大,可以只做这些) ``` ├─ │ └─ (自动按 G 渲染) ├─ └─ children (自定义 UI) ``` Card / Token 的渲染都从 `game.ui.cards` / `game.ui.tokens` 读模板,业务代码不直接 new Card。