refactor: clean up
This commit is contained in:
parent
2b59adf000
commit
2984d8b20d
|
|
@ -1,689 +0,0 @@
|
||||||
# boardgame-core 使用指南
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
`boardgame-core` 是一个基于 Preact Signals 的桌游状态管理库,提供响应式状态、实体集合、空间区域系统和命令驱动的游戏循环。
|
|
||||||
|
|
||||||
## 核心概念
|
|
||||||
|
|
||||||
### 1. MutableSignal — 响应式状态容器
|
|
||||||
|
|
||||||
`MutableSignal<T>` 扩展了 Preact Signal,增加了通过 Mutative 进行不可变状态更新的能力。
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { mutableSignal } from 'boardgame-core';
|
|
||||||
|
|
||||||
// 创建响应式状态
|
|
||||||
const state = mutableSignal({ score: 0, players: [] });
|
|
||||||
|
|
||||||
// 读取状态
|
|
||||||
console.log(state.value.score);
|
|
||||||
|
|
||||||
// 更新状态 — 使用 produce 进行不可变更新
|
|
||||||
state.produce(draft => {
|
|
||||||
draft.score += 10;
|
|
||||||
draft.players.push('Alice');
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键 API:**
|
|
||||||
- `.value` — 访问当前状态值
|
|
||||||
- `.produce(fn)` — 通过 Mutative 更新状态(类似 Immer 的 `produce`)
|
|
||||||
|
|
||||||
### 3. Region System — 空间区域管理
|
|
||||||
|
|
||||||
`Region` 用于管理游戏部件的空间位置和分组。
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createRegion, applyAlign, shuffle, moveToRegion } from 'boardgame-core';
|
|
||||||
|
|
||||||
// 创建区域
|
|
||||||
const board = createRegion('board', [
|
|
||||||
{ name: 'x', min: 0, max: 7 },
|
|
||||||
{ name: 'y', min: 0, max: 7 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hand = createRegion('hand', [
|
|
||||||
{ name: 'x', min: 0, max: 10, align: 'start' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 区域属性:
|
|
||||||
// - id: 区域标识
|
|
||||||
// - axes: 坐标轴定义(name, min, max, align)
|
|
||||||
// - childIds: 包含的部件 ID 列表
|
|
||||||
// - partMap: 位置 → ID 映射
|
|
||||||
```
|
|
||||||
|
|
||||||
**区域操作:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// 注意:parts 现在是 Record<string, Part> 格式
|
|
||||||
const parts: Record<string, Part> = { ... };
|
|
||||||
|
|
||||||
// 对齐/紧凑排列(根据 axis.align 自动调整位置)
|
|
||||||
applyAlign(hand, parts);
|
|
||||||
|
|
||||||
// 随机打乱位置(需要 RNG)
|
|
||||||
shuffle(board, parts, rng);
|
|
||||||
|
|
||||||
// 移动部件到目标区域
|
|
||||||
moveToRegion(part, sourceRegion, targetRegion, [2, 3]);
|
|
||||||
|
|
||||||
// 批量移动
|
|
||||||
moveToRegionAll(parts, sourceRegion, targetRegion, positions);
|
|
||||||
|
|
||||||
// 从区域移除部件
|
|
||||||
removeFromRegion(part, region);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Part System — 游戏部件系统
|
|
||||||
|
|
||||||
`Part<TMeta>` 表示游戏中的一个部件(棋子、卡牌等)。
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createPart, createParts, createPartPool, flip, flipTo, roll, mergePartPools } from 'boardgame-core';
|
|
||||||
|
|
||||||
// 创建单个部件
|
|
||||||
const piece = createPart(
|
|
||||||
{ regionId: 'board', position: [0, 0], player: 'X' },
|
|
||||||
'piece-1'
|
|
||||||
);
|
|
||||||
|
|
||||||
// 批量创建(生成 piece-pawn-1, piece-pawn-2, ...)
|
|
||||||
const pawns = createParts(
|
|
||||||
{ regionId: 'supply', position: [0, 0] },
|
|
||||||
8,
|
|
||||||
'piece-pawn'
|
|
||||||
);
|
|
||||||
|
|
||||||
// 部件池(用于抽牌堆等)
|
|
||||||
const deck = createPartPool(
|
|
||||||
{ regionId: 'deck', sides: 4 }, // template
|
|
||||||
52, // count
|
|
||||||
'card' // id prefix
|
|
||||||
);
|
|
||||||
|
|
||||||
// 抽牌
|
|
||||||
const card = deck.draw();
|
|
||||||
|
|
||||||
// 返回部件到池中
|
|
||||||
deck.return(card);
|
|
||||||
|
|
||||||
// 剩余数量
|
|
||||||
console.log(deck.remaining());
|
|
||||||
|
|
||||||
// 合并多个牌池
|
|
||||||
const merged = mergePartPools(deck1, deck2);
|
|
||||||
```
|
|
||||||
|
|
||||||
**部件属性:**
|
|
||||||
- `id` — 唯一标识
|
|
||||||
- `sides` — 面数(如骰子 6 面,卡牌 2 面)
|
|
||||||
- `side` — 当前朝向(0-based 索引)
|
|
||||||
- `regionId` — 所属区域
|
|
||||||
- `position` — 位置坐标(数组,长度 = axes 数量)
|
|
||||||
- `alignments` — 对齐方式列表
|
|
||||||
- `alignment` — 当前对齐方式
|
|
||||||
- `...TMeta` — 自定义元数据
|
|
||||||
|
|
||||||
**部件操作:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// 翻面(循环到下一面)
|
|
||||||
flip(card);
|
|
||||||
|
|
||||||
// 翻到指定面
|
|
||||||
flipTo(card, 1);
|
|
||||||
|
|
||||||
// 随机面(使用 RNG)
|
|
||||||
roll(dice, rng);
|
|
||||||
```
|
|
||||||
|
|
||||||
**部件查询:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { findPartById, isCellOccupied, getPartAtPosition } from 'boardgame-core';
|
|
||||||
|
|
||||||
// Parts 现在是 Record<string, Part> 格式
|
|
||||||
const parts: Record<string, Part> = {
|
|
||||||
'piece-1': piece1,
|
|
||||||
'piece-2': piece2,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 按 ID 查找
|
|
||||||
const piece = findPartById(parts, 'piece-1');
|
|
||||||
|
|
||||||
// 检查位置是否被占用
|
|
||||||
if (isCellOccupied(parts, 'board', [2, 3])) {
|
|
||||||
// 位置已被占用
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取指定位置的部件
|
|
||||||
const part = getPartAtPosition(parts, 'board', [2, 3]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Command System — 命令系统
|
|
||||||
|
|
||||||
CLI 风格的命令解析、验证和执行系统。
|
|
||||||
|
|
||||||
#### 命令字符串格式
|
|
||||||
|
|
||||||
```
|
|
||||||
commandName <param1> <param2:type> [--option value] [-s short] [--flag]
|
|
||||||
```
|
|
||||||
|
|
||||||
**示例:**
|
|
||||||
```
|
|
||||||
place 2 3:number --force -x 10
|
|
||||||
move card1 hand --type=kitten
|
|
||||||
turn X 1
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 定义命令注册表
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createGameCommandRegistry, MutableSignal } from 'boardgame-core';
|
|
||||||
|
|
||||||
type GameState = {
|
|
||||||
board: Region;
|
|
||||||
parts: Part[];
|
|
||||||
currentPlayer: 'X' | 'O';
|
|
||||||
winner: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 创建注册表
|
|
||||||
const registration = createGameCommandRegistry<GameState>();
|
|
||||||
export const registry = registration.registry;
|
|
||||||
|
|
||||||
// 注册命令(链式 API)
|
|
||||||
registration.add('place <row:number> <col:number>', async function(cmd) {
|
|
||||||
const [row, col] = cmd.params as [number, number];
|
|
||||||
const player = this.context.value.currentPlayer;
|
|
||||||
|
|
||||||
// 更新状态
|
|
||||||
this.context.produce(state => {
|
|
||||||
state.parts.push({ id: `piece-${row}-${col}`, regionId: 'board', position: [row, col], player });
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, row, col };
|
|
||||||
});
|
|
||||||
|
|
||||||
// 继续注册更多命令
|
|
||||||
registration.add('turn <player> <turn:number>', async function(cmd) {
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 命令处理器的 `this` 上下文
|
|
||||||
|
|
||||||
命令处理函数中,`this` 是 `CommandRunnerContext`,提供:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
registration.add('my-command <arg>', async function(cmd) {
|
|
||||||
// this.context — 游戏上下文(MutableSignal<GameState>)
|
|
||||||
const state = this.context.value;
|
|
||||||
|
|
||||||
// this.context.produce — 更新状态
|
|
||||||
this.context.produce(draft => {
|
|
||||||
draft.score += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
// this.run — 运行子命令
|
|
||||||
const result = await this.run<PlaceResult>(`place 2 3`);
|
|
||||||
if (result.success) {
|
|
||||||
console.log(result.result.row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// this.prompt — 等待玩家输入
|
|
||||||
const playCmd = await this.prompt(
|
|
||||||
'play <player> <row:number> <col:number>',
|
|
||||||
(command) => {
|
|
||||||
// 验证函数:返回 null 表示有效,返回 string 表示错误消息
|
|
||||||
const [player, row, col] = command.params;
|
|
||||||
if (player !== state.currentPlayer) {
|
|
||||||
return `Invalid player: ${player}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 运行命令
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createGameContext } from 'boardgame-core';
|
|
||||||
|
|
||||||
const game = createGameContext(registry, createInitialState);
|
|
||||||
|
|
||||||
// 运行命令(返回 Promise<CommandResult>)
|
|
||||||
const result = await game.commands.run('place 2 3');
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
console.log('Command succeeded:', result.result);
|
|
||||||
} else {
|
|
||||||
console.error('Command failed:', result.error);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Prompt 系统 — 等待玩家输入
|
|
||||||
|
|
||||||
某些命令需要等待玩家输入(如选择落子位置)。使用 `this.prompt()` 和 `promptQueue`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// 方法 1:内部使用(命令处理器中)
|
|
||||||
registration.add('turn <player>', async function(cmd) {
|
|
||||||
const player = cmd.params[0] as string;
|
|
||||||
|
|
||||||
// 等待玩家输入,带验证
|
|
||||||
const playCmd = await this.prompt(
|
|
||||||
'play <row:number> <col:number>',
|
|
||||||
(command) => {
|
|
||||||
const [row, col] = command.params;
|
|
||||||
if (isCellOccupied(this.context.value.parts, 'board', [row, col])) {
|
|
||||||
return `Cell (${row}, ${col}) is occupied`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 继续处理...
|
|
||||||
const [row, col] = playCmd.params;
|
|
||||||
placePiece(this.context, row, col, player);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 方法 2:外部使用(UI/网络层)
|
|
||||||
const game = createGameContext(registry, initialState);
|
|
||||||
|
|
||||||
// 启动需要玩家输入的命令
|
|
||||||
const runPromise = game.commands.run('turn X');
|
|
||||||
|
|
||||||
// 等待 prompt 事件
|
|
||||||
const promptEvent = await game.commands.promptQueue.pop();
|
|
||||||
console.log('Expected input:', promptEvent.schema);
|
|
||||||
|
|
||||||
// 提交玩家输入
|
|
||||||
const error = promptEvent.tryCommit('play 2 3');
|
|
||||||
if (error) {
|
|
||||||
console.log('Invalid move:', error);
|
|
||||||
// 可以再次尝试
|
|
||||||
const error2 = promptEvent.tryCommit('play 1 1');
|
|
||||||
} else {
|
|
||||||
// 输入已接受,命令继续执行
|
|
||||||
const result = await runPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 或者取消
|
|
||||||
promptEvent.cancel('player quit');
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Random Number Generation — 确定性随机数
|
|
||||||
|
|
||||||
使用 Mulberry32 算法提供可重现的随机数生成。
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createRNG } from 'boardgame-core';
|
|
||||||
|
|
||||||
// 创建 RNG(可选种子)
|
|
||||||
const rng = createRNG(12345);
|
|
||||||
|
|
||||||
// 获取 [0, 1) 随机数
|
|
||||||
const r1 = rng.next();
|
|
||||||
|
|
||||||
// 获取 [0, max) 随机数
|
|
||||||
const r2 = rng.next(100);
|
|
||||||
|
|
||||||
// 获取 [0, max) 随机整数
|
|
||||||
const dice = rng.nextInt(6); // 0-5
|
|
||||||
|
|
||||||
// 重新设置种子
|
|
||||||
rng.setSeed(999);
|
|
||||||
|
|
||||||
// 获取当前种子
|
|
||||||
const seed = rng.getSeed();
|
|
||||||
```
|
|
||||||
|
|
||||||
## 完整示例:井字棋
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import {
|
|
||||||
createGameCommandRegistry,
|
|
||||||
createRegion,
|
|
||||||
createPart,
|
|
||||||
MutableSignal,
|
|
||||||
isCellOccupied as isCellOccupiedUtil,
|
|
||||||
} from 'boardgame-core';
|
|
||||||
|
|
||||||
const BOARD_SIZE = 3;
|
|
||||||
|
|
||||||
type PlayerType = 'X' | 'O';
|
|
||||||
type TicTacToePart = Part<{ player: PlayerType }>;
|
|
||||||
|
|
||||||
export function createInitialState() {
|
|
||||||
return {
|
|
||||||
board: createRegion('board', [
|
|
||||||
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
|
||||||
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
|
||||||
]),
|
|
||||||
parts: {} as Record<string, TicTacToePart>,
|
|
||||||
currentPlayer: 'X' as PlayerType,
|
|
||||||
winner: null as PlayerType | 'draw' | null,
|
|
||||||
turn: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
|
||||||
|
|
||||||
const registration = createGameCommandRegistry<TicTacToeState>();
|
|
||||||
export const registry = registration.registry;
|
|
||||||
|
|
||||||
// 游戏主循环
|
|
||||||
registration.add('setup', async function() {
|
|
||||||
const { context } = this;
|
|
||||||
while (true) {
|
|
||||||
const currentPlayer = context.value.currentPlayer;
|
|
||||||
const turnNumber = context.value.turn + 1;
|
|
||||||
|
|
||||||
const turnOutput = await this.run<{winner: PlayerType | 'draw' | null}>(
|
|
||||||
`turn ${currentPlayer} ${turnNumber}`
|
|
||||||
);
|
|
||||||
if (!turnOutput.success) throw new Error(turnOutput.error);
|
|
||||||
|
|
||||||
context.produce(state => {
|
|
||||||
state.winner = turnOutput.result.winner;
|
|
||||||
if (!state.winner) {
|
|
||||||
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
|
||||||
state.turn = turnNumber;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (context.value.winner) break;
|
|
||||||
}
|
|
||||||
return context.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 单个回合
|
|
||||||
registration.add('turn <player> <turn:number>', async function(cmd) {
|
|
||||||
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
|
|
||||||
|
|
||||||
// 等待玩家输入
|
|
||||||
const playCmd = await this.prompt(
|
|
||||||
'play <player> <row:number> <col:number>',
|
|
||||||
(command) => {
|
|
||||||
const [player, row, col] = command.params as [PlayerType, number, number];
|
|
||||||
|
|
||||||
if (player !== turnPlayer) {
|
|
||||||
return `Invalid player: ${player}`;
|
|
||||||
}
|
|
||||||
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
|
|
||||||
return `Invalid position: (${row}, ${col})`;
|
|
||||||
}
|
|
||||||
if (isCellOccupied(this.context.value.parts, 'board', [row, col])) {
|
|
||||||
return `Cell (${row}, ${col}) is occupied`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const [player, row, col] = playCmd.params as [PlayerType, number, number];
|
|
||||||
|
|
||||||
// 放置棋子
|
|
||||||
const piece = createPart(
|
|
||||||
{ regionId: 'board', position: [row, col], player },
|
|
||||||
`piece-${player}-${turnNumber}`
|
|
||||||
);
|
|
||||||
this.context.produce(state => {
|
|
||||||
state.parts[piece.id] = piece;
|
|
||||||
state.board.childIds.push(piece.id);
|
|
||||||
state.board.partMap[`${row},${col}`] = piece.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 检查胜负
|
|
||||||
const winner = checkWinner(this.context);
|
|
||||||
if (winner) return { winner };
|
|
||||||
if (turnNumber >= BOARD_SIZE * BOARD_SIZE) return { winner: 'draw' };
|
|
||||||
|
|
||||||
return { winner: null };
|
|
||||||
});
|
|
||||||
|
|
||||||
function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean {
|
|
||||||
return isCellOccupiedUtil(host.value.parts, 'board', [row, col]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkWinner(host: MutableSignal<TicTacToeState>): PlayerType | 'draw' | null {
|
|
||||||
// 实现胜负判断...
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 从模块创建游戏上下文
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createGameContextFromModule } from 'boardgame-core';
|
|
||||||
import * as ticTacToe from './tic-tac-toe';
|
|
||||||
|
|
||||||
const game = createGameContextFromModule(ticTacToe);
|
|
||||||
// 等同于:
|
|
||||||
// const game = createGameContext(ticTacToe.registry, ticTacToe.createInitialState());
|
|
||||||
```
|
|
||||||
|
|
||||||
## API 参考
|
|
||||||
|
|
||||||
### 核心
|
|
||||||
|
|
||||||
| 导出 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `IGameContext<TState>` | 游戏上下文接口(包含 state 和 commands) |
|
|
||||||
| `createGameContext(registry, initialState?)` | 创建游戏上下文实例 |
|
|
||||||
| `createGameContextFromModule(module)` | 从模块(registry + createInitialState)创建游戏上下文 |
|
|
||||||
| `createGameCommandRegistry<TState>()` | 创建命令注册表(带 `.add()` 链式 API) |
|
|
||||||
|
|
||||||
### MutableSignal
|
|
||||||
|
|
||||||
| 导出 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `MutableSignal<T>` | 响应式信号类型,扩展 Preact Signal |
|
|
||||||
| `mutableSignal(initial?)` | 创建 MutableSignal |
|
|
||||||
|
|
||||||
### Part
|
|
||||||
|
|
||||||
| 导出 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `Part<TMeta>` | 部件类型 |
|
|
||||||
| `PartTemplate<TMeta>` | 部件模板(创建时排除 id) |
|
|
||||||
| `PartPool<TMeta>` | 部件池(draw/return/remaining) |
|
|
||||||
| `createPart(template, id)` | 创建单个部件 |
|
|
||||||
| `createParts(template, count, idPrefix)` | 批量创建部件(ID 从 1 开始:`prefix-1`, `prefix-2`, ...) |
|
|
||||||
| `createPartPool(template, count, idPrefix)` | 创建部件池 |
|
|
||||||
| `mergePartPools(...pools)` | 合并部件池 |
|
|
||||||
| `findPartById(parts, id)` | 按 ID 查找(`parts` 为 `Record<string, Part>`) |
|
|
||||||
| `isCellOccupied(parts, regionId, position)` | 检查位置占用(`parts` 为 `Record<string, Part>`) |
|
|
||||||
| `getPartAtPosition(parts, regionId, position)` | 获取位置上的部件(`parts` 为 `Record<string, Part>`) |
|
|
||||||
| `flip(part)` | 翻面 |
|
|
||||||
| `flipTo(part, side)` | 翻到指定面 |
|
|
||||||
| `roll(part, rng)` | 随机面 |
|
|
||||||
|
|
||||||
### Region
|
|
||||||
|
|
||||||
| 导出 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `Region` | 区域类型 |
|
|
||||||
| `RegionAxis` | 坐标轴定义 |
|
|
||||||
| `createRegion(id, axes)` | 创建区域 |
|
|
||||||
| `applyAlign(region, parts)` | 对齐/紧凑排列(`parts` 为 `Record<string, Part>`) |
|
|
||||||
| `shuffle(region, parts, rng)` | 随机打乱位置(`parts` 为 `Record<string, Part>`) |
|
|
||||||
| `moveToRegion(part, sourceRegion?, targetRegion, position?)` | 移动部件(`sourceRegion` 可选) |
|
|
||||||
| `moveToRegionAll(parts, sourceRegion?, targetRegion, positions?)` | 批量移动(`parts` 为 `Record<string, Part>`,`sourceRegion` 可选) |
|
|
||||||
| `removeFromRegion(part, region)` | 移除部件 |
|
|
||||||
|
|
||||||
### Command
|
|
||||||
|
|
||||||
| 导出 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `Command` | 命令对象(name, params, options, flags) |
|
|
||||||
| `CommandSchema` | 命令模式定义 |
|
|
||||||
| `CommandResult<T>` | 命令结果(成功/失败联合类型) |
|
|
||||||
| `parseCommand(input)` | 解析命令字符串 |
|
|
||||||
| `parseCommandSchema(schema)` | 解析模式字符串 |
|
|
||||||
| `validateCommand(cmd, schema)` | 验证命令 |
|
|
||||||
| `parseCommandWithSchema(cmd, schema)` | 解析并验证 |
|
|
||||||
| `applyCommandSchema(cmd, schema)` | 应用模式验证并返回验证后的命令 |
|
|
||||||
| `createCommandRegistry<TContext>()` | 创建命令注册表 |
|
|
||||||
| `registerCommand(registry, runner)` | 注册命令 |
|
|
||||||
| `unregisterCommand(registry, name)` | 取消注册命令 |
|
|
||||||
| `hasCommand(registry, name)` | 检查命令是否存在 |
|
|
||||||
| `getCommand(registry, name)` | 获取命令处理器 |
|
|
||||||
| `runCommand(registry, context, input)` | 解析并运行命令 |
|
|
||||||
| `runCommandParsed(registry, context, command)` | 运行已解析的命令 |
|
|
||||||
| `createCommandRunnerContext(registry, context)` | 创建命令运行器上下文 |
|
|
||||||
| `PromptEvent` | Prompt 事件(tryCommit/cancel) |
|
|
||||||
| `CommandRunnerContext<TContext>` | 命令运行器上下文 |
|
|
||||||
| `CommandRunnerContextExport<TContext>` | 导出的命令运行器上下文(含 `promptQueue`) |
|
|
||||||
|
|
||||||
### Utilities
|
|
||||||
|
|
||||||
| 导出 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `createRNG(seed?)` | 创建随机数生成器 |
|
|
||||||
| `Mulberry32RNG` | Mulberry32 PRNG 类 |
|
|
||||||
| `AsyncQueue<T>` | 异步队列(用于 promptQueue) |
|
|
||||||
|
|
||||||
## 最佳实践
|
|
||||||
|
|
||||||
### 状态更新
|
|
||||||
|
|
||||||
- **总是使用 `produce()`** 更新状态,不要直接修改 `.value`
|
|
||||||
- `produce()` 内部是 draft 模式,可以直接修改属性
|
|
||||||
- **Parts 使用 Record 格式**,通过 ID 作为键访问
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ✅ 正确
|
|
||||||
state.produce(draft => {
|
|
||||||
draft.score += 1;
|
|
||||||
draft.parts[newPart.id] = newPart;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ❌ 错误 — 会破坏响应式
|
|
||||||
state.value.score = 10;
|
|
||||||
|
|
||||||
// ❌ 错误 — parts 是 Record 不是数组
|
|
||||||
state.parts.push(newPart);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 命令设计
|
|
||||||
|
|
||||||
- **命令应该是纯操作**:只修改状态,不处理 UI
|
|
||||||
- **使用 schema 验证**:在命令定义中声明参数类型
|
|
||||||
- **使用 prompt 处理玩家输入**:不要假设输入顺序
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ✅ 好的命令设计
|
|
||||||
registration.add('move <from> <to>', async function(cmd) {
|
|
||||||
// 只负责移动逻辑
|
|
||||||
});
|
|
||||||
|
|
||||||
// ❌ 避免在命令中处理 UI
|
|
||||||
registration.add('show-alert', async function(cmd) {
|
|
||||||
alert('Hello'); // 不要这样做
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 错误处理
|
|
||||||
|
|
||||||
- **命令返回 `CommandResult`**:使用 `{ success, result/error }` 模式
|
|
||||||
- **使用 try/catch 包装外部调用**:捕获错误并返回失败结果
|
|
||||||
|
|
||||||
```ts
|
|
||||||
registration.add('risky-op', async function(cmd) {
|
|
||||||
try {
|
|
||||||
// 可能失败的操作
|
|
||||||
const data = await fetchSomething();
|
|
||||||
return { success: true, data };
|
|
||||||
} catch (e) {
|
|
||||||
const error = e as Error;
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt 验证器
|
|
||||||
|
|
||||||
- **验证器返回 `null` 表示有效**
|
|
||||||
- **验证器返回 `string` 表示错误消息**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const cmd = await this.prompt(
|
|
||||||
'play <row:number>',
|
|
||||||
(command) => {
|
|
||||||
const [row] = command.params;
|
|
||||||
if (row < 0 || row > 2) {
|
|
||||||
return `Row must be 0-2, got ${row}`;
|
|
||||||
}
|
|
||||||
return null; // 验证通过
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 确定性游戏
|
|
||||||
|
|
||||||
- **使用固定种子的 RNG**:保证游戏可重现
|
|
||||||
- **所有随机操作都通过 RNG**:不要用 `Math.random()`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const rng = createRNG(12345); // 固定种子
|
|
||||||
shuffle(deck, parts, rng); // 使用 RNG 打乱
|
|
||||||
roll(dice, rng); // 使用 RNG 掷骰子
|
|
||||||
```
|
|
||||||
|
|
||||||
## 与 boardgame-phaser 集成
|
|
||||||
|
|
||||||
在 boardgame-phaser 中使用 boardgame-core:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createGameContextFromModule } from 'boardgame-core';
|
|
||||||
import { ReactiveScene, bindRegion, bindSignal } from 'boardgame-phaser';
|
|
||||||
import * as ticTacToe from './tic-tac-toe';
|
|
||||||
|
|
||||||
type GameState = ReturnType<typeof ticTacToe.createInitialState>;
|
|
||||||
|
|
||||||
class TicTacToeScene extends ReactiveScene<GameState> {
|
|
||||||
protected onStateReady() {
|
|
||||||
// 初始化 Phaser 对象
|
|
||||||
}
|
|
||||||
|
|
||||||
protected setupBindings() {
|
|
||||||
// 绑定简单信号值
|
|
||||||
bindSignal(this.gameContext.state, state => state.winner, winner => {
|
|
||||||
if (winner) this.showWinMessage(winner);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 绑定区域(响应 parts 数组变化)
|
|
||||||
bindRegion(
|
|
||||||
this.gameContext.state,
|
|
||||||
state => state.parts,
|
|
||||||
this.gameContext.state.value.board,
|
|
||||||
{
|
|
||||||
cellSize: { x: 100, y: 100 },
|
|
||||||
offset: { x: 50, y: 50 },
|
|
||||||
factory: (part, pos) => {
|
|
||||||
return this.add.text(pos.x, pos.y, part.player);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
this.boardContainer,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建场景
|
|
||||||
const gameContext = createGameContextFromModule(ticTacToe);
|
|
||||||
const scene = new TicTacToeScene(gameContext);
|
|
||||||
```
|
|
||||||
|
|
||||||
**bindRegion 参数说明:**
|
|
||||||
- `state` — MutableSignal 游戏状态
|
|
||||||
- `state => state.parts` — 获取 parts 数组的 getter(effect 会追踪此依赖)
|
|
||||||
- `region` — Region 对象
|
|
||||||
- `options` — 配置项(cellSize, offset, factory)
|
|
||||||
- `container` — Phaser 容器
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@preact/signals": "^1.2.0 || ^2.0.0",
|
||||||
"@preact/signals-core": "^1.5.1",
|
"@preact/signals-core": "^1.5.1",
|
||||||
"boardgame-core": ">=1.0.0",
|
"boardgame-core": ">=1.0.0",
|
||||||
"mutative": "^1.3.0",
|
"mutative": "^1.3.0",
|
||||||
|
|
@ -25,6 +26,7 @@
|
||||||
"preact": "^10.19.0"
|
"preact": "^10.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@preact/signals": "^2.9.0",
|
||||||
"@preact/signals-core": "^1.5.1",
|
"@preact/signals-core": "^1.5.1",
|
||||||
"boardgame-core": "link:../../../boardgame-core",
|
"boardgame-core": "link:../../../boardgame-core",
|
||||||
"mutative": "^1.3.0",
|
"mutative": "^1.3.0",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
export { ReactiveScene } from './scenes/ReactiveScene';
|
// Resource management
|
||||||
export type { ReactiveSceneOptions } from './scenes/ReactiveScene';
|
export { DisposableBag } from './utils';
|
||||||
|
export type { IDisposable, DisposableItem } from './utils';
|
||||||
|
|
||||||
export { bindSignal, bindGameObjectProperty, bindRegion, bindCollection } from './bindings';
|
// Data-driven object spawning
|
||||||
export type { BindRegionOptions, BindCollectionOptions } from './bindings';
|
export { spawnEffect } from './spawner';
|
||||||
|
export type { Spawner } from './spawner';
|
||||||
|
|
||||||
export { InputMapper, PromptHandler, createInputMapper, createPromptHandler } from './input';
|
// Scene base classes
|
||||||
export type { InputMapperOptions, PromptHandlerOptions } from './input';
|
export { GameHostScene } from './scenes';
|
||||||
|
export type { GameHostSceneOptions } from './scenes';
|
||||||
|
|
||||||
export { GameUI } from './ui/GameUI';
|
// React ↔ Phaser bridge
|
||||||
export type { GameUIOptions } from './ui/GameUI';
|
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, GameUI } from './ui';
|
||||||
|
export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from './ui';
|
||||||
export { PromptDialog } from './ui/PromptDialog';
|
|
||||||
export { CommandLog } from './ui/CommandLog';
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import Phaser from 'phaser';
|
||||||
|
import { effect, type ReadonlySignal } from '@preact/signals-core';
|
||||||
|
import type { GameHost } from 'boardgame-core';
|
||||||
|
import { DisposableBag, type IDisposable } from '../utils';
|
||||||
|
|
||||||
|
type CleanupFn = void | (() => void);
|
||||||
|
|
||||||
|
export interface GameHostSceneOptions<TState extends Record<string, unknown>> {
|
||||||
|
gameHost: GameHost<TState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class GameHostScene<TState extends Record<string, unknown>>
|
||||||
|
extends Phaser.Scene
|
||||||
|
implements IDisposable
|
||||||
|
{
|
||||||
|
protected disposables = new DisposableBag();
|
||||||
|
protected gameHost!: GameHost<TState>;
|
||||||
|
|
||||||
|
init(data: GameHostSceneOptions<TState>): void {
|
||||||
|
this.gameHost = data.gameHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
this.events.on('shutdown', this.dispose, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.disposables.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前状态的只读快照 */
|
||||||
|
protected get state(): TState {
|
||||||
|
return this.gameHost.state.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册响应式监听(场景关闭时自动清理) */
|
||||||
|
protected watch(fn: () => CleanupFn): void {
|
||||||
|
this.disposables.add(effect(fn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { GameHostScene } from './GameHostScene';
|
||||||
|
export type { GameHostSceneOptions } from './GameHostScene';
|
||||||
|
|
@ -1,27 +1,41 @@
|
||||||
import Phaser from "phaser";
|
import Phaser from 'phaser';
|
||||||
import {effect} from "@preact/signals";
|
import { effect, type ReadonlySignal } from '@preact/signals-core';
|
||||||
|
|
||||||
type GO = Phaser.GameObjects.GameObject;
|
type GO = Phaser.GameObjects.GameObject;
|
||||||
|
|
||||||
export interface Spawner<TData, TObject extends GO = GO> {
|
export interface Spawner<TData, TObject extends GO = GO> {
|
||||||
|
/** 数据源迭代器 */
|
||||||
getData(): Iterable<TData>;
|
getData(): Iterable<TData>;
|
||||||
|
/** 获取数据的唯一键 */
|
||||||
getKey(t: TData): string;
|
getKey(t: TData): string;
|
||||||
|
/** 创建新对象 */
|
||||||
onSpawn(t: TData): TObject | null;
|
onSpawn(t: TData): TObject | null;
|
||||||
|
/** 销毁旧对象 */
|
||||||
onDespawn(obj: TObject): void;
|
onDespawn(obj: TObject): void;
|
||||||
|
/** 更新已有对象 */
|
||||||
onUpdate(t: TData, obj: TObject): void;
|
onUpdate(t: TData, obj: TObject): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spawnEffect<TData, TObject extends GO = GO>(spawner: Spawner<TData, TObject>){
|
export function spawnEffect<TData, TObject extends GO = GO>(
|
||||||
|
spawner: Spawner<TData, TObject>,
|
||||||
|
): () => void {
|
||||||
const objects = new Map<string, TObject>();
|
const objects = new Map<string, TObject>();
|
||||||
|
|
||||||
return effect(() => {
|
return effect(() => {
|
||||||
const current = new Set<string>();
|
const current = new Set<string>();
|
||||||
|
|
||||||
for (const t of spawner.getData()) {
|
for (const t of spawner.getData()) {
|
||||||
const key = spawner.getKey(t);
|
const key = spawner.getKey(t);
|
||||||
current.add(key);
|
current.add(key);
|
||||||
if (!objects.has(key)) {
|
|
||||||
|
const existing = objects.get(key);
|
||||||
|
if (!existing) {
|
||||||
const obj = spawner.onSpawn(t);
|
const obj = spawner.onSpawn(t);
|
||||||
if(obj) objects.set(key, obj);
|
if (obj) {
|
||||||
|
objects.set(key, obj);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
spawner.onUpdate(t, objects.get(key)!);
|
spawner.onUpdate(t, existing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import Phaser from "phaser";
|
import Phaser from 'phaser';
|
||||||
import {createContext, h} from "preact";
|
import { signal, useSignal, useSignalEffect, type Signal } from '@preact/signals';
|
||||||
import {Signal, useSignalEffect, signal, useSignal} from "@preact/signals";
|
import { createContext, h } from 'preact';
|
||||||
import {useContext} from "preact/hooks";
|
import { useContext } from 'preact/hooks';
|
||||||
|
|
||||||
export const phaserContext = createContext<Signal<Phaser.Game | undefined>>(signal());
|
export const phaserContext = createContext<Signal<Phaser.Game | undefined>>(signal(undefined));
|
||||||
|
|
||||||
export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
|
export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -14,8 +14,12 @@ export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
|
||||||
scene: [],
|
scene: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PhaserGame(props: { config?: Partial<Phaser.Types.Core.GameConfig>, children?: any}){
|
export interface PhaserGameProps {
|
||||||
|
config?: Partial<Phaser.Types.Core.GameConfig>;
|
||||||
|
children?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhaserGame(props: PhaserGameProps) {
|
||||||
const gameSignal = useSignal<Phaser.Game>();
|
const gameSignal = useSignal<Phaser.Game>();
|
||||||
|
|
||||||
useSignalEffect(() => {
|
useSignalEffect(() => {
|
||||||
|
|
@ -25,18 +29,28 @@ export function PhaserGame(props: { config?: Partial<Phaser.Types.Core.GameConfi
|
||||||
return () => {
|
return () => {
|
||||||
gameSignal.value = undefined;
|
gameSignal.value = undefined;
|
||||||
phaserGame.destroy(true);
|
phaserGame.destroy(true);
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return <div id="phaser-container" className="w-full h-full" >
|
return (
|
||||||
|
<div id="phaser-container" className="w-full h-full">
|
||||||
<phaserContext.Provider value={gameSignal}>
|
<phaserContext.Provider value={gameSignal}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</phaserContext.Provider>
|
</phaserContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhaserScene(props: { sceneKey: string, scene: Phaser.Scene, autoStart: boolean, data?: object}){
|
export interface PhaserSceneProps {
|
||||||
|
sceneKey: string;
|
||||||
|
scene: Phaser.Scene;
|
||||||
|
autoStart: boolean;
|
||||||
|
data?: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhaserScene(props: PhaserSceneProps) {
|
||||||
const context = useContext(phaserContext);
|
const context = useContext(phaserContext);
|
||||||
|
|
||||||
useSignalEffect(() => {
|
useSignalEffect(() => {
|
||||||
const game = context.value;
|
const game = context.value;
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
|
|
@ -44,7 +58,7 @@ export function PhaserScene(props: { sceneKey: string, scene: Phaser.Scene, auto
|
||||||
game.scene.add(props.sceneKey, props.scene, props.autoStart, props.data);
|
game.scene.add(props.sceneKey, props.scene, props.autoStart, props.data);
|
||||||
return () => {
|
return () => {
|
||||||
game.scene.remove(props.sceneKey);
|
game.scene.remove(props.sceneKey);
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { GameUI } from './GameUI';
|
||||||
|
export type { GameUIOptions } from './GameUI';
|
||||||
|
|
||||||
|
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig } from './PhaserBridge';
|
||||||
|
export type { PhaserGameProps, PhaserSceneProps } from './PhaserBridge';
|
||||||
|
|
@ -1,22 +1,17 @@
|
||||||
export interface IDisposable {
|
export interface IDisposable {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisposableItem = IDisposable | (() => void);
|
export type DisposableItem = IDisposable | (() => void);
|
||||||
|
|
||||||
export class DisposableBag implements IDisposable {
|
export class DisposableBag implements IDisposable {
|
||||||
private _disposables = new Set<DisposableItem>();
|
private _disposables = new Set<DisposableItem>();
|
||||||
private _isDisposed = false;
|
private _isDisposed = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the bag has already been disposed.
|
|
||||||
*/
|
|
||||||
get isDisposed(): boolean {
|
get isDisposed(): boolean {
|
||||||
return this._isDisposed;
|
return this._isDisposed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a disposable or a cleanup function to the bag.
|
|
||||||
*/
|
|
||||||
add(item: DisposableItem): void {
|
add(item: DisposableItem): void {
|
||||||
if (this._isDisposed) {
|
if (this._isDisposed) {
|
||||||
this._execute(item);
|
this._execute(item);
|
||||||
|
|
@ -25,9 +20,6 @@ export class DisposableBag implements IDisposable {
|
||||||
this._disposables.add(item);
|
this._disposables.add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposes all items currently in the bag and clears the collection.
|
|
||||||
*/
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (this._isDisposed) return;
|
if (this._isDisposed) return;
|
||||||
|
|
||||||
|
|
@ -37,7 +29,7 @@ export class DisposableBag implements IDisposable {
|
||||||
try {
|
try {
|
||||||
this._execute(item);
|
this._execute(item);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during resource disposal:", error);
|
console.error('Error during resource disposal:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { DisposableBag } from './disposable';
|
||||||
|
export type { IDisposable, DisposableItem } from './disposable';
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
type Part,
|
type Part,
|
||||||
createRegion,
|
createRegion,
|
||||||
type MutableSignal,
|
type MutableSignal,
|
||||||
|
type GameModule,
|
||||||
} from 'boardgame-core';
|
} from 'boardgame-core';
|
||||||
|
|
||||||
const BOARD_SIZE = 3;
|
const BOARD_SIZE = 3;
|
||||||
|
|
@ -40,6 +41,11 @@ export type TicTacToeState = ReturnType<typeof createInitialState>;
|
||||||
const registration = createGameCommandRegistry<TicTacToeState>();
|
const registration = createGameCommandRegistry<TicTacToeState>();
|
||||||
export const registry = registration.registry;
|
export const registry = registration.registry;
|
||||||
|
|
||||||
|
export const gameModule: GameModule<TicTacToeState> = {
|
||||||
|
registry,
|
||||||
|
createInitialState,
|
||||||
|
};
|
||||||
|
|
||||||
registration.add('setup', async function () {
|
registration.add('setup', async function () {
|
||||||
const { context } = this;
|
const { context } = this;
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { GameUI } from 'boardgame-phaser';
|
import { GameUI } from 'boardgame-phaser';
|
||||||
import * as ticTacToe from './game/tic-tac-toe';
|
import { gameModule } from './game/tic-tac-toe';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
import App from "@/ui/App";
|
import App from "@/ui/App";
|
||||||
import {GameScene} from "@/scenes/GameScene";
|
import {GameScene} from "@/scenes/GameScene";
|
||||||
|
|
||||||
const ui = new GameUI({
|
const ui = new GameUI({
|
||||||
container: document.getElementById('ui-root')!,
|
container: document.getElementById('ui-root')!,
|
||||||
root: <App gameModule={ticTacToe} gameScene={GameScene}/>,
|
root: <App gameModule={gameModule} gameScene={GameScene}/>,
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.mount();
|
ui.mount();
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import {DisposableBag, IDisposable} from "@/utils/disposable";
|
|
||||||
import type {GameHost} from "../../../../../boardgame-core/src";
|
|
||||||
import {effect} from "@preact/signals";
|
|
||||||
|
|
||||||
export abstract class GameHostScene<T extends Record<string,unknown>> extends Phaser.Scene implements IDisposable{
|
|
||||||
protected disposables = new DisposableBag();
|
|
||||||
|
|
||||||
protected gameHost!: GameHost<T>;
|
|
||||||
|
|
||||||
init(data: { gameHost: GameHost<T> }): void {
|
|
||||||
this.gameHost = data.gameHost;
|
|
||||||
}
|
|
||||||
create(){
|
|
||||||
this.events.on('shutdown', this.dispose, this);
|
|
||||||
}
|
|
||||||
dispose() {
|
|
||||||
this.disposables.dispose();
|
|
||||||
}
|
|
||||||
public get state(): T {
|
|
||||||
return this.gameHost.state.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected watch(fn: () => void | (()=>void)){
|
|
||||||
this.disposables.add(effect(fn));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import type {TicTacToeState, TicTacToePart} from '@/game/tic-tac-toe';
|
import type {TicTacToeState, TicTacToePart} from '@/game/tic-tac-toe';
|
||||||
import {ReadonlySignal} from "@preact/signals";
|
import { GameHostScene } from 'boardgame-phaser';
|
||||||
import {GameHostScene} from "@/scenes/GameHostScene";
|
import { spawnEffect, type Spawner } from 'boardgame-phaser';
|
||||||
import {spawnEffect, Spawner} from "@/utils/spawner";
|
import type { ReadonlySignal } from '@preact/signals-core';
|
||||||
import {commands} from "@/game/tic-tac-toe";
|
import {commands} from "@/game/tic-tac-toe";
|
||||||
|
|
||||||
const CELL_SIZE = 120;
|
const CELL_SIZE = 120;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
import { useComputed } from '@preact/signals';
|
import { useComputed } from '@preact/signals';
|
||||||
import { createGameHost, GameModule } from "boardgame-core";
|
import { createGameHost, type GameModule } from 'boardgame-core';
|
||||||
import Phaser from "phaser";
|
import Phaser from 'phaser';
|
||||||
import {h} from "preact";
|
import { h } from 'preact';
|
||||||
import {PhaserGame, PhaserScene} from "@/ui/PhaserGame";
|
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
|
||||||
|
|
||||||
export default function App<T extends Record<string, unknown>>(props: { gameModule: GameModule<T>, gameScene: {new(): Phaser.Scene} }) {
|
export default function App<TState extends Record<string, unknown>>(props: { gameModule: GameModule<TState>, gameScene: { new(): Phaser.Scene } }) {
|
||||||
|
|
||||||
const gameHost = useComputed(() => {
|
const gameHost = useComputed(() => {
|
||||||
const gameHost = createGameHost(props.gameModule);
|
const gameHost = createGameHost(props.gameModule);
|
||||||
gameHost.setup('setup');
|
gameHost.setup('setup');
|
||||||
return {
|
return { gameHost };
|
||||||
gameHost
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const scene = useComputed(() => new props.gameScene());
|
const scene = useComputed(() => new props.gameScene());
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ importers:
|
||||||
|
|
||||||
packages/framework:
|
packages/framework:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@preact/signals':
|
||||||
|
specifier: ^2.9.0
|
||||||
|
version: 2.9.0(preact@10.29.0)
|
||||||
'@preact/signals-core':
|
'@preact/signals-core':
|
||||||
specifier: ^1.5.1
|
specifier: ^1.5.1
|
||||||
version: 1.14.1
|
version: 1.14.1
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue