refactor(onitama): extract tweens into factory functions

Centralize Onitama animation logic by moving inline Phaser tweens
into a dedicated `tweens.ts` configuration file. This improves
reusability and cleans up spawners and renderers.

Also updated documentation to warn against adding interruptions to
looped tweens to prevent game logic hangs.
This commit is contained in:
hypercross 2026-04-19 12:45:05 +08:00
parent 3568d99e6e
commit c25759d147
9 changed files with 440 additions and 179 deletions

View File

@ -59,6 +59,8 @@ Extend `Phaser.GameObjects.Container` to encapsulate visuals and state.
### 5. Tween Interruption
Always register state-related tweens: `this.scene.addTweenInterruption(tween)`.
Prevents visual glitches when game state changes mid-animation.
DO NOT add interruptions for looped tweens.
Otherwise the game logic will hang.
### 6. Scene Navigation
Use `await this.sceneController.launch('SceneKey')`.

View File

@ -219,3 +219,33 @@ export function getCardPosition(
export function colorToStr(hex: number) {
return Display.Color.ValueToColor(hex).toString();
}
// Tween factory functions
export {
// Card tweens
createCardMoveTween,
createCardRotateTween,
createCardSpawnTween,
createCardDespawnTween,
// Pawn tweens
createPawnMoveTween,
createPawnSpawnTween,
createPawnDespawnTween,
// Highlight tweens
createHighlightSpawnTween,
createHighlightDespawnTween,
createHighlightClickFeedbackTween,
createHighlightInnerPulseTween,
createHighlightOuterPulseTween,
// Selection tweens
createSelectionFadeInTween,
createSelectionPulseTween,
createSelectionFadeOutTween,
createSelectionShowTween,
createSelectionRingPulseTween,
createSelectionHideTween,
// Overlay tweens
createWinnerPulseTween,
// Types
type TweenScene,
} from "@/config/tweens";

View File

@ -0,0 +1,322 @@
import type Phaser from "phaser";
import { ANIMATIONS } from "@/config";
export type TweenScene = Phaser.Scene & {
addTweenInterruption(tween: Phaser.Tweens.Tween): void;
};
// ─────────────────────────────────────────────────────────────
// Card Tweens
// ─────────────────────────────────────────────────────────────
export function createCardMoveTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
x: number,
y: number,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
x,
y,
duration: ANIMATIONS.cardMove,
ease: "Back.easeOut",
});
scene.addTweenInterruption(tween);
return tween;
}
export function createCardRotateTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
fromAngle: number,
toAngle: number,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
angle: { from: fromAngle, to: toAngle },
duration: ANIMATIONS.cardRotate,
ease: "Back.easeOut",
});
scene.addTweenInterruption(tween);
return tween;
}
export function createCardSpawnTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
alpha: 1,
duration: ANIMATIONS.cardSpawn,
ease: "Power2",
});
scene.addTweenInterruption(tween);
return tween;
}
export function createCardDespawnTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onComplete: () => void,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
alpha: 0,
scale: 0.8,
duration: ANIMATIONS.cardDespawn,
ease: "Power2",
onComplete,
});
scene.addTweenInterruption(tween);
return tween;
}
// ─────────────────────────────────────────────────────────────
// Pawn Tweens
// ─────────────────────────────────────────────────────────────
export function createPawnMoveTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
x: number,
y: number,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
x,
y,
duration: ANIMATIONS.pawnMove,
ease: "Back.easeOut",
});
scene.addTweenInterruption(tween);
return tween;
}
export function createPawnSpawnTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
scale: 1,
duration: ANIMATIONS.pawnSpawn,
ease: "Back.easeOut",
});
scene.addTweenInterruption(tween);
return tween;
}
export function createPawnDespawnTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onComplete: () => void,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
scale: 0,
alpha: 0,
duration: ANIMATIONS.pawnDespawn,
ease: "Back.easeIn",
onComplete,
});
scene.addTweenInterruption(tween);
return tween;
}
// ─────────────────────────────────────────────────────────────
// Highlight Tweens
// ─────────────────────────────────────────────────────────────
export function createHighlightSpawnTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
alpha: 1,
scale: 1,
duration: ANIMATIONS.highlightSpawn,
ease: "Back.easeOut",
});
scene.addTweenInterruption(tween);
return tween;
}
export function createHighlightDespawnTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onComplete: () => void,
): Phaser.Tweens.Tween {
const tween = scene.tweens.add({
targets: target,
scale: 0,
alpha: 0,
duration: ANIMATIONS.highlightDespawn,
ease: "Back.easeIn",
onComplete,
});
scene.addTweenInterruption(tween);
return tween;
}
export function createHighlightClickFeedbackTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
scale: 1.5,
alpha: 0.8,
duration: ANIMATIONS.clickFeedback,
ease: "Power2",
yoyo: true,
});
}
export function createHighlightInnerPulseTween(
scene: TweenScene,
targets: Phaser.GameObjects.GameObject[],
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets,
scale: 1.2,
alpha: 0.6,
duration: 600,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
});
}
export function createHighlightOuterPulseTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
scale: 1.3,
alpha: 0.3,
duration: 800,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
delay: 200,
});
}
// ─────────────────────────────────────────────────────────────
// Selection Tweens
// ─────────────────────────────────────────────────────────────
export function createSelectionFadeInTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onComplete: () => void,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
alpha: 1,
scale: 1.05,
duration: ANIMATIONS.selectionFadeIn,
ease: "Power2",
onComplete,
});
}
export function createSelectionPulseTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
lineWidth: number,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
alpha: 0.7,
lineWidth: lineWidth + 1,
duration: ANIMATIONS.selectionPulse,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
});
}
export function createSelectionFadeOutTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onComplete: () => void,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
alpha: 0,
scale: 0.95,
duration: ANIMATIONS.selectionFadeOut,
ease: "Power2",
onComplete,
});
}
export function createSelectionShowTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onCompleteFadeIn: () => void,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
alpha: 0.8,
duration: ANIMATIONS.selectionFadeIn,
ease: "Power2",
onComplete: onCompleteFadeIn,
});
}
export function createSelectionRingPulseTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
scale: 1.15,
alpha: 0.6,
duration: ANIMATIONS.selectionPulse,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
});
}
export function createSelectionHideTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
onComplete: () => void,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
alpha: 0,
scale: 0.9,
duration: ANIMATIONS.selectionFadeOut,
ease: "Power2",
onComplete: () => {
target.destroy();
onComplete();
},
});
}
// ─────────────────────────────────────────────────────────────
// Overlay Tweens
// ─────────────────────────────────────────────────────────────
export function createWinnerPulseTween(
scene: TweenScene,
target: Phaser.GameObjects.GameObject,
): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
scale: 1.2,
duration: ANIMATIONS.winnerPulse,
yoyo: true,
repeat: 1,
});
}

