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.
15 KiB
15 KiB
UI 原语 API 设计
Phase 0.5:动手写代码之前先把组件 API、game.ui 字段结构、数据流定义清楚。本文是设计稿,可能在实际实现时微调。
1. 设计原则
- 声明式:业务代码只描述"是什么"(zone 类型、卡牌归属),不描述"怎么画"(transform、opacity 动画)。
- 数据驱动:所有可见状态都来自
G(boardgame.io 的 game state),本地 UI 状态仅限"拖拽中""已选中"这种瞬态。 - 服务器权威:所有 game-affecting 操作都通过
onMove(name, args)触发 reducer,不在客户端直接修改G。 - 玩家视角过滤交给 boardgame.io:UI 接收的
G已经是该玩家视角下的版本(master 会用playerView过滤)。UI 不需要再写"对面手牌不可见"这种逻辑。 - 可扩展:每个游戏可以注入自定义 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>
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>
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>
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 替换):
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 函数形式(用于动态生成):
front: (card) => {
if (card.joker) return `<svg>...joker svg...</svg>`;
return `<svg>...regular card...</svg>`;
}
3.4 <Token>
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>
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 风格):
when: 'phase == "play" and currentPlayer == "self" and hand.length > 0'
when: 'ctx.turn >= 3'
3.6 <Dice>(v2,先标记)
interface DiceProps {
sides: number; // 6, 20, ...
count?: number; // 几个骰子
onRoll?: (results: number[]) => void;
}
4. game.ui 字段 schema
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)
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. 下一步
- 用户审阅本文档。
- 待定项 8.2 至少挑出 3 个给出明确答案。
- 然后进入 Task #4:实现 UI 原语(先纯展示,无交互)。
- 实现完组件后写一个 "storybook"(在 apps/web 里一个
/dev路由),手动渲染所有组件做视觉验证。 - 之后再加拖拽(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。