update: various improvements

This commit is contained in:
hypercross 2026-04-03 19:39:07 +08:00
parent cb7f492bea
commit 656c33cb59
5 changed files with 164 additions and 37 deletions

View File

@ -35,7 +35,7 @@ export interface BindRegionOptions<TPart extends Part> {
export function bindRegion<TState, TMeta>( export function bindRegion<TState, TMeta>(
state: MutableSignal<TState>, state: MutableSignal<TState>,
partsGetter: (state: TState) => Record<string, Part<TMeta>>, partsGetter: (state: TState) => Record<string, Part<TMeta>>,
region: Region, regionGetter: (state: TState) => Region,
options: BindRegionOptions<Part<TMeta>>, options: BindRegionOptions<Part<TMeta>>,
container: Phaser.GameObjects.Container, container: Phaser.GameObjects.Container,
): { cleanup: () => void; objects: Map<string, Phaser.GameObjects.GameObject> } { ): { cleanup: () => void; objects: Map<string, Phaser.GameObjects.GameObject> } {
@ -44,7 +44,9 @@ export function bindRegion<TState, TMeta>(
const offset = options.offset ?? { x: 0, y: 0 }; const offset = options.offset ?? { x: 0, y: 0 };
const dispose = effect(function(this: { dispose: () => void }) { 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); const currentIds = new Set(region.childIds);
// 移除不在 region 中的对象 // 移除不在 region 中的对象

View File

@ -4,16 +4,22 @@ import type { IGameContext, PromptEvent } from 'boardgame-core';
export interface InputMapperOptions<TState extends Record<string, unknown>> { export interface InputMapperOptions<TState extends Record<string, unknown>> {
scene: Phaser.Scene; scene: Phaser.Scene;
commands: IGameContext<TState>['commands']; commands: IGameContext<TState>['commands'];
activePrompt: { current: PromptEvent | null };
onSubmitPrompt: (input: string) => string | null;
} }
export class InputMapper<TState extends Record<string, unknown>> { export class InputMapper<TState extends Record<string, unknown>> {
private scene: Phaser.Scene; private scene: Phaser.Scene;
private commands: IGameContext<TState>['commands']; private commands: IGameContext<TState>['commands'];
private activePrompt: { current: PromptEvent | null };
private onSubmitPrompt: (input: string) => string | null;
private pointerDownCallback: ((pointer: Phaser.Input.Pointer) => void) | null = null; private pointerDownCallback: ((pointer: Phaser.Input.Pointer) => void) | null = null;
constructor(options: InputMapperOptions<TState>) { constructor(options: InputMapperOptions<TState>) {
this.scene = options.scene; this.scene = options.scene;
this.commands = options.commands; this.commands = options.commands;
this.activePrompt = options.activePrompt;
this.onSubmitPrompt = options.onSubmitPrompt;
} }
mapGridClick( mapGridClick(
@ -35,8 +41,13 @@ export class InputMapper<TState extends Record<string, unknown>> {
const cmd = onCellClick(col, row); const cmd = onCellClick(col, row);
if (cmd) { if (cmd) {
// 如果有活跃的 prompt通过 submit 提交
if (this.activePrompt.current) {
this.onSubmitPrompt(cmd);
} else {
this.commands.run(cmd); this.commands.run(cmd);
} }
}
}; };
this.pointerDownCallback = pointerDown; this.pointerDownCallback = pointerDown;
@ -148,8 +159,10 @@ export class PromptHandler<TState extends Record<string, unknown>> {
export function createInputMapper<TState extends Record<string, unknown>>( export function createInputMapper<TState extends Record<string, unknown>>(
scene: Phaser.Scene, scene: Phaser.Scene,
commands: IGameContext<TState>['commands'], commands: IGameContext<TState>['commands'],
activePrompt: { current: PromptEvent | null },
onSubmitPrompt: (input: string) => string | null,
): InputMapper<TState> { ): InputMapper<TState> {
return new InputMapper({ scene, commands }); return new InputMapper({ scene, commands, activePrompt, onSubmitPrompt });
} }
export function createPromptHandler<TState extends Record<string, unknown>>( export function createPromptHandler<TState extends Record<string, unknown>>(

View File

@ -3,7 +3,6 @@ import {
type Part, type Part,
createRegion, createRegion,
type MutableSignal, type MutableSignal,
isCellOccupied as isCellOccupiedUtil,
} from 'boardgame-core'; } from 'boardgame-core';
const BOARD_SIZE = 3; const BOARD_SIZE = 3;
@ -62,6 +61,19 @@ registration.add('setup', async function () {
return context.value; 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 <player> <turn:number>', async function (cmd) { registration.add('turn <player> <turn:number>', async function (cmd) {
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number]; const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
@ -96,7 +108,7 @@ registration.add('turn <player> <turn:number>', async function (cmd) {
}); });
export function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean { export function isCellOccupied(host: MutableSignal<TicTacToeState>, 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 { export function hasWinningLine(positions: number[][]): boolean {

View File

@ -1,27 +1,23 @@
import { h, render } from 'preact'; import { h, render } from 'preact';
import { signal } from '@preact/signals-core'; import { signal, computed } from '@preact/signals-core';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState, useCallback } from 'preact/hooks';
import Phaser from 'phaser'; import Phaser from 'phaser';
import { createGameContext } from 'boardgame-core'; 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 { createInitialState, registry, type TicTacToeState } from './game/tic-tac-toe';
import { GameScene } from './scenes/GameScene'; import { GameScene } from './scenes/GameScene';
import './style.css'; import './style.css';
const gameContext = createGameContext<TicTacToeState>(registry, createInitialState); const gameContext = createGameContext<TicTacToeState>(registry, createInitialState);
const promptSignal = signal<null | Awaited<ReturnType<typeof gameContext.commands.promptQueue.pop>>>(null);
const commandLog = signal<Array<{ input: string; result: string; timestamp: number }>>([]); const commandLog = signal<Array<{ input: string; result: string; timestamp: number }>>([]);
// 监听 prompt 事件 // 创建 PromptHandler 用于处理 UI 层的 prompt
gameContext.commands.on('prompt', (event) => { let promptHandler: ReturnType<typeof createPromptHandler<TicTacToeState>> | null = null;
promptSignal.value = event; const promptSignal = signal<null | Awaited<ReturnType<typeof gameContext.commands.promptQueue.pop>>>(null);
});
// 包装 run 方法以记录命令日志 // 记录命令日志的辅助函数
const originalRun = gameContext.commands.run.bind(gameContext.commands); function logCommand(input: string, result: { success: boolean; result?: unknown; error?: string }) {
(gameContext.commands as any).run = async (input: string) => {
const result = await originalRun(input);
commandLog.value = [ commandLog.value = [
...commandLog.value, ...commandLog.value,
{ {
@ -30,12 +26,13 @@ const originalRun = gameContext.commands.run.bind(gameContext.commands);
timestamp: Date.now(), timestamp: Date.now(),
}, },
]; ];
return result; }
};
function App() { function App() {
const [phaserReady, setPhaserReady] = useState(false); const [phaserReady, setPhaserReady] = useState(false);
const [game, setGame] = useState<Phaser.Game | null>(null); const [game, setGame] = useState<Phaser.Game | null>(null);
const [scene, setScene] = useState<GameScene | null>(null);
const [gameState, setGameState] = useState<TicTacToeState | null>(null);
useEffect(() => { useEffect(() => {
const phaserConfig: Phaser.Types.Core.GameConfig = { const phaserConfig: Phaser.Types.Core.GameConfig = {
@ -49,9 +46,11 @@ function App() {
const phaserGame = new Phaser.Game(phaserConfig); const phaserGame = new Phaser.Game(phaserConfig);
// 通过 init 传递 gameContext // 通过 init 传递 gameContext
phaserGame.scene.add('GameScene', GameScene, true, { gameContext }); const gameScene = new GameScene();
phaserGame.scene.add('GameScene', gameScene, true, { gameContext });
setGame(phaserGame); setGame(phaserGame);
setScene(gameScene);
setPhaserReady(true); setPhaserReady(true);
return () => { return () => {
@ -60,28 +59,97 @@ function App() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (phaserReady) { if (phaserReady && scene) {
gameContext.commands.run('setup'); // 初始化 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 ( return (
<div className="flex flex-col h-screen"> <div className="flex flex-col h-screen">
<div className="flex-1 relative"> <div className="flex-1 relative">
<div id="phaser-container" className="w-full h-full" /> <div id="phaser-container" className="w-full h-full" />
{/* 游戏状态显示 */}
{gameState && !gameState.winner && (
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-white px-4 py-2 rounded-lg shadow-lg z-10">
<span className="text-lg font-semibold text-gray-800">
{gameState.currentPlayer}'s Turn
</span>
</div>
)}
{gameState?.winner && (
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-white px-4 py-2 rounded-lg shadow-lg z-10">
<span className="text-lg font-semibold text-yellow-600">
{gameState.winner === 'draw' ? "It's a Draw!" : `${gameState.winner} Wins!`}
</span>
</div>
)}
<PromptDialog <PromptDialog
prompt={promptSignal.value} prompt={promptSignal.value}
onSubmit={(input: string) => { onSubmit={handlePromptSubmit}
gameContext.commands._tryCommit(input); onCancel={handlePromptCancel}
promptSignal.value = null;
}}
onCancel={() => {
gameContext.commands._cancel('cancelled');
promptSignal.value = null;
}}
/> />
</div> </div>
<div className="p-4 bg-gray-100 border-t"> <div className="p-4 bg-gray-100 border-t">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-700">Command Log</span>
<button
className="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
onClick={handleReset}
>
Reset Game
</button>
</div>
<CommandLog entries={commandLog} /> <CommandLog entries={commandLog} />
</div> </div>
</div> </div>

View File

@ -1,6 +1,5 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import type { TicTacToeState, TicTacToePart, PlayerType } from '@/game/tic-tac-toe'; import type { TicTacToeState, TicTacToePart, PlayerType } from '@/game/tic-tac-toe';
import { isCellOccupied } from 'boardgame-core';
import { ReactiveScene, bindRegion, createInputMapper, createPromptHandler } from 'boardgame-phaser'; import { ReactiveScene, bindRegion, createInputMapper, createPromptHandler } from 'boardgame-phaser';
import type { PromptEvent } from 'boardgame-core'; import type { PromptEvent } from 'boardgame-core';
@ -48,7 +47,7 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
bindRegion<TicTacToeState, { player: PlayerType }>( bindRegion<TicTacToeState, { player: PlayerType }>(
this.state, this.state,
(state) => state.parts, (state) => state.parts,
this.state.value.board, (state) => state.board,
{ {
cellSize: { x: CELL_SIZE, y: CELL_SIZE }, cellSize: { x: CELL_SIZE, y: CELL_SIZE },
offset: BOARD_OFFSET, offset: BOARD_OFFSET,
@ -59,6 +58,15 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
color: part.player === 'X' ? '#3b82f6' : '#ef4444', color: part.player === 'X' ? '#3b82f6' : '#ef4444',
}).setOrigin(0.5); }).setOrigin(0.5);
// 添加落子动画
text.setScale(0);
this.tweens.add({
targets: text,
scale: 1,
duration: 200,
ease: 'Back.easeOut',
});
return text; return text;
}, },
update: (part, obj) => { update: (part, obj) => {
@ -69,8 +77,32 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
); );
} }
private isCellOccupied(row: number, col: number): boolean {
const state = this.state.value;
return !!state.board.partMap[`${row},${col}`];
}
private setupInput(): void { 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( this.inputMapper.mapGridClick(
{ x: CELL_SIZE, y: CELL_SIZE }, { x: CELL_SIZE, y: CELL_SIZE },
@ -80,7 +112,7 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
if (this.state.value.winner) return null; if (this.state.value.winner) return null;
const currentPlayer = this.state.value.currentPlayer; 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}`; return `play ${currentPlayer} ${row} ${col}`;
}, },