fix: update according to boardgame-core

This commit is contained in:
hypercross 2026-04-03 19:13:12 +08:00
parent cbee709a27
commit 5d4c169fea
7 changed files with 83 additions and 51 deletions

View File

@ -29,11 +29,12 @@ export interface BindRegionOptions<TPart extends Part> {
cellSize: { x: number; y: number }; cellSize: { x: number; y: number };
offset?: { x: number; y: number }; offset?: { x: number; y: number };
factory: (part: TPart, position: Phaser.Math.Vector2) => Phaser.GameObjects.GameObject; factory: (part: TPart, position: Phaser.Math.Vector2) => Phaser.GameObjects.GameObject;
update?: (part: TPart, obj: Phaser.GameObjects.GameObject) => void;
} }
export function bindRegion<TState, TMeta>( export function bindRegion<TState, TMeta>(
state: MutableSignal<TState>, state: MutableSignal<TState>,
partsGetter: (state: TState) => Part<TMeta>[], partsGetter: (state: TState) => Record<string, Part<TMeta>>,
region: Region, region: Region,
options: BindRegionOptions<Part<TMeta>>, options: BindRegionOptions<Part<TMeta>>,
container: Phaser.GameObjects.Container, container: Phaser.GameObjects.Container,
@ -45,6 +46,8 @@ export function bindRegion<TState, TMeta>(
const dispose = effect(function(this: { dispose: () => void }) { const dispose = effect(function(this: { dispose: () => void }) {
const parts = partsGetter(state.value); const parts = partsGetter(state.value);
const currentIds = new Set(region.childIds); const currentIds = new Set(region.childIds);
// 移除不在 region 中的对象
for (const [id, obj] of objects) { for (const [id, obj] of objects) {
if (!currentIds.has(id)) { if (!currentIds.has(id)) {
obj.destroy(); obj.destroy();
@ -52,13 +55,15 @@ export function bindRegion<TState, TMeta>(
} }
} }
// 同步 region 中的 parts
for (const childId of region.childIds) { for (const childId of region.childIds) {
const part = parts.find(p => p.id === childId); const part = parts[childId];
if (!part) continue; if (!part) continue;
// 支持动态维度:取前两个维度作为 x, y
const pos = new Phaser.Math.Vector2( const pos = new Phaser.Math.Vector2(
part.position[0] * options.cellSize.x + offset.x, (part.position[0] ?? 0) * options.cellSize.x + offset.x,
part.position[1] * options.cellSize.y + offset.y, (part.position[1] ?? 0) * options.cellSize.y + offset.y,
); );
let obj = objects.get(childId); let obj = objects.get(childId);
@ -67,9 +72,14 @@ export function bindRegion<TState, TMeta>(
objects.set(childId, obj); objects.set(childId, obj);
container.add(obj); container.add(obj);
} else { } else {
// 更新位置
if ('setPosition' in obj && typeof obj.setPosition === 'function') { if ('setPosition' in obj && typeof obj.setPosition === 'function') {
(obj as any).setPosition(pos.x, pos.y); (obj as any).setPosition(pos.x, pos.y);
} }
// 调用自定义更新函数
if (options.update) {
options.update(part, obj);
}
} }
} }
}); });

View File

@ -35,7 +35,7 @@ export class InputMapper<TState extends Record<string, unknown>> {
const cmd = onCellClick(col, row); const cmd = onCellClick(col, row);
if (cmd) { if (cmd) {
this.commands._tryCommit(cmd); this.commands.run(cmd);
} }
}; };
@ -73,7 +73,6 @@ export interface PromptHandlerOptions<TState extends Record<string, unknown>> {
scene: Phaser.Scene; scene: Phaser.Scene;
commands: IGameContext<TState>['commands']; commands: IGameContext<TState>['commands'];
onPrompt: (prompt: PromptEvent) => void; onPrompt: (prompt: PromptEvent) => void;
onSubmit: (input: string) => string | null;
onCancel: (reason?: string) => void; onCancel: (reason?: string) => void;
} }
@ -81,35 +80,68 @@ export class PromptHandler<TState extends Record<string, unknown>> {
private scene: Phaser.Scene; private scene: Phaser.Scene;
private commands: IGameContext<TState>['commands']; private commands: IGameContext<TState>['commands'];
private onPrompt: (prompt: PromptEvent) => void; private onPrompt: (prompt: PromptEvent) => void;
private onSubmit: (input: string) => string | null;
private onCancel: (reason?: string) => void; private onCancel: (reason?: string) => void;
private activePrompt: PromptEvent | null = null;
private isListening = false;
constructor(options: PromptHandlerOptions<TState>) { constructor(options: PromptHandlerOptions<TState>) {
this.scene = options.scene; this.scene = options.scene;
this.commands = options.commands; this.commands = options.commands;
this.onPrompt = options.onPrompt; this.onPrompt = options.onPrompt;
this.onSubmit = options.onSubmit;
this.onCancel = options.onCancel; this.onCancel = options.onCancel;
} }
start(): void { start(): void {
this.commands.promptQueue.pop().then((promptEvent) => { this.isListening = true;
this.listenForPrompt();
}
private listenForPrompt(): void {
if (!this.isListening) return;
this.commands.promptQueue.pop()
.then((promptEvent) => {
this.activePrompt = promptEvent;
this.onPrompt(promptEvent); this.onPrompt(promptEvent);
}).catch(() => { })
// prompt was cancelled .catch((reason) => {
this.activePrompt = null;
this.onCancel(reason?.message || 'Cancelled');
}); });
} }
submit(input: string): string | null { submit(input: string): string | null {
return this.onSubmit(input); if (!this.activePrompt) {
return 'No active prompt';
}
const error = this.activePrompt.tryCommit(input);
if (error === null) {
// 提交成功,重置并监听下一个 prompt
this.activePrompt = null;
this.listenForPrompt();
}
return error;
} }
cancel(reason?: string): void { cancel(reason?: string): void {
if (this.activePrompt) {
this.activePrompt.cancel(reason);
this.activePrompt = null;
}
this.onCancel(reason); this.onCancel(reason);
} }
stop(): void {
this.isListening = false;
if (this.activePrompt) {
this.activePrompt.cancel('Handler stopped');
this.activePrompt = null;
}
}
destroy(): void { destroy(): void {
// No cleanup needed - promptQueue handles its own lifecycle this.stop();
} }
} }
@ -125,7 +157,6 @@ export function createPromptHandler<TState extends Record<string, unknown>>(
commands: IGameContext<TState>['commands'], commands: IGameContext<TState>['commands'],
callbacks: { callbacks: {
onPrompt: (prompt: PromptEvent) => void; onPrompt: (prompt: PromptEvent) => void;
onSubmit: (input: string) => string | null;
onCancel: (reason?: string) => void; onCancel: (reason?: string) => void;
}, },
): PromptHandler<TState> { ): PromptHandler<TState> {

View File

@ -5,11 +5,11 @@ import type { MutableSignal, IGameContext, CommandResult } from 'boardgame-core'
type DisposeFn = () => void; type DisposeFn = () => void;
export interface ReactiveSceneOptions<TState extends Record<string, unknown>> { export interface ReactiveSceneOptions<TState extends Record<string, unknown>> {
state: MutableSignal<TState>; gameContext: IGameContext<TState>;
commands: IGameContext<TState>['commands'];
} }
export abstract class ReactiveScene<TState extends Record<string, unknown>> extends Phaser.Scene { export abstract class ReactiveScene<TState extends Record<string, unknown>> extends Phaser.Scene {
protected gameContext!: IGameContext<TState>;
protected state!: MutableSignal<TState>; protected state!: MutableSignal<TState>;
protected commands!: IGameContext<TState>['commands']; protected commands!: IGameContext<TState>['commands'];
private effects: DisposeFn[] = []; private effects: DisposeFn[] = [];
@ -18,6 +18,12 @@ export abstract class ReactiveScene<TState extends Record<string, unknown>> exte
super(key); super(key);
} }
init(data: ReactiveSceneOptions<TState>): void {
this.gameContext = data.gameContext;
this.state = data.gameContext.state;
this.commands = data.gameContext.commands;
}
protected watch(fn: () => void): DisposeFn { protected watch(fn: () => void): DisposeFn {
const e = effect(fn); const e = effect(fn);
this.effects.push(e); this.effects.push(e);

View File

@ -65,7 +65,7 @@ export function PromptDialog({ prompt, onSubmit, onCancel }: PromptDialogProps)
{fields.length > 0 ? ( {fields.length > 0 ? (
<div className="space-y-3 mb-4"> <div className="space-y-3 mb-4">
{fields.map(({ param, label }) => ( {fields.map(({ param, label }, index) => (
<div key={label}> <div key={label}>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{label} {label}
@ -76,7 +76,7 @@ export function PromptDialog({ prompt, onSubmit, onCancel }: PromptDialogProps)
value={values[label] || ''} value={values[label] || ''}
onInput={(e) => setValues(prev => ({ ...prev, [label]: (e.target as HTMLInputElement).value }))} onInput={(e) => setValues(prev => ({ ...prev, [label]: (e.target as HTMLInputElement).value }))}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }} onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
autoFocus={fields.indexOf({ param, label }) === 0} autoFocus={index === 0}
/> />
</div> </div>
))} ))}

View File

@ -30,7 +30,7 @@ export function createInitialState() {
{ name: 'x', min: 0, max: BOARD_SIZE - 1 }, { name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 }, { name: 'y', min: 0, max: BOARD_SIZE - 1 },
]), ]),
parts: [] as TicTacToePart[], parts: {} as Record<string, TicTacToePart>,
currentPlayer: 'X' as PlayerType, currentPlayer: 'X' as PlayerType,
winner: null as WinnerType, winner: null as WinnerType,
turn: 0, turn: 0,
@ -76,7 +76,9 @@ registration.add('turn <player> <turn:number>', async function (cmd) {
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) { if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
return `Invalid position: (${row}, ${col}).`; return `Invalid position: (${row}, ${col}).`;
} }
if (isCellOccupied(this.context, row, col)) { const state = this.context.value;
const partId = state.board.partMap[`${row},${col}`];
if (partId) {
return `Cell (${row}, ${col}) is already occupied.`; return `Cell (${row}, ${col}) is already occupied.`;
} }
return null; return null;
@ -108,18 +110,18 @@ export function hasWinningLine(positions: number[][]): boolean {
export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType { export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType {
const parts = host.value.parts; const parts = host.value.parts;
const xPositions = parts.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position); const xPositions = Object.values(parts).filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
const oPositions = parts.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position); const oPositions = Object.values(parts).filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
if (hasWinningLine(xPositions)) return 'X'; if (hasWinningLine(xPositions)) return 'X';
if (hasWinningLine(oPositions)) return 'O'; if (hasWinningLine(oPositions)) return 'O';
if (parts.length >= MAX_TURNS) return 'draw'; if (Object.keys(parts).length >= MAX_TURNS) return 'draw';
return null; return null;
} }
export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col: number, player: PlayerType) { export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col: number, player: PlayerType) {
const moveNumber = host.value.parts.length + 1; const moveNumber = Object.keys(host.value.parts).length + 1;
const piece: TicTacToePart = { const piece: TicTacToePart = {
id: `piece-${player}-${moveNumber}`, id: `piece-${player}-${moveNumber}`,
regionId: 'board', regionId: 'board',
@ -127,7 +129,7 @@ export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col
player, player,
}; };
host.produce(state => { host.produce(state => {
state.parts.push(piece); state.parts[piece.id] = piece;
state.board.childIds.push(piece.id); state.board.childIds.push(piece.id);
state.board.partMap[`${row},${col}`] = piece.id; state.board.partMap[`${row},${col}`] = piece.id;
}); });

View File

@ -5,7 +5,7 @@ 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 } 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, type GameSceneData } 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);
@ -33,11 +33,6 @@ const originalRun = gameContext.commands.run.bind(gameContext.commands);
return result; return result;
}; };
const sceneData: GameSceneData = {
state: gameContext.state,
commands: gameContext.commands,
};
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);
@ -53,7 +48,8 @@ function App() {
}; };
const phaserGame = new Phaser.Game(phaserConfig); const phaserGame = new Phaser.Game(phaserConfig);
phaserGame.scene.add('GameScene', GameScene, true, sceneData); // 通过 init 传递 gameContext
phaserGame.scene.add('GameScene', GameScene, true, { gameContext });
setGame(phaserGame); setGame(phaserGame);
setPhaserReady(true); setPhaserReady(true);

