feat: add card flying animation to Kiitos game

- Animate cards flying from player's panel to center slot on play/deviate moves
- Use tweens with Cubic.easeOut for smooth card placement (1200ms)
- Refactor applyMove to handle pass as early return and compute animation destination upfront
- Extract _postMoveEvents helper for post-move event handling
- Simplify forced move flow by removing manual delay and slot position capture
- Clean up flyCard reference on scene cleanup to prevent memory leaks
This commit is contained in:
Brian Fertig 2026-06-07 12:03:56 -06:00
parent 4597b31f5c
commit f3939435c6
1 changed files with 71 additions and 13 deletions

View File

@ -72,6 +72,8 @@ export default class KiitosGame extends Phaser.Scene {
cleanup() { cleanup() {
this.input?.removeAllListeners(); this.input?.removeAllListeners();
this.clearHumanControls(); this.clearHumanControls();
this._flyCard?.destroy();
this._flyCard = null;
} }
// ── Table backdrop ────────────────────────────────────────────────────────── // ── Table backdrop ──────────────────────────────────────────────────────────
@ -405,28 +407,86 @@ export default class KiitosGame extends Phaser.Scene {
} }
applyMove(move, playerIdx) { applyMove(move, playerIdx) {
let evt; if (move.type === 'pass') {
if (move.type === 'play') { const evt = this.logic.applyPass(playerIdx);
evt = this.logic.applyForced(playerIdx);
playSound(this, SFX.CARD_PLACE);
} else if (move.type === 'deviate') {
evt = this.logic.applyDeviate(playerIdx, move.letter, move.prefix ?? move.newBuilt, move.word);
playSound(this, SFX.CARD_PLACE);
} else {
evt = this.logic.applyPass(playerIdx);
if (evt.resolved) { if (evt.resolved) {
const owner = this.logic.players[evt.resolved.ownerIdx]; const owner = this.logic.players[evt.resolved.ownerIdx];
this.flashCenter(`${owner.name === 'You' ? 'Your' : owner.name + "'s"} word fizzled — ${evt.resolved.cards} cards to penalty`, THEME.negativeHex); this.flashCenter(`${owner.name === 'You' ? 'Your' : owner.name + "'s"} word fizzled — ${evt.resolved.cards} cards to penalty`, THEME.negativeHex);
playSound(this, SFX.CARD_SHUFFLE); playSound(this, SFX.CARD_SHUFFLE);
} }
this.renderState();
this._postMoveEvents(evt);
return;
} }
// Compute animation destination before applying logic (center may be cleared on completion).
const oldBuilt = this.logic.center.built;
let cardIndex, displayWord;
if (move.type === 'play') {
cardIndex = oldBuilt.length;
displayWord = this.logic.center.targetWord ?? (oldBuilt + move.letter);
} else {
const newBuilt = move.prefix ?? move.newBuilt;
const inserts = insertionsFromTo(oldBuilt, newBuilt);
cardIndex = inserts[0]?.index ?? Math.max(0, newBuilt.length - 1);
displayWord = move.word;
}
const totW = displayWord.length * CARD_W + (displayWord.length - 1) * CARD_GAP;
const rowStartX = GAME_WIDTH / 2 - totW / 2 + CARD_W / 2;
const destX = rowStartX + cardIndex * (CARD_W + CARD_GAP);
// Apply logic.
let evt;
if (move.type === 'play') {
evt = this.logic.applyForced(playerIdx);
} else {
evt = this.logic.applyDeviate(playerIdx, move.letter, move.prefix ?? move.newBuilt, move.word);
}
playSound(this, SFX.CARD_PLACE);
// Render post-move state. Hide the newly placed card until the animation lands.
this._deviateView = false;
this.renderState(); this.renderState();
let hiddenCard = null;
if (!evt?.completed && this.centerLayer?.list) {
hiddenCard = this.centerLayer.list[cardIndex] ?? null;
if (hiddenCard?.active) hiddenCard.setAlpha(0);
}
// Fly the card from the playing seat's panel to its slot in the centre row.
const seatId = this.seats[playerIdx].id;
const panel = this.seatPanels?.[seatId];
const startX = panel?.x ?? GAME_WIDTH / 2;
const startY = panel?.y ?? CENTER_Y;
this._flyCard?.destroy();
this._flyCard = this.makeCard(startX, startY, CARD_W, CARD_H, move.letter);
this._flyCard.setDepth(D.bubble);
this._flyCard.setScale(0.4);
this.tweens.add({
targets: this._flyCard,
x: destX, y: CENTER_Y,
scaleX: 1, scaleY: 1,
duration: 1200,
ease: 'Cubic.easeOut',
onComplete: () => {
this._flyCard?.destroy();
this._flyCard = null;
if (hiddenCard?.active) {
this.tweens.add({ targets: hiddenCard, alpha: 1, duration: 150, ease: 'Sine.easeIn' });
}
this._postMoveEvents(evt);
},
});
}
_postMoveEvents(evt) {
if (evt?.completed) { if (evt?.completed) {
const owner = this.logic.players[evt.ownerIdx]; const owner = this.logic.players[evt.ownerIdx];
this.kiitosBubble(owner, evt.word); this.kiitosBubble(owner, evt.word);
} }
if (evt?.roundOver) { this.time.delayedCall(700, () => this.endRound()); return; } if (evt?.roundOver) { this.time.delayedCall(700, () => this.endRound()); return; }
this.time.delayedCall(evt?.completed ? 1100 : 360, () => this.nextTurn()); this.time.delayedCall(evt?.completed ? 1100 : 360, () => this.nextTurn());
} }
@ -503,12 +563,10 @@ export default class KiitosGame extends Phaser.Scene {
if (this._humanForced) { if (this._humanForced) {
if (letter !== this._humanForced || insertIndex !== built.length) { this.snapBack(card); return; } if (letter !== this._humanForced || insertIndex !== built.length) { this.snapBack(card); return; }
const slotX = zone.getData('mx'); // capture before clearSlots() destroys the zone
this._humanActive = false; this._humanActive = false;
this.lockDrag(); this.lockDrag();
this.clearSlots(); this.clearSlots();
this.tweenCardToSlot(card, slotX); this.applyMove({ type: 'play', letter, forced: true }, 0);
this.time.delayedCall(170, () => this.applyMove({ type: 'play', letter, forced: true }, 0));
return; return;
} }
const newBuilt = built.slice(0, insertIndex) + letter + built.slice(insertIndex); const newBuilt = built.slice(0, insertIndex) + letter + built.slice(insertIndex);