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

428 lines
12 KiB
TypeScript
Raw Normal View History

2026-04-07 16:29:21 +08:00
import Phaser from 'phaser';
2026-04-07 17:13:45 +08:00
import type { OnitamaState, PlayerType, Pawn } from '@/game/onitama';
2026-04-07 16:29:21 +08:00
import { prompts } from '@/game/onitama';
import { GameHostScene } from 'boardgame-phaser';
2026-04-07 17:13:45 +08:00
import { spawnEffect } from 'boardgame-phaser';
import { effect } from '@preact/signals-core';
import type { Signal } from '@preact/signals';
import { PawnSpawner, CardSpawner, BOARD_OFFSET, CELL_SIZE, CARD_WIDTH, CARD_HEIGHT } from '@/spawners';
import type { HighlightData } from '@/spawners/HighlightSpawner';
import { createOnitamaUIState, clearSelection, selectPiece, selectCard, deselectCard, setValidMoves } from '@/state';
import type { OnitamaUIState, ValidMove } from '@/state';
2026-04-07 16:29:21 +08:00
const BOARD_SIZE = 5;
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;
2026-04-07 17:13:45 +08:00
private cardLabelContainers: Map<string, Phaser.GameObjects.Text> = new Map();
// UI State managed by signal
public uiState!: Signal<OnitamaUIState>;
private highlightContainers: Map<string, Phaser.GameObjects.GameObject> = new Map();
private highlightDispose?: () => void;
2026-04-07 16:29:21 +08:00
constructor() {
super('OnitamaScene');
}
create(): void {
super.create();
2026-04-07 17:13:45 +08:00
// Create UI state signal
this.uiState = createOnitamaUIState();
// Cleanup effect on scene shutdown
this.events.once('shutdown', () => {
if (this.highlightDispose) {
this.highlightDispose();
}
});
2026-04-07 16:29:21 +08:00
this.boardContainer = this.add.container(0, 0);
this.gridGraphics = this.add.graphics();
this.drawBoard();
2026-04-07 17:13:45 +08:00
// Add spawners
2026-04-07 16:29:21 +08:00
this.disposables.add(spawnEffect(new PawnSpawner(this)));
2026-04-07 17:13:45 +08:00
this.disposables.add(spawnEffect(new CardSpawner(this)));
2026-04-07 16:29:21 +08:00
2026-04-07 17:13:45 +08:00
// Create card labels
this.createCardLabels();
2026-04-07 16:29:21 +08:00
2026-04-07 17:13:45 +08:00
// Setup highlight effect - react to validMoves changes
this.highlightDispose = effect(() => {
const validMoves = this.uiState.value.validMoves;
this.updateHighlights(validMoves);
2026-04-07 16:29:21 +08:00
});
2026-04-07 17:13:45 +08:00
// Winner overlay effect
2026-04-07 16:29:21 +08:00
this.addEffect(() => {
const winner = this.state.winner;
if (winner) {
this.showWinner(winner);
} else if (this.winnerOverlay) {
this.winnerOverlay.destroy();
this.winnerOverlay = undefined;
}
});
2026-04-07 17:13:45 +08:00
// Info text
this.infoText = this.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 30,
'',
{
fontSize: '20px',
fontFamily: 'Arial',
color: '#4b5563',
}
).setOrigin(0.5);
2026-04-07 16:29:21 +08:00
2026-04-07 17:13:45 +08:00
// Update info text when UI state changes
2026-04-07 16:29:21 +08:00
this.addEffect(() => {
this.updateInfoText();
});
2026-04-07 17:13:45 +08:00
// Input handling
2026-04-07 16:29:21 +08:00
this.setupInput();
}
2026-04-07 17:13:45 +08:00
private createCardLabels(): void {
const boardLeft = BOARD_OFFSET.x;
const boardTop = BOARD_OFFSET.y;
const boardRight = BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE;
// Red cards label
const redLabel = this.add.text(
boardLeft - CARD_WIDTH - 60 + CARD_WIDTH / 2,
boardTop + 50 + 2 * (CARD_HEIGHT + 15) + 10,
"RED's Cards",
{
fontSize: '16px',
fontFamily: 'Arial',
color: '#ef4444',
}
).setOrigin(0.5, 0);
this.cardLabelContainers.set('red', redLabel);
// Black cards label
const blackLabel = this.add.text(
boardRight + 60 + CARD_WIDTH / 2,
boardTop + 50 + 2 * (CARD_HEIGHT + 15) + 10,
"BLACK's Cards",
{
fontSize: '16px',
fontFamily: 'Arial',
color: '#3b82f6',
}
).setOrigin(0.5, 0);
this.cardLabelContainers.set('black', blackLabel);
// Spare card label
const boardCenterX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2;
const spareLabel = this.add.text(
boardCenterX,
boardTop - 50,
'Spare Card',
{
fontSize: '16px',
fontFamily: 'Arial',
color: '#6b7280',
}
).setOrigin(0.5, 0);
this.cardLabelContainers.set('spare', spareLabel);
}
2026-04-07 16:29:21 +08:00
private updateInfoText(): void {
const currentPlayer = this.state.currentPlayer;
2026-04-07 17:13:45 +08:00
const selectedCard = this.uiState.value.selectedCard;
const selectedPiece = this.uiState.value.selectedPiece;
2026-04-07 16:29:21 +08:00
if (this.state.winner) {
this.infoText.setText(`${this.state.winner} wins!`);
2026-04-07 17:13:45 +08:00
} else if (!selectedCard) {
this.infoText.setText(`${currentPlayer}'s turn - Select a card first`);
} else if (!selectedPiece) {
this.infoText.setText(`Card: ${selectedCard} - Select a piece to move`);
2026-04-07 16:29:21 +08:00
} else {
this.infoText.setText(`${currentPlayer}'s turn (Turn ${this.state.turn + 1})`);
}
}
private drawBoard(): void {
const g = this.gridGraphics;
g.lineStyle(2, 0x6b7280);
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,
2026-04-07 17:13:45 +08:00
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE
2026-04-07 16:29:21 +08:00
);
g.lineBetween(
BOARD_OFFSET.x,
BOARD_OFFSET.y + i * CELL_SIZE,
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
2026-04-07 17:13:45 +08:00
BOARD_OFFSET.y + i * CELL_SIZE
2026-04-07 16:29:21 +08:00
);
}
g.strokePath();
2026-04-07 17:13:45 +08:00
this.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y - 40,
'Onitama',
{
fontSize: '28px',
fontFamily: 'Arial',
color: '#1f2937',
}
).setOrigin(0.5);
2026-04-07 16:29:21 +08:00
}
private setupInput(): void {
2026-04-07 17:13:45 +08:00
// Board cell clicks
2026-04-07 16:29:21 +08:00
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;
this.handleCellClick(col, row);
});
}
}
}
private handleCellClick(x: number, y: number): void {
const pawn = this.getPawnAtPosition(x, y);
2026-04-07 17:13:45 +08:00
// 如果没有选中卡牌,提示先选卡牌
if (!this.uiState.value.selectedCard) {
console.log('请先选择一张卡牌');
return;
}
if (this.uiState.value.selectedPiece) {
// 已经选中了棋子
2026-04-07 16:29:21 +08:00
if (pawn && pawn.owner === this.state.currentPlayer) {
2026-04-07 17:13:45 +08:00
// 点击了自己的另一个棋子,更新选择
selectPiece(this.uiState, x, y);
this.updateValidMoves();
2026-04-07 16:29:21 +08:00
return;
}
2026-04-07 17:13:45 +08:00
const fromX = this.uiState.value.selectedPiece.x;
const fromY = this.uiState.value.selectedPiece.y;
2026-04-07 16:29:21 +08:00
if (pawn && pawn.owner === this.state.currentPlayer) {
return;
}
2026-04-07 17:13:45 +08:00
// 尝试移动到目标位置,必须使用选中的卡牌
const validMoves = this.getValidMovesForPiece(fromX, fromY, [this.uiState.value.selectedCard]);
const targetMove = validMoves.find(m => m.toX === x && m.toY === y);
if (targetMove) {
this.executeMove(targetMove);
}
2026-04-07 16:29:21 +08:00
} else {
2026-04-07 17:13:45 +08:00
// 还没有选中棋子
2026-04-07 16:29:21 +08:00
if (pawn && pawn.owner === this.state.currentPlayer) {
2026-04-07 17:13:45 +08:00
selectPiece(this.uiState, x, y);
this.updateValidMoves();
2026-04-07 16:29:21 +08:00
}
}
}
2026-04-07 17:13:45 +08:00
public onCardClick(cardId: string): void {
// 只能选择当前玩家的手牌
2026-04-07 16:29:21 +08:00
const currentPlayer = this.state.currentPlayer;
2026-04-07 17:13:45 +08:00
const playerCards = currentPlayer === 'red' ? this.state.redCards : this.state.blackCards;
if (!playerCards.includes(cardId)) {
return;
}
selectCard(this.uiState, cardId);
// 如果已经选中了棋子,更新有效移动
if (this.uiState.value.selectedPiece) {
this.updateValidMoves();
}
}
2026-04-07 16:29:21 +08:00
2026-04-07 17:13:45 +08:00
private updateValidMoves(): void {
const selectedPiece = this.uiState.value.selectedPiece;
const selectedCard = this.uiState.value.selectedCard;
if (!selectedPiece || !selectedCard) {
setValidMoves(this.uiState, []);
return;
}
const moves = this.getValidMovesForPiece(selectedPiece.x, selectedPiece.y, [selectedCard]);
setValidMoves(this.uiState, moves);
}
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 updateHighlights(validMoves: ValidMove[]): void {
// Clear old highlights
for (const [, circle] of this.highlightContainers) {
circle.destroy();
}
this.highlightContainers.clear();
// Create new highlights
for (const move of validMoves) {
const key = `${move.card}-${move.toX}-${move.toY}`;
2026-04-07 16:29:21 +08:00
const x = BOARD_OFFSET.x + move.toX * CELL_SIZE + CELL_SIZE / 2;
const y = BOARD_OFFSET.y + move.toY * CELL_SIZE + CELL_SIZE / 2;
2026-04-07 17:13:45 +08:00
const circle = this.add.circle(x, y, CELL_SIZE / 3, 0x3b82f6, 0.3).setDepth(100);
circle.setInteractive({ useHandCursor: true });
circle.on('pointerdown', () => {
this.onHighlightClick({
key,
x,
y,
card: move.card,
fromX: move.fromX,
fromY: move.fromY,
toX: move.toX,
toY: move.toY,
});
2026-04-07 16:29:21 +08:00
});
2026-04-07 17:13:45 +08:00
this.highlightContainers.set(key, circle as Phaser.GameObjects.GameObject);
}
2026-04-07 16:29:21 +08:00
}
2026-04-07 17:13:45 +08:00
private getValidMovesForPiece(
fromX: number,
fromY: number,
cardNames: string[]
): ValidMove[] {
const moves: ValidMove[] = [];
2026-04-07 16:29:21 +08:00
const player = this.state.currentPlayer;
for (const cardName of cardNames) {
const card = this.state.cards[cardName];
if (!card) continue;
for (const move of card.moveCandidates) {
const toX = fromX + move.dx;
const toY = fromY + move.dy;
if (this.isValidMove(fromX, fromY, toX, toY, player)) {
moves.push({ card: cardName, fromX, fromY, toX, toY });
}
}
}
return moves;
}
private isValidMove(fromX: number, fromY: number, toX: number, toY: number, player: PlayerType): boolean {
if (toX < 0 || toX >= BOARD_SIZE || toY < 0 || toY >= BOARD_SIZE) {
return false;
}
const targetPawn = this.getPawnAtPosition(toX, toY);
if (targetPawn && targetPawn.owner === player) {
return false;
}
const pawn = this.getPawnAtPosition(fromX, fromY);
if (!pawn || pawn.owner !== player) {
return false;
}
return true;
}
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 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 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,
2026-04-07 17:13:45 +08:00
0.6
2026-04-07 16:29:21 +08:00
).setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
this.gameHost.start();
});
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',
2026-04-07 17:13:45 +08:00
}
2026-04-07 16:29:21 +08:00
).setOrigin(0.5);
this.winnerOverlay.add(winText);
this.tweens.add({
targets: winText,
scale: 1.2,
duration: 500,
yoyo: true,
repeat: 1,
});
}
}