View File

@ -2,17 +2,12 @@ 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 { isCellOccupied } from 'boardgame-core';
import { ReactiveScene, bindRegion, createInputMapper, createPromptHandler } from 'boardgame-phaser'; import { ReactiveScene, bindRegion, createInputMapper, createPromptHandler } from 'boardgame-phaser';
import type { PromptEvent, MutableSignal, IGameContext } from 'boardgame-core'; import type { PromptEvent } from 'boardgame-core';
const CELL_SIZE = 120; const CELL_SIZE = 120;
const BOARD_OFFSET = { x: 100, y: 100 }; const BOARD_OFFSET = { x: 100, y: 100 };
const BOARD_SIZE = 3; const BOARD_SIZE = 3;
export interface GameSceneData {
state: MutableSignal<TicTacToeState>;
commands: IGameContext<TicTacToeState>['commands'];
}
export class GameScene extends ReactiveScene<TicTacToeState> { export class GameScene extends ReactiveScene<TicTacToeState> {
private boardContainer!: Phaser.GameObjects.Container; private boardContainer!: Phaser.GameObjects.Container;
private gridGraphics!: Phaser.GameObjects.Graphics; private gridGraphics!: Phaser.GameObjects.Graphics;
@ -25,11 +20,6 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
super('GameScene'); super('GameScene');
} }
init(data: GameSceneData): void {
this.state = data.state;
this.commands = data.commands;
}
protected onStateReady(_state: TicTacToeState): void { protected onStateReady(_state: TicTacToeState): void {
} }
@ -71,6 +61,9 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
return text; return text;
}, },
update: (part, obj) => {
// 可以在这里更新部件的视觉状态
},
}, },
this.boardContainer, this.boardContainer,
); );
@ -97,12 +90,6 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
onPrompt: (prompt) => { onPrompt: (prompt) => {
this.activePrompt = prompt; this.activePrompt = prompt;
}, },
onSubmit: (input) => {
if (this.activePrompt) {
return this.activePrompt.tryCommit(input);
}
return null;
},
onCancel: () => { onCancel: () => {
this.activePrompt = null; this.activePrompt = null;
}, },