import Phaser from 'phaser'; import type { OnitamaState, PlayerType, Pawn } from '@/game/onitama'; import { prompts } from '@/game/onitama'; import { GameHostScene } from 'boardgame-phaser'; import { spawnEffect } from 'boardgame-phaser'; import { effect } from '@preact/signals-core'; import type { MutableSignal } from 'boardgame-core'; import { PawnSpawner, CardSpawner, BOARD_OFFSET, CELL_SIZE, CARD_WIDTH, CARD_HEIGHT, boardToScreen, BOARD_SIZE } from '@/spawners'; import type { HighlightData } from '@/spawners/HighlightSpawner'; import { createOnitamaUIState, clearSelection, selectPiece, selectCard, deselectCard, setValidMoves } from '@/state'; import type { OnitamaUIState, ValidMove } from '@/state'; export class OnitamaScene extends GameHostScene { private boardContainer!: Phaser.GameObjects.Container; private gridGraphics!: Phaser.GameObjects.Graphics; private infoText!: Phaser.GameObjects.Text; private winnerOverlay?: Phaser.GameObjects.Container; private cardLabelContainers: Map = new Map(); // UI State managed by MutableSignal public uiState!: MutableSignal; private highlightContainers: Map = new Map(); private highlightDispose?: () => void; constructor() { super('OnitamaScene'); } create(): void { super.create(); // Create UI state signal this.uiState = createOnitamaUIState(); // Cleanup effect on scene shutdown this.events.once('shutdown', () => { if (this.highlightDispose) { this.highlightDispose(); } }); 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))); // Create card labels this.createCardLabels(); // Setup highlight effect - react to validMoves changes this.highlightDispose = effect(() => { const validMoves = this.uiState.value.validMoves; this.updateHighlights(validMoves); }); // 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( 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); // Update info text when UI state changes this.addEffect(() => { this.updateInfoText(); }); // Input handling this.setupInput(); } 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); } private updateInfoText(): void { const currentPlayer = this.state.currentPlayer; const selectedCard = this.uiState.value.selectedCard; const selectedPiece = this.uiState.value.selectedPiece; if (this.state.winner) { this.infoText.setText(`${this.state.winner} wins!`); } 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`); } 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, 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 - 40, 'Onitama', { fontSize: '28px', fontFamily: 'Arial', color: '#1f2937', } ).setOrigin(0.5); } 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 (!this.uiState.value.selectedCard) { console.log('请先选择一张卡牌'); return; } if (this.uiState.value.selectedPiece) { // 已经选中了棋子 if (pawn && pawn.owner === this.state.currentPlayer) { // 点击了自己的另一个棋子,更新选择 selectPiece(this.uiState, x, y); this.updateValidMoves(); return; } const fromX = this.uiState.value.selectedPiece.x; const fromY = this.uiState.value.selectedPiece.y; if (pawn && pawn.owner === this.state.currentPlayer) { return; } // 尝试移动到目标位置,必须使用选中的卡牌 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); } } else { // 还没有选中棋子 if (pawn && pawn.owner === this.state.currentPlayer) { selectPiece(this.uiState, x, y); this.updateValidMoves(); } } } 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); // 如果已经选中了棋子,更新有效移动 if (this.uiState.value.selectedPiece) { this.updateValidMoves(); } } 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}`; const pos = boardToScreen(move.toX, move.toY); const circle = this.add.circle(pos.x, pos.y, CELL_SIZE / 3, 0x3b82f6, 0.3).setDepth(100); circle.setInteractive({ useHandCursor: true }); circle.on('pointerdown', () => { this.onHighlightClick({ key, x: pos.x, y: pos.y, card: move.card, fromX: move.fromX, fromY: move.fromY, toX: move.toX, toY: move.toY, }); }); this.highlightContainers.set(key, circle as Phaser.GameObjects.GameObject); } } private getValidMovesForPiece( fromX: number, fromY: number, cardNames: string[] ): ValidMove[] { const moves: ValidMove[] = []; 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, 0.6 ).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', } ).setOrigin(0.5); this.winnerOverlay.add(winText); this.tweens.add({ targets: winText, scale: 1.2, duration: 500, yoyo: true, repeat: 1, }); } }