Compare commits

...

2 Commits

Author SHA1 Message Date
hypercross 111d2e69eb chore: just remove all the docs 2026-04-06 14:08:25 +08:00
hypercross aa7e99bd0f chore: make sure peers are not bundled 2026-04-06 12:42:45 +08:00
7 changed files with 1 additions and 712 deletions

View File

@ -1,90 +0,0 @@
# 动画与状态更新同步
命令执行时,效应函数如果通过 `produce()` 立即更新状态UI 层只能看到最终结果,
无法在中间插入动画。为了解决这个问题,`MutableSignal` 提供了动画中断机制。
## 基本原理
核心思路:**逻辑层将 `produce` 替换为 `produceAsync`UI 层负责注册动画 interruption。**
时序如下:
```
逻辑层: produceAsync(fn1) produceAsync(fn2) produceAsync(fn3)
↓ (无 interruption, ↓ 等待 anim1 完成 ↓ 等待 anim2 完成
立即更新状态 1)
UI层: effect 检测到状态 1 变化 effect 检测到状态 2 变化 effect 检测到状态 3 变化
播放动画 1 播放动画 2 播放动画 3
addInterruption(anim1) addInterruption(anim2) addInterruption(anim3)
```
1. 第一个 `produceAsync` 没有 interruption 可等,立即更新状态
2. UI 层通过 `effect` 检测到状态变化,播放动画并调用 `addInterruption`
3. 第二个 `produceAsync` 被前一步注册的 interruption 阻塞,等待动画完成后再更新状态
4. 依此类推,形成链式等待
## 逻辑层:将 `produce` 替换为 `produceAsync`
```ts
// 之前
async function turn(game: BoopGame, turnPlayer: PlayerType) {
game.produce(state => {
game.scores[turnPlayer] ++;
});
// 这里不能触发动画等待
game.produce(state => {
game.currentPlayer = turnPlayer;
});
};
// 之后:改为 produceAsync
async function turn(game: BoopGame, turnPlayer: PlayerType) {
await game.produceAsync(state => {
game.scores[turnPlayer] ++;
});
// 这里会等待interruption结束再继续
await game.produceAsync(state => {
game.currentPlayer = turnPlayer;
});
};
```
## UI 层:监听状态变化并注册 interruption
```ts
import { effect } from '@preact/signals-core';
const host = createGameHost(module);
effect(() => {
const state = host.context.value;
// 每次 produceAsync 更新状态后,这里会被触发
// 播放对应的动画
const animation = playAnimationForState(state);
// 为下一个 produceAsync 注册 interruption
// 注意animation 必须是 Promise<void>,在动画完成时 resolve
host.addInterruption(animation);
});
```
> **注意**`playAnimationForState` 函数需要返回 `Promise<void>`,在动画播放完成并 resolve 后,下一个 `produceAsync` 才会继续执行状态更新。
## 中断 API
`GameHost` 直接暴露了以下方法,供 UI 层调用:
| 方法 | 说明 |
|---|---|
| `addInterruption(promise: Promise<void>)` | 注册中断,下一个 `produceAsync` 会等待它 |
| `clearInterruptions()` | 清除所有未完成的中断 |
`MutableSignal` 上还有 `produceAsync`,逻辑层使用。
## 注意事项
- `produce()` 仍然保持同步,适合不需要动画的场景(如 setup 阶段)
- `produceAsync()` 使用 `Promise.allSettled` 等待所有 interruption即使某个动画 reject 也不会阻止状态更新
- 不要忘记 `await` `produceAsync()`,否则多个效应可能并发执行导致竞态
- 第一个 `produceAsync` 总是立即执行(无前序 interruption从第二个开始等待动画

View File

@ -1,142 +0,0 @@
# API 参考
## 核心
| 导出 | 说明 |
|---|---|
| `GameHost<TState>` | 游戏生命周期管理类 |
| `createGameHost(module)` | 从 GameModule 创建 GameHost |
| `GameHostStatus` | 类型: `'created' \| 'running' \| 'disposed'` |
| `GameModule` | 游戏模块类型,包含 `registry``createInitialState` |
| `createGameModule(module)` | 辅助函数,标记 GameModule |
| `createGameCommandRegistry<TState>()` | 创建游戏命令注册表 |
### GameHost
| 成员 | 说明 |
|---|---|
| `context: IGameContext<TState>` | 游戏上下文,含状态和命令运行能力 |
| `status: Signal<GameHostStatus>` | 当前状态 |
| `activePromptSchema: Signal<CommandSchema \| null>` | 当前等待的 prompt schema |
| `activePromptPlayer: Signal<string \| null>` | 当前等待的玩家 |
| `setup(setupCommand: string)` | 启动游戏,运行 setup 命令 |
| `onInput(input: string)` | 提交玩家输入到当前 prompt |
| `addInterruption(promise)` / `clearInterruptions()` | 动画中断控制 |
| `on(event, listener)` | 监听 `setup` / `dispose` 事件 |
| `dispose()` | 销毁游戏实例 |
### IGameContext
| 成员 | 说明 |
|---|---|
| `value: TState` | 当前游戏状态 |
| `produce(fn)` | 同步更新状态 |
| `produceAsync(fn)` | 等待动画中断后更新状态 |
| `run(input)` / `runParsed(command)` | 运行命令 |
| `prompt(schema, validator, currentPlayer?)` | 等待玩家输入 |
| `addInterruption(promise)` | 注册动画中断 |
## 棋子与区域
### Part
| 导出 | 说明 |
|---|---|
| `Part<TMeta>` | 棋子类型,含 `id`、`regionId`、`position`、`side` 等 |
| `PartTemplate<TMeta>` / `PartPool<TMeta>` | 棋子模板和棋子池类型 |
| `createPart(template, id)` | 创建单个棋子 |
| `createParts(template, count, idPrefix)` | 批量创建棋子 |
| `createPartPool(template, count, idPrefix)` | 创建棋子池,支持 `draw()` / `return()` / `remaining()` |
| `mergePartPools(...pools)` | 合并棋子池 |
| `createPartsFromTable(items, getId, getCount?)` | 从表格数据创建棋子 |
| `findPartById(parts, id)` | 按 ID 查找 |
| `getPartAtPosition(parts, regionId, position)` | 获取位置上的棋子 |
| `isCellOccupied(parts, regionId, position)` | 检查格子占用 |
| `flip(part)` / `flipTo(part, side)` / `roll(part, rng)` | 翻面/掷骰 |
### Region
| 导出 | 说明 |
|---|---|
| `Region` / `RegionAxis` | 区域类型 |
| `createRegion(id, axes)` | 创建区域 |
| `applyAlign(region, parts)` | 按 axis 配置紧凑排列棋子 |
| `shuffle(region, parts, rng)` | 打乱区域内棋子位置 |
| `moveToRegion(part, source?, target, position?)` | 移动棋子到区域 |
| `isCellOccupiedByRegion(region, position)` | O(1) 检查格子占用(使用 region.partMap |
| `getPartAtPositionInRegion(region, parts, position)` | O(1) 获取棋子(使用 region.partMap |
## 命令系统
### 基础类型
| 导出 | 说明 |
|---|---|
| `Command` | 解析后的命令对象,含 `name`、`params`、`options`、`flags` |
| `CommandSchema` | 命令 schema 定义 |
| `CommandResult<T>` | 命令执行结果: `{ success: true, result: T } | { success: false, error: string }` |
### 注册与运行
| 导出 | 说明 |
|---|---|
| `CommandRegistry<TContext>` | 命令注册表,继承自 `Map<string, CommandRunner>`,含 `register(schema, handler)` 快捷方法 |
| `CommandRunner<TContext, TResult>` | 命令运行器,含 `schema``run` |
| `CommandRunnerContext<TContext>` | 命令运行器上下文,提供 `run`、`prompt`、`on`、`off` |
| `PromptEvent` | prompt 事件,含 `schema`、`currentPlayer`、`tryCommit`、`cancel` |
| `PromptValidator<T>` | prompt 验证器: `(command) => T`throw 字符串表示验证失败 |
| `createCommandRegistry()` | 创建命令注册表 |
| `registerCommand(registry, runner)` | 注册命令 |
| `unregisterCommand(registry, name)` / `hasCommand(registry, name)` / `getCommand(registry, name)` | 命令管理 |
| `runCommand(registry, context, input)` | 解析并运行命令 |
### 命令解析
| 导出 | 说明 |
|---|---|
| `parseCommand(input)` | 解析命令字符串为 `Command` 对象 |
| `parseCommandSchema(schemaStr)` | 解析 schema 字符串为 `CommandSchema` |
| `validateCommand(command, schema)` | 验证命令是否符合 schema |
| `parseCommandWithSchema(input, schemaStr)` | 解析并验证命令 |
| `applyCommandSchema(command, schema)` | 应用 schema 到命令,含类型转换 |
### Game Command Registry
通过 `createGameCommandRegistry()` 创建的注册表有快捷 `register` 方法:
```ts
const moveCmd = registry.register('move <from> <to>', async (ctx, from, to) => {
ctx.produce(state => { /* ... */ });
});
// 作为子命令调用
await moveCmd(ctx, 'A1', 'A2');
```
处理器签名: `(ctx, ...args) => Promise<TResult>`
`game.prompt()` 等待玩家输入,验证器 throw 字符串触发重新提示,返回非 null 值表示通过。
## MutableSignal
| 导出 | 说明 |
|---|---|
| `MutableSignal<T>` | 支持突变和动画中断的响应式信号,继承 Preact Signal |
| `mutableSignal(initial?, options?)` | 创建 MutableSignal |
| `EntityCollection<T>` / `createEntityCollection()` | 实体集合辅助函数,管理 `MutableSignal` 字典 |
| 成员 | 说明 |
|---|---|
| `value: T` | 获取/设置当前值 |
| `produce(fn)` | 同步不可变更新(使用 mutative |
| `produceAsync(fn)` | 等待所有 interruption 完成后更新状态 |
| `addInterruption(promise)` | 注册中断,下一个 produceAsync 会等待 |
| `clearInterruptions()` | 清除所有未完成的中断 |
详见 [动画与状态更新同步](./animation-sync.md)。
## 工具
| 导出 | 说明 |
|---|---|
| `RNG` | 随机数生成器接口: `setSeed`、`getSeed`、`next`、`nextInt` |
| `createRNG(seed?)` / `Mulberry32RNG` | Mulberry32 算法 PRNG |

View File

@ -1,32 +0,0 @@
# 开发指南
## 安装
```bash
npm install boardgame-core
```
## 脚本
```bash
npm run build # 构建 ESM bundle + 类型声明到 dist/
npm run test # 以 watch 模式运行测试
npm run test:run # 运行测试一次
npm run typecheck # TypeScript 类型检查
```
### 运行单个测试文件
```bash
npx vitest run tests/samples/tic-tac-toe.test.ts
```
### 按名称运行单个测试
```bash
npx vitest run -t "should detect horizontal win for X"
```
## License
MIT

View File

@ -1,165 +0,0 @@
# 使用 GameHost
`GameHost` 是游戏运行的核心容器,负责管理游戏状态、命令执行和玩家交互的生命周期。
## 创建 GameHost
通过 `createGameHost` 传入一个 GameModule 来创建:
```ts
import { createGameHost } from 'boardgame-core';
import * as tictactoe from 'boardgame-core/samples/tic-tac-toe';
const host = createGameHost(tictactoe);
```
## 响应式状态
GameHost 暴露的所有属性都是响应式 Signal可以直接用于 UI 渲染或 `effect()`
```ts
import { effect } from '@preact/signals-core';
// 游戏状态
effect(() => {
console.log(host.context.value.currentPlayer);
console.log(host.context.value.winner);
});
// 生命周期状态: 'created' | 'running' | 'disposed'
effect(() => {
console.log('Status:', host.status.value);
});
// 当前等待的玩家输入 schema
effect(() => {
const schema = host.activePromptSchema.value;
if (schema) {
console.log('Waiting for:', schema.name, schema.params);
}
});
// 当前等待的玩家
effect(() => {
console.log('Current player prompt:', host.activePromptPlayer.value);
});
```
## 启动游戏
调用 `setup()` 并传入初始化命令名来启动游戏:
```ts
await host.setup('setup');
```
这会重置游戏状态、取消当前活动提示、在后台启动指定的 setup 命令(不等待完成),并将状态设为 `'running'`
## 处理玩家输入
当命令通过 `this.prompt()` 等待玩家输入时,使用 `onInput()` 提交输入:
```ts
// 提交玩家操作,返回错误信息或 null
const error = host.onInput('play X 1 2');
if (error) {
console.log('输入无效:', error);
// 玩家可以重新输入
} else {
// 输入已被接受,命令继续执行
}
```
## 监听事件
```ts
// 监听游戏设置完成
host.on('setup', () => {
console.log('Game initialized');
});
// 监听游戏销毁
host.on('dispose', () => {
console.log('Game disposed');
});
// on() 返回取消订阅函数
const unsubscribe = host.on('setup', handler);
unsubscribe(); // 取消监听
```
## 重新开始游戏
```ts
// 取消当前命令,重置状态,重新运行 setup 命令
await host.setup('setup');
```
## 销毁游戏
```ts
host.dispose();
```
销毁后会取消所有活动命令、清理事件监听器,并将状态设为 `'disposed'`。销毁后无法再次使用。
## 完整示例
```ts
import { effect } from '@preact/signals-core';
import { createGameHost } from 'boardgame-core';
import * as tictactoe from 'boardgame-core/samples/tic-tac-toe';
const host = createGameHost(tictactoe);
// 监听状态变化
effect(() => {
const state = host.context.value;
console.log(`${state.currentPlayer}'s turn (turn ${state.turn + 1})`);
if (state.winner) {
console.log('Winner:', state.winner);
}
});
// 启动游戏
await host.setup('setup');
// 游戏循环:等待提示 → 提交输入
// 注意setup() 会立即返回,但 prompt 可能需要一些时间才能激活
// 实际应用中应该等待 activePromptSchema 变为非 null
while (host.status.value === 'running' && host.activePromptSchema.value) {
const schema = host.activePromptSchema.value!;
console.log('Waiting for input:', schema.name);
// 这里可以从 UI/网络等获取输入
const input = await getPlayerInput();
const error = host.onInput(input);
if (error) {
console.log('Invalid:', error);
}
}
// 游戏结束后可以重新开始
// await host.setup('setup');
// 或彻底销毁
// host.dispose();
```
## 动画同步
如需在状态更新之间播放动画,参考 [动画与状态更新同步](./animation-sync.md)。
`GameHost` 提供了两个方法:
| 方法 | 说明 |
|---|---|
| `addInterruption(promise: Promise<void>)` | 注册动画中断,下一个 `produceAsync` 会等待它 |
| `clearInterruptions()` | 清除所有未完成的中断 |
```ts
// UI 层:检测到状态变化后播放动画并注册中断
host.addInterruption(playAnimation('place', data));
```

View File

@ -1,167 +0,0 @@
# 编写 GameModule
GameModule 是定义游戏逻辑的模块,包含状态定义和命令注册。
## GameModule 结构
一个 GameModule 必须导出两个东西:
```ts
import { createGameCommandRegistry, createRegion } from 'boardgame-core';
export function createInitialState() {
return {
board: createRegion('board', [
{ name: 'x', min: 0, max: 2 },
{ name: 'y', min: 0, max: 2 },
]),
parts: {} as Record<string, Part>,
currentPlayer: 'X' as PlayerType,
winner: null as WinnerType,
turn: 0,
};
}
export const registry = createGameCommandRegistry<ReturnType<typeof createInitialState>>();
registry.register('setup', async function (game) { /* ... */ });
registry.register('play <player> <row:number> <col:number>', async function (game, cmd) { /* ... */ });
```
也可用 `createGameModule` 辅助函数包装:
```ts
export const gameModule = createGameModule({
registry,
createInitialState,
});
```
## 定义游戏状态
建议用 `ReturnType` 推导状态类型:
```ts
export type GameState = ReturnType<typeof createInitialState>;
```
状态通常包含 Region、parts`Record<string, Part>`)以及游戏专属字段(当前玩家、分数等)。
## 注册命令
使用 `registry.register()` 注册命令。Schema 字符串定义了命令格式:
```ts
registry.register('play <player> <row:number> <col:number>', async function (game, player, row, col) {
// game 是 IGameContext<TState>,可访问和修改状态
game.produce(state => {
// state.parts[...].position = [row, col];
});
return { success: true };
});
```
### Schema 语法
| 语法 | 含义 |
|---|---|
| `name` | 命令名 |
| `<param>` | 必填参数(字符串) |
| `<param:number>` | 必填参数(自动转为数字) |
| `[--flag]` | 可选标志 |
| `[-x:number]` | 可选选项(带类型) |
### 命令处理器函数签名
命令处理器接收 `game``IGameContext<TState>`)作为第一个参数,后续参数来自命令解析:
```ts
registry.register('myCommand <arg>', async function (game, arg) {
const state = game.value; // 读取状态
game.produce(d => { d.currentPlayer = 'O'; }); // 同步修改状态
await game.produceAsync(d => { /* ... */ }); // 异步修改(等待动画)
const result = await game.prompt('confirm <action>', validator, currentPlayer);
const subResult = await subCommand(game, player); // 调用子命令
return { success: true };
});
```
`registry.register()` 返回一个可调用函数,可在其他命令中直接调用:
```ts
const subCommand = registry.register('sub <player>', async function (game, player) {
return { score: 10 };
});
// 在另一个命令中使用
registry.register('main', async function (game) {
const result = await subCommand(game, 'X');
// result = { success: true, result: { score: 10 } }
});
```
详见 [API 参考](./api-reference.md)。
## 使用 prompt 等待玩家输入
`game.prompt()` 暂停命令执行,等待外部通过 `host.onInput()` 提交输入:
```ts
const playCmd = await game.prompt(
'play <player> <row:number> <col:number>',
(command) => {
const [player, row, col] = command.params as [PlayerType, number, number];
if (player !== turnPlayer) throw `Invalid player: ${player}`;
if (row < 0 || row > 2 || col < 0 || col > 2) throw `Invalid position`;
if (isCellOccupied(game, row, col)) throw `Cell occupied`;
return { player, row, col }; // 验证通过,返回所需数据
},
game.value.currentPlayer
);
// playCmd = { player, row, col }
```
验证函数中 `throw` 字符串会触发重新提示,返回非 null 值表示验证通过并通过该值 resolve Promise。
## 使用 setup 驱动游戏循环
`setup` 作为入口点驱动游戏循环,通过调用其他命令函数实现:
```ts
// 注册 turn 命令并获取可调用函数
const turnCommand = registry.register('turn <player>', async function (game, player) {
// ... 执行回合逻辑
return { winner: null as WinnerType };
});
// 注册 setup 命令
registry.register('setup', async function (game) {
while (true) {
const currentPlayer = game.value.currentPlayer;
const turnOutput = await turnCommand(game, currentPlayer);
if (!turnOutput.success) throw new Error(turnOutput.error);
game.produce(state => {
state.winner = turnOutput.result.winner;
if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
}
});
if (game.value.winner) break;
}
return game.value;
});
```
## Part、Region 和 RNG
详见 [棋子、区域与 RNG](./parts-regions-rng.md)。
## 完整示例
参考以下示例:
- [`src/samples/tic-tac-toe.ts`](../src/samples/tic-tac-toe.ts) - 井字棋2D 棋盘、玩家轮流输入、胜负判定
- [`src/samples/boop/`](../src/samples/boop/) - Boop 游戏:六边形棋盘、推动机制、小猫升级

View File

@ -1,116 +0,0 @@
# 棋子、区域与 RNG
## 创建和放置 Part
```ts
import { createPart, createRegion, moveToRegion } from 'boardgame-core';
const board = createRegion('board', [
{ name: 'row', min: 0, max: 2 },
{ name: 'col', min: 0, max: 2 },
]);
const piece = createPart<{ owner: string }>(
{ regionId: '', position: [], owner: 'white' },
'piece-1'
);
state.produce(draft => {
draft.parts[piece.id] = piece;
// 推荐使用 moveToRegion 自动维护 childIds 和 partMap
moveToRegion(piece, null, draft.board, [1, 1]);
});
// 或者手动操作(不推荐,容易出错):
// state.produce(draft => {
// draft.parts[piece.id] = piece;
// draft.board.childIds.push(piece.id);
// draft.board.partMap['1,1'] = piece.id;
// piece.regionId = 'board';
// piece.position = [1, 1];
// });
```
## Part 池
```ts
const pool = createPartPool<{ type: string }>(
{ regionId: 'supply', type: 'kitten' },
10,
'kitten'
);
const piece = pool.draw(); // 取出一个
pool.return(piece); // 放回
pool.remaining(); // 剩余数量
```
## 从表格数据创建 Part
```ts
const parts = createPartsFromTable(
[
{ id: 'p1', regionId: 'board', position: [0, 0], owner: 'white' },
{ id: 'p2', regionId: 'board', position: [1, 1], owner: 'black' },
],
(item, index) => item.id, // 返回 ID 的函数
// 可选:每个 item 创建几个,默认 1
1
);
// parts = {
// 'p1': { id: 'p1', regionId: 'board', position: [0, 0], owner: 'white' },
// 'p2': { id: 'p2', regionId: 'board', position: [1, 1], owner: 'black' },
// }
```
`createPartsFromTable` 接受对象数组,每个对象的所有字段都会被展开到 Part 中。
## 查询棋子
```ts
// O(n) 遍历查找
findPartById(state.parts, 'piece-1');
getPartAtPosition(state.parts, 'board', [1, 1]);
isCellOccupied(state.parts, 'board', [1, 1]);
// O(1) 使用 Region.partMap 查找
getPartAtPositionInRegion(board, state.parts, [1, 1]);
isCellOccupiedByRegion(board, [1, 1]);
```
## 区域操作
```ts
import { applyAlign, shuffle, moveToRegion } from 'boardgame-core';
applyAlign(handRegion, state.parts); // 紧凑排列
shuffle(deckRegion, state.parts, rng); // 打乱
// 移动棋子sourceRegion 为 null 表示棋子当前不在区域中
moveToRegion(piece, sourceRegion, targetRegion, [0, 0]);
moveToRegion(piece, null, boardRegion, [0, 0]); // 从外部放入区域
moveToRegion(piece, boardRegion, null); // 从区域中移除(返回外部)
```
## 翻面与掷骰
```ts
import { flip, flipTo, roll } from 'boardgame-core';
flip(piece); // 翻到下一面
flipTo(piece, 2); // 翻到指定面
roll(piece, rng); // 随机面
```
## RNG
```ts
import { createRNG } from 'boardgame-core';
const rng = createRNG(12345);
rng.nextInt(6); // 0-5
rng.next(); // [0, 1)
```
完整 API 列表详见 [API 参考](./api-reference.md)。

View File

@ -10,6 +10,7 @@ export default defineConfig({
dts: true, dts: true,
clean: true, clean: true,
sourcemap: true, sourcemap: true,
external: ['@preact/signals-core', 'mutative', 'inline-schema'],
plugins: [csvLoader()], plugins: [csvLoader()],
esbuildOptions(options) { esbuildOptions(options) {
options.alias = { options.alias = {