# 04 · UI 原语实现 ## 做什么 实现 ` ` 五个组件,让它们按 `game.ui` schema 驱动渲染。 ## 遇到什么问题 ### 问题 1:Board 怎么知道 zone 持有什么实体? zone 定义是 UI 层的(位置、类型),但哪些 card/tokens 在哪个 zone 是数据层的。 **解决**:引入 `collection` 字段约定: ```ts { id: 'p0-deck', collection: 'p0Deck', ... } ``` Board 调 `resolveZoneEntities(zone, G)` → 读 `G[collection]`(ID 数组)+ `G[entityField ?? 'drawPile']`(ID → 实体映射)。 ### 问题 2:手牌区怎么扇形展示? `spacing` 和 `width` 谁先决定?人手少时扇形,人多时网格? **解决**:fan type 内部用三角函数算 offset: ```ts const fanAngle = layout?.fan ?? 30; const spacing = layout?.spacing ?? 30; const half = (entities.length - 1) / 2; const offset = (i - half) * spacing; const rot = (i - half) * (fanAngle / Math.max(half, 1)); ``` ### 问题 3:pile / discard 渲染策略不同 - `pile`(牌堆):只显示顶牌 + 数量徽章 - `discard`(弃牌堆):同 pile,但可能面朝上 - `area`(区域):所有牌铺开 - `hand`(手牌):扇形展开 **解决**:在 Zone 组件按 `type` 分支渲染。 ### 问题 4:SVG 在 React 里直接渲染 用 `dangerouslySetInnerHTML` 注入 SVG 字符串(来自 `template.front(card)`)。 **风险**:v1 接受,因为游戏定义是可信方写的;如果 v2 接入第三方游戏,需要 SVG 沙箱。 ## 怎么解决 ### 组件分工 ``` ├─ 遍历 game.ui.zones │ └─ │ ├─ / 渲染 │ └─ (debug 模式下) ├─ children(自定义 UI 覆盖层) └─ 点击空白区域(清空选择) ``` ### 完整文件 - `packages/protocol/src/index.ts` — 共享类型 + `resolveZoneEntities` helper - `packages/ui/src/Board.tsx` — 顶层容器 - `packages/ui/src/Zone.tsx` — 区域容器(6 种 type) - `packages/ui/src/Card.tsx` — 单卡(SVG 渲染) - `packages/ui/src/Token.tsx` — 单 token - `packages/ui/src/ActionButton.tsx` — 触发 move ### 验证 - `pnpm -r ts`:6 个包全部通过 - `pnpm --filter @tts-like/web build`:成功(283 KB / 90 KB gzip) - `pnpm --filter @tts-like/web dev`:HTTP 200 ## 成果 - Commit `b2d6236 feat(ui): render zones/cards/actions from game.ui schema` - War 游戏的 4 个 pile zone + 1 个翻牌按钮都能在浏览器里看到 - 切到拖拽前的最后状态 ## 关联 - [05-drag-and-drop.md](./05-drag-and-drop.md) — 接下来加拖拽 - [06-three-games.md](./06-three-games.md) — 三个游戏靠这些原语 + 拖拽