feat: add more tweens
This commit is contained in:
parent
00fd395873
commit
3e064f437b
|
|
@ -3,7 +3,7 @@ import type { Card } from '@/game/onitama';
|
||||||
import type { Spawner } from 'boardgame-phaser';
|
import type { Spawner } from 'boardgame-phaser';
|
||||||
import type { OnitamaScene } from '@/scenes/OnitamaScene';
|
import type { OnitamaScene } from '@/scenes/OnitamaScene';
|
||||||
import { BOARD_OFFSET, CELL_SIZE } from './PawnSpawner';
|
import { BOARD_OFFSET, CELL_SIZE } from './PawnSpawner';
|
||||||
import {effect} from "@preact/signals-core";
|
import { effect } from "@preact/signals-core";
|
||||||
|
|
||||||
export const CARD_WIDTH = 100;
|
export const CARD_WIDTH = 100;
|
||||||
export const CARD_HEIGHT = 140;
|
export const CARD_HEIGHT = 140;
|
||||||
|
|
@ -15,7 +15,185 @@ export interface CardSpawnData {
|
||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Container> {
|
/**
|
||||||
|
* 继承自 Phaser.GameObjects.Container 的卡牌容器类
|
||||||
|
* 管理卡牌视觉元素和高亮状态
|
||||||
|
*/
|
||||||
|
export class CardContainer extends Phaser.GameObjects.Container {
|
||||||
|
private highlightRect: Phaser.GameObjects.Rectangle | null = null;
|
||||||
|
private highlightTween: Phaser.Tweens.Tween | null = null;
|
||||||
|
private _cardId: string;
|
||||||
|
|
||||||
|
constructor(scene: OnitamaScene, cardId: string, card: Card) {
|
||||||
|
super(scene, 0, 0);
|
||||||
|
this._cardId = cardId;
|
||||||
|
|
||||||
|
// 将容器添加到场景
|
||||||
|
scene.add.existing(this);
|
||||||
|
|
||||||
|
// 创建卡牌视觉
|
||||||
|
this.createCardVisual(card);
|
||||||
|
|
||||||
|
// 使卡牌可点击
|
||||||
|
const hitArea = new Phaser.Geom.Rectangle(-CARD_WIDTH / 2, -CARD_HEIGHT / 2, CARD_WIDTH, CARD_HEIGHT);
|
||||||
|
this.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
||||||
|
|
||||||
|
// 添加场景 effect 监听高亮状态变化
|
||||||
|
this.addHighlightEffect(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高亮卡牌
|
||||||
|
*/
|
||||||
|
highlight(color: number, lineWidth: number): void {
|
||||||
|
if (!this.active) return;
|
||||||
|
|
||||||
|
if (!this.highlightRect) {
|
||||||
|
// 创建高亮边框(初始透明)
|
||||||
|
this.highlightRect = (this.scene as OnitamaScene).add.rectangle(
|
||||||
|
0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0
|
||||||
|
)
|
||||||
|
.setStrokeStyle(lineWidth, color)
|
||||||
|
.setAlpha(0)
|
||||||
|
.setDepth(-1);
|
||||||
|
this.addAt(this.highlightRect, 0);
|
||||||
|
|
||||||
|
// 淡入动画
|
||||||
|
const fadeIn = this.scene.tweens.add({
|
||||||
|
targets: this.highlightRect,
|
||||||
|
alpha: 1,
|
||||||
|
scale: 1.05,
|
||||||
|
duration: 200,
|
||||||
|
ease: 'Power2',
|
||||||
|
onComplete: () => {
|
||||||
|
// 淡入完成后开始脉冲动画
|
||||||
|
this.highlightTween = this.scene.tweens.add({
|
||||||
|
targets: this.highlightRect,
|
||||||
|
alpha: 0.7,
|
||||||
|
lineWidth: lineWidth + 1,
|
||||||
|
duration: 500,
|
||||||
|
ease: 'Sine.easeInOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 如果已经存在,停止当前动画并重新开始脉冲
|
||||||
|
if (this.highlightTween) {
|
||||||
|
this.highlightTween.stop();
|
||||||
|
}
|
||||||
|
this.highlightRect.setStrokeStyle(lineWidth, color);
|
||||||
|
this.highlightTween = this.scene.tweens.add({
|
||||||
|
targets: this.highlightRect,
|
||||||
|
alpha: 0.7,
|
||||||
|
lineWidth: lineWidth + 1,
|
||||||
|
duration: 500,
|
||||||
|
ease: 'Sine.easeInOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消高亮
|
||||||
|
*/
|
||||||
|
unhighlight(): void {
|
||||||
|
if (this.highlightRect) {
|
||||||
|
// 停止所有动画
|
||||||
|
if (this.highlightTween) {
|
||||||
|
this.highlightTween.stop();
|
||||||
|
this.highlightTween = null;
|
||||||
|
}
|
||||||
|
this.scene.tweens.killTweensOf(this.highlightRect);
|
||||||
|
|
||||||
|
// 淡出动画
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this.highlightRect,
|
||||||
|
alpha: 0,
|
||||||
|
scale: 0.95,
|
||||||
|
duration: 150,
|
||||||
|
ease: 'Power2',
|
||||||
|
onComplete: () => {
|
||||||
|
// 淡出完成后销毁矩形
|
||||||
|
this.highlightRect?.destroy();
|
||||||
|
this.highlightRect = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加高亮效果的 effect 监听
|
||||||
|
*/
|
||||||
|
private addHighlightEffect(scene: OnitamaScene): void {
|
||||||
|
// 创建一个 effect 来持续监听高亮状态变化
|
||||||
|
const dispose = effect(() => {
|
||||||
|
if (scene.uiState.value.selectedCard === this._cardId) {
|
||||||
|
this.highlight(0xfbbf24, 3);
|
||||||
|
} else {
|
||||||
|
this.unhighlight();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 在容器销毁时清理 effect
|
||||||
|
this.on('destroy', () => {
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建卡牌视觉元素
|
||||||
|
*/
|
||||||
|
private createCardVisual(card: Card): void {
|
||||||
|
const bg = (this.scene as OnitamaScene).add.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, 0xf9fafb, 1)
|
||||||
|
.setStrokeStyle(2, 0x6b7280);
|
||||||
|
this.add(bg);
|
||||||
|
|
||||||
|
const title = (this.scene as OnitamaScene).add.text(0, -CARD_HEIGHT / 2 + 15, card.id, {
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#1f2937',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.add(title);
|
||||||
|
|
||||||
|
const grid = (this.scene as OnitamaScene).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.add(grid);
|
||||||
|
|
||||||
|
const playerText = (this.scene as OnitamaScene).add.text(0, CARD_HEIGHT / 2 - 15, card.startingPlayer, {
|
||||||
|
fontSize: '10px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#6b7280',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.add(playerText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
||||||
private previousData = new Map<string, CardSpawnData>();
|
private previousData = new Map<string, CardSpawnData>();
|
||||||
|
|
||||||
constructor(public readonly scene: OnitamaScene) {}
|
constructor(public readonly scene: OnitamaScene) {}
|
||||||
|
|
@ -34,7 +212,8 @@ export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Co
|
||||||
}
|
}
|
||||||
|
|
||||||
// 备用卡牌
|
// 备用卡牌
|
||||||
yield { cardId: state.spareCard, position: 'spare', index: 0 };
|
if(state.spareCard)
|
||||||
|
yield { cardId: state.spareCard, position: 'spare', index: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
getKey(data: CardSpawnData): string {
|
getKey(data: CardSpawnData): string {
|
||||||
|
|
@ -71,7 +250,7 @@ export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Co
|
||||||
return prev.position !== data.position || prev.index !== data.index;
|
return prev.position !== data.position || prev.index !== data.index;
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpdate(data: CardSpawnData, obj: Phaser.GameObjects.Container): void {
|
onUpdate(data: CardSpawnData, obj: CardContainer): void {
|
||||||
// 只在位置实际变化时才播放移动动画
|
// 只在位置实际变化时才播放移动动画
|
||||||
if (!this.hasPositionChanged(data)) {
|
if (!this.hasPositionChanged(data)) {
|
||||||
this.previousData.set(data.cardId, { ...data });
|
this.previousData.set(data.cardId, { ...data });
|
||||||
|
|
@ -93,66 +272,35 @@ export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Co
|
||||||
this.previousData.set(data.cardId, { ...data });
|
this.previousData.set(data.cardId, { ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
private highlightCard(container: Phaser.GameObjects.Container, color: number, lineWidth: number): void {
|
onSpawn(data: CardSpawnData): CardContainer {
|
||||||
// 检查是否已经有高亮边框
|
|
||||||
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];
|
const card = this.scene.state.cards[data.cardId];
|
||||||
if (!card) {
|
if (!card) {
|
||||||
this.previousData.set(data.cardId, { ...data });
|
this.previousData.set(data.cardId, { ...data });
|
||||||
return this.scene.add.container(0, 0);
|
// 返回空容器
|
||||||
|
const emptyContainer = new CardContainer(this.scene, data.cardId, {
|
||||||
|
id: data.cardId, regionId: '', position: [],
|
||||||
|
moveCandidates: [],
|
||||||
|
startingPlayer: 'red'
|
||||||
|
} as Card);
|
||||||
|
return emptyContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = this.scene.add.container(0, 0);
|
const container = new CardContainer(this.scene, data.cardId, card);
|
||||||
const pos = this.getCardPosition(data);
|
const pos = this.getCardPosition(data);
|
||||||
container.x = pos.x;
|
container.x = pos.x;
|
||||||
container.y = pos.y;
|
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', () => {
|
container.on('pointerover', () => {
|
||||||
if (this.scene.uiState.value.selectedCard !== data.cardId) {
|
if (this.scene.uiState.value.selectedCard !== data.cardId) {
|
||||||
container.setAlpha(0.8);
|
container.setAlpha(0.8);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
container.on('pointerout', () => {
|
container.on('pointerout', () => {
|
||||||
container.setAlpha(1);
|
container.setAlpha(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
container.on('pointerdown', () => {
|
container.on('pointerdown', () => {
|
||||||
this.scene.onCardClick(data.cardId);
|
this.scene.onCardClick(data.cardId);
|
||||||
});
|
});
|
||||||
|
|
@ -166,19 +314,12 @@ export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Co
|
||||||
ease: 'Power2',
|
ease: 'Power2',
|
||||||
});
|
});
|
||||||
this.scene.addTweenInterruption(tween);
|
this.scene.addTweenInterruption(tween);
|
||||||
|
|
||||||
container.once('destroy', effect(() => {
|
|
||||||
if(this.scene.uiState.value.selectedCard === data.cardId)
|
|
||||||
this.highlightCard(container, 0xfbbf24, 3);
|
|
||||||
else
|
|
||||||
this.unhighlightCard(container);
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.previousData.set(data.cardId, { ...data });
|
this.previousData.set(data.cardId, { ...data });
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
onDespawn(obj: Phaser.GameObjects.Container): void {
|
onDespawn(obj: CardContainer): void {
|
||||||
const tween = this.scene.tweens.add({
|
const tween = this.scene.tweens.add({
|
||||||
targets: obj,
|
targets: obj,
|
||||||
alpha: 0,
|
alpha: 0,
|
||||||
|
|
@ -189,54 +330,4 @@ export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Co
|
||||||
});
|
});
|
||||||
this.scene.addTweenInterruption(tween);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
|
||||||
*getData(): Iterable<HighlightData> {
|
*getData(): Iterable<HighlightData> {
|
||||||
const state = this.scene.state;
|
const state = this.scene.state;
|
||||||
const uiState = this.scene.uiState.value;
|
const uiState = this.scene.uiState.value;
|
||||||
|
|
||||||
// 如果没有选择卡牌或棋子,不显示高亮
|
// 如果没有选择卡牌或棋子,不显示高亮
|
||||||
if (!uiState.selectedCard || !uiState.selectedPiece) {
|
if (!uiState.selectedCard || !uiState.selectedPiece) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -29,25 +29,23 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
|
||||||
|
|
||||||
const currentPlayer = state.currentPlayer;
|
const currentPlayer = state.currentPlayer;
|
||||||
const availableMoves = getAvailableMoves(state, currentPlayer);
|
const availableMoves = getAvailableMoves(state, currentPlayer);
|
||||||
|
|
||||||
// 过滤出符合当前选择的移动
|
|
||||||
const filteredMoves = availableMoves.filter(move =>
|
|
||||||
move.fromX === uiState.selectedPiece!.x &&
|
|
||||||
move.fromY === uiState.selectedPiece!.y &&
|
|
||||||
move.card === uiState.selectedCard
|
|
||||||
);
|
|
||||||
|
|
||||||
for(const move of filteredMoves){
|
// 过滤出符合当前选择的移动
|
||||||
const pos = boardToScreen(move.toX, move.toY);
|
for(const move of availableMoves){
|
||||||
yield {
|
if(move.fromX === uiState.selectedPiece!.x &&
|
||||||
key: `${move.fromX}-${move.fromY}-${move.card}-${move.toX}-${move.toY}`,
|
move.fromY === uiState.selectedPiece!.y &&
|
||||||
x: pos.x,
|
move.card === uiState.selectedCard){
|
||||||
y: pos.y,
|
const pos = boardToScreen(move.toX, move.toY);
|
||||||
card: move.card,
|
yield {
|
||||||
fromX: move.fromX,
|
key: `${move.fromX}-${move.fromY}-${move.card}-${move.toX}-${move.toY}`,
|
||||||
fromY: move.fromY,
|
x: pos.x,
|
||||||
toX: move.toX,
|
y: pos.y,
|
||||||
toY: move.toY
|
card: move.card,
|
||||||
|
fromX: move.fromX,
|
||||||
|
fromY: move.fromY,
|
||||||
|
toX: move.toX,
|
||||||
|
toY: move.toY
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,22 +61,78 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
|
||||||
onSpawn(data: HighlightData): Phaser.GameObjects.Container {
|
onSpawn(data: HighlightData): Phaser.GameObjects.Container {
|
||||||
const container = this.scene.add.container(data.x, data.y);
|
const container = this.scene.add.container(data.x, data.y);
|
||||||
|
|
||||||
const circle = this.scene.add.circle(
|
// 外圈光环(动画)
|
||||||
|
const outerCircle = this.scene.add.circle(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
CELL_SIZE / 3,
|
CELL_SIZE / 3,
|
||||||
0x3b82f6,
|
0x3b82f6,
|
||||||
0.3
|
0.2
|
||||||
);
|
);
|
||||||
container.add(circle);
|
container.add(outerCircle);
|
||||||
|
|
||||||
|
// 内圈
|
||||||
|
const innerCircle = this.scene.add.circle(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CELL_SIZE / 4,
|
||||||
|
0x3b82f6,
|
||||||
|
0.4
|
||||||
|
);
|
||||||
|
container.add(innerCircle);
|
||||||
|
|
||||||
const hitArea = new Phaser.Geom.Circle(0, 0, CELL_SIZE / 3);
|
const hitArea = new Phaser.Geom.Circle(0, 0, CELL_SIZE / 3);
|
||||||
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
|
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
|
||||||
if (container.input) {
|
if (container.input) {
|
||||||
container.input.cursor = 'pointer';
|
container.input.cursor = 'pointer';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 出现动画:从0缩放和透明淡入
|
||||||
|
container.setAlpha(0);
|
||||||
|
container.setScale(0);
|
||||||
|
const spawnTween = this.scene.tweens.add({
|
||||||
|
targets: container,
|
||||||
|
alpha: 1,
|
||||||
|
scale: 1,
|
||||||
|
duration: 250,
|
||||||
|
ease: 'Back.easeOut',
|
||||||
|
});
|
||||||
|
this.scene.addTweenInterruption(spawnTween);
|
||||||
|
|
||||||
|
// 脉冲动画
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: [outerCircle, innerCircle],
|
||||||
|
scale: 1.2,
|
||||||
|
alpha: 0.6,
|
||||||
|
duration: 600,
|
||||||
|
ease: 'Sine.easeInOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 外圈延迟动画,形成错开效果
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: outerCircle,
|
||||||
|
scale: 1.3,
|
||||||
|
alpha: 0.3,
|
||||||
|
duration: 800,
|
||||||
|
ease: 'Sine.easeInOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
delay: 200,
|
||||||
|
});
|
||||||
|
|
||||||
container.on('pointerdown', () => {
|
container.on('pointerdown', () => {
|
||||||
|
// 点击时的反馈动画
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: container,
|
||||||
|
scale: 1.5,
|
||||||
|
alpha: 0.8,
|
||||||
|
duration: 150,
|
||||||
|
ease: 'Power2',
|
||||||
|
yoyo: true,
|
||||||
|
});
|
||||||
|
|
||||||
this.scene.onHighlightClick(data);
|
this.scene.onHighlightClick(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -86,7 +140,16 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
|
||||||
}
|
}
|
||||||
|
|
||||||
onDespawn(obj: Phaser.GameObjects.Container): void {
|
onDespawn(obj: Phaser.GameObjects.Container): void {
|
||||||
obj.destroy();
|
// 消失动画:缩小并淡出
|
||||||
|
const despawnTween = this.scene.tweens.add({
|
||||||
|
targets: obj,
|
||||||
|
scale: 0,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 200,
|
||||||
|
ease: 'Back.easeIn',
|
||||||
|
onComplete: () => obj.destroy(),
|
||||||
|
});
|
||||||
|
this.scene.addTweenInterruption(despawnTween);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import Phaser from 'phaser';
|
||||||
import type { Pawn } from '@/game/onitama';
|
import type { Pawn } from '@/game/onitama';
|
||||||
import type { Spawner } from 'boardgame-phaser';
|
import type { Spawner } from 'boardgame-phaser';
|
||||||
import type { OnitamaScene } from '@/scenes/OnitamaScene';
|
import type { OnitamaScene } from '@/scenes/OnitamaScene';
|
||||||
|
import type { OnitamaUIState } from '@/state';
|
||||||
|
import { effect } from "@preact/signals-core";
|
||||||
|
|
||||||
export const CELL_SIZE = 80;
|
export const CELL_SIZE = 80;
|
||||||
export const BOARD_OFFSET = { x: 200, y: 180 };
|
export const BOARD_OFFSET = { x: 200, y: 180 };
|
||||||
|
|
@ -14,7 +16,161 @@ export function boardToScreen(boardX: number, boardY: number): { x: number; y: n
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PawnSpawner implements Spawner<Pawn, Phaser.GameObjects.Container> {
|
/**
|
||||||
|
* 继承自 Phaser.GameObjects.Container 的棋子容器类
|
||||||
|
* 管理棋子视觉元素和选中状态
|
||||||
|
*/
|
||||||
|
export class PawnContainer extends Phaser.GameObjects.Container {
|
||||||
|
private selectionRing: Phaser.GameObjects.Arc | null = null;
|
||||||
|
private selectionTween: Phaser.Tweens.Tween | null = null;
|
||||||
|
private _position: [number, number];
|
||||||
|
private _owner: 'red' | 'black';
|
||||||
|
private _type: 'master' | 'student';
|
||||||
|
|
||||||
|
constructor(scene: OnitamaScene, pawn: Pawn) {
|
||||||
|
super(scene, 0, 0);
|
||||||
|
this._owner = pawn.owner;
|
||||||
|
this._type = pawn.type;
|
||||||
|
this._position = pawn.position as [number, number];
|
||||||
|
|
||||||
|
// 将容器添加到场景
|
||||||
|
scene.add.existing(this);
|
||||||
|
|
||||||
|
// 创建棋子视觉
|
||||||
|
this.createPawnVisual();
|
||||||
|
|
||||||
|
// 添加选中状态监听
|
||||||
|
this.addSelectionEffect(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示选中光环
|
||||||
|
*/
|
||||||
|
showSelection(): void {
|
||||||
|
if (!this.active) return;
|
||||||
|
|
||||||
|
if (!this.selectionRing) {
|
||||||
|
// 创建选中光环(初始透明)
|
||||||
|
this.selectionRing = (this.scene as OnitamaScene).add.arc(
|
||||||
|
0, 0, CELL_SIZE / 3 + 5, 0, 360, false, 0xfbbf24, 0
|
||||||
|
)
|
||||||
|
.setStrokeStyle(3, 0xf59e0b, 1)
|
||||||
|
.setAlpha(0);
|
||||||
|
this.addAt(this.selectionRing, 0);
|
||||||
|
|
||||||
|
// 淡入动画
|
||||||
|
const fadeIn = this.scene.tweens.add({
|
||||||
|
targets: this.selectionRing,
|
||||||
|
alpha: 0.8,
|
||||||
|
duration: 200,
|
||||||
|
ease: 'Power2',
|
||||||
|
onComplete: () => {
|
||||||
|
// 淡入完成后开始脉冲动画
|
||||||
|
this.selectionTween = this.scene.tweens.add({
|
||||||
|
targets: this.selectionRing,
|
||||||
|
scale: 1.15,
|
||||||
|
alpha: 0.6,
|
||||||
|
duration: 500,
|
||||||
|
ease: 'Sine.easeInOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隐藏选中光环
|
||||||
|
*/
|
||||||
|
hideSelection(): void {
|
||||||
|
if (this.selectionRing) {
|
||||||
|
// 停止所有动画
|
||||||
|
if (this.selectionTween) {
|
||||||
|
this.selectionTween.stop();
|
||||||
|
this.selectionTween = null;
|
||||||
|
}
|
||||||
|
this.scene.tweens.killTweensOf(this.selectionRing);
|
||||||
|
|
||||||
|
// 淡出动画
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this.selectionRing,
|
||||||
|
alpha: 0,
|
||||||
|
scale: 0.9,
|
||||||
|
duration: 150,
|
||||||
|
ease: 'Power2',
|
||||||
|
onComplete: () => {
|
||||||
|
// 淡出完成后销毁
|
||||||
|
this.selectionRing?.destroy();
|
||||||
|
this.selectionRing = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加选中状态的 effect 监听
|
||||||
|
*/
|
||||||
|
private addSelectionEffect(scene: OnitamaScene): void {
|
||||||
|
const dispose = effect(() => {
|
||||||
|
const uiState = scene.uiState.value;
|
||||||
|
const isSelected = uiState.selectedPiece?.x === this._position[0] &&
|
||||||
|
uiState.selectedPiece?.y === this._position[1];
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
this.showSelection();
|
||||||
|
} else {
|
||||||
|
this.hideSelection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.on('destroy', () => {
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新棋子位置
|
||||||
|
*/
|
||||||
|
updatePosition(newPosition: [number, number], animated: boolean = false): void {
|
||||||
|
this._position = newPosition;
|
||||||
|
const targetPos = boardToScreen(newPosition[0], newPosition[1]);
|
||||||
|
|
||||||
|
if (animated) {
|
||||||
|
const tween = this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
x: targetPos.x,
|
||||||
|
y: targetPos.y,
|
||||||
|
duration: 400,
|
||||||
|
ease: 'Back.easeOut',
|
||||||
|
});
|
||||||
|
(this.scene as OnitamaScene).addTweenInterruption(tween);
|
||||||
|
} else {
|
||||||
|
this.x = targetPos.x;
|
||||||
|
this.y = targetPos.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建棋子视觉元素
|
||||||
|
*/
|
||||||
|
private createPawnVisual(): void {
|
||||||
|
const bgColor = this._owner === 'red' ? 0xef4444 : 0x3b82f6;
|
||||||
|
const circle = (this.scene as OnitamaScene).add.circle(0, 0, CELL_SIZE / 3, bgColor, 1)
|
||||||
|
.setStrokeStyle(2, 0x1f2937);
|
||||||
|
this.add(circle);
|
||||||
|
|
||||||
|
const label = this._type === 'master' ? 'M' : 'S';
|
||||||
|
const text = (this.scene as OnitamaScene).add.text(0, 0, label, {
|
||||||
|
fontSize: '24px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#ffffff',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
|
||||||
private previousPositions = new Map<string, [number, number]>();
|
private previousPositions = new Map<string, [number, number]>();
|
||||||
|
|
||||||
constructor(public readonly scene: OnitamaScene) {}
|
constructor(public readonly scene: OnitamaScene) {}
|
||||||
|
|
@ -31,49 +187,20 @@ export class PawnSpawner implements Spawner<Pawn, Phaser.GameObjects.Container>
|
||||||
return pawn.id;
|
return pawn.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpdate(pawn: Pawn, obj: Phaser.GameObjects.Container): void {
|
onUpdate(pawn: Pawn, obj: PawnContainer): void {
|
||||||
const [x, y] = pawn.position;
|
const [x, y] = pawn.position;
|
||||||
const prevPos = this.previousPositions.get(pawn.id);
|
const prevPos = this.previousPositions.get(pawn.id);
|
||||||
const hasMoved = !prevPos || prevPos[0] !== x || prevPos[1] !== y;
|
const hasMoved = !prevPos || prevPos[0] !== x || prevPos[1] !== y;
|
||||||
|
|
||||||
if (hasMoved && prevPos) {
|
if (hasMoved) {
|
||||||
// 播放移动动画并添加中断
|
obj.updatePosition([x, y], !!prevPos);
|
||||||
const targetPos = boardToScreen(x, y);
|
|
||||||
|
|
||||||
const tween = this.scene.tweens.add({
|
|
||||||
targets: obj,
|
|
||||||
x: targetPos.x,
|
|
||||||
y: targetPos.y,
|
|
||||||
duration: 400,
|
|
||||||
ease: 'Back.easeOut',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.scene.addTweenInterruption(tween);
|
|
||||||
} else if (!prevPos) {
|
|
||||||
// 初次生成,直接设置位置
|
|
||||||
const pos = boardToScreen(x, y);
|
|
||||||
obj.x = pos.x;
|
|
||||||
obj.y = pos.y;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.previousPositions.set(pawn.id, [x, y]);
|
this.previousPositions.set(pawn.id, [x, y]);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSpawn(pawn: Pawn) {
|
onSpawn(pawn: Pawn): PawnContainer {
|
||||||
const container = this.scene.add.container(0, 0);
|
const container = new PawnContainer(this.scene, pawn);
|
||||||
|
|
||||||
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;
|
const [x, y] = pawn.position;
|
||||||
const pos = boardToScreen(x, y);
|
const pos = boardToScreen(x, y);
|
||||||
|
|
@ -82,18 +209,20 @@ export class PawnSpawner implements Spawner<Pawn, Phaser.GameObjects.Container>
|
||||||
|
|
||||||
this.previousPositions.set(pawn.id, [x, y]);
|
this.previousPositions.set(pawn.id, [x, y]);
|
||||||
|
|
||||||
|
// 淡入动画
|
||||||
container.setScale(0);
|
container.setScale(0);
|
||||||
this.scene.tweens.add({
|
const tween = this.scene.tweens.add({
|
||||||
targets: container,
|
targets: container,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
duration: 300,
|
duration: 300,
|
||||||
ease: 'Back.easeOut',
|
ease: 'Back.easeOut',
|
||||||
});
|
});
|
||||||
|
this.scene.addTweenInterruption(tween);
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
onDespawn(obj: Phaser.GameObjects.Container) {
|
onDespawn(obj: PawnContainer): void {
|
||||||
// 播放消失动画并添加中断
|
// 播放消失动画并添加中断
|
||||||
const tween = this.scene.tweens.add({
|
const tween = this.scene.tweens.add({
|
||||||
targets: obj,
|
targets: obj,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue