From f3939435c644ece2b28515fb4bb9f385b93bc509 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 7 Jun 2026 12:03:56 -0600 Subject: [PATCH] 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 --- public/src/games/kiitos/KiitosGame.js | 84 ++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 13 deletions(-) diff --git a/public/src/games/kiitos/KiitosGame.js b/public/src/games/kiitos/KiitosGame.js index 92bc6b3..4ea6095 100644 --- a/public/src/games/kiitos/KiitosGame.js +++ b/public/src/games/kiitos/KiitosGame.js @@ -72,6 +72,8 @@ export default class KiitosGame extends Phaser.Scene { cleanup() { this.input?.removeAllListeners(); this.clearHumanControls(); + this._flyCard?.destroy(); + this._flyCard = null; } // ── Table backdrop ────────────────────────────────────────────────────────── @@ -405,28 +407,86 @@ export default class KiitosGame extends Phaser.Scene { } applyMove(move, playerIdx) { - let evt; - if (move.type === 'play') { - 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 (move.type === 'pass') { + const evt = this.logic.applyPass(playerIdx); if (evt.resolved) { 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); 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(); + 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) { const owner = this.logic.players[evt.ownerIdx]; this.kiitosBubble(owner, evt.word); } - if (evt?.roundOver) { this.time.delayedCall(700, () => this.endRound()); return; } this.time.delayedCall(evt?.completed ? 1100 : 360, () => this.nextTurn()); } @@ -503,12 +563,10 @@ export default class KiitosGame extends Phaser.Scene { if (this._humanForced) { 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.lockDrag(); this.clearSlots(); - this.tweenCardToSlot(card, slotX); - this.time.delayedCall(170, () => this.applyMove({ type: 'play', letter, forced: true }, 0)); + this.applyMove({ type: 'play', letter, forced: true }, 0); return; } const newBuilt = built.slice(0, insertIndex) + letter + built.slice(insertIndex);