fix: update according to boardgame-core
This commit is contained in:
parent
cbee709a27
commit
5d4c169fea
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue