diff --git a/public/assets/images/gameicons.png b/public/assets/images/gameicons.png new file mode 100644 index 0000000..b94e201 Binary files /dev/null and b/public/assets/images/gameicons.png differ diff --git a/public/assets/images/gameicons.psd b/public/assets/images/gameicons.psd new file mode 100644 index 0000000..aa2f951 Binary files /dev/null and b/public/assets/images/gameicons.psd differ diff --git a/public/src/games/uno/UnoGame.js b/public/src/games/uno/UnoGame.js index 3fb34ef..8ebe4ea 100644 --- a/public/src/games/uno/UnoGame.js +++ b/public/src/games/uno/UnoGame.js @@ -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; }