update: various improvements
This commit is contained in:
parent
cb7f492bea
commit
656c33cb59
|
|
@ -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 中的对象
|
||||||
|
|
|
||||||
|
|
@ -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>>(
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue