boardgame-phaser/packages/boop-game/src/scenes/GameScene.ts

472 lines
14 KiB
TypeScript
Raw Normal View History

2026-04-04 14:22:35 +08:00
import Phaser from 'phaser';
import type {BoopState, BoopPart, PlayerType, PieceType} from '@/game/boop';
import { GameHostScene } from 'boardgame-phaser';
import { spawnEffect, type Spawner } from 'boardgame-phaser';
import type { ReadonlySignal } from '@preact/signals-core';
import {commands} from "@/game/boop";
2026-04-04 15:40:18 +08:00
import {MutableSignal} from "boardgame-core";
2026-04-04 14:22:35 +08:00
const BOARD_SIZE = 6;
const CELL_SIZE = 80;
const BOARD_OFFSET = { x: 80, y: 100 };
export class GameScene extends GameHostScene<BoopState> {
private boardContainer!: Phaser.GameObjects.Container;
private gridGraphics!: Phaser.GameObjects.Graphics;
private turnText!: Phaser.GameObjects.Text;
private infoText!: Phaser.GameObjects.Text;
private winnerOverlay?: Phaser.GameObjects.Container;
2026-04-04 14:47:03 +08:00
private whiteSupplyContainer!: Phaser.GameObjects.Container;
private blackSupplyContainer!: Phaser.GameObjects.Container;
private whiteSupplyBg!: Phaser.GameObjects.Rectangle;
private blackSupplyBg!: Phaser.GameObjects.Rectangle;
2026-04-04 14:30:49 +08:00
private whiteSupplyText!: Phaser.GameObjects.Text;
private blackSupplyText!: Phaser.GameObjects.Text;
private pieceTypeSelector!: Phaser.GameObjects.Container;
private selectedPieceType: PieceType = 'kitten';
private kittenButton!: Phaser.GameObjects.Container;
private catButton!: Phaser.GameObjects.Container;
2026-04-04 14:22:35 +08:00
constructor() {
super('GameScene');
}
create(): void {
super.create();
this.boardContainer = this.add.container(0, 0);
this.gridGraphics = this.add.graphics();
this.drawGrid();
2026-04-04 14:30:49 +08:00
this.createSupplyUI();
2026-04-04 14:22:35 +08:00
2026-04-04 15:40:18 +08:00
this.disposables.add(spawnEffect(new BoopPartSpawner(this, this.gameHost.state as MutableSignal<BoopState>)));
2026-04-04 14:22:35 +08:00
this.watch(() => {
const winner = this.state.winner;
if (winner) {
this.showWinner(winner);
} else if (this.winnerOverlay) {
this.winnerOverlay.destroy();
this.winnerOverlay = undefined;
}
});
this.watch(() => {
const currentPlayer = this.state.currentPlayer;
this.updateTurnText(currentPlayer);
2026-04-04 14:30:49 +08:00
this.updateSupplyUI();
this.updatePieceTypeSelector();
2026-04-04 14:22:35 +08:00
});
2026-04-04 14:30:49 +08:00
this.createPieceTypeSelector();
2026-04-04 14:22:35 +08:00
this.setupInput();
}
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();
zone.on('pointerdown', () => {
if (this.state.winner) return;
if (this.isCellOccupied(row, col)) return;
2026-04-04 14:30:49 +08:00
const cmd = commands.play(this.state.currentPlayer, row, col, this.selectedPieceType);
2026-04-04 14:22:35 +08:00
const error = this.gameHost.onInput(cmd);
if (error) {
console.warn('Invalid move:', error);
}
});
}
}
}
private drawGrid(): void {
const g = this.gridGraphics;
g.lineStyle(2, 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 - 50, 'Boop Game', {
fontSize: '32px',
fontFamily: 'Arial',
color: '#1f2937',
}).setOrigin(0.5);
this.turnText = this.add.text(
2026-04-04 14:30:49 +08:00
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 30,
2026-04-04 14:22:35 +08:00
'',
{
fontSize: '22px',
fontFamily: 'Arial',
color: '#4b5563',
}
).setOrigin(0.5);
this.infoText = this.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 60,
'Click to place kitten. Cats win with 3 in a row!',
{
fontSize: '16px',
fontFamily: 'Arial',
color: '#6b7280',
}
).setOrigin(0.5);
this.updateTurnText(this.state.currentPlayer);
}
2026-04-04 14:30:49 +08:00
private createSupplyUI(): void {
const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
const uiY = BOARD_OFFSET.y - 20;
2026-04-04 14:47:03 +08:00
// 白色玩家容器
this.whiteSupplyContainer = this.add.container(boardCenterX - 150, uiY);
this.whiteSupplyBg = this.add.rectangle(0, 0, 120, 50, 0x000000);
this.whiteSupplyText = this.add.text(0, 0, '', {
fontSize: '16px',
fontFamily: 'Arial',
color: '#ffffff',
align: 'center',
}).setOrigin(0.5);
this.whiteSupplyContainer.add([this.whiteSupplyBg, this.whiteSupplyText]);
this.whiteSupplyContainer.setDepth(100);
// 黑色玩家容器
this.blackSupplyContainer = this.add.container(boardCenterX + 150, uiY);
this.blackSupplyBg = this.add.rectangle(0, 0, 120, 50, 0x333333);
this.blackSupplyText = this.add.text(0, 0, '', {
fontSize: '16px',
fontFamily: 'Arial',
color: '#ffffff',
align: 'center',
}).setOrigin(0.5);
this.blackSupplyContainer.add([this.blackSupplyBg, this.blackSupplyText]);
this.blackSupplyContainer.setDepth(100);
2026-04-04 14:30:49 +08:00
this.updateSupplyUI();
}
private updateSupplyUI(): void {
const white = this.state.players.white;
const black = this.state.players.black;
this.whiteSupplyText.setText(
`⚪ WHITE\n🐾 ${white.kitten.supply} | 🐱 ${white.cat.supply}`
);
this.blackSupplyText.setText(
`⚫ BLACK\n🐾 ${black.kitten.supply} | 🐱 ${black.cat.supply}`
);
2026-04-04 14:47:03 +08:00
// 高亮当前玩家(使用动画)
2026-04-04 14:30:49 +08:00
const isWhiteTurn = this.state.currentPlayer === 'white';
2026-04-04 14:47:03 +08:00
// 停止之前的动画
this.tweens.killTweensOf(this.whiteSupplyContainer);
this.tweens.killTweensOf(this.blackSupplyContainer);
if (isWhiteTurn) {
// 白色玩家弹跳 + 脉冲
this.whiteSupplyBg.setFillStyle(0xfbbf24);
this.whiteSupplyContainer.setScale(1);
this.tweens.add({
targets: this.whiteSupplyContainer,
scale: 1.15,
duration: 200,
ease: 'Back.easeOut',
yoyo: true,
hold: 400,
onComplete: () => {
this.tweens.add({
targets: this.whiteSupplyContainer,
alpha: 0.6,
duration: 600,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
},
});
this.blackSupplyBg.setFillStyle(0x333333);
this.blackSupplyContainer.setAlpha(1);
this.blackSupplyContainer.setScale(1);
} else {
// 黑色玩家弹跳 + 脉冲
this.blackSupplyBg.setFillStyle(0xfbbf24);
this.blackSupplyContainer.setScale(1);
this.tweens.add({
targets: this.blackSupplyContainer,
scale: 1.15,
duration: 200,
ease: 'Back.easeOut',
yoyo: true,
hold: 400,
onComplete: () => {
this.tweens.add({
targets: this.blackSupplyContainer,
alpha: 0.6,
duration: 600,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
},
});
this.whiteSupplyBg.setFillStyle(0x000000);
this.whiteSupplyContainer.setAlpha(1);
this.whiteSupplyContainer.setScale(1);
}
2026-04-04 14:30:49 +08:00
}
private createPieceTypeSelector(): void {
const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
const selectorY = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 100;
this.pieceTypeSelector = this.add.container(boardCenterX, selectorY);
// 标签文字
2026-04-04 14:47:03 +08:00
const label = this.add.text(-160, 0, '放置:', {
2026-04-04 14:30:49 +08:00
fontSize: '18px',
fontFamily: 'Arial',
color: '#4b5563',
}).setOrigin(0.5, 0.5);
// 小猫按钮
2026-04-04 14:47:03 +08:00
this.kittenButton = this.createPieceButton('kitten', '🐾 小猫', -50);
// 大猫按钮
this.catButton = this.createPieceButton('cat', '🐱 大猫', 70);
2026-04-04 14:30:49 +08:00
this.pieceTypeSelector.add([label, this.kittenButton, this.catButton]);
this.updatePieceTypeSelector();
}
private createPieceButton(type: PieceType, text: string, xOffset: number): Phaser.GameObjects.Container {
const container = this.add.container(xOffset, 0);
const bg = this.add.rectangle(0, 0, 100, 40, 0xe5e7eb)
.setStrokeStyle(2, 0x9ca3af);
const textObj = this.add.text(0, 0, text, {
fontSize: '16px',
fontFamily: 'Arial',
color: '#1f2937',
}).setOrigin(0.5);
container.add([bg, textObj]);
// 使按钮可交互
bg.setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
this.selectedPieceType = type;
this.updatePieceTypeSelector();
});
// 存储引用以便后续更新
if (type === 'kitten') {
this.kittenButton = container;
} else {
this.catButton = container;
}
return container;
}
private updatePieceTypeSelector(): void {
if (!this.kittenButton || !this.catButton) return;
const white = this.state.players.white;
const black = this.state.players.black;
const currentPlayer = this.state.players[this.state.currentPlayer];
const isWhiteTurn = this.state.currentPlayer === 'white';
// 更新按钮状态
const kittenAvailable = currentPlayer.kitten.supply > 0;
const catAvailable = currentPlayer.cat.supply > 0;
// 更新小猫按钮
const kittenBg = this.kittenButton.list[0] as Phaser.GameObjects.Rectangle;
const kittenText = this.kittenButton.list[1] as Phaser.GameObjects.Text;
const isKittenSelected = this.selectedPieceType === 'kitten';
kittenBg.setFillStyle(isKittenSelected ? 0xfbbf24 : (kittenAvailable ? 0xe5e7eb : 0xd1d5db));
kittenText.setText(`🐾 小猫 (${currentPlayer.kitten.supply})`);
// 更新大猫按钮
const catBg = this.catButton.list[0] as Phaser.GameObjects.Rectangle;
const catText = this.catButton.list[1] as Phaser.GameObjects.Text;
const isCatSelected = this.selectedPieceType === 'cat';
catBg.setFillStyle(isCatSelected ? 0xfbbf24 : (catAvailable ? 0xe5e7eb : 0xd1d5db));
catText.setText(`🐱 大猫 (${currentPlayer.cat.supply})`);
// 如果选中的类型不可用,切换到可用的
if (!kittenAvailable && this.selectedPieceType === 'kitten' && catAvailable) {
this.selectedPieceType = 'cat';
this.updatePieceTypeSelector();
} else if (!catAvailable && this.selectedPieceType === 'cat' && kittenAvailable) {
this.selectedPieceType = 'kitten';
this.updatePieceTypeSelector();
}
}
2026-04-04 14:22:35 +08:00
private updateTurnText(player: PlayerType): void {
if (this.turnText) {
const whitePieces = this.state.players.white;
const blackPieces = this.state.players.black;
const current = player === 'white' ? whitePieces : blackPieces;
this.turnText.setText(
`${player.toUpperCase()}'s turn | Kittens: ${current.kitten.supply} | Cats: ${current.cat.supply}`
);
}
}
private showWinner(winner: PlayerType | 'draw' | null): void {
if (this.winnerOverlay) {
this.winnerOverlay.destroy();
}
this.winnerOverlay = this.add.container();
const text = winner === 'draw' ? "It's a draw!" : winner ? `${winner.toUpperCase()} wins!` : '';
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,
0.6,
).setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
this.gameHost.setup('setup');
});
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: '40px',
fontFamily: 'Arial',
color: '#fbbf24',
},
).setOrigin(0.5);
this.winnerOverlay.add(winText);
this.tweens.add({
targets: winText,
scale: 1.2,
duration: 500,
yoyo: true,
repeat: 1,
});
}
private isCellOccupied(row: number, col: number): boolean {
return !!this.state.board.partMap[`${row},${col}`];
}
}
class BoopPartSpawner implements Spawner<BoopPart, Phaser.GameObjects.Container> {
2026-04-04 15:40:18 +08:00
constructor(public readonly scene: GameHostScene<BoopState>) {}
2026-04-04 14:22:35 +08:00
*getData() {
2026-04-04 15:40:18 +08:00
for (const part of Object.values(this.scene.state.pieces)) {
2026-04-04 14:22:35 +08:00
yield part;
}
}
getKey(part: BoopPart): string {
return part.id;
}
onUpdate(part: BoopPart, obj: Phaser.GameObjects.Container): void {
const [row, col] = part.position;
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
2026-04-04 14:30:49 +08:00
// 使用 tween 动画平滑移动棋子
this.scene.tweens.add({
targets: obj,
x: x,
y: y,
duration: 200,
ease: 'Power2',
});
2026-04-04 14:22:35 +08:00
}
onSpawn(part: BoopPart) {
const [row, col] = part.position;
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
const container = this.scene.add.container(x, y);
const isCat = part.pieceType === 'cat';
const baseColor = part.player === 'white' ? 0xffffff : 0x333333;
const strokeColor = part.player === 'white' ? 0x000000 : 0xffffff;
// 绘制圆形背景
const circle = this.scene.add.circle(0, 0, CELL_SIZE * 0.4, baseColor)
.setStrokeStyle(3, strokeColor);
// 添加文字标识
const text = isCat ? '🐱' : '🐾';
const textObj = this.scene.add.text(0, 0, text, {
fontSize: `${isCat ? 40 : 32}px`,
fontFamily: 'Arial',
}).setOrigin(0.5);
container.add([circle, textObj]);
// 添加落子动画
container.setScale(0);
this.scene.tweens.add({
targets: container,
scale: 1,
duration: 200,
ease: 'Back.easeOut',
});
2026-04-04 15:40:18 +08:00
this.state.addInterruption(new Promise(resolve => setTimeout(resolve, 200)));
2026-04-04 14:22:35 +08:00
return container;
}
onDespawn(obj: Phaser.GameObjects.Container) {
this.scene.tweens.add({
targets: obj,
alpha: 0,
scale: 0.5,
duration: 200,
ease: 'Back.easeIn',
onComplete: () => obj.destroy(),
});
}
}