boardgame-phaser/packages/onitama-game/src/spawners/CardSpawner.ts

245 lines
7.2 KiB
TypeScript
Raw Normal View History

2026-04-07 17:13:45 +08:00
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<CardSpawnData, Phaser.GameObjects.Container> {
private previousData = new Map<string, CardSpawnData>();
constructor(public readonly scene: OnitamaScene) {}
*getData(): Iterable<CardSpawnData> {
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;
}
}