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._jokerSprites = {};
|
||||||
this._toastText = null;
|
this._toastText = null;
|
||||||
this._recorded = false;
|
this._recorded = false;
|
||||||
|
this._pendingDeal = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
|
|
@ -663,13 +664,23 @@ export default class BalatroGame extends Phaser.Scene {
|
||||||
const maxSpan = 1300;
|
const maxSpan = 1300;
|
||||||
const step = hand.length > 1 ? Math.min(w * 0.72, (maxSpan - w) / (hand.length - 1)) : 0;
|
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 x0 = centerX - ((hand.length - 1) * step) / 2;
|
||||||
|
const pendingDeal = this._pendingDeal;
|
||||||
|
this._pendingDeal = null;
|
||||||
|
const dealList = [];
|
||||||
hand.forEach((card, i) => {
|
hand.forEach((card, i) => {
|
||||||
const selected = this.selected.includes(card.uid);
|
const selected = this.selected.includes(card.uid);
|
||||||
const cx = x0 + i * step;
|
const cx = x0 + i * step;
|
||||||
const cy = y - (selected ? 44 : 0);
|
const cy = y - (selected ? 44 : 0);
|
||||||
const cont = this.drawPlayingCard(cx, cy, card, { w, h, debuffed: isDebuffed(run, card) });
|
const cont = this.drawPlayingCard(cx, cy, card, { w, h, debuffed: isDebuffed(run, card) });
|
||||||
const tilt = (i - (hand.length - 1) / 2) * 1.6;
|
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.add2(cont, this.handLayer);
|
||||||
this._handSprites[card.uid] = cont;
|
this._handSprites[card.uid] = cont;
|
||||||
if (selected) {
|
if (selected) {
|
||||||
|
|
@ -689,6 +700,55 @@ export default class BalatroGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.updateHandPreview();
|
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) {
|
toggleSelect(uid) {
|
||||||
|
|
@ -747,12 +807,19 @@ export default class BalatroGame extends Phaser.Scene {
|
||||||
|
|
||||||
doDiscard() {
|
doDiscard() {
|
||||||
if (!this.selected.length) { this.toast('Select cards to discard'); return; }
|
if (!this.selected.length) { this.toast('Select cards to discard'); return; }
|
||||||
const r = discard(this.run, this.selected.slice());
|
const uids = this.selected.slice();
|
||||||
if (!r.ok) { this.toast(r.error); return; }
|
const sprites = uids.map((u) => this._handSprites[u]).filter(Boolean);
|
||||||
playSound(this, SFX.CARD_DEAL);
|
sprites.forEach((s) => s.disableInteractive());
|
||||||
|
uids.forEach((u) => delete this._handSprites[u]);
|
||||||
this.selected = [];
|
this.selected = [];
|
||||||
this.save();
|
playSound(this, SFX.CARD_DEAL);
|
||||||
this.renderView();
|
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() {
|
doPlay() {
|
||||||
|
|
@ -760,21 +827,28 @@ export default class BalatroGame extends Phaser.Scene {
|
||||||
const run = this.run;
|
const run = this.run;
|
||||||
const uids = run.hand.filter((u) => this.selected.includes(u));
|
const uids = run.hand.filter((u) => this.selected.includes(u));
|
||||||
const playedCards = cardsOf(run, uids).map((c) => ({ ...c })); // snapshot for display
|
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);
|
const res = playHand(run, uids);
|
||||||
if (!res.ok) { this.toast(res.error); return; }
|
if (!res.ok) { this.toast(res.error); return; }
|
||||||
this.selected = [];
|
this.selected = [];
|
||||||
this.save();
|
this.save();
|
||||||
playSound(this, SFX.CARD_PLACE);
|
playSound(this, SFX.CARD_PLACE);
|
||||||
this.animateScoring(playedCards, res);
|
this.animateScoring(playedCards, res, startPos);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── scoring animation: pure playback of the trace ─────────────────────────
|
// ── scoring animation: pure playback of the trace ─────────────────────────
|
||||||
animateScoring(playedCards, res) {
|
animateScoring(playedCards, res, startPos = {}) {
|
||||||
this.animating = true;
|
this.animating = true;
|
||||||
this._skipAnim = false;
|
this._skipAnim = false;
|
||||||
this.dismissPanel();
|
this.dismissPanel();
|
||||||
|
|
||||||
// Freeze the table (re-render without input), lift played cards to center.
|
// Freeze the table (re-render without input).
|
||||||
this.clearView();
|
this.clearView();
|
||||||
this.renderLeftPanel();
|
this.renderLeftPanel();
|
||||||
this.renderJokerRow(true);
|
this.renderJokerRow(true);
|
||||||
|
|
@ -784,19 +858,13 @@ export default class BalatroGame extends Phaser.Scene {
|
||||||
// Hand without the played cards
|
// Hand without the played cards
|
||||||
this.renderHand(true);
|
this.renderHand(true);
|
||||||
|
|
||||||
// Played row
|
// Played row targets
|
||||||
const w = 150, h = 204;
|
const w = 150, h = 204;
|
||||||
const centerX = 1160, py = 480;
|
const centerX = 1160, py = 480;
|
||||||
const step = Math.min(w + 22, 900 / Math.max(playedCards.length, 1));
|
const step = Math.min(w + 22, 900 / Math.max(playedCards.length, 1));
|
||||||
const x0 = centerX - ((playedCards.length - 1) * step) / 2;
|
const x0 = centerX - ((playedCards.length - 1) * step) / 2;
|
||||||
this._playedSprites = {};
|
this._playedSprites = {};
|
||||||
const scoringSet = new Set(res.trace.scoringUids);
|
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 events = res.trace.events;
|
||||||
const stepMs = Phaser.Math.Clamp(Math.round(2600 / Math.max(events.length, 1)), 70, 240);
|
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));
|
if (this._roundScoreText && this._roundScoreText.active) this._roundScoreText.setText(fmtChips(this.run.roundScore));
|
||||||
|
|
||||||
this.time.delayedCall(this._skipAnim ? 250 : 900, () => {
|
this.time.delayedCall(this._skipAnim ? 250 : 900, () => {
|
||||||
this.animating = false;
|
|
||||||
if (res.destroyedJokers && res.destroyedJokers.length) {
|
if (res.destroyedJokers && res.destroyedJokers.length) {
|
||||||
this.toast(`${res.destroyedJokers.map((id) => JOKER_BY_ID[id].name).join(', ')} destroyed`);
|
this.toast(`${res.destroyedJokers.map((id) => JOKER_BY_ID[id].name).join(', ')} destroyed`);
|
||||||
}
|
}
|
||||||
if (res.outcome === 'blindWon') {
|
if (res.outcome === 'blindWon') {
|
||||||
|
this.animating = false;
|
||||||
playSound(this, SFX.CASINO_WIN);
|
playSound(this, SFX.CASINO_WIN);
|
||||||
this.setView('cashout');
|
this.setView('cashout');
|
||||||
} else if (res.outcome === 'runLost') {
|
} else if (res.outcome === 'runLost') {
|
||||||
|
this.animating = false;
|
||||||
playSound(this, SFX.CASINO_LOSE);
|
playSound(this, SFX.CASINO_LOSE);
|
||||||
this.onRunOver();
|
this.onRunOver();
|
||||||
this.setView('gameover');
|
this.setView('gameover');
|
||||||
} else {
|
} else {
|
||||||
|
// animating stays true; the new deal-in sequence clears it once cards land.
|
||||||
|
this._pendingDeal = new Set(res.drawn);
|
||||||
this.setView('play');
|
this.setView('play');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -899,7 +970,31 @@ export default class BalatroGame extends Phaser.Scene {
|
||||||
applyEvent(ev);
|
applyEvent(ev);
|
||||||
this.time.delayedCall(ev.t === 'base' ? 420 : stepMs, tick);
|
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; }
|
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; }
|
if (!r.ok) { scene.toast(r.error); return; }
|
||||||
playSound(scene, SFX.CARD_SHUFFLE);
|
playSound(scene, SFX.CARD_SHUFFLE);
|
||||||
scene.save();
|
scene.save();
|
||||||
|
scene.animating = true;
|
||||||
|
scene._pendingDeal = new Set(run.hand);
|
||||||
scene.setView('play');
|
scene.setView('play');
|
||||||
}, { width: 240, height: 64, fontSize: 26, bg: color });
|
}, { width: 240, height: 64, fontSize: 26, bg: color });
|
||||||
if (BLINDS[stage].skippable) {
|
if (BLINDS[stage].skippable) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue