Files
huajishe-tts/docs/ui-primitives.md
Claude c5e55150ca chore: scaffold monorepo skeleton
Initial scaffold for tts-like tabletop simulator.

- pnpm workspace: packages/{protocol,engine,ui,server} + apps/{web,desktop}
- engine: boardgame.io 0.50.2 re-exports + War (战争) game definition
- ui: placeholder React primitives (Board / Zone / Card / Token / ActionButton)
- server: Koa + boardgame.io Server relay (war game registered)
- web: Vite + React SPA wired to engine
- desktop: Tauri shell placeholder (awaiting tauri init)
- docs: implementation plan + UI primitives API spec (copied from boardgame.io/docs/new/)

Verified: pnpm install (312 deps), pnpm -r ts passes for all 4 packages + apps/web.
2026-08-16 23:25:03 +08:00

493 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
| 组件 | 角色 | 备注 |
| --- | --- | --- |
| `<Board>` | 顶层容器,坐标系 + 缩放 + 背景 | 一个游戏实例一个 `<Board>` |
| `<Zone>` | 区域容器(手牌/牌堆/弃牌堆/棋盘格/任意容器) | 通过 `type` 切换布局策略 |
| `<Hand>` | 手牌专用 zone自动扇形 + 隐私) | 语法糖,等价于 `<Zone type="hand" />` |
| `<Deck>` | 牌堆专用 zone仅显示顶牌可点击翻牌 | 等价于 `<Zone type="pile" />` + 翻牌交互 |
| `<Card>` | 单张牌 | SVG 矢量 |
| `<Token>` | 单个 token棋子、标记 | SVG / 图片 |
| `<ActionButton>` | 触发 move | 根据 `when` 规则自动启用/禁用 |
| `<Dice>` | 投骰子(可选) | v2 再说 |
---
## 3. 组件 API
### 3.1 `<Board>`
```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` 实例化所有 `<Zone>`
- 处理滚轮缩放、拖拽空白处平移
-`state``onMove` 透传给子组件Context
### 3.2 `<Zone>`
```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' = 玩家 0null = 公共区域 */
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 `<Card>`
```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: `
<svg viewBox="0 0 63 88" xmlns="http://www.w3.org/2000/svg">
<rect width="63" height="88" fill="white" stroke="black" />
<text x="6" y="20" font-size="14" font-weight="bold">{{value}}</text>
<text x="6" y="34" font-size="14">{{suitSymbol}}</text>
<text x="57" y="84" font-size="14" text-anchor="end" transform="rotate(180 57 84)">{{value}}</text>
</svg>
`
```
**front 函数形式**(用于动态生成):
```typescript
front: (card) => {
if (card.joker) return `<svg>...joker svg...</svg>`;
return `<svg>...regular card...</svg>`;
}
```
### 3.4 `<Token>`
```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 `<ActionButton>`
```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 `<Dice>`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<ZoneProps>;
Card?: React.ComponentType<CardProps>;
Token?: React.ComponentType<TokenProps>;
};
}
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 }
<Board game={game} state={{G, ctx}}>
├─ 遍历 game.ui.zones
│ └─ <Zone id={z.id} ...>
│ ├─ 根据 type 决定布局
│ ├─ 拉取 G.zones[z.id] 里的 cardIds
│ └─ 对每个 cardId 渲染 <Card>
├─ <ActionButton move="hit" when="...">
│ └─ 根据 when 评估 enabled
└─ children自定义覆盖层
User drags <Card> 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>
→ 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 点击 <Card> → selected 集合加/减
2. 拖动任一选中牌 → 整个 selected 集合一起 ghost
3. 落下 → zone.onDrop([id1, id2, ...])
```
### 7.3 点击牌查看
```
1. User click <Card>(非拖拽)
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/ctxUI 层只有瞬态 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.frontG 里存 cardId不存 SVG。
---
## 9. 与 boardgame.io 的对接点
| UI 概念 | 对应 boardgame.io 概念 |
| --- | --- |
| `<Board>` | 不直接对应,是 wrapper |
| `<Zone>` | 不直接对应,是 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 集合**(如果想先小后大,可以只做这些)
```
<Board>
├─ <Zone type="hand|pile|area">
│ └─ <Card> (自动按 G 渲染)
├─ <ActionButton>
└─ children (自定义 UI)
```
Card / Token 的渲染都从 `game.ui.cards` / `game.ui.tokens` 读模板,业务代码不直接 new Card。