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();
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue