From 2984d8b20db2c593dd8bd30b7276bdcbec68deda Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 4 Apr 2026 13:08:28 +0800 Subject: [PATCH] refactor: clean up --- docs/boardgame-core-guide.md | 689 ------------------ packages/framework/package.json | 2 + packages/framework/src/index.ts | 23 +- .../framework/src/scenes/GameHostScene.ts | 40 + packages/framework/src/scenes/index.ts | 2 + .../src/spawner/index.ts} | 34 +- .../src/ui/PhaserBridge.tsx} | 58 +- packages/framework/src/ui/index.ts | 5 + .../src/utils/disposable.ts | 16 +- packages/framework/src/utils/index.ts | 2 + packages/sample-game/src/game/tic-tac-toe.ts | 6 + packages/sample-game/src/main.tsx | 4 +- .../sample-game/src/scenes/GameHostScene.ts | 26 - packages/sample-game/src/scenes/GameScene.ts | 6 +- packages/sample-game/src/ui/App.tsx | 24 +- pnpm-lock.yaml | 3 + 16 files changed, 152 insertions(+), 788 deletions(-) delete mode 100644 docs/boardgame-core-guide.md create mode 100644 packages/framework/src/scenes/GameHostScene.ts create mode 100644 packages/framework/src/scenes/index.ts rename packages/{sample-game/src/utils/spawner.ts => framework/src/spawner/index.ts} (53%) rename packages/{sample-game/src/ui/PhaserGame.tsx => framework/src/ui/PhaserBridge.tsx} (50%) create mode 100644 packages/framework/src/ui/index.ts rename packages/{sample-game => framework}/src/utils/disposable.ts (73%) create mode 100644 packages/framework/src/utils/index.ts delete mode 100644 packages/sample-game/src/scenes/GameHostScene.ts diff --git a/docs/boardgame-core-guide.md b/docs/boardgame-core-guide.md deleted file mode 100644 index 4c3dd0d..0000000 --- a/docs/boardgame-core-guide.md +++ /dev/null @@ -1,689 +0,0 @@ -# boardgame-core 使用指南 - -## 概述 - -`boardgame-core` 是一个基于 Preact Signals 的桌游状态管理库,提供响应式状态、实体集合、空间区域系统和命令驱动的游戏循环。 - -## 核心概念 - -### 1. MutableSignal — 响应式状态容器 - -`MutableSignal` 扩展了 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 格式 -const parts: Record = { ... }; - -// 对齐/紧凑排列(根据 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` 表示游戏中的一个部件(棋子、卡牌等)。 - -```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 格式 -const parts: Record = { - '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 [--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(); -export const registry = registration.registry; - -// 注册命令(链式 API) -registration.add('place ', 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 ', async function(cmd) { - // ... -}); -``` - -#### 命令处理器的 `this` 上下文 - -命令处理函数中,`this` 是 `CommandRunnerContext`,提供: - -```ts -registration.add('my-command ', async function(cmd) { - // this.context — 游戏上下文(MutableSignal) - const state = this.context.value; - - // this.context.produce — 更新状态 - this.context.produce(draft => { - draft.score += 1; - }); - - // this.run — 运行子命令 - const result = await this.run(`place 2 3`); - if (result.success) { - console.log(result.result.row); - } - - // this.prompt — 等待玩家输入 - const playCmd = await this.prompt( - 'play ', - (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) -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 ', async function(cmd) { - const player = cmd.params[0] as string; - - // 等待玩家输入,带验证 - const playCmd = await this.prompt( - 'play ', - (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, - currentPlayer: 'X' as PlayerType, - winner: null as PlayerType | 'draw' | null, - turn: 0, - }; -} - -export type TicTacToeState = ReturnType; - -const registration = createGameCommandRegistry(); -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 ', async function(cmd) { - const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number]; - - // 等待玩家输入 - const playCmd = await this.prompt( - 'play ', - (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, row: number, col: number): boolean { - return isCellOccupiedUtil(host.value.parts, 'board', [row, col]); -} - -function checkWinner(host: MutableSignal): 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` | 游戏上下文接口(包含 state 和 commands) | -| `createGameContext(registry, initialState?)` | 创建游戏上下文实例 | -| `createGameContextFromModule(module)` | 从模块(registry + createInitialState)创建游戏上下文 | -| `createGameCommandRegistry()` | 创建命令注册表(带 `.add()` 链式 API) | - -### MutableSignal - -| 导出 | 说明 | -|---|---| -| `MutableSignal` | 响应式信号类型,扩展 Preact Signal | -| `mutableSignal(initial?)` | 创建 MutableSignal | - -### Part - -| 导出 | 说明 | -|---|---| -| `Part` | 部件类型 | -| `PartTemplate` | 部件模板(创建时排除 id) | -| `PartPool` | 部件池(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`) | -| `isCellOccupied(parts, regionId, position)` | 检查位置占用(`parts` 为 `Record`) | -| `getPartAtPosition(parts, regionId, position)` | 获取位置上的部件(`parts` 为 `Record`) | -| `flip(part)` | 翻面 | -| `flipTo(part, side)` | 翻到指定面 | -| `roll(part, rng)` | 随机面 | - -### Region - -| 导出 | 说明 | -|---|---| -| `Region` | 区域类型 | -| `RegionAxis` | 坐标轴定义 | -| `createRegion(id, axes)` | 创建区域 | -| `applyAlign(region, parts)` | 对齐/紧凑排列(`parts` 为 `Record`) | -| `shuffle(region, parts, rng)` | 随机打乱位置(`parts` 为 `Record`) | -| `moveToRegion(part, sourceRegion?, targetRegion, position?)` | 移动部件(`sourceRegion` 可选) | -| `moveToRegionAll(parts, sourceRegion?, targetRegion, positions?)` | 批量移动(`parts` 为 `Record`,`sourceRegion` 可选) | -| `removeFromRegion(part, region)` | 移除部件 | - -### Command - -| 导出 | 说明 | -|---|---| -| `Command` | 命令对象(name, params, options, flags) | -| `CommandSchema` | 命令模式定义 | -| `CommandResult` | 命令结果(成功/失败联合类型) | -| `parseCommand(input)` | 解析命令字符串 | -| `parseCommandSchema(schema)` | 解析模式字符串 | -| `validateCommand(cmd, schema)` | 验证命令 | -| `parseCommandWithSchema(cmd, schema)` | 解析并验证 | -| `applyCommandSchema(cmd, schema)` | 应用模式验证并返回验证后的命令 | -| `createCommandRegistry()` | 创建命令注册表 | -| `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` | 命令运行器上下文 | -| `CommandRunnerContextExport` | 导出的命令运行器上下文(含 `promptQueue`) | - -### Utilities - -| 导出 | 说明 | -|---|---| -| `createRNG(seed?)` | 创建随机数生成器 | -| `Mulberry32RNG` | Mulberry32 PRNG 类 | -| `AsyncQueue` | 异步队列(用于 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 ', 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 ', - (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; - -class TicTacToeScene extends ReactiveScene { - 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 容器 diff --git a/packages/framework/package.json b/packages/framework/package.json index f2cca9e..2ed628f 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -18,6 +18,7 @@ "test:watch": "vitest" }, "peerDependencies": { + "@preact/signals": "^1.2.0 || ^2.0.0", "@preact/signals-core": "^1.5.1", "boardgame-core": ">=1.0.0", "mutative": "^1.3.0", @@ -25,6 +26,7 @@ "preact": "^10.19.0" }, "devDependencies": { + "@preact/signals": "^2.9.0", "@preact/signals-core": "^1.5.1", "boardgame-core": "link:../../../boardgame-core", "mutative": "^1.3.0", diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 9e68607..aff1aac 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -1,14 +1,15 @@ -export { ReactiveScene } from './scenes/ReactiveScene'; -export type { ReactiveSceneOptions } from './scenes/ReactiveScene'; +// Resource management +export { DisposableBag } from './utils'; +export type { IDisposable, DisposableItem } from './utils'; -export { bindSignal, bindGameObjectProperty, bindRegion, bindCollection } from './bindings'; -export type { BindRegionOptions, BindCollectionOptions } from './bindings'; +// Data-driven object spawning +export { spawnEffect } from './spawner'; +export type { Spawner } from './spawner'; -export { InputMapper, PromptHandler, createInputMapper, createPromptHandler } from './input'; -export type { InputMapperOptions, PromptHandlerOptions } from './input'; +// Scene base classes +export { GameHostScene } from './scenes'; +export type { GameHostSceneOptions } from './scenes'; -export { GameUI } from './ui/GameUI'; -export type { GameUIOptions } from './ui/GameUI'; - -export { PromptDialog } from './ui/PromptDialog'; -export { CommandLog } from './ui/CommandLog'; +// React ↔ Phaser bridge +export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, GameUI } from './ui'; +export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from './ui'; diff --git a/packages/framework/src/scenes/GameHostScene.ts b/packages/framework/src/scenes/GameHostScene.ts new file mode 100644 index 0000000..185cbe9 --- /dev/null +++ b/packages/framework/src/scenes/GameHostScene.ts @@ -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> { + gameHost: GameHost; +} + +export abstract class GameHostScene> + extends Phaser.Scene + implements IDisposable +{ + protected disposables = new DisposableBag(); + protected gameHost!: GameHost; + + init(data: GameHostSceneOptions): 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)); + } +} diff --git a/packages/framework/src/scenes/index.ts b/packages/framework/src/scenes/index.ts new file mode 100644 index 0000000..98fb1d7 --- /dev/null +++ b/packages/framework/src/scenes/index.ts @@ -0,0 +1,2 @@ +export { GameHostScene } from './GameHostScene'; +export type { GameHostSceneOptions } from './GameHostScene'; diff --git a/packages/sample-game/src/utils/spawner.ts b/packages/framework/src/spawner/index.ts similarity index 53% rename from packages/sample-game/src/utils/spawner.ts rename to packages/framework/src/spawner/index.ts index 046c88d..1897eb8 100644 --- a/packages/sample-game/src/utils/spawner.ts +++ b/packages/framework/src/spawner/index.ts @@ -1,30 +1,44 @@ -import Phaser from "phaser"; -import {effect} from "@preact/signals"; +import Phaser from 'phaser'; +import { effect, type ReadonlySignal } from '@preact/signals-core'; type GO = Phaser.GameObjects.GameObject; + export interface Spawner { + /** 数据源迭代器 */ getData(): Iterable; + /** 获取数据的唯一键 */ getKey(t: TData): string; + /** 创建新对象 */ onSpawn(t: TData): TObject | null; + /** 销毁旧对象 */ onDespawn(obj: TObject): void; + /** 更新已有对象 */ onUpdate(t: TData, obj: TObject): void; } -export function spawnEffect(spawner: Spawner){ +export function spawnEffect( + spawner: Spawner, +): () => void { const objects = new Map(); + return effect(() => { const current = new Set(); - for (const t of spawner.getData()) { + + for (const t of spawner.getData()) { const key = spawner.getKey(t); current.add(key); - if (!objects.has(key)) { + + const existing = objects.get(key); + if (!existing) { const obj = spawner.onSpawn(t); - if(obj) objects.set(key, obj); - }else{ - spawner.onUpdate(t, objects.get(key)!); + if (obj) { + objects.set(key, obj); + } + } else { + spawner.onUpdate(t, existing); } } - + for (const [key, obj] of objects) { if (!current.has(key)) { spawner.onDespawn(obj); @@ -32,4 +46,4 @@ export function spawnEffect(spawner: Spawner>(signal()); +export const phaserContext = createContext>(signal(undefined)); export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = { type: Phaser.AUTO, @@ -14,38 +14,52 @@ export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = { scene: [], }; -export function PhaserGame(props: { config?: Partial, children?: any}){ - +export interface PhaserGameProps { + config?: Partial; + children?: any; +} + +export function PhaserGame(props: PhaserGameProps) { const gameSignal = useSignal(); - + useSignalEffect(() => { const phaserGame = new Phaser.Game(props.config || defaultPhaserConfig); gameSignal.value = phaserGame; - + return () => { gameSignal.value = undefined; phaserGame.destroy(true); - } + }; }); - - return
- - {props.children} - -
+ + return ( +
+ + {props.children} + +
+ ); } -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); + useSignalEffect(() => { const game = context.value; - if(!game) return; - + if (!game) return; + game.scene.add(props.sceneKey, props.scene, props.autoStart, props.data); return () => { game.scene.remove(props.sceneKey); - } + }; }); - + return null; -} \ No newline at end of file +} diff --git a/packages/framework/src/ui/index.ts b/packages/framework/src/ui/index.ts new file mode 100644 index 0000000..029b0fe --- /dev/null +++ b/packages/framework/src/ui/index.ts @@ -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'; diff --git a/packages/sample-game/src/utils/disposable.ts b/packages/framework/src/utils/disposable.ts similarity index 73% rename from packages/sample-game/src/utils/disposable.ts rename to packages/framework/src/utils/disposable.ts index f013237..e56ae57 100644 --- a/packages/sample-game/src/utils/disposable.ts +++ b/packages/framework/src/utils/disposable.ts @@ -1,22 +1,17 @@ -export interface IDisposable { +export interface IDisposable { dispose(): void; } + export type DisposableItem = IDisposable | (() => void); export class DisposableBag implements IDisposable { private _disposables = new Set(); private _isDisposed = false; - /** - * Returns true if the bag has already been disposed. - */ get isDisposed(): boolean { return this._isDisposed; } - /** - * Adds a disposable or a cleanup function to the bag. - */ add(item: DisposableItem): void { if (this._isDisposed) { this._execute(item); @@ -25,9 +20,6 @@ export class DisposableBag implements IDisposable { this._disposables.add(item); } - /** - * Disposes all items currently in the bag and clears the collection. - */ dispose(): void { if (this._isDisposed) return; @@ -37,7 +29,7 @@ export class DisposableBag implements IDisposable { try { this._execute(item); } catch (error) { - console.error("Error during resource disposal:", error); + console.error('Error during resource disposal:', error); } } @@ -51,4 +43,4 @@ export class DisposableBag implements IDisposable { item.dispose(); } } -} \ No newline at end of file +} diff --git a/packages/framework/src/utils/index.ts b/packages/framework/src/utils/index.ts new file mode 100644 index 0000000..106634b --- /dev/null +++ b/packages/framework/src/utils/index.ts @@ -0,0 +1,2 @@ +export { DisposableBag } from './disposable'; +export type { IDisposable, DisposableItem } from './disposable'; diff --git a/packages/sample-game/src/game/tic-tac-toe.ts b/packages/sample-game/src/game/tic-tac-toe.ts index e92da47..97629fa 100644 --- a/packages/sample-game/src/game/tic-tac-toe.ts +++ b/packages/sample-game/src/game/tic-tac-toe.ts @@ -3,6 +3,7 @@ import { type Part, createRegion, type MutableSignal, + type GameModule, } from 'boardgame-core'; const BOARD_SIZE = 3; @@ -40,6 +41,11 @@ export type TicTacToeState = ReturnType; const registration = createGameCommandRegistry(); export const registry = registration.registry; +export const gameModule: GameModule = { + registry, + createInitialState, +}; + registration.add('setup', async function () { const { context } = this; while (true) { diff --git a/packages/sample-game/src/main.tsx b/packages/sample-game/src/main.tsx index b6414d7..7ef9cb0 100644 --- a/packages/sample-game/src/main.tsx +++ b/packages/sample-game/src/main.tsx @@ -1,13 +1,13 @@ import { h } from 'preact'; import { GameUI } from 'boardgame-phaser'; -import * as ticTacToe from './game/tic-tac-toe'; +import { gameModule } from './game/tic-tac-toe'; import './style.css'; import App from "@/ui/App"; import {GameScene} from "@/scenes/GameScene"; const ui = new GameUI({ container: document.getElementById('ui-root')!, - root: , + root: , }); ui.mount(); diff --git a/packages/sample-game/src/scenes/GameHostScene.ts b/packages/sample-game/src/scenes/GameHostScene.ts deleted file mode 100644 index 39c9f7e..0000000 --- a/packages/sample-game/src/scenes/GameHostScene.ts +++ /dev/null @@ -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> extends Phaser.Scene implements IDisposable{ - protected disposables = new DisposableBag(); - - protected gameHost!: GameHost; - - init(data: { gameHost: GameHost }): 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)); - } -} \ No newline at end of file diff --git a/packages/sample-game/src/scenes/GameScene.ts b/packages/sample-game/src/scenes/GameScene.ts index 48e6a58..fbdd5da 100644 --- a/packages/sample-game/src/scenes/GameScene.ts +++ b/packages/sample-game/src/scenes/GameScene.ts @@ -1,8 +1,8 @@ import Phaser from 'phaser'; import type {TicTacToeState, TicTacToePart} from '@/game/tic-tac-toe'; -import {ReadonlySignal} from "@preact/signals"; -import {GameHostScene} from "@/scenes/GameHostScene"; -import {spawnEffect, Spawner} from "@/utils/spawner"; +import { GameHostScene } from 'boardgame-phaser'; +import { spawnEffect, type Spawner } from 'boardgame-phaser'; +import type { ReadonlySignal } from '@preact/signals-core'; import {commands} from "@/game/tic-tac-toe"; const CELL_SIZE = 120; diff --git a/packages/sample-game/src/ui/App.tsx b/packages/sample-game/src/ui/App.tsx index cdab408..5730c1b 100644 --- a/packages/sample-game/src/ui/App.tsx +++ b/packages/sample-game/src/ui/App.tsx @@ -1,26 +1,24 @@ -import {useComputed } from '@preact/signals'; -import { createGameHost, GameModule } from "boardgame-core"; -import Phaser from "phaser"; -import {h} from "preact"; -import {PhaserGame, PhaserScene} from "@/ui/PhaserGame"; +import { useComputed } from '@preact/signals'; +import { createGameHost, type GameModule } from 'boardgame-core'; +import Phaser from 'phaser'; +import { h } from 'preact'; +import { PhaserGame, PhaserScene } from 'boardgame-phaser'; + +export default function App>(props: { gameModule: GameModule, gameScene: { new(): Phaser.Scene } }) { -export default function App>(props: { gameModule: GameModule, gameScene: {new(): Phaser.Scene} }) { - const gameHost = useComputed(() => { const gameHost = createGameHost(props.gameModule); gameHost.setup('setup'); - return { - gameHost - } + return { gameHost }; }); - + const scene = useComputed(() => new props.gameScene()); - + return (
- +
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a10864c..809f8d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: packages/framework: devDependencies: + '@preact/signals': + specifier: ^2.9.0 + version: 2.9.0(preact@10.29.0) '@preact/signals-core': specifier: ^1.5.1 version: 1.14.1