View File

@ -1,6 +1,11 @@
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import { CELL_SIZE, COLORS } from "@/config";
import {
CELL_SIZE,
COLORS,
createHighlightInnerPulseTween,
createHighlightOuterPulseTween,
} from "@/config";
export interface HighlightRenderOptions {
x: number;
@ -78,26 +83,9 @@ export class HighlightRenderer {
if (!outerCircle || !innerCircle) return;
// Inner circle pulse
this.scene.tweens.add({
targets: [outerCircle, innerCircle],
scale: 1.2,
alpha: 0.6,
duration: 600,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
});
createHighlightInnerPulseTween(this.scene, [outerCircle, innerCircle]);
// Outer circle staggered pulse
this.scene.tweens.add({
targets: outerCircle,
scale: 1.3,
alpha: 0.3,
duration: 800,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
delay: 200,
});
createHighlightOuterPulseTween(this.scene, outerCircle);
}
}

View File

@ -2,7 +2,13 @@ import { GameObjects } from "phaser";
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import { CELL_SIZE, COLORS, ANIMATIONS } from "@/config";
import {
CELL_SIZE,
COLORS,
createSelectionShowTween,
createSelectionRingPulseTween,
createSelectionHideTween,
} from "@/config";
export interface SelectionRenderOptions {
x: number;
@ -51,23 +57,9 @@ export class SelectionRenderer {
// Fade in animation
// DO NOT add interruption here
// otherwise the game will stop indefinitely
tweens.add({
targets: ring,
alpha: 0.8,
duration: ANIMATIONS.selectionFadeIn,
ease: "Power2",
onComplete: () => {
createSelectionShowTween(this.scene, ring, () => {
// Start pulse animation after fade-in completes
pulseTween = tweens.add({
targets: ring,
scale: 1.15,
alpha: 0.6,
duration: ANIMATIONS.selectionPulse,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
});
},
pulseTween = createSelectionRingPulseTween(this.scene, ring);
});
// Return cleanup function
@ -97,17 +89,7 @@ export class SelectionRenderer {
tweens.killTweensOf(ring);
// Fade out animation
tweens.add({
targets: ring,
alpha: 0,
scale: 0.9,
duration: ANIMATIONS.selectionFadeOut,
ease: "Power2",
onComplete: () => {
ring.destroy();
onComplete?.();
},
});
createSelectionHideTween(this.scene, ring, () => onComplete?.());
}
/**

View File

@ -9,11 +9,11 @@ import type Phaser from "phaser";
import {
COLORS,
FONTS,
ANIMATIONS,
MENU_BUTTON,
getBoardCenter,
getCardLabelPosition,
colorToStr,
createWinnerPulseTween,
} from "@/config";
import { prompts } from "@/game/onitama";
import {
@ -304,12 +304,6 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
this.winnerOverlay.add(winText);
this.tweens.add({
targets: winText,
scale: 1.2,
duration: ANIMATIONS.winnerPulse,
yoyo: true,
repeat: 1,
});
createWinnerPulseTween(this, winText);
}
}

View File

@ -4,7 +4,18 @@ import type { Card } from "@/game/onitama";
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import type { Spawner } from "boardgame-phaser";
import { CARD_WIDTH, CARD_HEIGHT, ANIMATIONS, getCardPosition } from "@/config";
import {
CARD_WIDTH,
CARD_HEIGHT,
getCardPosition,
createCardMoveTween,
createCardRotateTween,
createCardSpawnTween,
createCardDespawnTween,
createSelectionFadeInTween,
createSelectionPulseTween,
createSelectionFadeOutTween,
} from "@/config";
import { CardRenderer } from "@/renderers";
// Re-export for backward compatibility
@ -66,13 +77,12 @@ export class CardContainer extends Phaser.GameObjects.Container {
setRotation(rotation: number, animated: boolean = false): this {
if (animated) {
const currentAngle = this.angle;
const tween = this.scene.tweens.add({
targets: this,
angle: { from: currentAngle, to: rotation },
duration: ANIMATIONS.cardRotate,
ease: "Back.easeOut",
});
(this.scene as OnitamaScene).addTweenInterruption(tween);
createCardRotateTween(
this.scene as OnitamaScene,
this,
currentAngle,
rotation,
);
} else {
this.angle = rotation;
}
@ -88,32 +98,22 @@ export class CardContainer extends Phaser.GameObjects.Container {
if (!this.highlightRect) {
// 创建高亮边框(初始透明)
this.highlightRect = (this.scene as OnitamaScene).add
const rect = (this.scene as OnitamaScene).add
.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0)
.setStrokeStyle(lineWidth, color)
.setAlpha(0)
.setDepth(-1);
this.highlightRect = rect;
this.addAt(this.highlightRect, 0);
// 淡入动画
this.scene.tweens.add({
targets: this.highlightRect,
alpha: 1,
scale: 1.05,
duration: ANIMATIONS.selectionFadeIn,
ease: "Power2",
onComplete: () => {
createSelectionFadeInTween(this.scene as OnitamaScene, rect, () => {
// 淡入完成后开始脉冲动画
this.highlightTween = this.scene.tweens.add({
targets: this.highlightRect,
alpha: 0.7,
lineWidth: lineWidth + 1,
duration: ANIMATIONS.selectionPulse,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
});
},
this.highlightTween = createSelectionPulseTween(
this.scene as OnitamaScene,
rect,
lineWidth,
);
});
} else {
// 如果已经存在,停止当前动画并重新开始脉冲
@ -121,15 +121,11 @@ export class CardContainer extends Phaser.GameObjects.Container {
this.highlightTween.stop();
}
this.highlightRect.setStrokeStyle(lineWidth, color);
this.highlightTween = this.scene.tweens.add({
targets: this.highlightRect,
alpha: 0.7,
lineWidth: lineWidth + 1,
duration: ANIMATIONS.selectionPulse,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
});
this.highlightTween = createSelectionPulseTween(
this.scene as OnitamaScene,
this.highlightRect,
lineWidth,
);
}
}
@ -146,18 +142,15 @@ export class CardContainer extends Phaser.GameObjects.Container {
this.scene.tweens.killTweensOf(this.highlightRect);
// 淡出动画
this.scene.tweens.add({
targets: this.highlightRect,
alpha: 0,
scale: 0.95,
duration: ANIMATIONS.selectionFadeOut,
ease: "Power2",
onComplete: () => {
createSelectionFadeOutTween(
this.scene as OnitamaScene,
this.highlightRect,
() => {
// 淡出完成后销毁矩形
this.highlightRect?.destroy();
this.highlightRect = null;
},
});
);
}
}
@ -242,15 +235,7 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
const pos = getCardPosition(data.position, data.index);
// 播放移动动画并添加中断
const tween = this.scene.tweens.add({
targets: obj,
x: pos.x,
y: pos.y,
duration: ANIMATIONS.cardMove,
ease: "Back.easeOut",
});
this.scene.addTweenInterruption(tween);
createCardMoveTween(this.scene, obj, pos.x, pos.y);
this.previousData.set(data.cardId, { ...data });
}
@ -305,27 +290,13 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
// 初始状态为透明,然后淡入
container.setAlpha(0);
const tween = this.scene.tweens.add({
targets: container,
alpha: 1,
duration: ANIMATIONS.cardSpawn,
ease: "Power2",
});
this.scene.addTweenInterruption(tween);
createCardSpawnTween(this.scene, container);
this.previousData.set(data.cardId, { ...data });
return container;
}
onDespawn(obj: CardContainer): void {
const tween = this.scene.tweens.add({
targets: obj,
alpha: 0,
scale: 0.8,
duration: ANIMATIONS.cardDespawn,
ease: "Power2",
onComplete: () => obj.destroy(),
});
this.scene.addTweenInterruption(tween);
createCardDespawnTween(this.scene, obj, () => obj.destroy());
}
}

View File

@ -4,7 +4,13 @@ import { Geom } from "phaser";
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import type { Spawner } from "boardgame-phaser";
import { boardToScreen, CELL_SIZE, ANIMATIONS } from "@/config";
import {
boardToScreen,
CELL_SIZE,
createHighlightSpawnTween,
createHighlightDespawnTween,
createHighlightClickFeedbackTween,
} from "@/config";
import { HighlightRenderer } from "@/renderers";
export interface HighlightData {
@ -86,28 +92,14 @@ export class HighlightSpawner implements Spawner<
// 出现动画从0缩放和透明淡入
container.setAlpha(0);
container.setScale(0);
const spawnTween = this.scene.tweens.add({
targets: container,
alpha: 1,
scale: 1,
duration: ANIMATIONS.highlightSpawn,
ease: "Back.easeOut",
});
this.scene.addTweenInterruption(spawnTween);
createHighlightSpawnTween(this.scene, container);
// 使用 HighlightRenderer 设置脉冲动画
this.renderer.setupPulseAnimations(container);
container.on("pointerdown", () => {
// 点击时的反馈动画
this.scene.tweens.add({
targets: container,
scale: 1.5,
alpha: 0.8,
duration: ANIMATIONS.clickFeedback,
ease: "Power2",
yoyo: true,
});
createHighlightClickFeedbackTween(this.scene, container);
this.scene.onHighlightClick(data);
});
@ -117,14 +109,6 @@ export class HighlightSpawner implements Spawner<
onDespawn(obj: Phaser.GameObjects.Container): void {
// 消失动画:缩小并淡出
const despawnTween = this.scene.tweens.add({
targets: obj,
scale: 0,
alpha: 0,
duration: ANIMATIONS.highlightDespawn,
ease: "Back.easeIn",
onComplete: () => obj.destroy(),
});
this.scene.addTweenInterruption(despawnTween);
createHighlightDespawnTween(this.scene, obj, () => obj.destroy());
}
}

View File

@ -5,7 +5,12 @@ import type { Pawn } from "@/game/onitama";
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import type { Spawner } from "boardgame-phaser";
import { boardToScreen, ANIMATIONS } from "@/config";
import {
boardToScreen,
createPawnMoveTween,
createPawnSpawnTween,
createPawnDespawnTween,
} from "@/config";
import { PawnRenderer, SelectionRenderer } from "@/renderers";
// Re-export for backward compatibility
@ -97,14 +102,12 @@ export class PawnContainer extends GameObjects.Container {
const targetPos = boardToScreen(newPosition[0], newPosition[1]);
if (animated) {
const tween = this.scene.tweens.add({
targets: this,
x: targetPos.x,
y: targetPos.y,
duration: ANIMATIONS.pawnMove,
ease: "Back.easeOut",
});
(this.scene as OnitamaScene).addTweenInterruption(tween);
createPawnMoveTween(
this.scene as OnitamaScene,
this,
targetPos.x,
targetPos.y,
);
} else {
this.x = targetPos.x;
this.y = targetPos.y;
@ -153,28 +156,13 @@ export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
// 淡入动画
container.setScale(0);
const tween = this.scene.tweens.add({
targets: container,
scale: 1,
duration: ANIMATIONS.pawnSpawn,
ease: "Back.easeOut",
});
this.scene.addTweenInterruption(tween);
createPawnSpawnTween(this.scene, container);
return container;
}
onDespawn(obj: PawnContainer): void {
// 播放消失动画并添加中断
const tween = this.scene.tweens.add({
targets: obj,
scale: 0,
alpha: 0,
duration: ANIMATIONS.pawnDespawn,
ease: "Back.easeIn",
onComplete: () => obj.destroy(),
});
this.scene.addTweenInterruption(tween);
createPawnDespawnTween(this.scene, obj, () => obj.destroy());
}
}