245 lines
7.2 KiB
TypeScript
245 lines
7.2 KiB
TypeScript
|
|
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;
|
||
|
|
}
|
||
|
|
}
|