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.
493 lines
15 KiB
Markdown
493 lines
15 KiB
Markdown
# 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' = 玩家 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 `<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/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 概念 |
|
||
| --- | --- |
|
||
| `<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。
|