update: various improvements

This commit is contained in:
2026-04-03 19:39:07 +08:00
parent cb7f492bea
commit 656c33cb59
5 changed files with 164 additions and 37 deletions
+14 -2
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 {
+94 -26
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>
+36 -4
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}`;
},