From 25ccdb5da9ad913b0c04c85825f8c8551b3d09ec Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 4 Jun 2026 22:32:33 -0600 Subject: [PATCH] feat(splendor): add noble claiming animations, token interactions, and UI polish - Implement cinematic noble claiming animation with fireworks and sound, shrinking to panel thumbnails - Add hover ring highlight and gem sound effects for token selection zones - Render claimed noble thumbnails in player panels with dynamic positioning - Trigger happy emotion on player portraits when purchasing point cards - Relocate Leave button to bottom-right and remove unused playIntro flag - Exclude Splendor from card back selection in OpponentSelectScene --- public/src/games/splendor/SplendorGame.js | 194 ++++++++++++++++++++-- public/src/scenes/OpponentSelectScene.js | 4 +- 2 files changed, 183 insertions(+), 15 deletions(-) diff --git a/public/src/games/splendor/SplendorGame.js b/public/src/games/splendor/SplendorGame.js index dbf608e..6594172 100644 --- a/public/src/games/splendor/SplendorGame.js +++ b/public/src/games/splendor/SplendorGame.js @@ -24,6 +24,7 @@ const MARKET_X = 80; // left edge of decks const CARDS_X = MARKET_X + DECK_W + 24; // first face-up card const TIER_Y = { 3: 168, 2: 168 + CH + 26, 1: 168 + (CH + 26) * 2 }; const NOBLE = 96, NOBLE_Y = 56; +const NOBLE_THUMB = 34, NOBLE_THUMB_GAP = 4; // square noble thumbnails in player panels const BANK_X = 830, BANK_Y0 = 176, BANK_STEP = 88, TOKEN_R = 36; @@ -65,6 +66,8 @@ export default class SplendorGame extends Phaser.Scene { this.animatingBuy = null; // { seat, cardId } suppresses thumbnail during animation this.boughtCards = {}; // seat → card[] — persists purchased cards for display this._thumbHoverTimer = null; // pending 500 ms intent timer for thumbnail preview + this.animatingNoble = null; // { seat, nobleId } suppresses noble thumbnail during animation + this._pendingNoble = null; // { seat, noble, srcX, srcY } queued noble animation } create() { @@ -109,7 +112,7 @@ export default class SplendorGame extends Phaser.Scene { this.portraits[idx] = createPlayerPortrait(this, px, py, portraitR, DEPTH.ui + 1, 'SplendorGame'); } else { const opp = this.opponents[idx - 1]; - this.portraits[idx] = createOpponentPortrait(this, opp, px, py, portraitR, DEPTH.ui + 1, { playIntro: false }); + this.portraits[idx] = createOpponentPortrait(this, opp, px, py, portraitR, DEPTH.ui + 1); } }); } @@ -129,7 +132,7 @@ export default class SplendorGame extends Phaser.Scene { fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex, }).setOrigin(0.5, 0).setDepth(DEPTH.ui); - new Button(this, GAME_WIDTH - 96, 40, 'Leave', () => this.scene.start('GameMenu'), + new Button(this, GAME_WIDTH - 96, GAME_HEIGHT - 36, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 140, height: 42, fontSize: 18 }).setDepth(DEPTH.ui); } @@ -180,6 +183,42 @@ export default class SplendorGame extends Phaser.Scene { return null; } + // Wraps applyAction and detects if a noble was earned this turn. + _applyAction(action) { + const prevNobles = this.gs.nobles.slice(); + const prevCounts = this.gs.players.map((p) => p.nobles.length); + const next = applyAction(this.gs, action); + this.gs = next; + for (let seat = 0; seat < this.gs.players.length; seat++) { + if (this.gs.players[seat].nobles.length > prevCounts[seat]) { + const claimed = prevNobles.find((pn) => !this.gs.nobles.some((n) => n.id === pn.id)); + if (claimed) { + const si = prevNobles.indexOf(claimed); + this._pendingNoble = { + seat, + noble: claimed, + srcX: MARKET_X + si * (NOBLE + 14) + NOBLE / 2, + srcY: NOBLE_Y + NOBLE / 2, + }; + } + break; + } + } + } + + nobleThumbPos(seat, nobleIdx) { + const n = this.gs.players.length; + const gap = 16; + const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n)); + const panelY = 76 + seat * (h + gap); + const totalCY = panelY + 70 + 57; + const nobleCount = this.gs.players[seat].nobles.length; + const groupW = nobleCount * NOBLE_THUMB + Math.max(0, nobleCount - 1) * NOBLE_THUMB_GAP; + const tx = PANEL_X + PANEL_W - 16 - groupW + nobleIdx * (NOBLE_THUMB + NOBLE_THUMB_GAP); + const ty = totalCY + 14 + 3; + return { cx: tx + NOBLE_THUMB / 2, cy: ty + NOBLE_THUMB / 2 }; + } + boughtCardThumbPos(seat, idx) { const n = this.gs.players.length; const gap = 16; @@ -435,8 +474,13 @@ export default class SplendorGame extends Phaser.Scene { }).setOrigin(1, 0).setDepth(DEPTH.card + 3)); } if (this.isHumanTurn() && color !== GOLD && n > 0) { + const ring = this.reg(this.add.graphics().setDepth(DEPTH.card + 3).setAlpha(0)); + ring.lineStyle(4, 0xffffff, 1).strokeCircle(cx, cy, TOKEN_R + 7); + const zone = this.reg(this.add.zone(cx, cy, TOKEN_R * 2, TOKEN_R * 2) - .setInteractive({ useHandCursor: true }).setDepth(DEPTH.card + 3)); + .setInteractive({ useHandCursor: true }).setDepth(DEPTH.card + 4)); + zone.on('pointerover', () => ring.setAlpha(1)); + zone.on('pointerout', () => ring.setAlpha(0)); zone.on('pointerdown', () => this.onTokenClick(color)); } }); @@ -502,7 +546,7 @@ export default class SplendorGame extends Phaser.Scene { // divider + total row (card bonuses + tokens) const ccy = y + 70; const divY = ccy + 38; - const totalCY = ccy + 53; + const totalCY = ccy + 57; const divG = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1)); divG.lineStyle(1, COLORS.accent, 0.35) .lineBetween(gemStartX - totalR - 2, divY, gemStartX + (order.length - 1) * gemSpacing + totalR + 2, divY); @@ -518,6 +562,29 @@ export default class SplendorGame extends Phaser.Scene { }).setOrigin(0.5).setDepth(DEPTH.ui + 2)); }); + // noble thumbnails — right-aligned below the total circles row + if (p.nobles.length > 0) { + const nobleCount = p.nobles.length; + const nobleGroupW = nobleCount * NOBLE_THUMB + (nobleCount - 1) * NOBLE_THUMB_GAP; + const nobleGroupLeft = x + w - 16 - nobleGroupW; + const nTY = totalCY + 14 + 3; + p.nobles.forEach((noble, ni) => { + if (this.animatingNoble?.seat === idx && this.animatingNoble?.nobleId === noble.id) return; + const nTX = nobleGroupLeft + ni * (NOBLE_THUMB + NOBLE_THUMB_GAP); + const ng = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1)); + ng.fillStyle(0x000000, 0.32).fillRoundedRect(nTX + 2, nTY + 2, NOBLE_THUMB, NOBLE_THUMB, 4); + ng.fillStyle(0x2b2620, 1).fillRoundedRect(nTX, nTY, NOBLE_THUMB, NOBLE_THUMB, 4); + if (this.hasArt) { + this.regMaskedImg(nTX, nTY, NOBLE_THUMB, NOBLE_THUMB, 'splendor-cards', nobleFrame(noble), DEPTH.ui + 2, 4); + } + this.reg(this.add.graphics().setDepth(DEPTH.ui + 3)) + .lineStyle(1.5, COLORS.accent, 0.85).strokeRoundedRect(nTX, nTY, NOBLE_THUMB, NOBLE_THUMB, 4); + this.reg(this.add.text(nTX + 3, nTY + 1, '3', { + fontFamily: 'Righteous', fontSize: '10px', color: COLORS.goldHex, + }).setDepth(DEPTH.ui + 4)); + }); + } + // bought card thumbnails (right of portrait, left of gems, wrapping into rows) (this.boughtCards[idx] ?? []).forEach((bc, bi) => { if (this.animatingBuy?.seat === idx && this.animatingBuy?.cardId === bc.id) return; @@ -714,7 +781,7 @@ export default class SplendorGame extends Phaser.Scene { : action.colors; const preTokens = { ...this.gs.players[seat].tokens }; - this.gs = applyAction(this.gs, action); + this._applyAction(action); this.playActionSound(action); if (this.gs.phase === 'discard') { this.gs = applyDiscard(this.gs, defaultDiscards(this.gs)); @@ -740,6 +807,12 @@ export default class SplendorGame extends Phaser.Scene { duration: 380, delay: i * 90, ease: 'Cubic.easeOut', + onStart: () => { + try { + const a = new Audio(`/assets/fx/gem-0${(i % 2) + 1}.mp3`); + a.volume = 0.7; a.play(); + } catch { /* */ } + }, onComplete: () => { gem.destroy(); if (--pending === 0) { @@ -767,10 +840,80 @@ export default class SplendorGame extends Phaser.Scene { displayWidth: 32, displayHeight: 32, duration: 300, ease: 'Cubic.easeOut', + onStart: () => { + try { + const a = new Audio(`/assets/fx/gem-0${(destIdx % 2) + 1}.mp3`); + a.volume = 0.7; a.play(); + } catch { /* */ } + }, onComplete: () => gem.destroy(), }); } + // ── noble animation ────────────────────────────────────────────────────────── + animNoble(seat, noble, srcX, srcY, onDone) { + const DISP = 270; + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + + for (const p of this.portraits) p?.hide(); + + const overlay = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000) + .setAlpha(0).setDepth(DEPTH.popup); + this.tweens.add({ targets: overlay, alpha: 0.52, duration: 280 }); + + const container = this.add.container(srcX, srcY).setDepth(DEPTH.popup + 1); + const bg = this.add.graphics(); + bg.fillStyle(0x2b2620, 1).fillRoundedRect(-NOBLE / 2, -NOBLE / 2, NOBLE, NOBLE, 10); + container.add(bg); + if (this.hasArt) { + container.add( + this.add.image(0, 0, 'splendor-cards', nobleFrame(noble)).setDisplaySize(NOBLE, NOBLE) + ); + } + const border = this.add.graphics(); + border.lineStyle(2, COLORS.accent, 0.9).strokeRoundedRect(-NOBLE / 2, -NOBLE / 2, NOBLE, NOBLE, 10); + container.add(border); + container.add(this.add.text(-NOBLE / 2 + 8, -NOBLE / 2 + 4, '3', { + fontFamily: 'Righteous', fontSize: '22px', color: COLORS.goldHex, + })); + + this.tweens.add({ + targets: container, + x: cx, y: cy, + scale: DISP / NOBLE, + duration: 420, + ease: 'Cubic.easeOut', + onComplete: () => { + this._playReserveFireworks(cx, cy, 'white'); + try { const a = new Audio('/assets/fx/firework.mp3'); a.volume = 0.7; a.play(); } catch { /* */ } + + this.time.delayedCall(1200, () => { + const ni = this.gs.players[seat].nobles.findIndex((n) => n.id === noble.id); + const { cx: thumbCX, cy: thumbCY } = this.nobleThumbPos(seat, ni >= 0 ? ni : 0); + + this.tweens.add({ targets: overlay, alpha: 0, duration: 300, + onComplete: () => { try { overlay.destroy(); } catch { /* */ } } }); + try { const a = new Audio('/assets/fx/ui-attach.mp3'); a.volume = 0.8; a.play(); } catch { /* */ } + + this.tweens.add({ + targets: container, + x: thumbCX, y: thumbCY, + scale: NOBLE_THUMB / NOBLE, + alpha: 0, + duration: 360, + ease: 'Cubic.easeIn', + onComplete: () => { + for (const p of this.portraits) p?.show(); + container.removeAll(true); + container.destroy(); + onDone(); + }, + }); + }); + }, + }); + } + // Builds a card container centred at (x,y) in world space with full overlay art. // All draw coords are in container-local space (0,0 = card centre). _buildCardContainer(x, y, card) { @@ -1043,7 +1186,7 @@ export default class SplendorGame extends Phaser.Scene { // Save token state before and after applying the action const preTokens = { ...this.gs.players[this.humanSeat].tokens }; - this.gs = applyAction(this.gs, action); + this._applyAction(action); this.playActionSound(action); const postTokens = this.gs.players[this.humanSeat].tokens; @@ -1073,6 +1216,12 @@ export default class SplendorGame extends Phaser.Scene { duration: 380, delay: i * 90, ease: 'Cubic.easeOut', + onStart: () => { + try { + const a = new Audio(`/assets/fx/gem-0${(i % 2) + 1}.mp3`); + a.volume = 0.7; a.play(); + } catch { /* */ } + }, onComplete: () => { gem.destroy(); if (--pending === 0) { @@ -1139,7 +1288,7 @@ export default class SplendorGame extends Phaser.Scene { srcX = PANEL_X + 150 + ri * 116 + 52; srcY = panelY + h - 40 + 15; } - this.gs = applyAction(this.gs, action); + this._applyAction(action); this.playActionSound(action); if (this.gs.phase === 'discard' && this.gs.current === seat) { this.render(); return; } @@ -1165,7 +1314,7 @@ export default class SplendorGame extends Phaser.Scene { const srcX = srcPos?.x ?? GAME_WIDTH / 2; const srcY = srcPos?.y ?? GAME_HEIGHT / 2; - this.gs = applyAction(this.gs, action); + this._applyAction(action); this.playActionSound(action); if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) { this.render(); @@ -1187,7 +1336,7 @@ export default class SplendorGame extends Phaser.Scene { return; } - this.gs = applyAction(this.gs, action); + this._applyAction(action); this.playActionSound(action); if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) { this.render(); @@ -1199,6 +1348,20 @@ export default class SplendorGame extends Phaser.Scene { // ── turn loop ─────────────────────────────────────────────────────────────── advance() { + if (this._pendingNoble) { + const { seat, noble, srcX, srcY } = this._pendingNoble; + this._pendingNoble = null; + this.animatingNoble = { seat, nobleId: noble.id }; + this.busy = true; + this.render(); + this.animNoble(seat, noble, srcX, srcY, () => { + this.animatingNoble = null; + this.busy = false; + this.render(); + this.advance(); + }); + return; + } if (isGameOver(this.gs)) { this.render(); this.onGameOver(); return; } if (this.gs.phase === 'discard') { // Only the human ever parks here interactively; AI auto-discards inline. @@ -1235,7 +1398,7 @@ export default class SplendorGame extends Phaser.Scene { const srcX = srcPos?.x ?? GAME_WIDTH / 2; const srcY = srcPos?.y ?? GAME_HEIGHT / 2; - this.gs = applyAction(this.gs, action); + this._applyAction(action); this.playActionSound(action); if (this.gs.phase === 'discard') { this.gs = applyDiscard(this.gs, defaultDiscards(this.gs)); @@ -1269,12 +1432,13 @@ export default class SplendorGame extends Phaser.Scene { srcX = PANEL_X + 150 + ri * 116 + 52; srcY = panelY + h - 40 + 15; } - this.gs = applyAction(this.gs, action); + this._applyAction(action); this.playActionSound(action); if (this.gs.phase === 'discard') { this.gs = applyDiscard(this.gs, defaultDiscards(this.gs)); } + if (card.points > 0) this.portraits[seat]?.playEmotion('happy'); if (!this.boughtCards[seat]) this.boughtCards[seat] = []; this.boughtCards[seat].push(card); this.animatingBuy = { seat, cardId: card.id }; @@ -1289,7 +1453,11 @@ export default class SplendorGame extends Phaser.Scene { return; } - this.gs = applyAction(this.gs, action); + if (action.type === 'buy') { + const card = this.findCard(action.cardId); + if (card?.points > 0) this.portraits[seat]?.playEmotion('happy'); + } + this._applyAction(action); this.playActionSound(action); if (this.gs.phase === 'discard') { this.gs = applyDiscard(this.gs, defaultDiscards(this.gs)); @@ -1304,7 +1472,7 @@ export default class SplendorGame extends Phaser.Scene { if (!action) return; if (action.type === 'buy') playSound(this, SFX.PURCHASE); else if (action.type === 'reserve' || action.type === 'reserveDeck') playSound(this, SFX.CARD_DEAL); - else if (action.type === 'take2' || action.type === 'take3') playSound(this, SFX.COINS); + // gem sounds played per-gem in animAITake / confirmTake } showTurnBanner(text) { diff --git a/public/src/scenes/OpponentSelectScene.js b/public/src/scenes/OpponentSelectScene.js index 64a60ba..923b1b6 100644 --- a/public/src/scenes/OpponentSelectScene.js +++ b/public/src/scenes/OpponentSelectScene.js @@ -139,7 +139,7 @@ export default class OpponentSelectScene extends Phaser.Scene { 'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf)); } - if (this.gameDef.cardGame) { + if (this.gameDef.cardGame && this.gameDef.slug !== 'splendor') { this.buildOptionSection('Card Back', 798, this.cache.json.get('card-backs')?.cardBacks ?? [], 'selectedCardBack', 'cardBackTiles', (cb) => this.selectCardBack(cb), CARD_TILE_W, CARD_TILE_H, CARD_TILE_GAP); @@ -150,7 +150,7 @@ export default class OpponentSelectScene extends Phaser.Scene { const pfd = this.cache.json.get('playfields') ?? {}; this.applyDefault('playfields', pfd.default, 'selectedPlayfield', 'playfieldTiles'); } - if (this.gameDef.cardGame) { + if (this.gameDef.cardGame && this.gameDef.slug !== 'splendor') { const cardBacks = this.cache.json.get('card-backs')?.cardBacks ?? []; if (cardBacks.length > 0) { const randomCb = cardBacks[Math.floor(Math.random() * cardBacks.length)];