feat: add more tweens

This commit is contained in:
hypercross 2026-04-08 11:30:31 +08:00
parent 00fd395873
commit 3e064f437b
3 changed files with 449 additions and 166 deletions

View File

@ -3,7 +3,7 @@ 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';
import {effect} from "@preact/signals-core";
import { effect } from "@preact/signals-core";
export const CARD_WIDTH = 100;
export const CARD_HEIGHT = 140;
@ -15,7 +15,185 @@ export interface CardSpawnData {
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>();
constructor(public readonly scene: OnitamaScene) {}
@ -34,6 +212,7 @@ export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Co
}
// 备用卡牌
if(state.spareCard)
yield { cardId: state.spareCard, position: 'spare', index: 0 };
}
@ -71,7 +250,7 @@ export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Co
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)) {
this.previousData.set(data.cardId, { ...data });
@ -93,56 +272,25 @@ export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Co
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 {
onSpawn(data: CardSpawnData): CardContainer {
const card = this.scene.state.cards[data.cardId];
if (!card) {
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);
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);
@ -167,18 +315,11 @@ export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Co
});
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 });
return container;
}
onDespawn(obj: Phaser.GameObjects.Container): void {
onDespawn(obj: CardContainer): void {
const tween = this.scene.tweens.add({
targets: obj,
alpha: 0,
@ -189,54 +330,4 @@ export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Co
});
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;
}
}

View File

@ -31,13 +31,10 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
const availableMoves = getAvailableMoves(state, currentPlayer);
// 过滤出符合当前选择的移动
const filteredMoves = availableMoves.filter(move =>
move.fromX === uiState.selectedPiece!.x &&
for(const move of availableMoves){
if(move.fromX === uiState.selectedPiece!.x &&
move.fromY === uiState.selectedPiece!.y &&
move.card === uiState.selectedCard
);
for(const move of filteredMoves){
move.card === uiState.selectedCard){
const pos = boardToScreen(move.toX, move.toY);
yield {
key: `${move.fromX}-${move.fromY}-${move.card}-${move.toX}-${move.toY}`,
@ -51,6 +48,7 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
}
}
}
}
getKey(data: HighlightData): string {
return data.key;
@ -63,14 +61,25 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
onSpawn(data: HighlightData): Phaser.GameObjects.Container {
const container = this.scene.add.container(data.x, data.y);
const circle = this.scene.add.circle(
// 外圈光环(动画)
const outerCircle = this.scene.add.circle(
0,
0,
CELL_SIZE / 3,
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);
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
@ -78,7 +87,52 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
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', () => {
// 点击时的反馈动画
this.scene.tweens.add({
targets: container,
scale: 1.5,
alpha: 0.8,
duration: 150,
ease: 'Power2',
yoyo: true,
});
this.scene.onHighlightClick(data);
});
@ -86,7 +140,16 @@ export class HighlightSpawner implements Spawner<HighlightData, Phaser.GameObjec
}
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);
}
}

View File

@ -2,6 +2,8 @@ import Phaser from 'phaser';
import type { Pawn } from '@/game/onitama';
import type { Spawner } from 'boardgame-phaser';
import type { OnitamaScene } from '@/scenes/OnitamaScene';
import type { OnitamaUIState } from '@/state';
import { effect } from "@preact/signals-core";
export const CELL_SIZE = 80;
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]>();
constructor(public readonly scene: OnitamaScene) {}
@ -31,49 +187,20 @@ export class PawnSpawner implements Spawner<Pawn, Phaser.GameObjects.Container>
return pawn.id;
}
onUpdate(pawn: Pawn, obj: Phaser.GameObjects.Container): void {
onUpdate(pawn: Pawn, obj: PawnContainer): 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 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;
if (hasMoved) {
obj.updatePosition([x, y], !!prevPos);
}
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);
onSpawn(pawn: Pawn): PawnContainer {
const container = new PawnContainer(this.scene, pawn);
const [x, y] = pawn.position;
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]);
// 淡入动画
container.setScale(0);
this.scene.tweens.add({
const tween = this.scene.tweens.add({
targets: container,
scale: 1,
duration: 300,
ease: 'Back.easeOut',
});
this.scene.addTweenInterruption(tween);
return container;
}
onDespawn(obj: Phaser.GameObjects.Container) {
onDespawn(obj: PawnContainer): void {
// 播放消失动画并添加中断
const tween = this.scene.tweens.add({
targets: obj,