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>(
|
||||
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,9 +44,11 @@ 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 中的对象
|
||||
for (const [id, obj] of objects) {
|
||||
if (!currentIds.has(id)) {
|
||||
|
|
|
|||
|
|
@ -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>>(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue