docs: update docs
This commit is contained in:
parent
a0dd5c94f5
commit
60a6bcad77
|
|
@ -0,0 +1,91 @@
|
||||||
|
# 动画与状态更新同步
|
||||||
|
|
||||||
|
命令执行时,效应函数通过 `produce()` 立即更新状态,UI 层只能看到最终结果,
|
||||||
|
无法在中间插入动画。为了解决这个问题,`MutableSignal` 提供了动画中断机制。
|
||||||
|
|
||||||
|
## 基本原理
|
||||||
|
|
||||||
|
核心思路:**逻辑层将 `produce` 替换为 `produceAsync`,UI 层负责注册动画 interruption。**
|
||||||
|
|
||||||
|
时序如下:
|
||||||
|
|
||||||
|
```
|
||||||
|
逻辑层: produceAsync(fn1) produceAsync(fn2) produceAsync(fn3)
|
||||||
|
↓ (无 interruption, ↓ 等待 anim1 完成 ↓ 等待 anim2 完成
|
||||||
|
立即更新状态 1)
|
||||||
|
|
||||||
|
UI层: effect 检测到状态 1 变化 addInterruption(anim1) addInterruption(anim2)
|
||||||
|
播放动画 1 播放动画 2 播放动画 3
|
||||||
|
```
|
||||||
|
|
||||||
|
1. 第一个 `produceAsync` 没有 interruption 可等,立即更新状态
|
||||||
|
2. UI 层通过 `effect` 检测到状态变化,播放动画并调用 `addInterruption`
|
||||||
|
3. 第二个 `produceAsync` 被前一步的 interruption 阻塞,等待动画完成后再更新
|
||||||
|
4. 依此类推,形成链式等待
|
||||||
|
|
||||||
|
## 逻辑层:将 `produce` 替换为 `produceAsync`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 之前
|
||||||
|
registration.add('turn <player>', async function(cmd) {
|
||||||
|
const playCmd = await this.prompt('play <player> <row:number> <col:number>', validator, currentPlayer);
|
||||||
|
|
||||||
|
placePiece(this.context, row, col, pieceType); // 内部调用 produce
|
||||||
|
applyBoops(this.context, row, col, pieceType); // 内部调用 produce
|
||||||
|
});
|
||||||
|
|
||||||
|
// 之后:改为 produceAsync
|
||||||
|
registration.add('turn <player>', async function(cmd) {
|
||||||
|
const playCmd = await this.prompt('play <player> <row:number> <col:number>', validator, currentPlayer);
|
||||||
|
|
||||||
|
await placePieceAsync(this.context, row, col, pieceType); // 内部改用 produceAsync
|
||||||
|
await applyBoopsAsync(this.context, row, col, pieceType); // 内部改用 produceAsync
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI 层:监听状态变化并注册 interruption
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { effect } from '@preact/signals-core';
|
||||||
|
|
||||||
|
const host = createGameHost(module);
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const state = host.state.value;
|
||||||
|
// 每次 produceAsync 更新状态后,这里会被触发
|
||||||
|
// 播放对应的动画
|
||||||
|
const animation = playAnimationForState(state);
|
||||||
|
|
||||||
|
// 为下一个 produceAsync 注册 interruption
|
||||||
|
host.state.addInterruption(animation);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 辅助函数示例
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 将 produce 包装为 produceAsync 的辅助函数
|
||||||
|
async function placePieceAsync(context: MutableSignal<GameState>, row: number, col: number) {
|
||||||
|
await context.produceAsync(state => {
|
||||||
|
state.parts[piece.id] = piece;
|
||||||
|
board.childIds.push(piece.id);
|
||||||
|
board.partMap[`${row},${col}`] = piece.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## MutableSignal 中断 API
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `addInterruption(promise: Promise<void>)` | 添加中断 Promise,`produceAsync` 会等待它完成 |
|
||||||
|
| `clearInterruptions()` | 清除所有未完成的中断 |
|
||||||
|
| `produceAsync(fn: (draft: T) => void): Promise<void>` | 等待所有 interruption 完成后更新状态 |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- `produce()` 仍然保持同步,适合不需要动画的场景(如 setup 阶段)
|
||||||
|
- `produceAsync()` 使用 `Promise.allSettled` 等待所有 interruption,即使某个动画 reject 也不会阻止状态更新
|
||||||
|
- `clearInterruptions()` 会丢弃所有未完成的中断,建议在回合/阶段结束时调用
|
||||||
|
- 不要忘记 `await` `produceAsync()`,否则多个效应可能并发执行导致竞态
|
||||||
|
- 第一个 `produceAsync` 总是立即执行(无前序 interruption),从第二个开始等待动画
|
||||||
|
|
@ -49,3 +49,21 @@
|
||||||
| `Command` / `CommandSchema` / `CommandResult` | 命令相关类型 |
|
| `Command` / `CommandSchema` / `CommandResult` | 命令相关类型 |
|
||||||
| `PromptEvent` | 玩家输入提示事件 |
|
| `PromptEvent` | 玩家输入提示事件 |
|
||||||
| `createRNG(seed?)` | 创建种子 RNG |
|
| `createRNG(seed?)` | 创建种子 RNG |
|
||||||
|
|
||||||
|
## MutableSignal
|
||||||
|
|
||||||
|
| 导出 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `MutableSignal<T>` | 支持突变和动画中断的响应式信号 |
|
||||||
|
| `mutableSignal(initial?, options?)` | 创建 MutableSignal |
|
||||||
|
| `EntityCollection<T>` / `createEntityCollection()` | 实体集合辅助函数 |
|
||||||
|
|
||||||
|
### MutableSignal 成员
|
||||||
|
|
||||||
|
| 成员 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `value: T` | 获取当前值(继承自 Signal) |
|
||||||
|
| `produce(fn: (draft: T) => void): void` | 同步不可变更新 |
|
||||||
|
| `addInterruption(promise: Promise<void>): void` | 添加中断 Promise,`produceAsync` 会等待它完成 |
|
||||||
|
| `clearInterruptions(): void` | 清除所有未完成的中断 |
|
||||||
|
| `produceAsync(fn: (draft: T) => void): Promise<void>` | 等待所有 interruption 完成后更新状态 |
|
||||||
|
|
|
||||||
|
|
@ -145,3 +145,7 @@ while (host.status.value === 'running' && host.activePromptSchema.value) {
|
||||||
// 或彻底销毁
|
// 或彻底销毁
|
||||||
// host.dispose();
|
// host.dispose();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 动画同步
|
||||||
|
|
||||||
|
如需在状态更新之间播放动画,参考 [动画与状态更新同步](./animation-sync.md)。
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ GameModule 是定义游戏逻辑的模块,包含状态定义和命令注册。
|
||||||
```ts
|
```ts
|
||||||
import { createGameCommandRegistry, createRegion } from 'boardgame-core';
|
import { createGameCommandRegistry, createRegion } from 'boardgame-core';
|
||||||
|
|
||||||
// 1. 定义游戏状态
|
|
||||||
export function createInitialState() {
|
export function createInitialState() {
|
||||||
return {
|
return {
|
||||||
board: createRegion('board', [
|
board: createRegion('board', [
|
||||||
|
|
@ -23,25 +22,16 @@ export function createInitialState() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 创建命令注册表并注册命令
|
|
||||||
const registration = createGameCommandRegistry<ReturnType<typeof createInitialState>>();
|
const registration = createGameCommandRegistry<ReturnType<typeof createInitialState>>();
|
||||||
export const registry = registration.registry;
|
export const registry = registration.registry;
|
||||||
|
|
||||||
// 注册命令
|
registration.add('setup', async function () { /* ... */ });
|
||||||
registration.add('setup', async function () {
|
registration.add('play <player> <row:number> <col:number>', async function (cmd) { /* ... */ });
|
||||||
// ... 命令逻辑
|
|
||||||
});
|
|
||||||
|
|
||||||
registration.add('play <player> <row:number> <col:number>', async function (cmd) {
|
|
||||||
// ... 命令逻辑
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
也可以使用 `createGameModule` 辅助函数:
|
也可用 `createGameModule` 辅助函数包装:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { createGameModule, createGameCommandRegistry, createRegion } from 'boardgame-core';
|
|
||||||
|
|
||||||
export const gameModule = createGameModule({
|
export const gameModule = createGameModule({
|
||||||
registry: registration.registry,
|
registry: registration.registry,
|
||||||
createInitialState,
|
createInitialState,
|
||||||
|
|
@ -50,28 +40,13 @@ export const gameModule = createGameModule({
|
||||||
|
|
||||||
## 定义游戏状态
|
## 定义游戏状态
|
||||||
|
|
||||||
游戏状态是一个普通对象,通过 `createInitialState()` 工厂函数创建。建议使用 `ReturnType` 推导类型:
|
建议用 `ReturnType` 推导状态类型:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export function createInitialState() {
|
|
||||||
return {
|
|
||||||
board: createRegion('board', [
|
|
||||||
{ name: 'x', min: 0, max: 2 },
|
|
||||||
{ name: 'y', min: 0, max: 2 },
|
|
||||||
]),
|
|
||||||
parts: {} as Record<string, TicTacToePart>,
|
|
||||||
currentPlayer: 'X' as PlayerType,
|
|
||||||
winner: null as WinnerType,
|
|
||||||
turn: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export type GameState = ReturnType<typeof createInitialState>;
|
export type GameState = ReturnType<typeof createInitialState>;
|
||||||
```
|
```
|
||||||
|
|
||||||
状态通常包含:
|
状态通常包含 Region、parts(`Record<string, Part>`)以及游戏专属字段(当前玩家、分数等)。
|
||||||
- **Region**:用 `createRegion()` 创建的空间区域
|
|
||||||
- **parts**:`Record<string, Part>` 游戏棋子集合
|
|
||||||
- 游戏特有的字段:当前玩家、分数、回合数等
|
|
||||||
|
|
||||||
## 注册命令
|
## 注册命令
|
||||||
|
|
||||||
|
|
@ -106,96 +81,50 @@ registration.add('play <player> <row:number> <col:number>', async function (cmd)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
registration.add('myCommand <arg>', async function (cmd) {
|
registration.add('myCommand <arg>', async function (cmd) {
|
||||||
// 读取状态
|
const state = this.context.value; // 读取状态
|
||||||
const state = this.context.value;
|
this.context.produce(d => { d.currentPlayer = 'O'; }); // 修改状态
|
||||||
|
|
||||||
// 修改状态
|
const result = await this.prompt('confirm <action>', validator, currentPlayer);
|
||||||
this.context.produce(draft => {
|
|
||||||
draft.currentPlayer = 'O';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 提示玩家输入
|
|
||||||
const result = await this.prompt(
|
|
||||||
'confirm <action>',
|
|
||||||
(command) => {
|
|
||||||
// 验证函数:返回 null 表示有效,返回 string 表示错误信息
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
this.context.value.currentPlayer // currentPlayer 参数可选
|
|
||||||
);
|
|
||||||
|
|
||||||
// 调用子命令
|
|
||||||
const subResult = await this.run<{ score: number }>(`score ${player}`);
|
const subResult = await this.run<{ score: number }>(`score ${player}`);
|
||||||
if (subResult.success) {
|
|
||||||
console.log(subResult.result.score);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回命令结果
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
详见 [API 参考](./api-reference.md)。
|
||||||
|
|
||||||
## 使用 prompt 等待玩家输入
|
## 使用 prompt 等待玩家输入
|
||||||
|
|
||||||
`this.prompt()` 是处理玩家输入的核心方法。它会暂停命令执行,等待外部通过 `host.onInput()` 提交输入:
|
`this.prompt()` 暂停命令执行,等待外部通过 `host.onInput()` 提交输入:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
registration.add('turn <player> <turn:number>', async function (cmd) {
|
const playCmd = await this.prompt(
|
||||||
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
|
'play <player> <row:number> <col:number>',
|
||||||
|
(command) => {
|
||||||
// 等待玩家输入
|
const [player, row, col] = command.params as [PlayerType, number, number];
|
||||||
const playCmd = await this.prompt(
|
if (player !== turnPlayer) return `Invalid player: ${player}`;
|
||||||
'play <player> <row:number> <col:number>', // 期望的输入格式
|
if (row < 0 || row > 2 || col < 0 || col > 2) return `Invalid position`;
|
||||||
(command) => {
|
if (isCellOccupied(this.context, row, col)) return `Cell occupied`;
|
||||||
const [player, row, col] = command.params as [PlayerType, number, number];
|
return null;
|
||||||
|
},
|
||||||
// 验证逻辑
|
this.context.value.currentPlayer
|
||||||
if (player !== turnPlayer) {
|
);
|
||||||
return `Invalid player: ${player}`;
|
|
||||||
}
|
|
||||||
if (row < 0 || row > 2 || col < 0 || col > 2) {
|
|
||||||
return `Invalid position: (${row}, ${col})`;
|
|
||||||
}
|
|
||||||
if (isCellOccupied(this.context, row, col)) {
|
|
||||||
return `Cell (${row}, ${col}) is already occupied`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null; // 验证通过
|
|
||||||
},
|
|
||||||
this.context.value.currentPlayer // 可选:标记当前等待的玩家
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证通过后,playCmd 是已解析的命令对象
|
|
||||||
const [player, row, col] = playCmd.params as [PlayerType, number, number];
|
|
||||||
|
|
||||||
// 执行放置
|
|
||||||
placePiece(this.context, row, col, player);
|
|
||||||
|
|
||||||
return { winner: checkWinner(this.context) };
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
验证函数返回 `null` 表示输入有效,返回 `string` 表示错误信息。
|
验证函数返回 `null` 表示有效,返回 `string` 表示错误信息。验证通过后 `playCmd` 是已解析的命令对象。
|
||||||
|
|
||||||
## 使用 setup 命令驱动游戏循环
|
## 使用 setup 驱动游戏循环
|
||||||
|
|
||||||
`setup` 命令通常作为游戏的入口点,负责驱动整个游戏循环:
|
`setup` 作为入口点驱动游戏循环:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
registration.add('setup', async function () {
|
registration.add('setup', async function () {
|
||||||
const { context } = this;
|
const { context } = this;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const currentPlayer = context.value.currentPlayer;
|
const currentPlayer = context.value.currentPlayer;
|
||||||
const turnNumber = context.value.turn + 1;
|
const turnNumber = context.value.turn + 1;
|
||||||
|
const turnOutput = await this.run<{ winner: WinnerType }>(`turn ${currentPlayer} ${turnNumber}`);
|
||||||
// 运行回合命令
|
|
||||||
const turnOutput = await this.run<{ winner: WinnerType }>(
|
|
||||||
`turn ${currentPlayer} ${turnNumber}`
|
|
||||||
);
|
|
||||||
if (!turnOutput.success) throw new Error(turnOutput.error);
|
if (!turnOutput.success) throw new Error(turnOutput.error);
|
||||||
|
|
||||||
// 更新状态
|
|
||||||
context.produce(state => {
|
context.produce(state => {
|
||||||
state.winner = turnOutput.result.winner;
|
state.winner = turnOutput.result.winner;
|
||||||
if (!state.winner) {
|
if (!state.winner) {
|
||||||
|
|
@ -203,88 +132,17 @@ registration.add('setup', async function () {
|
||||||
state.turn = turnNumber;
|
state.turn = turnNumber;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 游戏结束条件
|
|
||||||
if (context.value.winner) break;
|
if (context.value.winner) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return context.value;
|
return context.value;
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## 使用 Part 和 Region
|
## Part、Region 和 RNG
|
||||||
|
|
||||||
### 创建和放置 Part
|
详见 [棋子、区域与 RNG](./parts-regions-rng.md)。
|
||||||
|
|
||||||
```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: 'board', position: [1, 1], owner: 'white' },
|
|
||||||
'piece-1'
|
|
||||||
);
|
|
||||||
|
|
||||||
// 放入状态
|
|
||||||
state.produce(draft => {
|
|
||||||
draft.parts[piece.id] = piece;
|
|
||||||
draft.board.childIds.push(piece.id);
|
|
||||||
draft.board.partMap['1,1'] = piece.id;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 创建 Part 池
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createPartPool } from 'boardgame-core';
|
|
||||||
|
|
||||||
// 从池中抽取
|
|
||||||
const pool = createPartPool<{ type: string }>(
|
|
||||||
{ regionId: 'supply', type: 'kitten' },
|
|
||||||
10,
|
|
||||||
'kitten'
|
|
||||||
);
|
|
||||||
|
|
||||||
const piece = pool.draw(); // 取出一个
|
|
||||||
pool.return(piece); // 放回
|
|
||||||
pool.remaining(); // 剩余数量
|
|
||||||
```
|
|
||||||
|
|
||||||
### 区域操作
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { applyAlign, shuffle, moveToRegion, isCellOccupied } from 'boardgame-core';
|
|
||||||
|
|
||||||
// 检查格子是否被占用
|
|
||||||
if (isCellOccupied(state.parts, 'board', [1, 1])) { ... }
|
|
||||||
|
|
||||||
// 对齐排列(紧凑排列)
|
|
||||||
applyAlign(handRegion, state.parts);
|
|
||||||
|
|
||||||
// 打乱位置
|
|
||||||
shuffle(deckRegion, state.parts, rng);
|
|
||||||
|
|
||||||
// 移动到其他区域
|
|
||||||
moveToRegion(piece, sourceRegion, targetRegion, [0, 0]);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用 RNG
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createRNG } from 'boardgame-core';
|
|
||||||
|
|
||||||
const rng = createRNG(12345); // 种子
|
|
||||||
rng.nextInt(6); // 0-5
|
|
||||||
rng.next(); // [0, 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 完整示例:井字棋
|
|
||||||
|
|
||||||
参考 [`src/samples/tic-tac-toe.ts`](../src/samples/tic-tac-toe.ts),包含:
|
参考 [`src/samples/tic-tac-toe.ts`](../src/samples/tic-tac-toe.ts),包含:
|
||||||
- 2D 棋盘区域
|
- 2D 棋盘区域
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# 棋子、区域与 RNG
|
||||||
|
|
||||||
|
## 创建和放置 Part
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createPart, createRegion } 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: 'board', position: [1, 1], owner: 'white' },
|
||||||
|
'piece-1'
|
||||||
|
);
|
||||||
|
|
||||||
|
state.produce(draft => {
|
||||||
|
draft.parts[piece.id] = piece;
|
||||||
|
draft.board.childIds.push(piece.id);
|
||||||
|
draft.board.partMap['1,1'] = piece.id;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Part 池
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const pool = createPartPool<{ type: string }>(
|
||||||
|
{ regionId: 'supply', type: 'kitten' },
|
||||||
|
10,
|
||||||
|
'kitten'
|
||||||
|
);
|
||||||
|
|
||||||
|
const piece = pool.draw(); // 取出一个
|
||||||
|
pool.return(piece); // 放回
|
||||||
|
pool.remaining(); // 剩余数量
|
||||||
|
```
|
||||||
|
|
||||||
|
## 区域操作
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { applyAlign, shuffle, moveToRegion, isCellOccupied } from 'boardgame-core';
|
||||||
|
|
||||||
|
isCellOccupied(state.parts, 'board', [1, 1]);
|
||||||
|
applyAlign(handRegion, state.parts); // 紧凑排列
|
||||||
|
shuffle(deckRegion, state.parts, rng); // 打乱
|
||||||
|
moveToRegion(piece, sourceRegion, targetRegion, [0, 0]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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)。
|
||||||
Loading…
Reference in New Issue