310 lines
7.7 KiB
TypeScript
310 lines
7.7 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,
|
|
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(40, 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(40, 40, "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,
|
|
0.6,
|
|
)
|
|
.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);
|
|
}
|
|
}
|