From 656c33cb5980e964948c352336172024ee88efc1 Mon Sep 17 00:00:00 2001 From: hypercross Date: Fri, 3 Apr 2026 19:39:07 +0800 Subject: [PATCH] update: various improvements --- packages/framework/src/bindings/index.ts | 8 +- packages/framework/src/input/index.ts | 17 ++- packages/sample-game/src/game/tic-tac-toe.ts | 16 ++- packages/sample-game/src/main.tsx | 120 +++++++++++++++---- packages/sample-game/src/scenes/GameScene.ts | 40 ++++++- 5 files changed, 164 insertions(+), 37 deletions(-) diff --git a/packages/framework/src/bindings/index.ts b/packages/framework/src/bindings/index.ts index fd15ea2..2985de2 100644 --- a/packages/framework/src/bindings/index.ts +++ b/packages/framework/src/bindings/index.ts @@ -35,7 +35,7 @@ export interface BindRegionOptions { export function bindRegion( state: MutableSignal, partsGetter: (state: TState) => Record>, - region: Region, + regionGetter: (state: TState) => Region, options: BindRegionOptions>, container: Phaser.GameObjects.Container, ): { cleanup: () => void; objects: Map } { @@ -44,9 +44,11 @@ export function bindRegion( const offset = options.offset ?? { x: 0, y: 0 }; const dispose = effect(function(this: { dispose: () => void }) { - const parts = partsGetter(state.value); + const currentState = state.value; + const parts = partsGetter(currentState); + const region = regionGetter(currentState); const currentIds = new Set(region.childIds); - + // 移除不在 region 中的对象 for (const [id, obj] of objects) { if (!currentIds.has(id)) { diff --git a/packages/framework/src/input/index.ts b/packages/framework/src/input/index.ts index 6fe9f5a..d8ffeba 100644 --- a/packages/framework/src/input/index.ts +++ b/packages/framework/src/input/index.ts @@ -4,16 +4,22 @@ import type { IGameContext, PromptEvent } from 'boardgame-core'; export interface InputMapperOptions> { scene: Phaser.Scene; commands: IGameContext['commands']; + activePrompt: { current: PromptEvent | null }; + onSubmitPrompt: (input: string) => string | null; } export class InputMapper> { private scene: Phaser.Scene; private commands: IGameContext['commands']; + private activePrompt: { current: PromptEvent | null }; + private onSubmitPrompt: (input: string) => string | null; private pointerDownCallback: ((pointer: Phaser.Input.Pointer) => void) | null = null; constructor(options: InputMapperOptions) { this.scene = options.scene; this.commands = options.commands; + this.activePrompt = options.activePrompt; + this.onSubmitPrompt = options.onSubmitPrompt; } mapGridClick( @@ -35,7 +41,12 @@ export class InputMapper> { const cmd = onCellClick(col, row); if (cmd) { - this.commands.run(cmd); + // 如果有活跃的 prompt,通过 submit 提交 + if (this.activePrompt.current) { + this.onSubmitPrompt(cmd); + } else { + this.commands.run(cmd); + } } }; @@ -148,8 +159,10 @@ export class PromptHandler> { export function createInputMapper>( scene: Phaser.Scene, commands: IGameContext['commands'], + activePrompt: { current: PromptEvent | null }, + onSubmitPrompt: (input: string) => string | null, ): InputMapper { - return new InputMapper({ scene, commands }); + return new InputMapper({ scene, commands, activePrompt, onSubmitPrompt }); } export function createPromptHandler>( diff --git a/packages/sample-game/src/game/tic-tac-toe.ts b/packages/sample-game/src/game/tic-tac-toe.ts index 0625e97..a471234 100644 --- a/packages/sample-game/src/game/tic-tac-toe.ts +++ b/packages/sample-game/src/game/tic-tac-toe.ts @@ -3,7 +3,6 @@ import { type Part, createRegion, type MutableSignal, - isCellOccupied as isCellOccupiedUtil, } from 'boardgame-core'; const BOARD_SIZE = 3; @@ -62,6 +61,19 @@ registration.add('setup', async function () { return context.value; }); +registration.add('reset', async function () { + const { context } = this; + context.produce(state => { + state.parts = {}; + state.board.childIds = []; + state.board.partMap = {}; + state.currentPlayer = 'X'; + state.winner = null; + state.turn = 0; + }); + return { success: true }; +}); + registration.add('turn ', async function (cmd) { const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number]; @@ -96,7 +108,7 @@ registration.add('turn ', async function (cmd) { }); export function isCellOccupied(host: MutableSignal, row: number, col: number): boolean { - return isCellOccupiedUtil(host.value.parts, 'board', [row, col]); + return !!host.value.board.partMap[`${row},${col}`]; } export function hasWinningLine(positions: number[][]): boolean { diff --git a/packages/sample-game/src/main.tsx b/packages/sample-game/src/main.tsx index 43455dc..4e6f4c9 100644 --- a/packages/sample-game/src/main.tsx +++ b/packages/sample-game/src/main.tsx @@ -1,27 +1,23 @@ import { h, render } from 'preact'; -import { signal } from '@preact/signals-core'; -import { useEffect, useState } from 'preact/hooks'; +import { signal, computed } from '@preact/signals-core'; +import { useEffect, useState, useCallback } from 'preact/hooks'; import Phaser from 'phaser'; import { createGameContext } from 'boardgame-core'; -import { GameUI, PromptDialog, CommandLog } from 'boardgame-phaser'; +import { GameUI, PromptDialog, CommandLog, createPromptHandler } from 'boardgame-phaser'; import { createInitialState, registry, type TicTacToeState } from './game/tic-tac-toe'; import { GameScene } from './scenes/GameScene'; import './style.css'; const gameContext = createGameContext(registry, createInitialState); -const promptSignal = signal>>(null); const commandLog = signal>([]); -// 监听 prompt 事件 -gameContext.commands.on('prompt', (event) => { - promptSignal.value = event; -}); +// 创建 PromptHandler 用于处理 UI 层的 prompt +let promptHandler: ReturnType> | null = null; +const promptSignal = signal>>(null); -// 包装 run 方法以记录命令日志 -const originalRun = gameContext.commands.run.bind(gameContext.commands); -(gameContext.commands as any).run = async (input: string) => { - const result = await originalRun(input); +// 记录命令日志的辅助函数 +function logCommand(input: string, result: { success: boolean; result?: unknown; error?: string }) { commandLog.value = [ ...commandLog.value, { @@ -30,12 +26,13 @@ const originalRun = gameContext.commands.run.bind(gameContext.commands); timestamp: Date.now(), }, ]; - return result; -}; +} function App() { const [phaserReady, setPhaserReady] = useState(false); const [game, setGame] = useState(null); + const [scene, setScene] = useState(null); + const [gameState, setGameState] = useState(null); useEffect(() => { const phaserConfig: Phaser.Types.Core.GameConfig = { @@ -49,9 +46,11 @@ function App() { const phaserGame = new Phaser.Game(phaserConfig); // 通过 init 传递 gameContext - phaserGame.scene.add('GameScene', GameScene, true, { gameContext }); + const gameScene = new GameScene(); + phaserGame.scene.add('GameScene', gameScene, true, { gameContext }); setGame(phaserGame); + setScene(gameScene); setPhaserReady(true); return () => { @@ -60,28 +59,97 @@ function App() { }, []); useEffect(() => { - if (phaserReady) { - gameContext.commands.run('setup'); + if (phaserReady && scene) { + // 初始化 PromptHandler + promptHandler = createPromptHandler(scene, gameContext.commands, { + onPrompt: (prompt) => { + promptSignal.value = prompt; + }, + onCancel: () => { + promptSignal.value = null; + }, + }); + promptHandler.start(); + + // 监听状态变化 + const dispose = gameContext.state.subscribe(() => { + setGameState({ ...gameContext.state.value }); + }); + + // 运行游戏设置 + gameContext.commands.run('setup').then(result => { + logCommand('setup', result); + }); + + return () => { + dispose(); + }; } - }, [phaserReady]); + }, [phaserReady, scene]); + + const handlePromptSubmit = useCallback((input: string) => { + if (promptHandler) { + const error = promptHandler.submit(input); + if (error === null) { + logCommand(input, { success: true }); + promptSignal.value = null; + } else { + logCommand(input, { success: false, error }); + } + } + }, []); + + const handlePromptCancel = useCallback(() => { + if (promptHandler) { + promptHandler.cancel('User cancelled'); + promptSignal.value = null; + } + }, []); + + const handleReset = useCallback(() => { + gameContext.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!`} + +
+ )} + { - gameContext.commands._tryCommit(input); - promptSignal.value = null; - }} - onCancel={() => { - gameContext.commands._cancel('cancelled'); - promptSignal.value = null; - }} + onSubmit={handlePromptSubmit} + onCancel={handlePromptCancel} />
+
+ Command Log + +
diff --git a/packages/sample-game/src/scenes/GameScene.ts b/packages/sample-game/src/scenes/GameScene.ts index 77c1e36..1e998c4 100644 --- a/packages/sample-game/src/scenes/GameScene.ts +++ b/packages/sample-game/src/scenes/GameScene.ts @@ -1,6 +1,5 @@ import Phaser from 'phaser'; import type { TicTacToeState, TicTacToePart, PlayerType } from '@/game/tic-tac-toe'; -import { isCellOccupied } from 'boardgame-core'; import { ReactiveScene, bindRegion, createInputMapper, createPromptHandler } from 'boardgame-phaser'; import type { PromptEvent } from 'boardgame-core'; @@ -48,7 +47,7 @@ export class GameScene extends ReactiveScene { bindRegion( this.state, (state) => state.parts, - this.state.value.board, + (state) => state.board, { cellSize: { x: CELL_SIZE, y: CELL_SIZE }, offset: BOARD_OFFSET, @@ -59,6 +58,15 @@ export class GameScene extends ReactiveScene { 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) => { @@ -69,8 +77,32 @@ export class GameScene extends ReactiveScene { ); } + private isCellOccupied(row: number, col: number): boolean { + const state = this.state.value; + return !!state.board.partMap[`${row},${col}`]; + } + private setupInput(): void { - this.inputMapper = createInputMapper(this, this.commands); + const scene = this; + const activePromptRef = { + get current() { return scene.activePrompt; } + }; + + this.inputMapper = createInputMapper( + this, + this.commands, + activePromptRef, + (cmd: string) => { + const activePrompt = this.activePrompt; + if (!activePrompt) return 'No active prompt'; + const error = activePrompt.tryCommit(cmd); + if (error === null) { + this.activePrompt = null; + this.promptHandler.start(); + } + return error; + } + ); this.inputMapper.mapGridClick( { x: CELL_SIZE, y: CELL_SIZE }, @@ -80,7 +112,7 @@ export class GameScene extends ReactiveScene { if (this.state.value.winner) return null; const currentPlayer = this.state.value.currentPlayer; - if (isCellOccupied(this.state.value.parts, 'board', [row, col])) return null; + if (this.isCellOccupied(row, col)) return null; return `play ${currentPlayer} ${row} ${col}`; },