feat: add card dealing and discarding animations

- 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
This commit is contained in:
Brian Fertig 2026-07-09 23:00:12 -06:00
parent 160f6010b1
commit 29a242b9f5
2 changed files with 115 additions and 18 deletions

View File

@ -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;
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 = [];
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; }

View File

@ -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) {