2026-04-03 15:18:47 +08:00
|
|
|
import Phaser from 'phaser';
|
2026-04-03 16:18:44 +08:00
|
|
|
import type { TicTacToeState, TicTacToePart, PlayerType } from '@/game/tic-tac-toe';
|
2026-04-04 00:16:30 +08:00
|
|
|
import { ReactiveScene, bindRegion, createInputMapper, InputMapper } from 'boardgame-phaser';
|
2026-04-03 15:18:47 +08:00
|
|
|
|
|
|
|
|
const CELL_SIZE = 120;
|
|
|
|
|
const BOARD_OFFSET = { x: 100, y: 100 };
|
|
|
|
|
const BOARD_SIZE = 3;
|
|
|
|
|
|
|
|
|
|
export class GameScene extends ReactiveScene<TicTacToeState> {
|
|
|
|
|
private boardContainer!: Phaser.GameObjects.Container;
|
|
|
|
|
private gridGraphics!: Phaser.GameObjects.Graphics;
|
2026-04-04 00:16:30 +08:00
|
|
|
private inputMapper!: InputMapper;
|
2026-04-03 15:18:47 +08:00
|
|
|
private turnText!: Phaser.GameObjects.Text;
|
2026-04-04 00:16:30 +08:00
|
|
|
/** Receives the active prompt from the single PromptHandler in main.tsx */
|
|
|
|
|
promptSignal: { current: any } = { current: null };
|
2026-04-03 15:18:47 +08:00
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
super('GameScene');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected onStateReady(_state: TicTacToeState): void {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
create(): void {
|
2026-04-03 16:18:44 +08:00
|
|
|
super.create();
|
2026-04-03 15:18:47 +08:00
|
|
|
this.boardContainer = this.add.container(0, 0);
|
|
|
|
|
this.gridGraphics = this.add.graphics();
|
|
|
|
|
this.drawGrid();
|
|
|
|
|
|
|
|
|
|
this.watch(() => {
|
|
|
|
|
const winner = this.state.value.winner;
|
|
|
|
|
if (winner) {
|
|
|
|
|
this.showWinner(winner);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.watch(() => {
|
|
|
|
|
const currentPlayer = this.state.value.currentPlayer;
|
|
|
|
|
this.updateTurnText(currentPlayer);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.setupInput();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected setupBindings(): void {
|
2026-04-03 16:18:44 +08:00
|
|
|
bindRegion<TicTacToeState, { player: PlayerType }>(
|
|
|
|
|
this.state,
|
|
|
|
|
(state) => state.parts,
|
2026-04-03 19:39:07 +08:00
|
|
|
(state) => state.board,
|
2026-04-03 15:18:47 +08:00
|
|
|
{
|
|
|
|
|
cellSize: { x: CELL_SIZE, y: CELL_SIZE },
|
|
|
|
|
offset: BOARD_OFFSET,
|
2026-04-03 16:18:44 +08:00
|
|
|
factory: (part, pos: Phaser.Math.Vector2) => {
|
2026-04-03 15:18:47 +08:00
|
|
|
const text = this.add.text(pos.x + CELL_SIZE / 2, pos.y + CELL_SIZE / 2, part.player, {
|
|
|
|
|
fontSize: '64px',
|
|
|
|
|
fontFamily: 'Arial',
|
|
|
|
|
color: part.player === 'X' ? '#3b82f6' : '#ef4444',
|
|
|
|
|
}).setOrigin(0.5);
|
|
|
|
|
|
2026-04-03 19:39:07 +08:00
|
|
|
// 添加落子动画
|
|
|
|
|
text.setScale(0);
|
|
|
|
|
this.tweens.add({
|
|
|
|
|
targets: text,
|
|
|
|
|
scale: 1,
|
|
|
|
|
duration: 200,
|
|
|
|
|
ease: 'Back.easeOut',
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-03 15:18:47 +08:00
|
|
|
return text;
|
|
|
|
|
},
|
2026-04-03 19:13:12 +08:00
|
|
|
update: (part, obj) => {
|
|
|
|
|
// 可以在这里更新部件的视觉状态
|
|
|
|
|
},
|
2026-04-03 15:18:47 +08:00
|
|
|
},
|
|
|
|
|
this.boardContainer,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 19:39:07 +08:00
|
|
|
private isCellOccupied(row: number, col: number): boolean {
|
|
|
|
|
const state = this.state.value;
|
|
|
|
|
return !!state.board.partMap[`${row},${col}`];
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 15:18:47 +08:00
|
|
|
private setupInput(): void {
|
2026-04-04 00:16:30 +08:00
|
|
|
this.inputMapper = createInputMapper(this, {
|
|
|
|
|
onSubmit: (cmd: string) => {
|
|
|
|
|
// Delegate to the single PromptHandler via the shared commands reference.
|
|
|
|
|
// The actual PromptHandler instance lives in main.tsx and is set up once.
|
|
|
|
|
// We call through a callback that main.tsx provides via the scene's public interface.
|
|
|
|
|
return this.submitToPrompt(cmd);
|
2026-04-03 19:39:07 +08:00
|
|
|
}
|
2026-04-04 00:16:30 +08:00
|
|
|
});
|
2026-04-03 15:18:47 +08:00
|
|
|
|
|
|
|
|
this.inputMapper.mapGridClick(
|
|
|
|
|
{ x: CELL_SIZE, y: CELL_SIZE },
|
|
|
|
|
BOARD_OFFSET,
|
|
|
|
|
{ cols: BOARD_SIZE, rows: BOARD_SIZE },
|
|
|
|
|
(col, row) => {
|
|
|
|
|
if (this.state.value.winner) return null;
|
|
|
|
|
|
|
|
|
|
const currentPlayer = this.state.value.currentPlayer;
|
2026-04-03 19:39:07 +08:00
|
|
|
if (this.isCellOccupied(row, col)) return null;
|
2026-04-03 15:18:47 +08:00
|
|
|
|
|
|
|
|
return `play ${currentPlayer} ${row} ${col}`;
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-04 00:16:30 +08:00
|
|
|
}
|
2026-04-03 15:18:47 +08:00
|
|
|
|
2026-04-04 00:16:30 +08:00
|
|
|
/**
|
|
|
|
|
* Called by main.tsx to wire up the single PromptHandler's submit function.
|
|
|
|
|
*/
|
|
|
|
|
private _submitToPrompt: ((cmd: string) => string | null) | null = null;
|
|
|
|
|
|
|
|
|
|
setSubmitPrompt(fn: (cmd: string) => string | null): void {
|
|
|
|
|
this._submitToPrompt = fn;
|
|
|
|
|
}
|
2026-04-03 15:18:47 +08:00
|
|
|
|
2026-04-04 00:16:30 +08:00
|
|
|
private submitToPrompt(cmd: string): string | null {
|
|
|
|
|
return this._submitToPrompt
|
|
|
|
|
? this._submitToPrompt(cmd)
|
|
|
|
|
: null; // no handler wired yet, accept silently
|
2026-04-03 15:18:47 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private drawGrid(): void {
|
|
|
|
|
const g = this.gridGraphics;
|
|
|
|
|
g.lineStyle(3, 0x6b7280);
|
|
|
|
|
|
|
|
|
|
for (let i = 1; i < BOARD_SIZE; i++) {
|
|
|
|
|
g.lineBetween(
|
|
|
|
|
BOARD_OFFSET.x + i * CELL_SIZE,
|
|
|
|
|
BOARD_OFFSET.y,
|
|
|
|
|
BOARD_OFFSET.x + i * CELL_SIZE,
|
|
|
|
|
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE,
|
|
|
|
|
);
|
|
|
|
|
g.lineBetween(
|
|
|
|
|
BOARD_OFFSET.x,
|
|
|
|
|
BOARD_OFFSET.y + i * CELL_SIZE,
|
|
|
|
|
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
|
|
|
|
|
BOARD_OFFSET.y + i * CELL_SIZE,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
g.strokePath();
|
|
|
|
|
|
|
|
|
|
this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 40, 'Tic-Tac-Toe', {
|
|
|
|
|
fontSize: '28px',
|
|
|
|
|
fontFamily: 'Arial',
|
|
|
|
|
color: '#1f2937',
|
|
|
|
|
}).setOrigin(0.5);
|
|
|
|
|
|
|
|
|
|
this.turnText = this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 20, '', {
|
|
|
|
|
fontSize: '20px',
|
|
|
|
|
fontFamily: 'Arial',
|
|
|
|
|
color: '#4b5563',
|
|
|
|
|
}).setOrigin(0.5);
|
|
|
|
|
|
|
|
|
|
this.updateTurnText(this.state.value.currentPlayer);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private updateTurnText(player: string): void {
|
|
|
|
|
if (this.turnText) {
|
|
|
|
|
this.turnText.setText(`${player}'s turn`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private showWinner(winner: string): void {
|
|
|
|
|
const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`;
|
|
|
|
|
|
|
|
|
|
this.add.rectangle(
|
|
|
|
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
|
|
|
|
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
|
|
|
|
BOARD_SIZE * CELL_SIZE,
|
|
|
|
|
BOARD_SIZE * CELL_SIZE,
|
|
|
|
|
0x000000,
|
|
|
|
|
0.6,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const winText = this.add.text(
|
|
|
|
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
|
|
|
|
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
|
|
|
|
text,
|
|
|
|
|
{
|
|
|
|
|
fontSize: '36px',
|
|
|
|
|
fontFamily: 'Arial',
|
|
|
|
|
color: '#fbbf24',
|
|
|
|
|
},
|
|
|
|
|
).setOrigin(0.5);
|
|
|
|
|
|
|
|
|
|
this.tweens.add({
|
|
|
|
|
targets: winText,
|
|
|
|
|
scale: 1.2,
|
|
|
|
|
duration: 500,
|
|
|
|
|
yoyo: true,
|
|
|
|
|
repeat: 1,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|