From 29a242b9f50649645284e804d69ceb246297c677 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 9 Jul 2026 23:00:12 -0600 Subject: [PATCH] feat: add card dealing and discarding animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement sequential deal animation with cards flying in from off-screen and rotating 400° before landing - Add discard animation where selected cards fly to screen center then shrink away - Animate played cards flying from their hand positions to the played row instead of popping into place - Track pending deals via _pendingDeal to coordinate animations with game state updates in doDiscard, doPlay, and blind selection --- src/games/balatro/BalatroGame.js | 131 ++++++++++++++++++++++++++---- src/games/balatro/BalatroViews.js | 2 + 2 files changed, 115 insertions(+), 18 deletions(-) diff --git a/src/games/balatro/BalatroGame.js b/src/games/balatro/BalatroGame.js index 81b7788..5abca4d 100644 --- a/src/games/balatro/BalatroGame.js +++ b/src/games/balatro/BalatroGame.js @@ -65,6 +65,7 @@ export default class BalatroGame extends Phaser.Scene { this._jokerSprites = {}; this._toastText = null; this._recorded = false; + this._pendingDeal = null; } create() { @@ -663,13 +664,23 @@ export default class BalatroGame extends Phaser.Scene { const maxSpan = 1300; const step = hand.length > 1 ? Math.min(w * 0.72, (maxSpan - w) / (hand.length - 1)) : 0; const x0 = centerX - ((hand.length - 1) * step) / 2; + const pendingDeal = this._pendingDeal; + this._pendingDeal = null; + const dealList = []; hand.forEach((card, i) => { const selected = this.selected.includes(card.uid); const cx = x0 + i * step; const cy = y - (selected ? 44 : 0); const cont = this.drawPlayingCard(cx, cy, card, { w, h, debuffed: isDebuffed(run, card) }); const tilt = (i - (hand.length - 1) / 2) * 1.6; - cont.setAngle(tilt); + const dealing = pendingDeal && pendingDeal.has(card.uid); + if (dealing) { + cont.setPosition(cx, -220); + cont.setAngle(tilt - 400); + dealList.push({ cont, cx, cy, tilt }); + } else { + cont.setAngle(tilt); + } this.add2(cont, this.handLayer); this._handSprites[card.uid] = cont; if (selected) { @@ -689,6 +700,55 @@ export default class BalatroGame extends Phaser.Scene { } }); this.updateHandPreview(); + if (pendingDeal) { + if (dealList.length) this.dealCardsSequential(dealList); + else this.animating = false; + } + } + + // Fly newly-drawn cards in from off-screen, one at a time, spinning through + // 400° so they always complete more than a full rotation before landing. + dealCardsSequential(list) { + this.animating = true; + const DUR = 340, GAP = 70; + const step = (i) => { + if (i >= list.length) { this.animating = false; return; } + const { cont, cx, cy, tilt } = list[i]; + if (!cont.active) { step(i + 1); return; } + playSound(this, SFX.CARD_DEAL); + this.tweens.add({ + targets: cont, x: cx, y: cy, angle: tilt, + duration: DUR, ease: 'Cubic.easeOut', + onComplete: () => this.time.delayedCall(GAP, () => step(i + 1)), + }); + }; + step(0); + } + + // Fly discarded cards to screen center, then shrink them away. Calls + // onComplete only once every card has finished both steps. + discardCardsOut(sprites, onComplete) { + this.animating = true; + if (!sprites.length) { onComplete(); return; } + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + let remaining = sprites.length; + sprites.forEach((cont) => { + this.tweens.add({ + targets: cont, x: cx, y: cy, + duration: 260, ease: 'Cubic.easeIn', + onComplete: () => { + this.tweens.add({ + targets: cont, scaleX: 0, scaleY: 0, + duration: 180, ease: 'Cubic.easeIn', + onComplete: () => { + cont.destroy(); + remaining -= 1; + if (remaining === 0) onComplete(); + }, + }); + }, + }); + }); } toggleSelect(uid) { @@ -747,12 +807,19 @@ export default class BalatroGame extends Phaser.Scene { doDiscard() { if (!this.selected.length) { this.toast('Select cards to discard'); return; } - const r = discard(this.run, this.selected.slice()); - if (!r.ok) { this.toast(r.error); return; } - playSound(this, SFX.CARD_DEAL); + const uids = this.selected.slice(); + const sprites = uids.map((u) => this._handSprites[u]).filter(Boolean); + sprites.forEach((s) => s.disableInteractive()); + uids.forEach((u) => delete this._handSprites[u]); this.selected = []; - this.save(); - this.renderView(); + playSound(this, SFX.CARD_DEAL); + this.discardCardsOut(sprites, () => { + const r = discard(this.run, uids); + if (!r.ok) { this.toast(r.error); this.animating = false; return; } + this.save(); + this._pendingDeal = new Set(r.drawn); + this.renderView(); + }); } doPlay() { @@ -760,21 +827,28 @@ export default class BalatroGame extends Phaser.Scene { const run = this.run; const uids = run.hand.filter((u) => this.selected.includes(u)); const playedCards = cardsOf(run, uids).map((c) => ({ ...c })); // snapshot for display + // Remember where each played card currently sits in the hand fan so it + // can fly from there to the played row instead of popping into place. + const startPos = {}; + uids.forEach((u) => { + const s = this._handSprites[u]; + if (s) startPos[u] = { x: s.x, y: s.y, angle: s.angle }; + }); const res = playHand(run, uids); if (!res.ok) { this.toast(res.error); return; } this.selected = []; this.save(); playSound(this, SFX.CARD_PLACE); - this.animateScoring(playedCards, res); + this.animateScoring(playedCards, res, startPos); } // ── scoring animation: pure playback of the trace ───────────────────────── - animateScoring(playedCards, res) { + animateScoring(playedCards, res, startPos = {}) { this.animating = true; this._skipAnim = false; this.dismissPanel(); - // Freeze the table (re-render without input), lift played cards to center. + // Freeze the table (re-render without input). this.clearView(); this.renderLeftPanel(); this.renderJokerRow(true); @@ -784,19 +858,13 @@ export default class BalatroGame extends Phaser.Scene { // Hand without the played cards this.renderHand(true); - // Played row + // Played row targets const w = 150, h = 204; const centerX = 1160, py = 480; const step = Math.min(w + 22, 900 / Math.max(playedCards.length, 1)); const x0 = centerX - ((playedCards.length - 1) * step) / 2; this._playedSprites = {}; const scoringSet = new Set(res.trace.scoringUids); - playedCards.forEach((card, i) => { - const cont = this.drawPlayingCard(x0 + i * step, py, card, { w, h, reveal: true }); - if (!scoringSet.has(card.uid)) cont.setAlpha(0.45); - this.add2(cont); - this._playedSprites[card.uid] = cont; - }); const events = res.trace.events; const stepMs = Phaser.Math.Clamp(Math.round(2600 / Math.max(events.length, 1)), 70, 240); @@ -871,18 +939,21 @@ export default class BalatroGame extends Phaser.Scene { if (this._roundScoreText && this._roundScoreText.active) this._roundScoreText.setText(fmtChips(this.run.roundScore)); this.time.delayedCall(this._skipAnim ? 250 : 900, () => { - this.animating = false; if (res.destroyedJokers && res.destroyedJokers.length) { this.toast(`${res.destroyedJokers.map((id) => JOKER_BY_ID[id].name).join(', ')} destroyed`); } if (res.outcome === 'blindWon') { + this.animating = false; playSound(this, SFX.CASINO_WIN); this.setView('cashout'); } else if (res.outcome === 'runLost') { + this.animating = false; playSound(this, SFX.CASINO_LOSE); this.onRunOver(); this.setView('gameover'); } else { + // animating stays true; the new deal-in sequence clears it once cards land. + this._pendingDeal = new Set(res.drawn); this.setView('play'); } }); @@ -899,7 +970,31 @@ export default class BalatroGame extends Phaser.Scene { applyEvent(ev); this.time.delayedCall(ev.t === 'base' ? 420 : stepMs, tick); }; - tick(); + + // Fly each played card in from its former hand position; scoring only + // begins once every card has landed in the played row. + const flyIn = () => { + let remaining = playedCards.length; + if (!remaining) { tick(); return; } + playedCards.forEach((card, i) => { + const tx = x0 + i * step, ty = py; + const from = startPos[card.uid] || { x: tx, y: -220, angle: 0 }; + const cont = this.drawPlayingCard(from.x, from.y, card, { w, h, reveal: true }); + cont.setAngle(from.angle); + this.add2(cont); + this.tweens.add({ + targets: cont, x: tx, y: ty, angle: 0, + duration: 260, ease: 'Cubic.easeOut', + onComplete: () => { + if (!scoringSet.has(card.uid)) cont.setAlpha(0.45); + this._playedSprites[card.uid] = cont; + remaining -= 1; + if (remaining === 0) tick(); + }, + }); + }); + }; + flyIn(); } fxX(sprite) { let x = sprite.x, p = sprite.parentContainer; while (p) { x += p.x; p = p.parentContainer; } return x; } diff --git a/src/games/balatro/BalatroViews.js b/src/games/balatro/BalatroViews.js index a5b3ded..bcb279a 100644 --- a/src/games/balatro/BalatroViews.js +++ b/src/games/balatro/BalatroViews.js @@ -139,6 +139,8 @@ export function renderBlindSelect(scene) { if (!r.ok) { scene.toast(r.error); return; } playSound(scene, SFX.CARD_SHUFFLE); scene.save(); + scene.animating = true; + scene._pendingDeal = new Set(run.hand); scene.setView('play'); }, { width: 240, height: 64, fontSize: 26, bg: color }); if (BLINDS[stage].skippable) {