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.
17 KiB
TTS-like 桌游模拟器实现规划
目标:一个可自定义玩法、轻视觉(2D 为主)、支持联机、可跨平台自编译分发的桌游模拟平台。
0. 关键约束复盘
| 维度 | 约束 |
|---|---|
| 玩法定义 | 用户编写代码(类 TTS 的 Mod,但用 JS/TS) |
| 联机 | 用户已有的公共域名服务器做中转 |
| 平台 | 跨平台,可自编译分发(类 reVC 的"从源码构建"模型) |
| 视觉 | 2D 简洁界面,重规则不重特效 |
| 内容范围 | 牌类、棋类、token、区域等基本元素 |
| 技术栈偏好 | Node / Web 可接受 |
1. 总体架构:四层分离
┌─────────────────────────────────────────────┐
│ UI Layer(渲染 + 交互) │ ← SVG / React
├─────────────────────────────────────────────┤
│ Game Definition(游戏定义,用户编写) │ ← TS/JS 模块,热重载
├─────────────────────────────────────────────┤
│ Rules Engine(规则引擎,纯函数) │ ← Reducer + Flow + Plugins
├─────────────────────────────────────────────┤
│ Network Layer(联机中转) │ ← WebSocket 中继
└─────────────────────────────────────────────┘
↑ ↑
浏览器 / Tauri 你拥有的公共域名
为什么这么分?
- 规则引擎与 UI 解耦 → 可以独立测试、可以离线运行、可以换 UI 框架。
- 游戏定义是纯数据 + 纯函数 → 可以在浏览器/服务器任意一端执行,安全且可序列化。
- 网络层只做"转发 + 房间管理",不掺业务逻辑 → 部署、扩容、防作弊都简单。
2. 推荐技术栈
| 层 | 选择 | 理由 |
|---|---|---|
| 语言 | TypeScript(全栈) | 类型安全,前后端共享类型 |
| 规则引擎 | boardgame.io core(本仓库已有的引擎) | 已实现 reducer / phases / stages / turn-order / plugins / log / MCTS,可直接复用,省下自写引擎的 1-2 个月 |
| 前端框架 | React 18+ | boardgame.io 已有 React 绑定,生态成熟 |
| 渲染 | SVG(优先) + 必要时 Canvas | 卡牌天然矢量、可访问、可直接 DOM 操作;只在需要粒子/复杂动画时退化到 Canvas |
| 网络 | Socket.IO | boardgame.io 已用,与降级、长连接、重连开箱即用 |
| 中转服务器 | Node.js + boardgame.io server | 复用 Koa + socket.io,省下自己写 WebSocket 路由的工作 |
| 桌面壳 | Tauri(Rust 内核) | 包小(~5 MB vs Electron ~100 MB)、安全、跨平台编译友好(macOS / Windows / Linux) |
| 移动端 | PWA(先)+ Capacitor(后) | 浏览器优先,必要时可打包成原生 App |
| 构建工具 | Vite | 启动快、HMR 稳定、产物干净 |
关于 boardgame.io:本仓库就是 boardgame.io 的源码。它的核心(reducer + flow + plugins + master)正好覆盖你需要的"规则引擎 + 联机中转",不需要从零写。建议把它作为底层依赖,而不是另起炉灶。
3. 关键设计
3.1 游戏定义格式("Game" 文件)
最自然的做法是直接用 boardgame.io 的 Game 定义,再加一层 UI 描述:
// games/blackjack/index.ts
export const game = {
name: 'Blackjack',
minPlayers: 2,
maxPlayers: 4,
// 1. 初始状态
setup: () => ({
deck: shuffle(makeDeck()),
hands: {}, // { [playerID]: Card[] }
dealers: [],
}),
// 2. 玩法(纯函数 reducer)
moves: {
deal: (G, ctx) => { /* 发牌 */ return G; },
hit: (G, ctx) => { /* 要牌 */ return G; },
stand: (G, ctx) => { /* 停牌 */ return G; },
},
// 3. 阶段 / 回合(turn structure)
phases: {
betting: { /* 下注阶段 */ },
play: { /* 出牌阶段 */ },
reveal: { /* 摊牌 */ },
},
// 4. UI 描述(增量,自定义字段,boardgame.io 不消费它)
ui: {
background: '/games/blackjack/table.svg',
zones: [
{ id: 'hand', type: 'hand', owner: 'self' },
{ id: 'dealer', type: 'zone', position: { x: 400, y: 50 } },
{ id: 'discard', type: 'pile', position: { x: 700, y: 50 }, faceDown: true },
],
cardTemplate: {
width: 63, height: 88,
back: '/games/blackjack/card-back.svg',
front: (card: Card) => renderSvgFace(card), // 返回 SVG 字符串或 DataURL
},
},
// 5. 客户端钩子(可选,用于自定义按钮、动画)
hooks: {
onMoveEnd: (G, ctx, moveName) => { /* 例如出牌音效 */ },
},
};
ui和hooks是 boardgame.io 之外的扩展,需要在 UI 层识别并消费,但不影响核心引擎。
3.2 资产(Asset)协议
推荐 SVG 内嵌 / DataURL + 远程 URL 两种混合:
// games/<name>/assets.json
{
"cards": [
{
"id": "AS",
"name": "黑桃A",
"image": "https://cdn.example.com/cards/AS.svg",
"fallback": "data:image/svg+xml;base64,..."
}
],
"tokens": [
{ "id": "red-meeple", "image": "data:image/svg+xml;base64,..." }
]
}
- SVG 优先:缩放无锯齿、文件小(一张卡 1-5 KB),可直接 inline。
- DataURL:用于离线/单文件游戏(一个
.json描述一个完整游戏)。 - CDN/远程 URL:用于大型素材库。
3.3 渲染原语(UI 组件)
声明式高层组件,业务层只描述"是什么":
// 用法示例:在一个 Blackjack 牌桌里
<Board game={game} state={G}>
<Zone id="dealer" position={[400, 50]} fan="horizontal" />
<Zone id="player-0" position={[200, 500]} fan="horizontal" hand />
<Zone id="discard" position={[700, 50]} pile faceDown />
{/* 引擎渲染每个实体 */}
<EntityRenderer state={G} />
{/* 自定义 UI(按钮等) */}
<ActionButton move="hit" disabled={!canHit}>要牌</ActionButton>
<ActionButton move="stand">停牌</ActionButton>
</Board>
内置组件清单(v1 至少要实现):
| 组件 | 职责 |
|---|---|
<Board> |
顶层容器,处理缩放/拖拽/坐标变换 |
<Card> |
单张牌(正/背面、可拖动、可堆叠) |
<Token> |
单个 token(棋子、标记) |
<Zone> |
容器(手牌、牌堆、弃牌堆、棋盘格) |
<Hand> |
手牌区,自动扇形/网格布局 |
<Deck> |
牌堆,支持翻牌/洗牌动画 |
<ActionButton> |
触发 move,自动根据 ctx 启用/禁用 |
<Dice> |
投骰子(可选) |
关键决策:渲染原语由"框架"提供,但每个游戏的视觉布局由游戏自己通过
ui字段描述。这避免了"卡死到一个固定布局"的问题(也是 TTS 灵活性的来源)。
3.4 交互(拖拽、点击、右键)
- 拖拽:原生 HTML5 drag-and-drop + 自定义 ghost 预览。
- 落点判定:拖到某个
<Zone>时触发zone.onDrop(card, zoneId),由游戏决定是否合法。 - 多选:Shift/Ctrl+点击,卡牌游戏中常用。
- 右键菜单:每个 game 可注册
contextMenu,例如"查看牌面 / 弃牌 / 标记"。 - 撤销/回放:复用 boardgame.io 的 log + undo(v1 可只做 undo)。
3.5 网络协议(CS 模式,服务器权威)
[Client A] [Relay Server] [Client B]
│ │ │
│── MAKE_MOVE('hit', []) ─────────>│ │
│ │ apply reducer (服务器权威) │
│ │── SYNC(state) ─────────────────>│
│<── SYNC(state) ──────────────────│ │
│ │ │
│ │<── MAKE_MOVE('stand', []) ──────│
│<── SYNC(state) ──────────────────│── SYNC(state) ─────────────────>│
消息类型(v1 最小集合):
| 方向 | type | 含义 |
|---|---|---|
| C → S | JOIN |
加入房间(带 roomId / playerID / credentials) |
| C → S | LEAVE |
离开 |
| C → S | MAKE_MOVE |
触发一个 move |
| C → S | GAME_EVENT |
客户端事件(如聊天、准备) |
| S → C | SYNC |
全量状态同步 |
| S → C | PATCH |
增量状态(v2,性能优化时再加) |
| S → C | CHAT |
聊天/系统消息 |
| S → C | ERROR |
错误(如非法 move) |
直接复用 boardgame.io master 已经定义的协议就行,不要发明新协议——master 已经处理了认证、状态快照、断开重连等。
3.6 中转服务器
部署在你的公共域名上:
wss://play.example.com/<roomID>
最小实现(直接用 boardgame.io server):
// server/index.ts
import { Server, Origins } from 'boardgame.io/server';
import { MyGame } from './games/index.js';
const server = Server({
games: [MyGame /* ... */],
origins: [Origins.WILDCARD], // 开发时,生产收紧
});
server.run(8000);
部署:
- Docker:
FROM node:24-alpine,COPY 打包好的 dist,启动node dist/cjs/server.js。 - 反向代理:Nginx/Caddy 终止 TLS,转发到 Node。
- 持久化:v1 用内存;v2 接 Redis(房间恢复)/ Postgres(历史回放)。
- 房间清理:30 分钟无活动自动销毁(释放资源)。
你已有的服务器是公共域名的,意味着 TLS 证书应该已有(Let's Encrypt / Cloudflare)。Socket.IO 默认走 WSS。
3.7 跨平台分发
┌──────────────────────────┐
│ Vite build → dist/web │
└────────────┬─────────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
浏览器直接访问 Tauri 壳(macOS) Tauri 壳(Win/Linux)
(https://app.xx) .dmg / .app .msi / .AppImage
类 reVC 的"自编译"模型:
# 拉源码
git clone https://github.com/you/tts-like
cd tts-like
corepack enable
pnpm install
# 跑起来(开发)
pnpm dev # 启动 vite dev server + 本地 socket.io server
# 打包桌面
pnpm tauri:dev # 开发模式(桌面壳 + 热重载前端)
pnpm tauri:build # 生产构建,输出原生安装包
Tauri 的 tauri:build 会按当前平台产出对应安装包;用 GitHub Actions 做 CI matrix 就能产出 macOS / Windows / Linux 三平台产物。
4. 实施阶段(建议时间线)
Phase 0:选型与验证(1-2 天)
- 在本仓库跑一个 boardgame.io 的简单示例(tic-tac-toe)
- 跑通
pnpm start(react-web example),确认开发链路 OK - 决定 SVG vs Canvas(建议先全 SVG)
- 决定 Tauri vs Electron(建议 Tauri)
Phase 1:本地 2D MVP(2-3 周)
- 实现
<Board> <Card> <Hand> <Deck> <Zone>基础组件 - 实现拖拽 + 落点判定
- 一个完整本地可玩的卡牌游戏(建议 War / 战争 或 Snap——规则极简,专注验证交互)
- 一个本地棋类(Morris / Nine Men's Morris——简单,验证棋盘交互)
- 截图、键盘可访问性测试
Phase 2:联机(1-2 周)
- 部署 boardgame.io server 到你的公共域名
- 联调:两台电脑能互相看到对方操作
- 实现房间码(6 位短码)+ 二维码分享
- 断线重连(boardgame.io 自带,需测试边界)
- 错误反馈(非法 move 提示)
Phase 3:游戏生态(2-3 周)
- 游戏定义文件热重载(开发模式改文件不用刷新)
- 游戏选择 UI(左/右栏列出可用游戏)
- 资产加载器(DataURL / 远程 URL 双模式)
- 至少 3 个示例游戏(War / Blackjack / 一款棋类)
- 写一篇
docs/game-authoring.md,告诉别人怎么写一个新游戏
Phase 4:跨平台打包(1 周)
- Tauri 壳接入
- 自动更新(v2 再说)
- GitHub Actions 三平台构建
Phase 5:可选打磨
- 回放系统(基于 boardgame.io log)
- 撤销(
undomove) - 观战模式
- 语音/视频(WebRTC,不在你的服务器上跑 SFU,用 LiveKit / Daily / 自建 mediasoup)
- 游戏市场(用户上传游戏)
5. 项目结构(建议)
如果你要单仓多包(推荐),结构如下:
tts-like/
├── packages/
│ ├── engine/ # boardgame.io core 的再封装 + UI 原语
│ ├── ui/ # <Card> <Board> <Hand> 等 React 组件
│ ├── protocol/ # 共享类型(消息、State schema)
│ └── server/ # 中转服务器(基于 boardgame.io)
├── games/ # 用户游戏(每个游戏一个目录)
│ ├── war/
│ ├── blackjack/
│ └── chess/
├── apps/
│ ├── web/ # Vite + React SPA
│ └── desktop/ # Tauri 壳
├── docs/
│ ├── game-authoring.md
│ └── deployment.md
├── pnpm-workspace.yaml
└── package.json
如果你想更激进地复用本仓库,可以把它改成 monorepo 的根:
src/→packages/engine- 新增
packages/ui、packages/server - 保留
examples/作为apps/
6. 风险与权衡
| 风险 | 影响 | 缓解 |
|---|---|---|
| 拖拽实时同步延迟 | 体验卡顿 | v1 不做"实时拖拽广播",只在落点时同步;v2 再加 CRDT |
| 大量 SVG 资产 | 首屏慢 | 按需加载 + DataURL 内嵌小图、CDN 大图 |
| 自定义规则的沙箱 | 用户代码搞坏服务 | v1 信任用户代码(游戏定义是可信方写的);v2 加 Web Worker / iframe 隔离 |
| 服务器被刷 | 资源耗尽 | 限流(每 IP 每分钟最多创建 N 个房间)、房间 TTL |
| 跨平台打包陷阱 | Tauri 在不同平台签名/公证麻烦 | v1 用 Tauri 默认(无签名),发"自己用"就好;正式分发再处理签名 |
| boardgame.io 维护活跃度 | 引擎 bug 无人修 | 本仓库就是它,主分支活跃;自有问题可改 |
7. 决策记录(已锁定 ✅)
下列三项决策已确定(2026-08-16),后续不再讨论:
- ✅ 基础引擎:使用 boardgame.io(不另写 reducer 引擎)
- ✅ 桌面壳:Tauri(不选 Electron)
- ✅ 游戏定义格式:沿用 boardgame.io
Game+ 自定义ui字段(不另创 DSL)
锁定理由(保留备查):
- boardgame.io 已经覆盖了 reducer + flow + plugins + server,省下大量造轮子;
- Tauri 包小、自编译友好,符合"reVC 式"分发;
- 沿用 boardgame.io Game 意味着示例代码可以直接借用现有 50+ 游戏样例。
8. 下一步
立即可做的三件事:
- 跑通示例:
pnpm start,玩一下examples/react-web的 tic-tac-toe / chess,确认开发链路 OK。 - 新建包:在
packages/下加一个ui.ts,开始写<Card>组件(先纯展示)。 - 写第一个游戏:从 War(战争)开始——规则简单(双方翻牌比大小),但能验证整套链路:定义 → 引擎 → 渲染 → 交互 → 联机。
准备好之后,建议先跑 Phase 1 的 War,2-3 周内拿到一个本地能玩的最小版本,再决定是否继续投入联机和打包。
9. 参考资料
- boardgame.io 文档:本仓库
docs/documentation/,或 https://boardgame.io/documentation/ - Tauri 跨平台打包:https://tauri.app/
- SVG vs Canvas 选型:卡牌/UI 选 SVG;粒子/复杂效果选 Canvas(参考 https://css-tricks.com/when-to-use-svg-vs-canvas/)
- Socket.IO 协议:复用 boardgame.io master 即可,无需自创
- TTS 的 Modding 模型:https://api.tabletopsimulator.com/ (Lua 脚本)—— 我们用 JS/TS 替代