Per user request: split the development process into per-topic docs under docs/dev-log/. Each entry follows the same structure: What was asked → Problems → How solved Files: - README.md index - 01-initial-scope-and-design.md - 02-ui-primitives-api-design.md - 03-monorepo-scaffold.md - 04-ui-primitive-impl.md - 05-drag-and-drop.md - 06-three-games.md - 07-bugfixes-round-1.md - lessons-learned.md all gotchas consolidated - decisions.md locked decisions with dates Total: 1071 lines across 10 files.
86 lines
2.7 KiB
Markdown
86 lines
2.7 KiB
Markdown
# 04 · UI 原语实现
|
||
|
||
## 做什么
|
||
|
||
实现 `<Board> <Zone> <Card> <Token> <ActionButton>` 五个组件,让它们按 `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 沙箱。
|
||
|
||
## 怎么解决
|
||
|
||
### 组件分工
|
||
|
||
```
|
||
<Board>
|
||
├─ 遍历 game.ui.zones
|
||
│ └─ <Zone def={...} entities={resolveZoneEntities(def, G)}>
|
||
│ ├─ <Card> / <Token> 渲染
|
||
│ └─ <ZoneDebugLabel>(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) — 三个游戏靠这些原语 + 拖拽
|