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:
parent
6b819e00b0
commit
afd8c78253
Binary file not shown.
|
After Width: | Height: | Size: 558 KiB |
Binary file not shown.
|
|
@ -1000,15 +1000,16 @@ export default class UnoGame extends Phaser.Scene {
|
|||
this.renderAll();
|
||||
// Auto-call UNO when we've just dropped to 1 card.
|
||||
if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat);
|
||||
this.handleLogEffects(before, after);
|
||||
// If the played card was a Wild and we have a pre-chosen color, apply it.
|
||||
if (chosenColor && this.gs.phase === 'choosingColor') {
|
||||
this.time.delayedCall(150, () => this.executeChooseColor(chosenColor));
|
||||
return;
|
||||
}
|
||||
this.animating = false;
|
||||
this.updateStatus();
|
||||
this.handlePostStateChange();
|
||||
this.handleLogEffects(before, after, () => {
|
||||
// If the played card was a Wild and we have a pre-chosen color, apply it.
|
||||
if (chosenColor && this.gs.phase === 'choosingColor') {
|
||||
this.time.delayedCall(150, () => this.executeChooseColor(chosenColor));
|
||||
return;
|
||||
}
|
||||
this.animating = false;
|
||||
this.updateStatus();
|
||||
this.handlePostStateChange();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1057,15 +1058,16 @@ export default class UnoGame extends Phaser.Scene {
|
|||
const banner = this.formatChallengeBanner(last);
|
||||
this.gs = after;
|
||||
this.renderAll();
|
||||
this.handleLogEffects(before, after);
|
||||
if (banner) {
|
||||
this.showBanner(banner);
|
||||
this.time.delayedCall(1300, () => this.hideBanner());
|
||||
}
|
||||
this.time.delayedCall(400, () => {
|
||||
this.animating = false;
|
||||
this.updateStatus();
|
||||
this.handlePostStateChange();
|
||||
this.handleLogEffects(before, after, () => {
|
||||
this.time.delayedCall(400, () => {
|
||||
this.animating = false;
|
||||
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,
|
||||
* draw cards into opponents' hands) and animates each in a brief banner.
|
||||
*/
|
||||
handleLogEffects(before, after) {
|
||||
handleLogEffects(before, after, onDone) {
|
||||
const oldLen = before.log.length;
|
||||
let drawAnim = null;
|
||||
for (let i = oldLen; i < after.log.length; i++) {
|
||||
const e = after.log[i];
|
||||
if (e.kind === 'skip') {
|
||||
|
|
@ -1098,8 +1101,10 @@ export default class UnoGame extends Phaser.Scene {
|
|||
this.opponentPortraits[e.seat]?.playEmotion('upset');
|
||||
this.showBanner(`${this.opponentName(e.seat)} draws 2 and is skipped.`);
|
||||
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') {
|
||||
this.opponentPortraits[e.seat]?.playEmotion('upset');
|
||||
drawAnim = { seat: e.seat, count: e.count ?? 4 };
|
||||
} else if (e.kind === 'play') {
|
||||
const playedCard = before.players[e.seat]?.hand?.find((c) => c.id === e.cardId);
|
||||
if (playedCard?.kind === 'wild4') {
|
||||
|
|
@ -1109,6 +1114,11 @@ export default class UnoGame extends Phaser.Scene {
|
|||
// handled by endGame
|
||||
}
|
||||
}
|
||||
if (drawAnim) {
|
||||
this.animateForcedDraw(drawAnim.seat, drawAnim.count, onDone);
|
||||
} else {
|
||||
onDone?.();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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
|
||||
* (for forced draws — Draw 2, Wild +4 effects).
|
||||
*/
|
||||
animateBatchDraw(seat, count, onComplete) {
|
||||
animateBatchDraw(seat, count, onComplete, timing = {}) {
|
||||
if (count <= 0) { onComplete && onComplete(); return; }
|
||||
const layout = slotLayout(this.slotForSeat[seat], this.gs.players.length);
|
||||
const isOpponent = seat !== 0;
|
||||
const flyDuration = isOpponent ? 480 : 320;
|
||||
const stagger = isOpponent ? 170 : 110;
|
||||
const flyDuration = timing.flyDuration ?? (isOpponent ? 480 : 320);
|
||||
const stagger = timing.stagger ?? (isOpponent ? 170 : 110);
|
||||
let remaining = count;
|
||||
const fire = (i) => {
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
openColorPicker(onPick) {
|
||||
|
|
@ -1449,10 +1490,11 @@ export default class UnoGame extends Phaser.Scene {
|
|||
this.gs = after;
|
||||
this.renderAll();
|
||||
if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat);
|
||||
this.handleLogEffects(before, after);
|
||||
this.animating = false;
|
||||
this.updateStatus();
|
||||
this.handlePostStateChange();
|
||||
this.handleLogEffects(before, after, () => {
|
||||
this.animating = false;
|
||||
this.updateStatus();
|
||||
this.handlePostStateChange();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1535,24 +1577,25 @@ export default class UnoGame extends Phaser.Scene {
|
|||
this.gs = after;
|
||||
this.renderAll();
|
||||
if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat);
|
||||
this.handleLogEffects(before, after);
|
||||
// If wild, the AI has a pre-chosen color.
|
||||
if (action.chosenColor && this.gs.phase === 'choosingColor') {
|
||||
this.time.delayedCall(450, () => {
|
||||
const after2 = applyChooseColor(this.gs, action.chosenColor);
|
||||
this.gs = after2;
|
||||
this.renderAll();
|
||||
this.showBanner(`${this.opponentName(seat)} chose ${UNO_COLOR_NAMES[action.chosenColor]}.`);
|
||||
this.time.delayedCall(900, () => this.hideBanner());
|
||||
this.animating = false;
|
||||
this.updateStatus();
|
||||
this.handlePostStateChange();
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.animating = false;
|
||||
this.updateStatus();
|
||||
this.handlePostStateChange();
|
||||
this.handleLogEffects(before, after, () => {
|
||||
// If wild, the AI has a pre-chosen color.
|
||||
if (action.chosenColor && this.gs.phase === 'choosingColor') {
|
||||
this.time.delayedCall(450, () => {
|
||||
const after2 = applyChooseColor(this.gs, action.chosenColor);
|
||||
this.gs = after2;
|
||||
this.renderAll();
|
||||
this.showBanner(`${this.opponentName(seat)} chose ${UNO_COLOR_NAMES[action.chosenColor]}.`);
|
||||
this.time.delayedCall(900, () => this.hideBanner());
|
||||
this.animating = false;
|
||||
this.updateStatus();
|
||||
this.handlePostStateChange();
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.animating = false;
|
||||
this.updateStatus();
|
||||
this.handlePostStateChange();
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -1583,25 +1626,26 @@ export default class UnoGame extends Phaser.Scene {
|
|||
this.gs = after;
|
||||
this.renderAll();
|
||||
if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat);
|
||||
this.handleLogEffects(before, after);
|
||||
if (this.gs.phase === 'choosingColor') {
|
||||
// AI just played a wild as the drawn card — pick its color now.
|
||||
const color = chooseAction(this.gs, seat).color ?? 'r';
|
||||
this.time.delayedCall(450, () => {
|
||||
const after2 = applyChooseColor(this.gs, color);
|
||||
this.gs = after2;
|
||||
this.renderAll();
|
||||
this.showBanner(`${this.opponentName(seat)} chose ${UNO_COLOR_NAMES[color]}.`);
|
||||
this.time.delayedCall(900, () => this.hideBanner());
|
||||
this.animating = false;
|
||||
this.updateStatus();
|
||||
this.handlePostStateChange();
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.animating = false;
|
||||
this.updateStatus();
|
||||
this.handlePostStateChange();
|
||||
this.handleLogEffects(before, after, () => {
|
||||
if (this.gs.phase === 'choosingColor') {
|
||||
// AI just played a wild as the drawn card — pick its color now.
|
||||
const color = chooseAction(this.gs, seat).color ?? 'r';
|
||||
this.time.delayedCall(450, () => {
|
||||
const after2 = applyChooseColor(this.gs, color);
|
||||
this.gs = after2;
|
||||
this.renderAll();
|
||||
this.showBanner(`${this.opponentName(seat)} chose ${UNO_COLOR_NAMES[color]}.`);
|
||||
this.time.delayedCall(900, () => this.hideBanner());
|
||||
this.animating = false;
|
||||
this.updateStatus();
|
||||
this.handlePostStateChange();
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.animating = false;
|
||||
this.updateStatus();
|
||||
this.handlePostStateChange();
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue