diff --git a/packages/sample-game/src/game/tic-tac-toe.ts b/packages/sample-game/src/game/tic-tac-toe.ts index a471234..e92da47 100644 --- a/packages/sample-game/src/game/tic-tac-toe.ts +++ b/packages/sample-game/src/game/tic-tac-toe.ts @@ -71,7 +71,8 @@ registration.add('reset', async function () { state.winner = null; state.turn = 0; }); - return { success: true }; + // 重启主循环 + return this.run('setup'); }); registration.add('turn ', async function (cmd) { @@ -146,3 +147,9 @@ export function placePiece(host: MutableSignal, row: number, col state.board.partMap[`${row},${col}`] = piece.id; }); } + +/** 命令构建器:类型安全地生成命令字符串 */ +export const commands = { + play: (player: PlayerType, row: number, col: number) => `play ${player} ${row} ${col}`, + turn: (player: PlayerType, turn: number) => `turn ${player} ${turn}`, +} as const; diff --git a/packages/sample-game/src/main.tsx b/packages/sample-game/src/main.tsx index 7b6d3b5..b6414d7 100644 --- a/packages/sample-game/src/main.tsx +++ b/packages/sample-game/src/main.tsx @@ -1,167 +1,13 @@ -import { h, render } from 'preact'; -import { signal } from '@preact/signals-core'; -import { useEffect, useState, useCallback } from 'preact/hooks'; -import Phaser from 'phaser'; -import { createGameHost } from 'boardgame-core'; -import { GameUI, PromptDialog, CommandLog } from 'boardgame-phaser'; -import { createInitialState, registry, type TicTacToeState } from './game/tic-tac-toe'; -import { GameScene } from './scenes/GameScene'; +import { h } from 'preact'; +import { GameUI } from 'boardgame-phaser'; +import * as ticTacToe from './game/tic-tac-toe'; import './style.css'; - -// 创建 GameHost 实例,自动管理状态和 prompt -const gameHost = createGameHost( - { registry, createInitialState }, - 'setup', - { autoStart: false } -); - -const commandLog = signal>([]); - -// 记录命令日志的辅助函数 -function logCommand(input: string, result: { success: boolean; result?: unknown; error?: string }) { - commandLog.value = [ - ...commandLog.value, - { - input, - result: result.success ? `OK: ${JSON.stringify(result.result)}` : `ERR: ${result.error}`, - timestamp: Date.now(), - }, - ]; -} - -function App() { - const [phaserReady, setPhaserReady] = useState(false); - const [game, setGame] = useState(null); - const [scene, setScene] = useState(null); - const [gameState, setGameState] = useState(null); - const [promptSchema, setPromptSchema] = useState(null); - - useEffect(() => { - const phaserConfig: Phaser.Types.Core.GameConfig = { - type: Phaser.AUTO, - width: 560, - height: 560, - parent: 'phaser-container', - backgroundColor: '#f9fafb', - scene: [], - }; - - const phaserGame = new Phaser.Game(phaserConfig); - // 通过 init 传递 gameHost - const gameScene = new GameScene(); - phaserGame.scene.add('GameScene', gameScene, true, { gameHost }); - - setGame(phaserGame); - setScene(gameScene); - setPhaserReady(true); - - return () => { - gameHost.dispose(); - phaserGame.destroy(true); - }; - }, []); - - useEffect(() => { - if (phaserReady && scene) { - // 监听 prompt 状态变化 - const disposePromptSchema = gameHost.activePromptSchema.subscribe((schema) => { - setPromptSchema(schema); - scene.promptSchema.current = schema; - }); - - // 监听状态变化 - const disposeState = gameHost.state.subscribe(() => { - setGameState(gameHost.state.value as TicTacToeState); - }); - - // 运行游戏设置 - gameHost.setup('setup').then(() => { - logCommand('setup', { success: true }); - }).catch(err => { - logCommand('setup', { success: false, error: err.message }); - }); - - return () => { - disposePromptSchema(); - disposeState(); - }; - } - }, [phaserReady, scene]); - - const handlePromptSubmit = useCallback((input: string) => { - const error = gameHost.onInput(input); - if (error === null) { - logCommand(input, { success: true }); - setPromptSchema(null); - if (scene) { - scene.promptSchema.current = null; - } - } else { - logCommand(input, { success: false, error }); - } - }, []); - - const handlePromptCancel = useCallback(() => { - gameHost.commands._cancel('User cancelled'); - setPromptSchema(null); - if (scene) { - scene.promptSchema.current = null; - } - }, []); - - const handleReset = useCallback(() => { - gameHost.commands.run('reset').then(result => { - logCommand('reset', result); - }); - }, []); - - return ( -
-
-
- - {/* 游戏状态显示 */} - {gameState && !gameState.winner && ( -
- - {gameState.currentPlayer}'s Turn - -
- )} - - {gameState?.winner && ( -
- - {gameState.winner === 'draw' ? "It's a Draw!" : `${gameState.winner} Wins!`} - -
- )} - - null, cancel: () => {} } : null} - onSubmit={handlePromptSubmit} - onCancel={handlePromptCancel} - /> -
-
-
- Command Log - -
- -
-
- ); -} +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 new file mode 100644 index 0000000..39c9f7e --- /dev/null +++ b/packages/sample-game/src/scenes/GameHostScene.ts @@ -0,0 +1,26 @@ +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 26dba5b..51e4d00 100644 --- a/packages/sample-game/src/scenes/GameScene.ts +++ b/packages/sample-game/src/scenes/GameScene.ts @@ -1,126 +1,53 @@ import Phaser from 'phaser'; -import type { TicTacToeState, TicTacToePart, PlayerType } from '@/game/tic-tac-toe'; -import type { GameHost, CommandSchema, IGameContext, MutableSignal } from 'boardgame-core'; -import { ReactiveScene, bindRegion, createInputMapper, InputMapper } from 'boardgame-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"; const CELL_SIZE = 120; const BOARD_OFFSET = { x: 100, y: 100 }; const BOARD_SIZE = 3; -export class GameScene extends ReactiveScene { +export class GameScene extends GameHostScene { private boardContainer!: Phaser.GameObjects.Container; private gridGraphics!: Phaser.GameObjects.Graphics; - private inputMapper!: InputMapper; private turnText!: Phaser.GameObjects.Text; - /** Receives the active prompt schema from main.tsx */ - promptSchema: { current: CommandSchema | null } = { current: null }; - /** GameHost instance passed from main.tsx */ - private gameHost!: GameHost; + private winnerOverlay?: Phaser.GameObjects.Container; constructor() { super('GameScene'); } - init(data: { gameHost: GameHost } | { gameContext: IGameContext }): void { - if ('gameHost' in data) { - this.gameHost = data.gameHost; - // Create a compatible gameContext from GameHost - this.gameContext = { - state: this.gameHost.state as MutableSignal, - commands: this.gameHost.commands, - } as IGameContext; - this.state = this.gameContext.state; - this.commands = this.gameContext.commands; - } else { - // Fallback for direct gameContext passing - super.init(data); - } - } - - protected onStateReady(_state: TicTacToeState): void { - } - create(): void { super.create(); + this.boardContainer = this.add.container(0, 0); this.gridGraphics = this.add.graphics(); this.drawGrid(); + + this.disposables.add(spawnEffect(new TicTacToePartSpawner(this, this.gameHost.state))); this.watch(() => { - const winner = this.state.value.winner; + const winner = this.state.winner; if (winner) { this.showWinner(winner); } }); this.watch(() => { - const currentPlayer = this.state.value.currentPlayer; + const currentPlayer = this.state.currentPlayer; this.updateTurnText(currentPlayer); }); this.setupInput(); } - protected setupBindings(): void { - bindRegion( - this.state, - (state) => state.parts, - (state) => state.board, - { - cellSize: { x: CELL_SIZE, y: CELL_SIZE }, - offset: BOARD_OFFSET, - factory: (part, pos: Phaser.Math.Vector2) => { - const text = this.add.text(pos.x + CELL_SIZE / 2, pos.y + CELL_SIZE / 2, part.player, { - fontSize: '64px', - fontFamily: 'Arial', - color: part.player === 'X' ? '#3b82f6' : '#ef4444', - }).setOrigin(0.5); - - // 添加落子动画 - text.setScale(0); - this.tweens.add({ - targets: text, - scale: 1, - duration: 200, - ease: 'Back.easeOut', - }); - - return text; - }, - update: (part, obj) => { - // 可以在这里更新部件的视觉状态 - }, - }, - this.boardContainer, - ); - } - private isCellOccupied(row: number, col: number): boolean { - const state = this.state.value; - return !!state.board.partMap[`${row},${col}`]; + return !!this.state.board.partMap[`${row},${col}`]; } private setupInput(): void { - this.inputMapper = createInputMapper(this, { - onSubmit: (cmd: string) => { - // Directly submit to GameHost - return this.gameHost.onInput(cmd); - } - }); - - this.inputMapper.mapGridClick( - { x: CELL_SIZE, y: CELL_SIZE }, - BOARD_OFFSET, - { cols: BOARD_SIZE, rows: BOARD_SIZE }, - (col, row) => { - if (this.state.value.winner) return null; - - const currentPlayer = this.state.value.currentPlayer; - if (this.isCellOccupied(row, col)) return null; - - return `play ${currentPlayer} ${row} ${col}`; - }, - ); + // todo } private drawGrid(): void { @@ -156,7 +83,7 @@ export class GameScene extends ReactiveScene { color: '#4b5563', }).setOrigin(0.5); - this.updateTurnText(this.state.value.currentPlayer); + this.updateTurnText(this.state.currentPlayer); } private updateTurnText(player: string): void { @@ -166,15 +93,24 @@ export class GameScene extends ReactiveScene { } private showWinner(winner: string): void { + // 清理旧的覆盖层防止叠加 + if (this.winnerOverlay) { + this.winnerOverlay.destroy(); + } + + this.winnerOverlay = this.add.container(); + const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`; - this.add.rectangle( - BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, - BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2, - BOARD_SIZE * CELL_SIZE, - BOARD_SIZE * CELL_SIZE, - 0x000000, - 0.6, + this.winnerOverlay.add( + this.add.rectangle( + BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, + BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2, + BOARD_SIZE * CELL_SIZE, + BOARD_SIZE * CELL_SIZE, + 0x000000, + 0.6, + ), ); const winText = this.add.text( @@ -188,6 +124,8 @@ export class GameScene extends ReactiveScene { }, ).setOrigin(0.5); + this.winnerOverlay.add(winText); + this.tweens.add({ targets: winText, scale: 1.2, @@ -197,3 +135,48 @@ export class GameScene extends ReactiveScene { }); } } + +class TicTacToePartSpawner implements Spawner { + constructor(public readonly scene: GameScene, public readonly state: ReadonlySignal) {} + + *getData() { + for (const part of Object.values(this.state.value.parts)) { + yield part; + } + } + getKey(part: TicTacToePart): string { + return part.id; + } + onUpdate(part: TicTacToePart, obj: Phaser.GameObjects.Text): void { + const [xIndex, yIndex] = part.position; + const x = xIndex * CELL_SIZE + BOARD_OFFSET.x; + const y = yIndex * CELL_SIZE + BOARD_OFFSET.y; + obj.x = x; + obj.y = y; + } + onSpawn(part: TicTacToePart) { + const [xIndex, yIndex] = part.position; + const x = xIndex * CELL_SIZE + BOARD_OFFSET.x; + const y = yIndex * CELL_SIZE + BOARD_OFFSET.y; + const text = this.scene.add.text(x + CELL_SIZE / 2, y + CELL_SIZE / 2, part.player, { + fontSize: '64px', + fontFamily: 'Arial', + color: part.player === 'X' ? '#3b82f6' : '#ef4444', + }).setOrigin(0.5); + + // 添加落子动画 + text.setScale(0); + this.scene.tweens.add({ + targets: text, + scale: 1, + duration: 200, + ease: 'Back.easeOut', + }); + + return text; + } + + onDespawn(obj: Phaser.GameObjects.Text) { + obj.removedFromScene(); + } +} \ No newline at end of file diff --git a/packages/sample-game/src/ui/App.tsx b/packages/sample-game/src/ui/App.tsx new file mode 100644 index 0000000..17b4577 --- /dev/null +++ b/packages/sample-game/src/ui/App.tsx @@ -0,0 +1,26 @@ +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"; + +export default function App>(props: { gameModule: GameModule, gameScene: {new(): Phaser.Scene} }) { + + const gameHost = useComputed(() => { + return { + gameHost: createGameHost(props.gameModule, 'setup') + } + }); + + const scene = useComputed(() => new props.gameScene()); + + return ( +
+
+ + + +
+
+ ); +} diff --git a/packages/sample-game/src/ui/PhaserGame.tsx b/packages/sample-game/src/ui/PhaserGame.tsx new file mode 100644 index 0000000..cee4815 --- /dev/null +++ b/packages/sample-game/src/ui/PhaserGame.tsx @@ -0,0 +1,51 @@ +import Phaser from "phaser"; +import {createContext, h} from "preact"; +import {Signal, useSignalEffect, signal, useSignal} from "@preact/signals"; +import {useContext} from "preact/hooks"; + +export const phaserContext = createContext>(signal()); + +export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = { + type: Phaser.AUTO, + width: 560, + height: 560, + parent: 'phaser-container', + backgroundColor: '#f9fafb', + scene: [], +}; + +export function PhaserGame(props: { config?: Partial, children?: any}){ + + 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} + +
+} + +export function PhaserScene(props: { sceneKey: string, scene: Phaser.Scene, autoStart: boolean, data?: object}){ + const context = useContext(phaserContext); + useSignalEffect(() => { + const game = context.value; + 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/sample-game/src/utils/disposable.ts b/packages/sample-game/src/utils/disposable.ts new file mode 100644 index 0000000..f013237 --- /dev/null +++ b/packages/sample-game/src/utils/disposable.ts @@ -0,0 +1,54 @@ +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); + return; + } + this._disposables.add(item); + } + + /** + * Disposes all items currently in the bag and clears the collection. + */ + dispose(): void { + if (this._isDisposed) return; + + this._isDisposed = true; + + for (const item of this._disposables) { + try { + this._execute(item); + } catch (error) { + console.error("Error during resource disposal:", error); + } + } + + this._disposables.clear(); + } + + private _execute(item: DisposableItem): void { + if (typeof item === 'function') { + item(); + } else { + item.dispose(); + } + } +} \ No newline at end of file diff --git a/packages/sample-game/src/utils/spawner.ts b/packages/sample-game/src/utils/spawner.ts new file mode 100644 index 0000000..046c88d --- /dev/null +++ b/packages/sample-game/src/utils/spawner.ts @@ -0,0 +1,35 @@ +import Phaser from "phaser"; +import {effect} from "@preact/signals"; + +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){ + const objects = new Map(); + return effect(() => { + const current = new Set(); + for (const t of spawner.getData()) { + const key = spawner.getKey(t); + current.add(key); + if (!objects.has(key)) { + const obj = spawner.onSpawn(t); + if(obj) objects.set(key, obj); + }else{ + spawner.onUpdate(t, objects.get(key)!); + } + } + + for (const [key, obj] of objects) { + if (!current.has(key)) { + spawner.onDespawn(obj); + objects.delete(key); + } + } + }); +} \ No newline at end of file