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

15 KiB
Raw Permalink Blame History

UI 原语 API 设计

Phase 0.5:动手写代码之前先把组件 API、game.ui 字段结构、数据流定义清楚。本文是设计稿,可能在实际实现时微调。


1. 设计原则

  1. 声明式:业务代码只描述"是什么"zone 类型、卡牌归属),不描述"怎么画"transform、opacity 动画)。
  2. 数据驱动:所有可见状态都来自 Gboardgame.io 的 game state本地 UI 状态仅限"拖拽中""已选中"这种瞬态。
  3. 服务器权威:所有 game-affecting 操作都通过 onMove(name, args) 触发 reducer不在客户端直接修改 G
  4. 玩家视角过滤交给 boardgame.ioUI 接收的 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>

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>
  • 处理滚轮缩放、拖拽空白处平移
  • stateonMove 透传给子组件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' = 玩家 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>

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/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。