255 lines
6.9 KiB
TypeScript
255 lines
6.9 KiB
TypeScript
|
|
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";
|
||
|
|
|
||
|
|
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;
|
||
|
|
|
||
|
|
constructor() {
|
||
|
|
super('GameScene');
|
||
|
|
}
|
||
|
|
|
||
|
|
create(): void {
|
||
|
|
super.create();
|
||
|
|
|
||
|
|
this.boardContainer = this.add.container(0, 0);
|
||
|
|
this.gridGraphics = this.add.graphics();
|
||
|
|
this.drawGrid();
|
||
|
|
|
||
|
|
this.disposables.add(spawnEffect(new BoopPartSpawner(this, this.gameHost.state)));
|
||
|
|
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
|
||
|
|
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;
|
||
|
|
|
||
|
|
const cmd = commands.play(this.state.currentPlayer, row, col, 'kitten');
|
||
|
|
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(
|
||
|
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||
|
|
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 30,
|
||
|
|
'',
|
||
|
|
{
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
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> {
|
||
|
|
constructor(public readonly scene: GameScene, public readonly state: ReadonlySignal<BoopState>) {}
|
||
|
|
|
||
|
|
*getData() {
|
||
|
|
for (const part of Object.values(this.state.value.pieces)) {
|
||
|
|
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;
|
||
|
|
obj.x = x;
|
||
|
|
obj.y = y;
|
||
|
|
}
|
||
|
|
|
||
|
|
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',
|
||
|
|
});
|
||
|
|
|
||
|
|
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(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|