Refactor Uno game state transitions to use callback-based animation sequencing

- Update `handleLogEffects` to accept an `onDone` callback, ensuring post-state logic (unblocking input, updating UI) only executes after visual effects complete.
- Introduce `animateForcedDraw` to display a prominent "+N" banner and animate card draws with custom timing parameters.
- Refactor `animateBatchDraw` to support configurable `flyDuration` and `stagger` via a `timing` object.
- Apply the new callback pattern across all move execution paths (`playCard`, `drawCard`, AI moves) to prevent race conditions and ensure consistent animation flow.
This commit is contained in:
Brian Fertig 2026-05-19 20:34:37 -06:00
parent 6b819e00b0
commit afd8c78253
3 changed files with 103 additions and 59 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

View File

@ -1000,15 +1000,16 @@ export default class UnoGame extends Phaser.Scene {
this.renderAll(); this.renderAll();
// Auto-call UNO when we've just dropped to 1 card. // Auto-call UNO when we've just dropped to 1 card.
if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat); if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat);
this.handleLogEffects(before, after); this.handleLogEffects(before, after, () => {
// If the played card was a Wild and we have a pre-chosen color, apply it. // If the played card was a Wild and we have a pre-chosen color, apply it.
if (chosenColor && this.gs.phase === 'choosingColor') { if (chosenColor && this.gs.phase === 'choosingColor') {
this.time.delayedCall(150, () => this.executeChooseColor(chosenColor)); this.time.delayedCall(150, () => this.executeChooseColor(chosenColor));
return; return;
} }
this.animating = false; this.animating = false;
this.updateStatus(); this.updateStatus();
this.handlePostStateChange(); this.handlePostStateChange();
});
}); });
} }
@ -1057,15 +1058,16 @@ export default class UnoGame extends Phaser.Scene {
const banner = this.formatChallengeBanner(last); const banner = this.formatChallengeBanner(last);
this.gs = after; this.gs = after;
this.renderAll(); this.renderAll();
this.handleLogEffects(before, after);
if (banner) { if (banner) {
this.showBanner(banner); this.showBanner(banner);
this.time.delayedCall(1300, () => this.hideBanner()); this.time.delayedCall(1300, () => this.hideBanner());
} }
this.time.delayedCall(400, () => { this.handleLogEffects(before, after, () => {
this.animating = false; this.time.delayedCall(400, () => {
this.updateStatus(); this.animating = false;
this.handlePostStateChange(); this.updateStatus();
this.handlePostStateChange();
});
}); });
} }
@ -1082,8 +1084,9 @@ export default class UnoGame extends Phaser.Scene {
* Inspects the state transition's log entries for visual effects (skip, reverse, * Inspects the state transition's log entries for visual effects (skip, reverse,
* draw cards into opponents' hands) and animates each in a brief banner. * draw cards into opponents' hands) and animates each in a brief banner.
*/ */
handleLogEffects(before, after) { handleLogEffects(before, after, onDone) {
const oldLen = before.log.length; const oldLen = before.log.length;
let drawAnim = null;
for (let i = oldLen; i < after.log.length; i++) { for (let i = oldLen; i < after.log.length; i++) {
const e = after.log[i]; const e = after.log[i];
if (e.kind === 'skip') { if (e.kind === 'skip') {
@ -1098,8 +1101,10 @@ export default class UnoGame extends Phaser.Scene {
this.opponentPortraits[e.seat]?.playEmotion('upset'); this.opponentPortraits[e.seat]?.playEmotion('upset');
this.showBanner(`${this.opponentName(e.seat)} draws 2 and is skipped.`); this.showBanner(`${this.opponentName(e.seat)} draws 2 and is skipped.`);
this.time.delayedCall(1100, () => this.hideBanner()); this.time.delayedCall(1100, () => this.hideBanner());
drawAnim = { seat: e.seat, count: e.count ?? 2 };
} else if (e.kind === 'wild4Accept' || e.kind === 'wild4ChallengeLose' || e.kind === 'wild4ChallengeWin') { } else if (e.kind === 'wild4Accept' || e.kind === 'wild4ChallengeLose' || e.kind === 'wild4ChallengeWin') {
this.opponentPortraits[e.seat]?.playEmotion('upset'); this.opponentPortraits[e.seat]?.playEmotion('upset');
drawAnim = { seat: e.seat, count: e.count ?? 4 };
} else if (e.kind === 'play') { } else if (e.kind === 'play') {
const playedCard = before.players[e.seat]?.hand?.find((c) => c.id === e.cardId); const playedCard = before.players[e.seat]?.hand?.find((c) => c.id === e.cardId);
if (playedCard?.kind === 'wild4') { if (playedCard?.kind === 'wild4') {
@ -1109,6 +1114,11 @@ export default class UnoGame extends Phaser.Scene {
// handled by endGame // handled by endGame
} }
} }
if (drawAnim) {
this.animateForcedDraw(drawAnim.seat, drawAnim.count, onDone);
} else {
onDone?.();
}
} }
// ── Animations ──────────────────────────────────────────────────────────── // ── Animations ────────────────────────────────────────────────────────────
@ -1197,12 +1207,12 @@ export default class UnoGame extends Phaser.Scene {
* Animate `count` card backs flying from the draw pile into `seat`'s hand * Animate `count` card backs flying from the draw pile into `seat`'s hand
* (for forced draws Draw 2, Wild +4 effects). * (for forced draws Draw 2, Wild +4 effects).
*/ */
animateBatchDraw(seat, count, onComplete) { animateBatchDraw(seat, count, onComplete, timing = {}) {
if (count <= 0) { onComplete && onComplete(); return; } if (count <= 0) { onComplete && onComplete(); return; }
const layout = slotLayout(this.slotForSeat[seat], this.gs.players.length); const layout = slotLayout(this.slotForSeat[seat], this.gs.players.length);
const isOpponent = seat !== 0; const isOpponent = seat !== 0;
const flyDuration = isOpponent ? 480 : 320; const flyDuration = timing.flyDuration ?? (isOpponent ? 480 : 320);
const stagger = isOpponent ? 170 : 110; const stagger = timing.stagger ?? (isOpponent ? 170 : 110);
let remaining = count; let remaining = count;
const fire = (i) => { const fire = (i) => {
const sprite = this.makeUnoCardSprite(null, DRAW_POS.x, DRAW_POS.y, { const sprite = this.makeUnoCardSprite(null, DRAW_POS.x, DRAW_POS.y, {
@ -1302,6 +1312,37 @@ export default class UnoGame extends Phaser.Scene {
}); });
} }
animateForcedDraw(seat, count, onDone) {
const label = `+${count}`;
const t = this.add.text(CX, CY - CARD_H - 20, label, {
fontFamily: 'Righteous',
fontSize: '90px',
color: '#ff3030',
stroke: '#ffffff',
strokeThickness: 8,
}).setOrigin(0.5).setDepth(D.banner + 1).setScale(0);
this.transientObjs.push(t);
this.tweens.add({
targets: t,
scaleX: 1, scaleY: 1,
duration: 450,
ease: 'Back.easeOut',
onComplete: () => {
this.animateBatchDraw(seat, count, () => {
this.tweens.add({
targets: t,
scaleX: 0, scaleY: 0,
alpha: 0,
duration: 300,
ease: 'Back.easeIn',
onComplete: () => onDone?.(),
});
}, { flyDuration: 700, stagger: 350 });
},
});
}
// ── Color picker modal ──────────────────────────────────────────────────── // ── Color picker modal ────────────────────────────────────────────────────
openColorPicker(onPick) { openColorPicker(onPick) {
@ -1449,10 +1490,11 @@ export default class UnoGame extends Phaser.Scene {
this.gs = after; this.gs = after;
this.renderAll(); this.renderAll();
if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat); if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat);
this.handleLogEffects(before, after); this.handleLogEffects(before, after, () => {
this.animating = false; this.animating = false;
this.updateStatus(); this.updateStatus();
this.handlePostStateChange(); this.handlePostStateChange();
});
}); });
} }
@ -1535,24 +1577,25 @@ export default class UnoGame extends Phaser.Scene {
this.gs = after; this.gs = after;
this.renderAll(); this.renderAll();
if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat); if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat);
this.handleLogEffects(before, after); this.handleLogEffects(before, after, () => {
// If wild, the AI has a pre-chosen color. // If wild, the AI has a pre-chosen color.
if (action.chosenColor && this.gs.phase === 'choosingColor') { if (action.chosenColor && this.gs.phase === 'choosingColor') {
this.time.delayedCall(450, () => { this.time.delayedCall(450, () => {
const after2 = applyChooseColor(this.gs, action.chosenColor); const after2 = applyChooseColor(this.gs, action.chosenColor);
this.gs = after2; this.gs = after2;
this.renderAll(); this.renderAll();
this.showBanner(`${this.opponentName(seat)} chose ${UNO_COLOR_NAMES[action.chosenColor]}.`); this.showBanner(`${this.opponentName(seat)} chose ${UNO_COLOR_NAMES[action.chosenColor]}.`);
this.time.delayedCall(900, () => this.hideBanner()); this.time.delayedCall(900, () => this.hideBanner());
this.animating = false; this.animating = false;
this.updateStatus(); this.updateStatus();
this.handlePostStateChange(); this.handlePostStateChange();
}); });
return; return;
} }
this.animating = false; this.animating = false;
this.updateStatus(); this.updateStatus();
this.handlePostStateChange(); this.handlePostStateChange();
});
}); });
return; return;
} }
@ -1583,25 +1626,26 @@ export default class UnoGame extends Phaser.Scene {
this.gs = after; this.gs = after;
this.renderAll(); this.renderAll();
if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat); if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat);
this.handleLogEffects(before, after); this.handleLogEffects(before, after, () => {
if (this.gs.phase === 'choosingColor') { if (this.gs.phase === 'choosingColor') {
// AI just played a wild as the drawn card — pick its color now. // AI just played a wild as the drawn card — pick its color now.
const color = chooseAction(this.gs, seat).color ?? 'r'; const color = chooseAction(this.gs, seat).color ?? 'r';
this.time.delayedCall(450, () => { this.time.delayedCall(450, () => {
const after2 = applyChooseColor(this.gs, color); const after2 = applyChooseColor(this.gs, color);
this.gs = after2; this.gs = after2;
this.renderAll(); this.renderAll();
this.showBanner(`${this.opponentName(seat)} chose ${UNO_COLOR_NAMES[color]}.`); this.showBanner(`${this.opponentName(seat)} chose ${UNO_COLOR_NAMES[color]}.`);
this.time.delayedCall(900, () => this.hideBanner()); this.time.delayedCall(900, () => this.hideBanner());
this.animating = false; this.animating = false;
this.updateStatus(); this.updateStatus();
this.handlePostStateChange(); this.handlePostStateChange();
}); });
return; return;
} }
this.animating = false; this.animating = false;
this.updateStatus(); this.updateStatus();
this.handlePostStateChange(); this.handlePostStateChange();
});
}); });
return; return;
} }