boardgame-phaser/packages/onitama-game/src/scenes/OnitamaScene.ts

322 lines
7.9 KiB
TypeScript

import { GameHostScene, spawnEffect } from "boardgame-phaser";
import type { OnitamaState, Pawn } from "@/game/onitama";
import type { HighlightData } from "@/spawners/HighlightSpawner";
import type { OnitamaUIState } from "@/state";
import type { MutableSignal } from "boardgame-core";
import type Phaser from "phaser";
import {
COLORS,
FONTS,
MENU_BUTTON,
TEXT_POSITION,
VISUAL,
getBoardCenter,
getCardLabelPosition,
colorToStr,
createWinnerPulseTween,
} from "@/config";
import { prompts } from "@/game/onitama";
import {
PawnSpawner,
CardSpawner,
BOARD_OFFSET,
CELL_SIZE,
BOARD_SIZE,
boardToScreen,
HighlightSpawner,
} from "@/spawners";
import {
createUIState,
clearSelection,
selectPiece,
selectCard,
} from "@/state";
export class OnitamaScene extends GameHostScene<OnitamaState> {
private boardContainer!: Phaser.GameObjects.Container;
private gridGraphics!: Phaser.GameObjects.Graphics;
private infoText!: Phaser.GameObjects.Text;
private winnerOverlay?: Phaser.GameObjects.Container;
private cardLabelContainers: Map<string, Phaser.GameObjects.Text> = new Map();
private menuButtonContainer!: Phaser.GameObjects.Container;
private menuButtonBg!: Phaser.GameObjects.Rectangle;
private menuButtonText!: Phaser.GameObjects.Text;
// UI State managed by MutableSignal
public uiState!: MutableSignal<OnitamaUIState>;
constructor() {
super("OnitamaScene");
}
create(): void {
super.create();
// Create UI state signal
this.uiState = createUIState();
this.boardContainer = this.add.container(0, 0);
this.gridGraphics = this.add.graphics();
this.drawBoard();
// Add spawners
this.disposables.add(spawnEffect(new PawnSpawner(this)));
this.disposables.add(spawnEffect(new CardSpawner(this)));
this.disposables.add(spawnEffect(new HighlightSpawner(this)));
// Create card labels
this.createCardLabels();
// Winner overlay effect
this.addEffect(() => {
const winner = this.state.winner;
if (winner) {
this.showWinner(winner);
} else if (this.winnerOverlay) {
this.winnerOverlay.destroy();
this.winnerOverlay = undefined;
}
});
// Info text
this.infoText = this.add.text(
TEXT_POSITION.infoX,
BOARD_OFFSET.y,
"",
FONTS.info,
);
// Update info text when UI state changes
this.addEffect(() => {
this.updateInfoText();
});
// Input handling
this.setupInput();
// Menu button
this.createMenuButton();
// Start the game
this.gameHost.start();
}
private createCardLabels(): void {
// Red cards label - 棋盘下方
const redPos = getCardLabelPosition("red");
const redLabel = this.add
.text(redPos.x, redPos.y, "RED", {
...FONTS.cardLabel,
color: colorToStr(COLORS.red),
})
.setOrigin(redPos.originX, redPos.originY);
this.cardLabelContainers.set("red", redLabel);
// Black cards label - 棋盘上方
const blackPos = getCardLabelPosition("black");
const blackLabel = this.add
.text(blackPos.x, blackPos.y, "BLACK", {
...FONTS.cardLabel,
color: colorToStr(COLORS.black),
})
.setOrigin(blackPos.originX, blackPos.originY);
this.cardLabelContainers.set("black", blackLabel);
}
private updateInfoText(): void {
const currentPlayer = this.state.currentPlayer;
if (this.state.winner) {
this.infoText.setText(`${this.state.winner} wins!`);
} else {
this.infoText.setText(`Turn ${this.state.turn + 1}\n\n${currentPlayer}`);
}
}
private drawBoard(): void {
const g = this.gridGraphics;
g.lineStyle(2, COLORS.gridLine);
for (let i = 0; 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(
TEXT_POSITION.titleX,
TEXT_POSITION.titleY,
"Onitama",
FONTS.title,
);
}
private setupInput(): void {
// Board cell clicks
for (let row = 0; row < BOARD_SIZE; row++) {
for (let col = 0; col < BOARD_SIZE; col++) {
const pos = boardToScreen(col, row);
const zone = this.add
.zone(pos.x, pos.y, CELL_SIZE, CELL_SIZE)
.setInteractive();
zone.on("pointerdown", () => {
if (this.state.winner) return;
this.handleCellClick(col, row);
});
}
}
}
private handleCellClick(x: number, y: number): void {
const pawn = this.getPawnAtPosition(x, y);
if (pawn?.owner !== this.state.currentPlayer) {
return;
}
selectPiece(this.uiState, x, y);
}
public onCardClick(cardId: string): void {
// 只能选择当前玩家的手牌
const currentPlayer = this.state.currentPlayer;
const playerCards =
currentPlayer === "red" ? this.state.redCards : this.state.blackCards;
if (!playerCards.includes(cardId)) {
return;
}
selectCard(this.uiState, cardId);
}
public onHighlightClick(data: HighlightData): void {
clearSelection(this.uiState);
this.executeMove({
card: data.card,
fromX: data.fromX,
fromY: data.fromY,
toX: data.toX,
toY: data.toY,
});
}
private executeMove(move: {
card: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
}): void {
const error = this.gameHost.tryAnswerPrompt(
prompts.move,
this.state.currentPlayer,
move.card,
move.fromX,
move.fromY,
move.toX,
move.toY,
);
if (error) {
console.warn("Invalid move:", error);
}
}
private getPawnAtPosition(x: number, y: number): Pawn | null {
const key = `${x},${y}`;
const pawnId = this.state.regions.board.partMap[key];
return pawnId ? this.state.pawns[pawnId] : null;
}
/** 创建菜单按钮 */
private createMenuButton(): void {
this.menuButtonBg = this.add
.rectangle(
MENU_BUTTON.x,
MENU_BUTTON.y,
MENU_BUTTON.width,
MENU_BUTTON.height,
COLORS.menuButton,
)
.setInteractive({ useHandCursor: true });
this.menuButtonText = this.add
.text(MENU_BUTTON.x, MENU_BUTTON.y, "Menu", FONTS.menuButton)
.setOrigin(0.5);
this.menuButtonContainer = this.add.container(
MENU_BUTTON.x,
MENU_BUTTON.y,
[this.menuButtonBg, this.menuButtonText],
);
this.menuButtonBg.on("pointerover", () => {
this.menuButtonBg.setFillStyle(COLORS.menuButtonHover);
});
this.menuButtonBg.on("pointerout", () => {
this.menuButtonBg.setFillStyle(COLORS.menuButton);
});
this.menuButtonBg.on("pointerdown", () => {
this.goToMenu();
});
}
/** 跳转到菜单场景 */
private async goToMenu(): Promise<void> {
await this.sceneController.launch("MenuScene");
}
private showWinner(winner: string): void {
if (this.winnerOverlay) {
this.winnerOverlay.destroy();
}
this.winnerOverlay = this.add.container();
const text = winner === "draw" ? "It's a draw!" : `${winner} wins!`;
const center = getBoardCenter();
const boardWidth = BOARD_SIZE * CELL_SIZE;
const boardHeight = BOARD_SIZE * CELL_SIZE;
const bg = this.add
.rectangle(
center.x,
center.y,
boardWidth,
boardHeight,
COLORS.overlayBg,
VISUAL.overlayAlpha,
)
.setInteractive({ useHandCursor: true });
bg.on("pointerdown", () => {
this.gameHost.start();
});
this.winnerOverlay.add(bg);
const winText = this.add
.text(center.x, center.y, text, FONTS.winner)
.setOrigin(0.5);
this.winnerOverlay.add(winText);
createWinnerPulseTween(this, winText);
}
}