Compare commits

...

6 Commits

Author SHA1 Message Date
hyper cc80cbad06 fix: fading 2026-04-12 18:18:45 +08:00
hyper 6584578316 fix: overlay init 2026-04-12 18:11:23 +08:00
hyper f6f18b4789 fix: wait for fade scene init 2026-04-12 18:02:10 +08:00
hyper 70334fa9e3 refactor: wait to launch scene 2026-04-12 18:00:08 +08:00
hyper fbf3f5e636 refactor:refactor: improve ttt 2026-04-12 17:54:46 +08:00
hyper 9ab7ae3e60 refactor: improved scene manager 2026-04-12 17:52:44 +08:00
7 changed files with 353 additions and 148 deletions

View File

@ -57,6 +57,12 @@ export class FadeScene extends ReactiveScene<FadeSceneData> {
* *
*/ */
private fadeTo(targetAlpha: number, duration: number): Promise<void> { private fadeTo(targetAlpha: number, duration: number): Promise<void> {
// 如果 overlay 还未初始化,直接返回 resolved promise
if (!this.overlay) {
console.warn('FadeScene: overlay 未初始化,跳过过渡动画');
return Promise.resolve();
}
if (this.isFading) { if (this.isFading) {
console.warn('FadeScene: 正在进行过渡动画'); console.warn('FadeScene: 正在进行过渡动画');
} }

View File

@ -10,7 +10,14 @@ export abstract class GameHostScene<TState extends Record<string, unknown>>
extends ReactiveScene<GameHostSceneOptions<TState>> extends ReactiveScene<GameHostSceneOptions<TState>>
{ {
public get gameHost(): GameHost<TState> { public get gameHost(): GameHost<TState> {
return this.initData.gameHost as GameHost<TState>; const gameHost = this.initData.gameHost as GameHost<TState>;
if (!gameHost) {
throw new Error(
`GameHostScene (${this.scene.key}): gameHost 未提供。` +
`确保在 PhaserScene 组件的 data 属性中传入 gameHost。`
);
}
return gameHost;
} }
public get state(): TState { public get state(): TState {

View File

@ -29,13 +29,19 @@ export abstract class ReactiveScene<TData extends Record<string, unknown> = {}>
implements IDisposable implements IDisposable
{ {
protected disposables = new DisposableBag(); protected disposables = new DisposableBag();
private _initData!: TData & ReactiveScenePhaserData; private _initData?: TData & ReactiveScenePhaserData;
/** /**
* init() * init()
* create() * create()
*/ */
public get initData(): TData & ReactiveScenePhaserData { public get initData(): TData & ReactiveScenePhaserData {
if (!this._initData) {
throw new Error(
`ReactiveScene (${this.scene.key}): initData 尚未初始化。` +
`确保场景通过 PhaserScene 组件注册,并在 create() 阶段访问。`
);
}
return this._initData; return this._initData;
} }
@ -43,14 +49,14 @@ export abstract class ReactiveScene<TData extends Record<string, unknown> = {}>
* Phaser game * Phaser game
*/ */
public get phaserGame(): ReadonlySignal<{ game: Phaser.Game }> { public get phaserGame(): ReadonlySignal<{ game: Phaser.Game }> {
return this._initData.phaserGame; return this.initData.phaserGame;
} }
/** /**
* *
*/ */
public get sceneController(): SceneController { public get sceneController(): SceneController {
return this._initData.sceneController; return this.initData.sceneController;
} }
constructor(key?: string) { constructor(key?: string) {

View File

@ -1,8 +1,8 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { signal, useSignal, useSignalEffect } from '@preact/signals'; import { signal, useSignal, useSignalEffect } from '@preact/signals';
import { createContext, h } from 'preact'; import { createContext, h } from 'preact';
import { useContext } from 'preact/hooks'; import { useContext, useEffect, useRef } from 'preact/hooks';
import {ReadonlySignal} from "@preact/signals-core"; import { ReadonlySignal } from "@preact/signals-core";
import type { ReactiveScene } from '../scenes'; import type { ReactiveScene } from '../scenes';
import { FadeScene as FadeSceneClass, FADE_SCENE_KEY } from '../scenes/FadeScene'; import { FadeScene as FadeSceneClass, FADE_SCENE_KEY } from '../scenes/FadeScene';
@ -40,7 +40,7 @@ export interface PhaserGameProps {
export function PhaserGame(props: PhaserGameProps) { export function PhaserGame(props: PhaserGameProps) {
const gameSignal = useSignal<PhaserGameContext>({ game: undefined!, sceneController: undefined! }); const gameSignal = useSignal<PhaserGameContext>({ game: undefined!, sceneController: undefined! });
const initialSceneLaunched = useSignal(false); const initialSceneLaunched = useRef(false);
useSignalEffect(() => { useSignalEffect(() => {
const config: Phaser.Types.Core.GameConfig = { const config: Phaser.Types.Core.GameConfig = {
@ -49,9 +49,9 @@ export function PhaserGame(props: PhaserGameProps) {
}; };
const phaserGame = new Phaser.Game(config); const phaserGame = new Phaser.Game(config);
// 添加 FadeScene // 添加 FadeScene 并启动它来初始化 overlay
const fadeScene = new FadeSceneClass(); const fadeScene = new FadeSceneClass();
phaserGame.scene.add(FADE_SCENE_KEY, fadeScene, false); phaserGame.scene.add(FADE_SCENE_KEY, fadeScene, true); // 改为 true 以触发 create
// 创建 SceneController // 创建 SceneController
const currentScene = signal<string | null>(null); const currentScene = signal<string | null>(null);
@ -64,10 +64,24 @@ export function PhaserGame(props: PhaserGameProps) {
return; return;
} }
// 等待场景注册完成(最多等待 100ms
let retries = 0;
while (!phaserGame.scene.getScene(sceneKey) && retries < 10) {
await new Promise(resolve => setTimeout(resolve, 10));
retries++;
}
// 验证场景是否已注册
if (!phaserGame.scene.getScene(sceneKey)) {
console.error(`SceneController: 场景 "${sceneKey}" 未注册`);
return;
}
isTransitioning.value = true; isTransitioning.value = true;
const fade = phaserGame.scene.getScene(FADE_SCENE_KEY) as FadeSceneClass; const fade = phaserGame.scene.getScene(FADE_SCENE_KEY) as FadeSceneClass;
// 淡出到黑色 // 淡出到黑色
phaserGame.scene.bringToTop(FADE_SCENE_KEY);
await fade.fadeOut(300); await fade.fadeOut(300);
// 停止当前场景 // 停止当前场景
@ -75,6 +89,14 @@ export function PhaserGame(props: PhaserGameProps) {
phaserGame.scene.stop(currentScene.value); phaserGame.scene.stop(currentScene.value);
} }
// 确保场景已注册后再启动
// (场景应该已经在 PhaserScene 组件中注册)
if (!phaserGame.scene.getScene(sceneKey)) {
console.error(`SceneController: 场景 "${sceneKey}" 在切换时仍未注册`);
isTransitioning.value = false;
return;
}
// 启动新场景 // 启动新场景
phaserGame.scene.start(sceneKey); phaserGame.scene.start(sceneKey);
currentScene.value = sceneKey; currentScene.value = sceneKey;
@ -91,19 +113,22 @@ export function PhaserGame(props: PhaserGameProps) {
return () => { return () => {
gameSignal.value = { game: undefined!, sceneController: undefined! }; gameSignal.value = { game: undefined!, sceneController: undefined! };
initialSceneLaunched.value = false; initialSceneLaunched.current = false;
phaserGame.destroy(true); phaserGame.destroy(true);
}; };
}); });
// 启动初始场景 // 启动初始场景(仅一次)
useSignalEffect(() => { useEffect(() => {
const ctx = gameSignal.value; const ctx = gameSignal.value;
if (!initialSceneLaunched.value && props.initialScene && ctx?.sceneController) { if (!initialSceneLaunched.current && props.initialScene && ctx?.sceneController) {
initialSceneLaunched.value = true; initialSceneLaunched.current = true;
ctx.sceneController.launch(props.initialScene); // 使用 microtask 确保所有子组件的场景注册已完成
} Promise.resolve().then(() => {
ctx.sceneController.launch(props.initialScene!);
}); });
}
}, [gameSignal.value, props.initialScene]);
return ( return (
<div id="phaser-container" className="w-full h-full"> <div id="phaser-container" className="w-full h-full">
@ -122,9 +147,11 @@ export interface PhaserSceneProps<TData extends Record<string, unknown> = {}> {
} }
export const phaserSceneContext = createContext<ReadonlySignal<ReactiveScene> | null>(null); export const phaserSceneContext = createContext<ReadonlySignal<ReactiveScene> | null>(null);
export function PhaserScene<TData extends Record<string, unknown> = {}>(props: PhaserSceneProps<TData>) { export function PhaserScene<TData extends Record<string, unknown> = {}>(props: PhaserSceneProps<TData>) {
const phaserGameSignal = useContext(phaserContext); const phaserGameSignal = useContext(phaserContext);
const sceneSignal = useSignal<ReactiveScene<TData>>(); const sceneSignal = useSignal<ReactiveScene<TData>>();
const registered = useRef(false);
useSignalEffect(() => { useSignalEffect(() => {
if (!phaserGameSignal) return; if (!phaserGameSignal) return;
@ -132,20 +159,23 @@ export function PhaserScene<TData extends Record<string, unknown> = {}>(props: P
if (!ctx?.game) return; if (!ctx?.game) return;
const game = ctx.game; const game = ctx.game;
// 注册场景到 Phaser但不启动
if (!game.scene.getScene(props.sceneKey)) {
const initData = { const initData = {
...props.data, ...props.data,
phaserGame: phaserGameSignal, phaserGame: phaserGameSignal,
sceneController: ctx.sceneController, sceneController: ctx.sceneController,
}; };
// 注册场景但不启动
if (!game.scene.getScene(props.sceneKey)) {
game.scene.add(props.sceneKey, props.scene, false, initData); game.scene.add(props.sceneKey, props.scene, false, initData);
} }
sceneSignal.value = props.scene; sceneSignal.value = props.scene;
registered.current = true;
return () => { return () => {
sceneSignal.value = undefined; sceneSignal.value = undefined;
registered.current = false;
// 不在这里移除场景,让 SceneController 管理生命周期 // 不在这里移除场景,让 SceneController 管理生命周期
}; };
}); });

View File

@ -1 +1,19 @@
export * from "boardgame-core/samples/tic-tac-toe"; /**
* Re-export tic-tac-toe game module from boardgame-core
* This provides a convenient import path within sample-game
*/
export {
registry,
prompts,
start,
createInitialState,
isCellOccupied,
hasWinningLine,
checkWinner,
placePiece,
type TicTacToePart,
type TicTacToeState,
type TicTacToeGame,
type PlayerType,
type WinnerType,
} from "boardgame-core/samples/tic-tac-toe";

View File

@ -4,18 +4,39 @@ import { GameHostScene } from 'boardgame-phaser';
import { spawnEffect, type Spawner } from 'boardgame-phaser'; import { spawnEffect, type Spawner } from 'boardgame-phaser';
import {prompts} from "@/game/tic-tac-toe"; import {prompts} from "@/game/tic-tac-toe";
const CELL_SIZE = 120; // 棋盘配置常量
const BOARD_OFFSET = { x: 100, y: 100 }; export const BOARD_CONFIG = {
const BOARD_SIZE = 3; cellSize: 120,
boardOffset: { x: 100, y: 100 },
boardSize: 3,
colors: {
grid: 0x6b7280,
x: '#3b82f6',
o: '#ef4444',
title: '#1f2937',
turn: '#4b5563',
menuButton: 0x6b7280,
menuButtonHover: 0x4b5563,
overlay: 0x000000,
winText: '#fbbf24',
},
fontSize: {
title: '28px',
turn: '20px',
cell: '64px',
menuButton: '18px',
winText: '36px',
},
} as const;
export class GameScene extends GameHostScene<TicTacToeState> { export class GameScene extends GameHostScene<TicTacToeState> {
private boardContainer!: Phaser.GameObjects.Container; private boardContainer!: Phaser.GameObjects.Container;
private gridGraphics!: Phaser.GameObjects.Graphics; private gridGraphics!: Phaser.GameObjects.Graphics;
private turnText!: Phaser.GameObjects.Text; private turnText!: Phaser.GameObjects.Text;
private winnerOverlay?: Phaser.GameObjects.Container; private winnerOverlay?: Phaser.GameObjects.Container;
private menuButton!: Phaser.GameObjects.Container; private menuButtonContainer!: Phaser.GameObjects.Container;
private menuButtonText!: Phaser.GameObjects.Text;
private menuButtonBg!: Phaser.GameObjects.Rectangle; private menuButtonBg!: Phaser.GameObjects.Rectangle;
private menuButtonText!: Phaser.GameObjects.Text;
constructor() { constructor() {
super('GameScene'); super('GameScene');
@ -26,8 +47,10 @@ export class GameScene extends GameHostScene<TicTacToeState> {
this.boardContainer = this.add.container(0, 0); this.boardContainer = this.add.container(0, 0);
this.gridGraphics = this.add.graphics(); this.gridGraphics = this.add.graphics();
this.drawGrid();
this.createBoardVisuals();
this.createMenuButton(); this.createMenuButton();
this.createInputZones();
this.disposables.add(spawnEffect(new TicTacToePartSpawner(this))); this.disposables.add(spawnEffect(new TicTacToePartSpawner(this)));
@ -46,38 +69,87 @@ export class GameScene extends GameHostScene<TicTacToeState> {
this.updateTurnText(currentPlayer); this.updateTurnText(currentPlayer);
}); });
this.setupInput(); this.gameHost.start();
} }
private isCellOccupied(row: number, col: number): boolean { /** 创建棋盘视觉元素(网格、标题、回合提示) */
return !!this.state.board.partMap[`${row},${col}`]; private createBoardVisuals(): void {
this.drawGrid();
const { boardSize, cellSize, boardOffset } = BOARD_CONFIG;
const centerX = boardOffset.x + (boardSize * cellSize) / 2;
this.add.text(centerX, boardOffset.y - 40, 'Tic-Tac-Toe', {
fontSize: BOARD_CONFIG.fontSize.title,
fontFamily: 'Arial',
color: BOARD_CONFIG.colors.title,
}).setOrigin(0.5);
this.turnText = this.add.text(
centerX,
boardOffset.y + boardSize * cellSize + 20,
'',
{
fontSize: BOARD_CONFIG.fontSize.turn,
fontFamily: 'Arial',
color: BOARD_CONFIG.colors.turn,
}
).setOrigin(0.5);
this.updateTurnText(this.state.currentPlayer);
} }
/** 绘制棋盘网格 */
private drawGrid(): void {
const g = this.gridGraphics;
const { boardSize, cellSize, boardOffset } = BOARD_CONFIG;
g.lineStyle(3, BOARD_CONFIG.colors.grid);
for (let i = 1; i < boardSize; i++) {
g.lineBetween(
boardOffset.x + i * cellSize,
boardOffset.y,
boardOffset.x + i * cellSize,
boardOffset.y + boardSize * cellSize,
);
g.lineBetween(
boardOffset.x,
boardOffset.y + i * cellSize,
boardOffset.x + boardSize * cellSize,
boardOffset.y + i * cellSize,
);
}
g.strokePath();
}
/** 创建菜单按钮 */
private createMenuButton(): void { private createMenuButton(): void {
const buttonX = this.game.scale.width - 80; const buttonX = this.game.scale.width - 80;
const buttonY = 30; const buttonY = 30;
this.menuButtonBg = this.add.rectangle(buttonX, buttonY, 120, 40, 0x6b7280) this.menuButtonBg = this.add.rectangle(buttonX, buttonY, 120, 40, BOARD_CONFIG.colors.menuButton)
.setInteractive({ useHandCursor: true }); .setInteractive({ useHandCursor: true });
this.menuButtonText = this.add.text(buttonX, buttonY, 'Menu', { this.menuButtonText = this.add.text(buttonX, buttonY, 'Menu', {
fontSize: '18px', fontSize: BOARD_CONFIG.fontSize.menuButton,
fontFamily: 'Arial', fontFamily: 'Arial',
color: '#ffffff', color: '#ffffff',
}).setOrigin(0.5); }).setOrigin(0.5);
this.menuButton = this.add.container(buttonX, buttonY, [ this.menuButtonContainer = this.add.container(buttonX, buttonY, [
this.menuButtonBg, this.menuButtonBg,
this.menuButtonText, this.menuButtonText,
]); ]);
// 按钮交互 // 按钮交互
this.menuButtonBg.on('pointerover', () => { this.menuButtonBg.on('pointerover', () => {
this.menuButtonBg.setFillStyle(0x4b5563); this.menuButtonBg.setFillStyle(BOARD_CONFIG.colors.menuButtonHover);
}); });
this.menuButtonBg.on('pointerout', () => { this.menuButtonBg.on('pointerout', () => {
this.menuButtonBg.setFillStyle(0x6b7280); this.menuButtonBg.setFillStyle(BOARD_CONFIG.colors.menuButton);
}); });
this.menuButtonBg.on('pointerdown', () => { this.menuButtonBg.on('pointerdown', () => {
@ -85,19 +157,26 @@ export class GameScene extends GameHostScene<TicTacToeState> {
}); });
} }
private async goToMenu(): Promise<void> { /** 创建输入区域 */
await this.sceneController.launch('MenuScene'); private createInputZones(): void {
} const { boardSize, cellSize, boardOffset } = BOARD_CONFIG;
private setupInput(): void { for (let row = 0; row < boardSize; row++) {
for (let row = 0; row < BOARD_SIZE; row++) { for (let col = 0; col < boardSize; col++) {
for (let col = 0; col < BOARD_SIZE; col++) { const x = boardOffset.x + col * cellSize + cellSize / 2;
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2; const y = boardOffset.y + row * cellSize + cellSize / 2;
const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
const zone = this.add.zone(x, y, CELL_SIZE, CELL_SIZE).setInteractive(); const zone = this.add.zone(x, y, cellSize, cellSize).setInteractive();
zone.on('pointerdown', () => { zone.on('pointerdown', () => {
this.handleCellClick(row, col);
});
}
}
}
/** 处理格子点击 */
private handleCellClick(row: number, col: number): void {
if (this.state.winner) return; if (this.state.winner) return;
if (this.isCellOccupied(row, col)) return; if (this.isCellOccupied(row, col)) return;
@ -105,53 +184,21 @@ export class GameScene extends GameHostScene<TicTacToeState> {
if (error) { if (error) {
console.warn('Invalid move:', error); console.warn('Invalid move:', error);
} }
});
}
}
} }
private drawGrid(): void { /** 检查格子是否被占用 */
const g = this.gridGraphics; private isCellOccupied(row: number, col: number): boolean {
g.lineStyle(3, 0x6b7280); return !!this.state.board.partMap[`${row},${col}`];
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.currentPlayer);
} }
/** 更新回合提示文本 */
private updateTurnText(player: string): void { private updateTurnText(player: string): void {
if (this.turnText) { if (this.turnText) {
this.turnText.setText(`${player}'s turn`); this.turnText.setText(`${player}'s turn`);
} }
} }
/** 显示获胜者 */
private showWinner(winner: string): void { private showWinner(winner: string): void {
// 清理旧的覆盖层防止叠加 // 清理旧的覆盖层防止叠加
if (this.winnerOverlay) { if (this.winnerOverlay) {
@ -161,13 +208,16 @@ export class GameScene extends GameHostScene<TicTacToeState> {
this.winnerOverlay = this.add.container(); this.winnerOverlay = this.add.container();
const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`; const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`;
const { boardSize, cellSize, boardOffset } = BOARD_CONFIG;
const centerX = boardOffset.x + (boardSize * cellSize) / 2;
const centerY = boardOffset.y + (boardSize * cellSize) / 2;
const bg = this.add.rectangle( const bg = this.add.rectangle(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, centerX,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2, centerY,
BOARD_SIZE * CELL_SIZE, boardSize * cellSize,
BOARD_SIZE * CELL_SIZE, boardSize * cellSize,
0x000000, BOARD_CONFIG.colors.overlay,
0.6, 0.6,
).setInteractive({ useHandCursor: true }); ).setInteractive({ useHandCursor: true });
@ -177,16 +227,11 @@ export class GameScene extends GameHostScene<TicTacToeState> {
this.winnerOverlay.add(bg); this.winnerOverlay.add(bg);
const winText = this.add.text( const winText = this.add.text(centerX, centerY, text, {
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, fontSize: BOARD_CONFIG.fontSize.winText,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
text,
{
fontSize: '36px',
fontFamily: 'Arial', fontFamily: 'Arial',
color: '#fbbf24', color: BOARD_CONFIG.colors.winText,
}, }).setOrigin(0.5);
).setOrigin(0.5);
this.winnerOverlay.add(winText); this.winnerOverlay.add(winText);
@ -198,8 +243,14 @@ export class GameScene extends GameHostScene<TicTacToeState> {
repeat: 1, repeat: 1,
}); });
} }
/** 跳转到菜单场景 */
private async goToMenu(): Promise<void> {
await this.sceneController.launch('MenuScene');
}
} }
/** 棋子生成器 */
class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.Text> { class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.Text> {
constructor(public readonly scene: GameScene) {} constructor(public readonly scene: GameScene) {}
@ -208,24 +259,23 @@ class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.
yield part; yield part;
} }
} }
getKey(part: TicTacToePart): string { getKey(part: TicTacToePart): string {
return part.id; return part.id;
} }
onUpdate(part: TicTacToePart, obj: Phaser.GameObjects.Text): void { onUpdate(part: TicTacToePart, obj: Phaser.GameObjects.Text): void {
const [yIndex, xIndex] = part.position; this.updatePosition(part, obj);
const x = xIndex * CELL_SIZE + BOARD_OFFSET.x;
const y = yIndex * CELL_SIZE + BOARD_OFFSET.y;
obj.x = x + CELL_SIZE / 2;
obj.y = y + CELL_SIZE / 2;
} }
onSpawn(part: TicTacToePart) { onSpawn(part: TicTacToePart) {
const [yIndex, xIndex] = part.position; const { cellSize, boardOffset } = BOARD_CONFIG;
const x = xIndex * CELL_SIZE + BOARD_OFFSET.x; const pos = this.calculatePosition(part.position);
const y = yIndex * CELL_SIZE + BOARD_OFFSET.y;
const text = this.scene.add.text(x + CELL_SIZE / 2, y + CELL_SIZE / 2, part.player, { const text = this.scene.add.text(pos.x, pos.y, part.player, {
fontSize: '64px', fontSize: BOARD_CONFIG.fontSize.cell,
fontFamily: 'Arial', fontFamily: 'Arial',
color: part.player === 'X' ? '#3b82f6' : '#ef4444', color: part.player === 'X' ? BOARD_CONFIG.colors.x : BOARD_CONFIG.colors.o,
}).setOrigin(0.5); }).setOrigin(0.5);
// 添加落子动画 // 添加落子动画
@ -249,4 +299,21 @@ class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.
onComplete: () => obj.destroy(), onComplete: () => obj.destroy(),
}); });
} }
/** 计算格子的屏幕位置 */
private calculatePosition(position: number[]): { x: number; y: number } {
const { cellSize, boardOffset } = BOARD_CONFIG;
const [yIndex, xIndex] = position;
return {
x: xIndex * cellSize + boardOffset.x + cellSize / 2,
y: yIndex * cellSize + boardOffset.y + cellSize / 2,
};
}
/** 更新对象位置 */
private updatePosition(part: TicTacToePart, obj: Phaser.GameObjects.Text): void {
const pos = this.calculatePosition(part.position);
obj.x = pos.x;
obj.y = pos.y;
}
} }

View File

@ -1,10 +1,35 @@
import { ReactiveScene } from 'boardgame-phaser'; import { ReactiveScene } from 'boardgame-phaser';
/** 菜单场景配置 */
const MENU_CONFIG = {
colors: {
title: '#1f2937',
buttonText: '#ffffff',
buttonBg: 0x3b82f6,
buttonBgHover: 0x2563eb,
subtitle: '#6b7280',
},
fontSize: {
title: '48px',
button: '24px',
subtitle: '16px',
},
button: {
width: 200,
height: 60,
},
positions: {
titleY: -100,
buttonY: 40,
subtitleY: 140,
},
} as const;
export class MenuScene extends ReactiveScene { export class MenuScene extends ReactiveScene {
private titleText!: Phaser.GameObjects.Text; private titleText!: Phaser.GameObjects.Text;
private startButton!: Phaser.GameObjects.Container; private startButtonContainer!: Phaser.GameObjects.Container;
private startButtonText!: Phaser.GameObjects.Text;
private startButtonBg!: Phaser.GameObjects.Rectangle; private startButtonBg!: Phaser.GameObjects.Rectangle;
private startButtonText!: Phaser.GameObjects.Text;
constructor() { constructor() {
super('MenuScene'); super('MenuScene');
@ -13,17 +38,35 @@ export class MenuScene extends ReactiveScene {
create(): void { create(): void {
super.create(); super.create();
const centerX = this.game.scale.width / 2; const center = this.getCenterPosition();
const centerY = this.game.scale.height / 2;
// 标题 this.createTitle(center);
this.titleText = this.add.text(centerX, centerY - 100, 'Tic-Tac-Toe', { this.createStartButton(center);
fontSize: '48px', this.createSubtitle(center);
}
/** 获取屏幕中心位置 */
private getCenterPosition(): { x: number; y: number } {
return {
x: this.game.scale.width / 2,
y: this.game.scale.height / 2,
};
}
/** 创建标题文本 */
private createTitle(center: { x: number; y: number }): void {
this.titleText = this.add.text(
center.x,
center.y + MENU_CONFIG.positions.titleY,
'Tic-Tac-Toe',
{
fontSize: MENU_CONFIG.fontSize.title,
fontFamily: 'Arial', fontFamily: 'Arial',
color: '#1f2937', color: MENU_CONFIG.colors.title,
}).setOrigin(0.5); }
).setOrigin(0.5);
// 添加标题动画 // 标题入场动画
this.titleText.setScale(0); this.titleText.setScale(0);
this.tweens.add({ this.tweens.add({
targets: this.titleText, targets: this.titleText,
@ -31,36 +74,56 @@ export class MenuScene extends ReactiveScene {
duration: 600, duration: 600,
ease: 'Back.easeOut', ease: 'Back.easeOut',
}); });
}
// 开始按钮 /** 创建开始按钮 */
this.startButtonBg = this.add.rectangle(centerX, centerY + 40, 200, 60, 0x3b82f6) private createStartButton(center: { x: number; y: number }): void {
.setInteractive({ useHandCursor: true }); const { button, colors } = MENU_CONFIG;
this.startButtonText = this.add.text(centerX, centerY + 40, 'Start Game', { this.startButtonBg = this.add.rectangle(
fontSize: '24px', -button.width/2,
-button.height/2,
button.width,
button.height,
colors.buttonBg
).setOrigin(0.5).setInteractive({ useHandCursor: true });
this.startButtonText = this.add.text(
-button.width/2,
-button.height/2,
'Start Game',
{
fontSize: MENU_CONFIG.fontSize.button,
fontFamily: 'Arial', fontFamily: 'Arial',
color: '#ffffff', color: colors.buttonText,
}).setOrigin(0.5); }
).setOrigin(0.5);
this.startButton = this.add.container(centerX, centerY + 40, [ this.startButtonContainer = this.add.container(
this.startButtonBg, center.x,
this.startButtonText, center.y + MENU_CONFIG.positions.buttonY,
]); [this.startButtonBg, this.startButtonText]
);
// 按钮交互 // 按钮交互
this.setupButtonInteraction();
}
/** 设置按钮交互效果 */
private setupButtonInteraction(): void {
this.startButtonBg.on('pointerover', () => { this.startButtonBg.on('pointerover', () => {
this.startButtonBg.setFillStyle(0x2563eb); this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
this.tweens.add({ this.tweens.add({
targets: this.startButton, targets: this.startButtonContainer,
scale: 1.05, scale: 1.05,
duration: 100, duration: 100,
}); });
}); });
this.startButtonBg.on('pointerout', () => { this.startButtonBg.on('pointerout', () => {
this.startButtonBg.setFillStyle(0x3b82f6); this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
this.tweens.add({ this.tweens.add({
targets: this.startButton, targets: this.startButtonContainer,
scale: 1, scale: 1,
duration: 100, duration: 100,
}); });
@ -69,15 +132,23 @@ export class MenuScene extends ReactiveScene {
this.startButtonBg.on('pointerdown', () => { this.startButtonBg.on('pointerdown', () => {
this.startGame(); this.startGame();
}); });
// 副标题
this.add.text(centerX, centerY + 140, 'Click to start playing', {
fontSize: '16px',
fontFamily: 'Arial',
color: '#6b7280',
}).setOrigin(0.5);
} }
/** 创建副标题 */
private createSubtitle(center: { x: number; y: number }): void {
this.add.text(
center.x,
center.y + MENU_CONFIG.positions.subtitleY,
'Click to start playing',
{
fontSize: MENU_CONFIG.fontSize.subtitle,
fontFamily: 'Arial',
color: MENU_CONFIG.colors.subtitle,
}
).setOrigin(0.5);
}
/** 开始游戏 */
private async startGame(): Promise<void> { private async startGame(): Promise<void> {
await this.sceneController.launch('GameScene'); await this.sceneController.launch('GameScene');
} }