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.
404 lines
17 KiB
Markdown
404 lines
17 KiB
Markdown
# 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 描述:
|
||
|
||
```typescript
|
||
// 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 两种混合**:
|
||
|
||
```jsonc
|
||
// 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 组件)
|
||
|
||
声明式高层组件,业务层只描述"是什么":
|
||
|
||
```tsx
|
||
// 用法示例:在一个 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):
|
||
|
||
```typescript
|
||
// 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 的"自编译"模型**:
|
||
|
||
```bash
|
||
# 拉源码
|
||
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)
|
||
- [ ] 撤销(`undo` move)
|
||
- [ ] 观战模式
|
||
- [ ] 语音/视频(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),后续不再讨论:
|
||
|
||
1. ✅ **基础引擎**:使用 boardgame.io(不另写 reducer 引擎)
|
||
2. ✅ **桌面壳**:Tauri(不选 Electron)
|
||
3. ✅ **游戏定义格式**:沿用 boardgame.io `Game` + 自定义 `ui` 字段(不另创 DSL)
|
||
|
||
锁定理由(保留备查):
|
||
- boardgame.io 已经覆盖了 reducer + flow + plugins + server,省下大量造轮子;
|
||
- Tauri 包小、自编译友好,符合"reVC 式"分发;
|
||
- 沿用 boardgame.io Game 意味着示例代码可以直接借用现有 50+ 游戏样例。
|
||
|
||
---
|
||
|
||
## 8. 下一步
|
||
|
||
立即可做的三件事:
|
||
|
||
1. **跑通示例**:`pnpm start`,玩一下 `examples/react-web` 的 tic-tac-toe / chess,确认开发链路 OK。
|
||
2. **新建包**:在 `packages/` 下加一个 `ui.ts`,开始写 `<Card>` 组件(先纯展示)。
|
||
3. **写第一个游戏**:从 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 替代
|