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:
parent
160f6010b1
commit
29a242b9f5
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue