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>(
state: MutableSignal<TState>,
partsGetter: (state: TState) => Record<string, Part<TMeta>>,
region: Region,
regionGetter: (state: TState) => Region,
options: BindRegionOptions<Part<TMeta>>,
container: Phaser.GameObjects.Container,
): { 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 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 中的对象

View File

@ -4,16 +4,22 @@ import type { IGameContext, PromptEvent } from 'boardgame-core';
export interface InputMapperOptions<TState extends Record<string, unknown>> {
scene: Phaser.Scene;
commands: IGameContext<TState>['commands'];
activePrompt: { current: PromptEvent | null };
onSubmitPrompt: (input: string) => string | null;
}
export class InputMapper<TState extends Record<string, unknown>> {
private scene: Phaser.Scene;
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;
constructor(options: InputMapperOptions<TState>) {
this.scene = options.scene;
this.commands = options.commands;
this.activePrompt = options.activePrompt;
this.onSubmitPrompt = options.onSubmitPrompt;
}
mapGridClick(
@ -35,7 +41,12 @@ export class InputMapper<TState extends Record<string, unknown>> {
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<TState extends Record<string, unknown>> {
export function createInputMapper<TState extends Record<string, unknown>>(
scene: Phaser.Scene,
commands: IGameContext<TState>['commands'],
activePrompt: { current: PromptEvent | null },
onSubmitPrompt: (input: string) => string | null,
): InputMapper<TState> {
return new InputMapper({ scene, commands });
return new InputMapper({ scene, commands, activePrompt, onSubmitPrompt });
}
export function createPromptHandler<TState extends Record<string, unknown>>(

View File

@ -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 <player> <turn:number>', async function (cmd) {
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 {
return isCellOccupiedUtil(host.value.parts, 'board', [row, col]);
return !!host.value.board.partMap[`${row},${col}`];
}
export function hasWinningLine(positions: number[][]): boolean {

View File

@ -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<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 }>>([]);
// 监听 prompt 事件
gameContext.commands.on('prompt', (event) => {
promptSignal.value = event;
});
// 创建 PromptHandler 用于处理 UI 层的 prompt
let promptHandler: ReturnType<typeof createPromptHandler<TicTacToeState>> | null = null;
const promptSignal = signal<null | Awaited<ReturnType<typeof gameContext.commands.promptQueue.pop>>>(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<Phaser.Game | null>(null);
const [scene, setScene] = useState<GameScene | null>(null);
const [gameState, setGameState] = useState<TicTacToeState | null>(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 (
<div className="flex flex-col h-screen">
<div className="flex-1 relative">
<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
prompt={promptSignal.value}
onSubmit={(input: string) => {
gameContext.commands._tryCommit(input);
promptSignal.value = null;
}}
onCancel={() => {
gameContext.commands._cancel('cancelled');
promptSignal.value = null;
}}
onSubmit={handlePromptSubmit}
onCancel={handlePromptCancel}
/>
</div>
<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} />
</div>
</div>

View File

@ -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<TicTacToeState> {
bindRegion<TicTacToeState, { player: PlayerType }>(
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<TicTacToeState> {
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<TicTacToeState> {
);
}
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<TicTacToeState> {
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}`;
},