From d4819f7cc3b8f9357fb0e936b3ffa21bc12bde01 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 7 Apr 2026 17:13:45 +0800 Subject: [PATCH] feat: more onitama goodness --- .../onitama-game/src/scenes/OnitamaScene.ts | 483 +++++++++--------- .../onitama-game/src/spawners/CardSpawner.ts | 244 +++++++++ .../src/spawners/HighlightSpawner.ts | 57 +++ .../onitama-game/src/spawners/PawnSpawner.ts | 101 ++++ packages/onitama-game/src/spawners/index.ts | 3 + packages/onitama-game/src/state/index.ts | 2 + packages/onitama-game/src/state/ui.ts | 86 ++++ packages/onitama-game/src/ui/App.tsx | 7 +- 8 files changed, 727 insertions(+), 256 deletions(-) create mode 100644 packages/onitama-game/src/spawners/CardSpawner.ts create mode 100644 packages/onitama-game/src/spawners/HighlightSpawner.ts create mode 100644 packages/onitama-game/src/spawners/PawnSpawner.ts create mode 100644 packages/onitama-game/src/spawners/index.ts create mode 100644 packages/onitama-game/src/state/index.ts create mode 100644 packages/onitama-game/src/state/ui.ts diff --git a/packages/onitama-game/src/scenes/OnitamaScene.ts b/packages/onitama-game/src/scenes/OnitamaScene.ts index e8abb33..a26fe88 100644 --- a/packages/onitama-game/src/scenes/OnitamaScene.ts +++ b/packages/onitama-game/src/scenes/OnitamaScene.ts @@ -1,24 +1,28 @@ import Phaser from 'phaser'; -import type { OnitamaState, PlayerType, Pawn, Card } from '@/game/onitama'; +import type { OnitamaState, PlayerType, Pawn } from '@/game/onitama'; import { prompts } from '@/game/onitama'; import { GameHostScene } from 'boardgame-phaser'; -import { spawnEffect, type Spawner } from 'boardgame-phaser'; +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'; -const CELL_SIZE = 80; -const BOARD_OFFSET = { x: 150, y: 100 }; const BOARD_SIZE = 5; -const CARD_WIDTH = 100; -const CARD_HEIGHT = 140; 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 redCardsContainer!: Phaser.GameObjects.Container; - private blackCardsContainer!: Phaser.GameObjects.Container; - private spareCardContainer!: Phaser.GameObjects.Container; - private cardGraphics!: Phaser.GameObjects.Graphics; + private cardLabelContainers: Map = new Map(); + + // UI State managed by signal + public uiState!: Signal; + private highlightContainers: Map = new Map(); + private highlightDispose?: () => void; constructor() { super('OnitamaScene'); @@ -27,21 +31,34 @@ export class OnitamaScene extends GameHostScene { create(): void { super.create(); - this.boardContainer = this.add.container(0, 0); - this.gridGraphics = this.add.graphics(); - this.cardGraphics = this.add.graphics(); - this.drawBoard(); + // Create UI state signal + this.uiState = createOnitamaUIState(); - this.disposables.add(spawnEffect(new PawnSpawner(this))); - - this.redCardsContainer = this.add.container(0, 0); - this.blackCardsContainer = this.add.container(0, 0); - this.spareCardContainer = this.add.container(0, 0); - - this.addEffect(() => { - this.updateCards(); + // 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) { @@ -52,23 +69,84 @@ export class OnitamaScene extends GameHostScene { } }); - 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); + // 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})`); } @@ -83,26 +161,32 @@ export class OnitamaScene extends GameHostScene { BOARD_OFFSET.x + i * CELL_SIZE, BOARD_OFFSET.y, BOARD_OFFSET.x + i * CELL_SIZE, - BOARD_OFFSET.y + BOARD_SIZE * 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, + 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); + 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 x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2; @@ -118,66 +202,140 @@ export class OnitamaScene extends GameHostScene { } } - private selectedPiece: { x: number, y: number } | null = null; - private handleCellClick(x: number, y: number): void { const pawn = this.getPawnAtPosition(x, y); - if (this.selectedPiece) { + // 如果没有选中卡牌,提示先选卡牌 + if (!this.uiState.value.selectedCard) { + console.log('请先选择一张卡牌'); + return; + } + + if (this.uiState.value.selectedPiece) { + // 已经选中了棋子 if (pawn && pawn.owner === this.state.currentPlayer) { - this.selectedPiece = { x, y }; - this.highlightValidMoves(); + // 点击了自己的另一个棋子,更新选择 + selectPiece(this.uiState, x, y); + this.updateValidMoves(); return; } - const fromX = this.selectedPiece.x; - const fromY = this.selectedPiece.y; - this.selectedPiece = null; + const fromX = this.uiState.value.selectedPiece.x; + const fromY = this.uiState.value.selectedPiece.y; if (pawn && pawn.owner === this.state.currentPlayer) { return; } - this.tryMove(fromX, fromY, x, y); + // 尝试移动到目标位置,必须使用选中的卡牌 + 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) { - this.selectedPiece = { x, y }; - this.highlightValidMoves(); + selectPiece(this.uiState, x, y); + this.updateValidMoves(); } } } - private highlightValidMoves(): void { - if (!this.selectedPiece) return; - + public onCardClick(cardId: string): void { + // 只能选择当前玩家的手牌 const currentPlayer = this.state.currentPlayer; - const cardNames = currentPlayer === 'red' ? this.state.redCards : this.state.blackCards; - const moves = this.getValidMovesForPiece(this.selectedPiece.x, this.selectedPiece.y, cardNames); + const playerCards = currentPlayer === 'red' ? this.state.redCards : this.state.blackCards; + + if (!playerCards.includes(cardId)) { + return; + } - moves.forEach(move => { + 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 x = BOARD_OFFSET.x + move.toX * CELL_SIZE + CELL_SIZE / 2; const y = BOARD_OFFSET.y + move.toY * CELL_SIZE + CELL_SIZE / 2; - const highlight = this.add.circle(x, y, CELL_SIZE / 3, 0x3b82f6, 0.3).setDepth(100); - highlight.setInteractive({ useHandCursor: true }); - highlight.on('pointerdown', () => { - this.selectedPiece = null; - this.clearHighlights(); - this.tryMove(move.fromX, move.fromY, move.toX, move.toY); + 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, + }); }); - }); + + this.highlightContainers.set(key, circle as Phaser.GameObjects.GameObject); + } } - private clearHighlights(): void { - this.children.list.forEach(child => { - if ('depth' in child && child.depth === 100) { - child.destroy(); - } - }); - } - - private getValidMovesForPiece(fromX: number, fromY: number, cardNames: string[]): Array<{ card: string, fromX: number, fromY: number, toX: number, toY: number }> { - const moves: Array<{ card: string, fromX: number, fromY: number, toX: number, toY: number }> = []; + private getValidMovesForPiece( + fromX: number, + fromY: number, + cardNames: string[] + ): ValidMove[] { + const moves: ValidMove[] = []; const player = this.state.currentPlayer; for (const cardName of cardNames) { @@ -215,133 +373,12 @@ export class OnitamaScene extends GameHostScene { return true; } - private tryMove(fromX: number, fromY: number, toX: number, toY: number): void { - this.clearHighlights(); - - const currentPlayer = this.state.currentPlayer; - const cardNames = currentPlayer === 'red' ? this.state.redCards : this.state.blackCards; - const validMoves = this.getValidMovesForPiece(fromX, fromY, cardNames); - - if (validMoves.length > 0) { - const move = validMoves[0]; - const error = this.gameHost.tryAnswerPrompt( - prompts.move, - currentPlayer, - move.card, - fromX, - fromY, - toX, - 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 updateCards(): void { - this.redCardsContainer.removeAll(true); - this.blackCardsContainer.removeAll(true); - this.spareCardContainer.removeAll(true); - this.cardGraphics.clear(); - - this.renderCardHand('red', this.state.redCards, 20, 200, this.redCardsContainer); - this.renderCardHand('black', this.state.blackCards, 20, 400, this.blackCardsContainer); - this.renderSpareCard(this.state.spareCard, 650, 300, this.spareCardContainer); - } - - private renderCardHand(player: PlayerType, cardNames: string[], x: number, y: number, container: Phaser.GameObjects.Container): void { - cardNames.forEach((cardName, index) => { - const card = this.state.cards[cardName]; - if (!card) return; - - const cardObj = this.createCardVisual(card, CARD_WIDTH, CARD_HEIGHT); - cardObj.x = x + index * (CARD_WIDTH + 10); - cardObj.y = y; - container.add(cardObj); - }); - - const label = this.add.text(x, y - 30, `${player.toUpperCase()}'s Cards`, { - fontSize: '16px', - fontFamily: 'Arial', - color: player === 'red' ? '#ef4444' : '#3b82f6', - }); - container.add(label); - } - - private renderSpareCard(cardName: string, x: number, y: number, container: Phaser.GameObjects.Container): void { - const card = this.state.cards[cardName]; - if (!card) return; - - const cardObj = this.createCardVisual(card, CARD_WIDTH, CARD_HEIGHT); - cardObj.x = x; - cardObj.y = y; - container.add(cardObj); - - const label = this.add.text(x, y - 30, 'Spare Card', { - fontSize: '16px', - fontFamily: 'Arial', - color: '#6b7280', - }).setOrigin(0.5, 0); - container.add(label); - } - - private createCardVisual(card: Card, width: number, height: number): Phaser.GameObjects.Container { - const container = this.add.container(0, 0); - - const bg = this.add.rectangle(0, 0, width, height, 0xf9fafb, 1) - .setStrokeStyle(2, 0x6b7280); - container.add(bg); - - const title = this.add.text(0, -height / 2 + 15, card.id, { - fontSize: '12px', - fontFamily: 'Arial', - color: '#1f2937', - }).setOrigin(0.5); - container.add(title); - - const grid = this.add.graphics(); - const cellSize = 16; - const gridWidth = 5 * cellSize; - const gridHeight = 5 * cellSize; - const gridStartX = -gridWidth / 2; - const gridStartY = -gridHeight / 2 + 30; - - for (let row = 0; row < 5; row++) { - for (let col = 0; col < 5; col++) { - const x = gridStartX + col * cellSize; - const y = gridStartY + row * cellSize; - - if (row === 2 && col === 2) { - grid.fillStyle(0x3b82f6, 1); - grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3); - } else { - const isTarget = card.moveCandidates.some(m => m.dx === col - 2 && m.dy === 2 - row); - if (isTarget) { - grid.fillStyle(0xef4444, 0.6); - grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3); - } - } - } - } - container.add(grid); - - const playerText = this.add.text(0, height / 2 - 15, card.startingPlayer, { - fontSize: '10px', - fontFamily: 'Arial', - color: '#6b7280', - }).setOrigin(0.5); - container.add(playerText); - - return container; - } - private showWinner(winner: string): void { if (this.winnerOverlay) { this.winnerOverlay.destroy(); @@ -357,7 +394,7 @@ export class OnitamaScene extends GameHostScene { BOARD_SIZE * CELL_SIZE, BOARD_SIZE * CELL_SIZE, 0x000000, - 0.6, + 0.6 ).setInteractive({ useHandCursor: true }); bg.on('pointerdown', () => { @@ -374,7 +411,7 @@ export class OnitamaScene extends GameHostScene { fontSize: '36px', fontFamily: 'Arial', color: '#fbbf24', - }, + } ).setOrigin(0.5); this.winnerOverlay.add(winText); @@ -388,67 +425,3 @@ export class OnitamaScene extends GameHostScene { }); } } - -class PawnSpawner implements Spawner { - constructor(public readonly scene: OnitamaScene) {} - - *getData() { - for (const pawn of Object.values(this.scene.state.pawns)) { - if (pawn.regionId === 'board') { - yield pawn; - } - } - } - - getKey(pawn: Pawn): string { - return pawn.id; - } - - onUpdate(pawn: Pawn, obj: Phaser.GameObjects.Container): void { - const [x, y] = pawn.position; - obj.x = BOARD_OFFSET.x + x * CELL_SIZE + CELL_SIZE / 2; - obj.y = BOARD_OFFSET.y + y * CELL_SIZE + CELL_SIZE / 2; - } - - onSpawn(pawn: Pawn) { - const container = this.scene.add.container(0, 0); - - const bgColor = pawn.owner === 'red' ? 0xef4444 : 0x3b82f6; - const circle = this.scene.add.circle(0, 0, CELL_SIZE / 3, bgColor, 1) - .setStrokeStyle(2, 0x1f2937); - container.add(circle); - - const label = pawn.type === 'master' ? 'M' : 'S'; - const text = this.scene.add.text(0, 0, label, { - fontSize: '24px', - fontFamily: 'Arial', - color: '#ffffff', - }).setOrigin(0.5); - container.add(text); - - const [x, y] = pawn.position; - container.x = BOARD_OFFSET.x + x * CELL_SIZE + CELL_SIZE / 2; - container.y = BOARD_OFFSET.y + y * CELL_SIZE + CELL_SIZE / 2; - - container.setScale(0); - this.scene.tweens.add({ - targets: container, - scale: 1, - duration: 300, - ease: 'Back.easeOut', - }); - - return container; - } - - onDespawn(obj: Phaser.GameObjects.Container) { - this.scene.tweens.add({ - targets: obj, - scale: 0, - alpha: 0, - duration: 300, - ease: 'Back.easeIn', - onComplete: () => obj.destroy(), - }); - } -} diff --git a/packages/onitama-game/src/spawners/CardSpawner.ts b/packages/onitama-game/src/spawners/CardSpawner.ts new file mode 100644 index 0000000..b3d39b1 --- /dev/null +++ b/packages/onitama-game/src/spawners/CardSpawner.ts @@ -0,0 +1,244 @@ +import Phaser from 'phaser'; +import type { Card } from '@/game/onitama'; +import type { Spawner } from 'boardgame-phaser'; +import type { OnitamaScene } from '@/scenes/OnitamaScene'; +import { BOARD_OFFSET, CELL_SIZE } from './PawnSpawner'; + +export const CARD_WIDTH = 100; +export const CARD_HEIGHT = 140; +const BOARD_SIZE = 5; + +export interface CardSpawnData { + cardId: string; + position: 'red' | 'black' | 'spare'; + index: number; +} + +export class CardSpawner implements Spawner { + private previousData = new Map(); + + constructor(public readonly scene: OnitamaScene) {} + + *getData(): Iterable { + const state = this.scene.state; + + // 红方卡牌 + for (let i = 0; i < state.redCards.length; i++) { + yield { cardId: state.redCards[i], position: 'red', index: i }; + } + + // 黑方卡牌 + for (let i = 0; i < state.blackCards.length; i++) { + yield { cardId: state.blackCards[i], position: 'black', index: i }; + } + + // 备用卡牌 + yield { cardId: state.spareCard, position: 'spare', index: 0 }; + } + + getKey(data: CardSpawnData): string { + return data.cardId; + } + + private getCardPosition(data: CardSpawnData): { x: number, y: number } { + const boardLeft = BOARD_OFFSET.x; + const boardTop = BOARD_OFFSET.y; + const boardRight = BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE; + const boardCenterX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2; + + if (data.position === 'red') { + return { + x: boardLeft - CARD_WIDTH - 60 + 60, + y: boardTop + 80 + data.index * (CARD_HEIGHT + 15), + }; + } else if (data.position === 'black') { + return { + x: boardRight + 60 + 40, + y: boardTop + 80 + data.index * (CARD_HEIGHT + 15), + }; + } else { + return { + x: boardCenterX, + y: boardTop - CARD_HEIGHT - 20, + }; + } + } + + private hasPositionChanged(data: CardSpawnData): boolean { + const prev = this.previousData.get(data.cardId); + if (!prev) return true; + return prev.position !== data.position || prev.index !== data.index; + } + + onUpdate(data: CardSpawnData, obj: Phaser.GameObjects.Container): void { + // 检查是否是选中的卡牌 + const isSelected = this.scene.uiState.value.selectedCard === data.cardId; + + // 高亮选中的卡牌 + if (isSelected) { + this.highlightCard(obj, 0xfbbf24, 3); + } else { + this.unhighlightCard(obj); + } + + // 只在位置实际变化时才播放移动动画 + if (!this.hasPositionChanged(data)) { + this.previousData.set(data.cardId, { ...data }); + return; + } + + const pos = this.getCardPosition(data); + + // 播放移动动画并添加中断 + const tween = this.scene.tweens.add({ + targets: obj, + x: pos.x, + y: pos.y, + duration: 350, + ease: 'Back.easeOut', + }); + + this.scene.addTweenInterruption(tween); + this.previousData.set(data.cardId, { ...data }); + } + + private highlightCard(container: Phaser.GameObjects.Container, color: number, lineWidth: number): void { + // 检查是否已经有高亮边框 + let highlight = container.list.find( + child => child instanceof Phaser.GameObjects.Rectangle && child.getData('isHighlight') + ) as Phaser.GameObjects.Rectangle; + + if (!highlight) { + // 创建高亮边框 + highlight = this.scene.add.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0) + .setStrokeStyle(lineWidth, color) + .setData('isHighlight', true); + container.addAt(highlight, 0); + } else { + // 更新现有高亮边框 + highlight.setStrokeStyle(lineWidth, color); + highlight.setAlpha(1); + } + } + + private unhighlightCard(container: Phaser.GameObjects.Container): void { + const highlight = container.list.find( + child => child instanceof Phaser.GameObjects.Rectangle && child.getData('isHighlight') + ) as Phaser.GameObjects.Rectangle; + + if (highlight) { + highlight.setAlpha(0); + } + } + + onSpawn(data: CardSpawnData): Phaser.GameObjects.Container { + const card = this.scene.state.cards[data.cardId]; + if (!card) { + this.previousData.set(data.cardId, { ...data }); + return this.scene.add.container(0, 0); + } + + const container = this.scene.add.container(0, 0); + const pos = this.getCardPosition(data); + container.x = pos.x; + container.y = pos.y; + + // 创建卡牌视觉 + const cardVisual = this.createCardVisual(card); + container.add(cardVisual); + + // 使卡牌可点击(设置矩形点击区域) + const hitArea = new Phaser.Geom.Rectangle(-CARD_WIDTH / 2, -CARD_HEIGHT / 2, CARD_WIDTH, CARD_HEIGHT); + container.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains); + + // 悬停效果 + container.on('pointerover', () => { + if (this.scene.uiState.value.selectedCard !== data.cardId) { + container.setAlpha(0.8); + } + }); + + container.on('pointerout', () => { + container.setAlpha(1); + }); + + container.on('pointerdown', () => { + this.scene.onCardClick(data.cardId); + }); + + // 初始状态为透明,然后淡入 + container.setAlpha(0); + const tween = this.scene.tweens.add({ + targets: container, + alpha: 1, + duration: 300, + ease: 'Power2', + }); + this.scene.addTweenInterruption(tween); + + this.previousData.set(data.cardId, { ...data }); + return container; + } + + onDespawn(obj: Phaser.GameObjects.Container): void { + const tween = this.scene.tweens.add({ + targets: obj, + alpha: 0, + scale: 0.8, + duration: 200, + ease: 'Power2', + onComplete: () => obj.destroy(), + }); + this.scene.addTweenInterruption(tween); + } + + private createCardVisual(card: Card): Phaser.GameObjects.Container { + const container = this.scene.add.container(0, 0); + + const bg = this.scene.add.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, 0xf9fafb, 1) + .setStrokeStyle(2, 0x6b7280); + container.add(bg); + + const title = this.scene.add.text(0, -CARD_HEIGHT / 2 + 15, card.id, { + fontSize: '12px', + fontFamily: 'Arial', + color: '#1f2937', + }).setOrigin(0.5); + container.add(title); + + const grid = this.scene.add.graphics(); + const cellSize = 16; + const gridWidth = 5 * cellSize; + const gridHeight = 5 * cellSize; + const gridStartX = -gridWidth / 2; + const gridStartY = -gridHeight / 2 + 30; + + for (let row = 0; row < 5; row++) { + for (let col = 0; col < 5; col++) { + const x = gridStartX + col * cellSize; + const y = gridStartY + row * cellSize; + + if (row === 2 && col === 2) { + grid.fillStyle(0x3b82f6, 1); + grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3); + } else { + const isTarget = card.moveCandidates.some(m => m.dx === col - 2 && m.dy === 2 - row); + if (isTarget) { + grid.fillStyle(0xef4444, 0.6); + grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3); + } + } + } + } + container.add(grid); + + const playerText = this.scene.add.text(0, CARD_HEIGHT / 2 - 15, card.startingPlayer, { + fontSize: '10px', + fontFamily: 'Arial', + color: '#6b7280', + }).setOrigin(0.5); + container.add(playerText); + + return container; + } +} diff --git a/packages/onitama-game/src/spawners/HighlightSpawner.ts b/packages/onitama-game/src/spawners/HighlightSpawner.ts new file mode 100644 index 0000000..d52b9ec --- /dev/null +++ b/packages/onitama-game/src/spawners/HighlightSpawner.ts @@ -0,0 +1,57 @@ +import Phaser from 'phaser'; +import type { Spawner } from 'boardgame-phaser'; +import type { OnitamaScene } from '@/scenes/OnitamaScene'; +import type { OnitamaUIState } from '@/state/ui'; +import { BOARD_OFFSET, CELL_SIZE } from './PawnSpawner'; + +export interface HighlightData { + key: string; + x: number; + y: number; + card: string; + fromX: number; + fromY: number; + toX: number; + toY: number; +} + +export class HighlightSpawner implements Spawner { + constructor(public readonly scene: OnitamaScene) {} + + *getData(): Iterable { + // HighlightSpawner 的数据由 UI state 控制,不从这里生成 + // 我们会在 scene 中手动调用 spawnEffect 来更新 + } + + getKey(data: HighlightData): string { + return data.key; + } + + onUpdate(data: HighlightData, obj: Phaser.GameObjects.GameObject): void { + if (obj instanceof Phaser.GameObjects.Arc) { + obj.setPosition(data.x, data.y); + } + } + + onSpawn(data: HighlightData): Phaser.GameObjects.GameObject { + const circle = this.scene.add.circle( + data.x, + data.y, + CELL_SIZE / 3, + 0x3b82f6, + 0.3 + ).setDepth(100); + + circle.setInteractive({ useHandCursor: true }); + circle.on('pointerdown', () => { + this.scene.onHighlightClick(data); + }); + + return circle; + } + + onDespawn(obj: Phaser.GameObjects.GameObject): void { + obj.destroy(); + } +} + diff --git a/packages/onitama-game/src/spawners/PawnSpawner.ts b/packages/onitama-game/src/spawners/PawnSpawner.ts new file mode 100644 index 0000000..ab39c2e --- /dev/null +++ b/packages/onitama-game/src/spawners/PawnSpawner.ts @@ -0,0 +1,101 @@ +import Phaser from 'phaser'; +import type { Pawn } from '@/game/onitama'; +import type { Spawner } from 'boardgame-phaser'; +import type { OnitamaScene } from '@/scenes/OnitamaScene'; + +export const CELL_SIZE = 80; +export const BOARD_OFFSET = { x: 200, y: 180 }; + +export class PawnSpawner implements Spawner { + private previousPositions = new Map(); + + constructor(public readonly scene: OnitamaScene) {} + + *getData() { + for (const pawn of Object.values(this.scene.state.pawns)) { + if (pawn.regionId === 'board') { + yield pawn; + } + } + } + + getKey(pawn: Pawn): string { + return pawn.id; + } + + onUpdate(pawn: Pawn, obj: Phaser.GameObjects.Container): void { + const [x, y] = pawn.position; + const prevPos = this.previousPositions.get(pawn.id); + const hasMoved = !prevPos || prevPos[0] !== x || prevPos[1] !== y; + + if (hasMoved && prevPos) { + // 播放移动动画并添加中断 + const targetX = BOARD_OFFSET.x + x * CELL_SIZE + CELL_SIZE / 2; + const targetY = BOARD_OFFSET.y + y * CELL_SIZE + CELL_SIZE / 2; + + const tween = this.scene.tweens.add({ + targets: obj, + x: targetX, + y: targetY, + duration: 400, + ease: 'Back.easeOut', + }); + + this.scene.addTweenInterruption(tween); + } else if (!prevPos) { + // 初次生成,直接设置位置 + obj.x = BOARD_OFFSET.x + x * CELL_SIZE + CELL_SIZE / 2; + obj.y = BOARD_OFFSET.y + y * CELL_SIZE + CELL_SIZE / 2; + } + + this.previousPositions.set(pawn.id, [x, y]); + } + + onSpawn(pawn: Pawn) { + const container = this.scene.add.container(0, 0); + + const bgColor = pawn.owner === 'red' ? 0xef4444 : 0x3b82f6; + const circle = this.scene.add.circle(0, 0, CELL_SIZE / 3, bgColor, 1) + .setStrokeStyle(2, 0x1f2937); + container.add(circle); + + const label = pawn.type === 'master' ? 'M' : 'S'; + const text = this.scene.add.text(0, 0, label, { + fontSize: '24px', + fontFamily: 'Arial', + color: '#ffffff', + }).setOrigin(0.5); + container.add(text); + + const [x, y] = pawn.position; + container.x = BOARD_OFFSET.x + x * CELL_SIZE + CELL_SIZE / 2; + container.y = BOARD_OFFSET.y + y * CELL_SIZE + CELL_SIZE / 2; + + this.previousPositions.set(pawn.id, [x, y]); + + container.setScale(0); + this.scene.tweens.add({ + targets: container, + scale: 1, + duration: 300, + ease: 'Back.easeOut', + }); + + return container; + } + + onDespawn(obj: Phaser.GameObjects.Container) { + // 播放消失动画并添加中断 + const tween = this.scene.tweens.add({ + targets: obj, + scale: 0, + alpha: 0, + y: obj.y - 30, + duration: 300, + ease: 'Back.easeIn', + onComplete: () => obj.destroy(), + }); + + this.scene.addTweenInterruption(tween); + } +} diff --git a/packages/onitama-game/src/spawners/index.ts b/packages/onitama-game/src/spawners/index.ts new file mode 100644 index 0000000..352f3e0 --- /dev/null +++ b/packages/onitama-game/src/spawners/index.ts @@ -0,0 +1,3 @@ +export { PawnSpawner, CELL_SIZE, BOARD_OFFSET } from './PawnSpawner'; +export { CardSpawner, CARD_WIDTH, CARD_HEIGHT, type CardSpawnData } from './CardSpawner'; +export { HighlightSpawner, type HighlightData } from './HighlightSpawner'; diff --git a/packages/onitama-game/src/state/index.ts b/packages/onitama-game/src/state/index.ts new file mode 100644 index 0000000..1bd6a22 --- /dev/null +++ b/packages/onitama-game/src/state/index.ts @@ -0,0 +1,2 @@ +export { createOnitamaUIState, clearSelection, selectPiece, selectCard, deselectCard, setValidMoves } from './ui'; +export type { OnitamaUIState, ValidMove } from './ui'; diff --git a/packages/onitama-game/src/state/ui.ts b/packages/onitama-game/src/state/ui.ts new file mode 100644 index 0000000..7e7b9a5 --- /dev/null +++ b/packages/onitama-game/src/state/ui.ts @@ -0,0 +1,86 @@ +import { signal, type Signal } from '@preact/signals'; + +export interface ValidMove { + card: string; + fromX: number; + fromY: number; + toX: number; + toY: number; +} + +export interface OnitamaUIState { + selectedPiece: { x: number; y: number } | null; + selectedCard: string | null; + validMoves: ValidMove[]; +} + +export function createOnitamaUIState(): Signal { + return signal({ + selectedPiece: null, + selectedCard: null, + validMoves: [], + }); +} + +export function clearSelection(uiState: Signal): void { + uiState.value = { + selectedPiece: null, + selectedCard: null, + validMoves: [], + }; +} + +export function selectPiece( + uiState: Signal, + x: number, + y: number +): void { + uiState.value = { + ...uiState.value, + selectedPiece: { x, y }, + selectedCard: null, + }; +} + +export function selectCard( + uiState: Signal, + card: string +): void { + // 如果点击已选中的卡牌,取消选择 + if (uiState.value.selectedCard === card) { + uiState.value = { + selectedPiece: null, + selectedCard: null, + validMoves: [], + }; + } else { + // 选择新卡牌,清除棋子选择 + uiState.value = { + selectedPiece: null, + selectedCard: card, + validMoves: [], + }; + } +} + +export function deselectCard( + uiState: Signal +): void { + uiState.value = { + ...uiState.value, + selectedCard: null, + selectedPiece: null, + validMoves: [], + }; +} + +export function setValidMoves( + uiState: Signal, + moves: ValidMove[] +): void { + uiState.value = { + ...uiState.value, + validMoves: moves, + }; +} + diff --git a/packages/onitama-game/src/ui/App.tsx b/packages/onitama-game/src/ui/App.tsx index 53b50ba..7a6cb6f 100644 --- a/packages/onitama-game/src/ui/App.tsx +++ b/packages/onitama-game/src/ui/App.tsx @@ -18,6 +18,11 @@ export default function App(props: { gameModule: any, gameScene: { new(): Phaser }; const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start'); + const phaserConfig: Partial = { + width: 800, + height: 700, + }; + return (
@@ -29,7 +34,7 @@ export default function App(props: { gameModule: any, gameScene: { new(): Phaser
- +