refactor:refactor: improve ttt

This commit is contained in:
hyper 2026-04-12 17:54:46 +08:00
parent 9ab7ae3e60
commit fbf3f5e636
3 changed files with 280 additions and 126 deletions

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 {prompts} from "@/game/tic-tac-toe";
const CELL_SIZE = 120;
const BOARD_OFFSET = { x: 100, y: 100 };
const BOARD_SIZE = 3;
// 棋盘配置常量
export const BOARD_CONFIG = {
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> {
private boardContainer!: Phaser.GameObjects.Container;
private gridGraphics!: Phaser.GameObjects.Graphics;
private turnText!: Phaser.GameObjects.Text;
private winnerOverlay?: Phaser.GameObjects.Container;
private menuButton!: Phaser.GameObjects.Container;
private menuButtonText!: Phaser.GameObjects.Text;
private menuButtonContainer!: Phaser.GameObjects.Container;
private menuButtonBg!: Phaser.GameObjects.Rectangle;
private menuButtonText!: Phaser.GameObjects.Text;
constructor() {
super('GameScene');
@ -26,8 +47,10 @@ export class GameScene extends GameHostScene<TicTacToeState> {
this.boardContainer = this.add.container(0, 0);
this.gridGraphics = this.add.graphics();
this.drawGrid();
this.createBoardVisuals();
this.createMenuButton();
this.createInputZones();
this.disposables.add(spawnEffect(new TicTacToePartSpawner(this)));
@ -45,39 +68,86 @@ export class GameScene extends GameHostScene<TicTacToeState> {
const currentPlayer = this.state.currentPlayer;
this.updateTurnText(currentPlayer);
});
this.setupInput();
}
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 {
const buttonX = this.game.scale.width - 80;
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 });
this.menuButtonText = this.add.text(buttonX, buttonY, 'Menu', {
fontSize: '18px',
fontSize: BOARD_CONFIG.fontSize.menuButton,
fontFamily: 'Arial',
color: '#ffffff',
}).setOrigin(0.5);
this.menuButton = this.add.container(buttonX, buttonY, [
this.menuButtonContainer = this.add.container(buttonX, buttonY, [
this.menuButtonBg,
this.menuButtonText,
]);
// 按钮交互
this.menuButtonBg.on('pointerover', () => {
this.menuButtonBg.setFillStyle(0x4b5563);
this.menuButtonBg.setFillStyle(BOARD_CONFIG.colors.menuButtonHover);
});
this.menuButtonBg.on('pointerout', () => {
this.menuButtonBg.setFillStyle(0x6b7280);
this.menuButtonBg.setFillStyle(BOARD_CONFIG.colors.menuButton);
});
this.menuButtonBg.on('pointerdown', () => {
@ -85,73 +155,48 @@ 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;
for (let row = 0; row < boardSize; row++) {
for (let col = 0; col < boardSize; col++) {
const x = boardOffset.x + col * cellSize + cellSize / 2;
const y = boardOffset.y + row * cellSize + cellSize / 2;
private setupInput(): void {
for (let row = 0; row < BOARD_SIZE; row++) {
for (let col = 0; col < BOARD_SIZE; col++) {
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 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', () => {
if (this.state.winner) return;
if (this.isCellOccupied(row, col)) return;
const error = this.gameHost.tryAnswerPrompt(prompts.play, this.state.currentPlayer, row, col);
if (error) {
console.warn('Invalid move:', error);
}
this.handleCellClick(row, col);
});
}
}
}
private drawGrid(): void {
const g = this.gridGraphics;
g.lineStyle(3, 0x6b7280);
/** 处理格子点击 */
private handleCellClick(row: number, col: number): void {
if (this.state.winner) return;
if (this.isCellOccupied(row, col)) return;
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,
);
const error = this.gameHost.tryAnswerPrompt(prompts.play, this.state.currentPlayer, row, col);
if (error) {
console.warn('Invalid move:', error);
}
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 isCellOccupied(row: number, col: number): boolean {
return !!this.state.board.partMap[`${row},${col}`];
}
/** 更新回合提示文本 */
private updateTurnText(player: string): void {
if (this.turnText) {
this.turnText.setText(`${player}'s turn`);
}
}
/** 显示获胜者 */
private showWinner(winner: string): void {
// 清理旧的覆盖层防止叠加
if (this.winnerOverlay) {
@ -161,13 +206,16 @@ export class GameScene extends GameHostScene<TicTacToeState> {
this.winnerOverlay = this.add.container();
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(
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,
centerX,
centerY,
boardSize * cellSize,
boardSize * cellSize,
BOARD_CONFIG.colors.overlay,
0.6,
).setInteractive({ useHandCursor: true });
@ -177,16 +225,11 @@ export class GameScene extends GameHostScene<TicTacToeState> {
this.winnerOverlay.add(bg);
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);
const winText = this.add.text(centerX, centerY, text, {
fontSize: BOARD_CONFIG.fontSize.winText,
fontFamily: 'Arial',
color: BOARD_CONFIG.colors.winText,
}).setOrigin(0.5);
this.winnerOverlay.add(winText);
@ -198,8 +241,14 @@ export class GameScene extends GameHostScene<TicTacToeState> {
repeat: 1,
});
}
/** 跳转到菜单场景 */
private async goToMenu(): Promise<void> {
await this.sceneController.launch('MenuScene');
}
}
/** 棋子生成器 */
class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.Text> {
constructor(public readonly scene: GameScene) {}
@ -208,24 +257,23 @@ class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.
yield part;
}
}
getKey(part: TicTacToePart): string {
return part.id;
}
onUpdate(part: TicTacToePart, obj: Phaser.GameObjects.Text): void {
const [yIndex, xIndex] = part.position;
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;
this.updatePosition(part, obj);
}
onSpawn(part: TicTacToePart) {
const [yIndex, xIndex] = part.position;
const x = xIndex * CELL_SIZE + BOARD_OFFSET.x;
const y = yIndex * CELL_SIZE + BOARD_OFFSET.y;
const text = this.scene.add.text(x + CELL_SIZE / 2, y + CELL_SIZE / 2, part.player, {
fontSize: '64px',
const { cellSize, boardOffset } = BOARD_CONFIG;
const pos = this.calculatePosition(part.position);
const text = this.scene.add.text(pos.x, pos.y, part.player, {
fontSize: BOARD_CONFIG.fontSize.cell,
fontFamily: 'Arial',
color: part.player === 'X' ? '#3b82f6' : '#ef4444',
color: part.player === 'X' ? BOARD_CONFIG.colors.x : BOARD_CONFIG.colors.o,
}).setOrigin(0.5);
// 添加落子动画
@ -239,7 +287,7 @@ class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.
return text;
}
onDespawn(obj: Phaser.GameObjects.Text) {
this.scene.tweens.add({
targets: obj,
@ -249,4 +297,21 @@ class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.
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';
/** 菜单场景配置 */
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 {
private titleText!: Phaser.GameObjects.Text;
private startButton!: Phaser.GameObjects.Container;
private startButtonText!: Phaser.GameObjects.Text;
private startButtonContainer!: Phaser.GameObjects.Container;
private startButtonBg!: Phaser.GameObjects.Rectangle;
private startButtonText!: Phaser.GameObjects.Text;
constructor() {
super('MenuScene');
@ -13,17 +38,35 @@ export class MenuScene extends ReactiveScene {
create(): void {
super.create();
const centerX = this.game.scale.width / 2;
const centerY = this.game.scale.height / 2;
const center = this.getCenterPosition();
this.createTitle(center);
this.createStartButton(center);
this.createSubtitle(center);
}
// 标题
this.titleText = this.add.text(centerX, centerY - 100, 'Tic-Tac-Toe', {
fontSize: '48px',
fontFamily: 'Arial',
color: '#1f2937',
}).setOrigin(0.5);
/** 获取屏幕中心位置 */
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',
color: MENU_CONFIG.colors.title,
}
).setOrigin(0.5);
// 标题入场动画
this.titleText.setScale(0);
this.tweens.add({
targets: this.titleText,
@ -31,36 +74,56 @@ export class MenuScene extends ReactiveScene {
duration: 600,
ease: 'Back.easeOut',
});
}
// 开始按钮
this.startButtonBg = this.add.rectangle(centerX, centerY + 40, 200, 60, 0x3b82f6)
.setInteractive({ useHandCursor: true });
/** 创建开始按钮 */
private createStartButton(center: { x: number; y: number }): void {
const { button, colors } = MENU_CONFIG;
this.startButtonText = this.add.text(centerX, centerY + 40, 'Start Game', {
fontSize: '24px',
fontFamily: 'Arial',
color: '#ffffff',
}).setOrigin(0.5);
this.startButtonBg = this.add.rectangle(
center.x,
center.y + MENU_CONFIG.positions.buttonY,
button.width,
button.height,
colors.buttonBg
).setInteractive({ useHandCursor: true });
this.startButton = this.add.container(centerX, centerY + 40, [
this.startButtonBg,
this.startButtonText,
]);
this.startButtonText = this.add.text(
center.x,
center.y + MENU_CONFIG.positions.buttonY,
'Start Game',
{
fontSize: MENU_CONFIG.fontSize.button,
fontFamily: 'Arial',
color: colors.buttonText,
}
).setOrigin(0.5);
this.startButtonContainer = this.add.container(
center.x,
center.y + MENU_CONFIG.positions.buttonY,
[this.startButtonBg, this.startButtonText]
);
// 按钮交互
this.setupButtonInteraction();
}
/** 设置按钮交互效果 */
private setupButtonInteraction(): void {
this.startButtonBg.on('pointerover', () => {
this.startButtonBg.setFillStyle(0x2563eb);
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
this.tweens.add({
targets: this.startButton,
targets: this.startButtonContainer,
scale: 1.05,
duration: 100,
});
});
this.startButtonBg.on('pointerout', () => {
this.startButtonBg.setFillStyle(0x3b82f6);
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
this.tweens.add({
targets: this.startButton,
targets: this.startButtonContainer,
scale: 1,
duration: 100,
});
@ -69,15 +132,23 @@ export class MenuScene extends ReactiveScene {
this.startButtonBg.on('pointerdown', () => {
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> {
await this.sceneController.launch('GameScene');
}