From 6abcbfa32b1a062a1fe148898381f71df4c5523a Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 4 Jun 2026 20:02:42 -0600 Subject: [PATCH] feat(splendor): add buy/reserve card animations and bought-card thumbnails - Implement card fly-to-center animation with fireworks for buy and reserve actions - Add persistent bought-card thumbnails in player panels with automatic wrapping - Build full card containers (costs, points, gem badges) for smooth scaling transitions - Suppress reserve pills and buy animations in UI until transitions complete - Apply animation triggers consistently for both human and AI turns --- public/src/games/splendor/SplendorGame.js | 431 +++++++++++++++++++++- 1 file changed, 427 insertions(+), 4 deletions(-) diff --git a/public/src/games/splendor/SplendorGame.js b/public/src/games/splendor/SplendorGame.js index 0d3ba2f..1722426 100644 --- a/public/src/games/splendor/SplendorGame.js +++ b/public/src/games/splendor/SplendorGame.js @@ -32,6 +32,17 @@ const CONTROL_Y = 794; // bottom context bar const DEPTH = { bg: 0, board: 10, card: 14, ui: 40, popup: 60, banner: 90 }; +// ── bought-card thumbnails in player panels ─────────────────────────────────── +const THUMB_W = 44; +const THUMB_H = Math.round(THUMB_W * CH / CW); // ≈ 60 — maintains card aspect ratio +const THUMB_GAP = 4; // horizontal gap between cards +const THUMB_ROW_GAP = 3; // vertical gap between rows +const PORTRAIT_R = 34; // must match buildPortraits +const THUMB_AREA_REL_X = PORTRAIT_R * 2 + 16 + 8; // portrait right-edge + 8 px left padding +const THUMB_GEM_REL_X = PANEL_W - 16 - 14 - 5 * 36 - 14; // gem-row left edge, panel-relative +const THUMB_PER_ROW = Math.floor((THUMB_GEM_REL_X - THUMB_AREA_REL_X) / (THUMB_W + THUMB_GAP)); +const THUMB_ROW_TOP_REL = 38; // panel-relative top (aligns with portrait top) + const hexStr = (n) => '#' + (n >>> 0).toString(16).padStart(6, '0').slice(-6); export default class SplendorGame extends Phaser.Scene { @@ -47,9 +58,12 @@ export default class SplendorGame extends Phaser.Scene { this.busy = false; this.selection = []; // colours chosen for a take action this.selectedCard = null; // { card, source } chosen to buy/reserve - this.dyn = []; // dynamic objects rebuilt every render - this.preview = []; // hover card preview — cleared separately - this.portraits = []; // one per seat, created once + this.dyn = []; // dynamic objects rebuilt every render + this.preview = []; // hover card preview — cleared separately + this.portraits = []; // one per seat, created once + this.animatingReserve = null; // { seat, cardId } suppresses that pill during animation + this.animatingBuy = null; // { seat, cardId } suppresses thumbnail during animation + this.boughtCards = {}; // seat → card[] — persists purchased cards for display } create() { @@ -142,6 +156,40 @@ export default class SplendorGame extends Phaser.Scene { this.preview = []; } + findCardBoardPos(cardId) { + for (const tier of [1, 2, 3]) { + const col = this.gs.board[tier].findIndex((c) => c?.id === cardId); + if (col !== -1) { + return { x: CARDS_X + col * (CW + CARD_GAP) + CW / 2, y: TIER_Y[tier] + CH / 2 }; + } + } + return null; + } + + findCard(cardId) { + for (const tier of [1, 2, 3]) { + const c = this.gs.board[tier].find((c) => c?.id === cardId); + if (c) return c; + } + for (const p of this.gs.players) { + const c = p.reserved.find((c) => c?.id === cardId); + if (c) return c; + } + return null; + } + + boughtCardThumbPos(seat, idx) { + 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 col = idx % THUMB_PER_ROW; + const row = Math.floor(idx / THUMB_PER_ROW); + const tx = PANEL_X + THUMB_AREA_REL_X + col * (THUMB_W + THUMB_GAP); + const ty = panelY + THUMB_ROW_TOP_REL + row * (THUMB_H + THUMB_ROW_GAP); + return { cx: tx + THUMB_W / 2, cy: ty + THUMB_H / 2 }; + } + showCardPreview(card, barCenterX, barBottomY) { this.clearPreview(); const pw = 270, ph = 390; @@ -468,6 +516,30 @@ export default class SplendorGame extends Phaser.Scene { }).setOrigin(0.5).setDepth(DEPTH.ui + 2)); }); + // 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; + const col = bi % THUMB_PER_ROW; + const row = Math.floor(bi / THUMB_PER_ROW); + const tx = x + THUMB_AREA_REL_X + col * (THUMB_W + THUMB_GAP); + const ty = y + THUMB_ROW_TOP_REL + row * (THUMB_H + THUMB_ROW_GAP); + const tg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1)); + tg.fillStyle(0x000000, 0.32).fillRoundedRect(tx + 2, ty + 2, THUMB_W, THUMB_H, 4); + tg.fillStyle(0x14110b, 1).fillRoundedRect(tx, ty, THUMB_W, THUMB_H, 4); + tg.fillStyle(GEM_HEX[bc.bonus], 0.22).fillRoundedRect(tx, ty, THUMB_W, THUMB_H, 4); + if (this.hasArt) { + this.regMaskedImg(tx, ty, THUMB_W, THUMB_H, 'splendor-cards', cardFrame(bc), DEPTH.ui + 2, 4); + } + this.reg(this.add.graphics().setDepth(DEPTH.ui + 3)) + .lineStyle(1, GEM_EDGE[bc.bonus], 0.9).strokeRoundedRect(tx, ty, THUMB_W, THUMB_H, 4); + if (bc.points > 0) { + this.reg(this.add.text(tx + 3, ty + 1, String(bc.points), { + fontFamily: 'Righteous', fontSize: '10px', + color: bc.bonus === 'white' ? '#333' : '#fff', + }).setDepth(DEPTH.ui + 4)); + } + }); + // reserved cards const rN = p.reserved.length; this.reg(this.add.text(x + 16, y + h - 30, `Reserved: ${rN}/${MAX_RESERVED}`, { @@ -476,6 +548,7 @@ export default class SplendorGame extends Phaser.Scene { if (rN > 0) { const isHuman = idx === this.humanSeat; p.reserved.forEach((card, ri) => { + if (this.animatingReserve?.seat === idx && this.animatingReserve?.cardId === card.id) return; const rx = x + 150 + ri * 116, ry = y + h - 40; const canBuy = isHuman && this.isHumanTurn() && canAfford(p, card); const mg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1)); @@ -679,6 +752,231 @@ export default class SplendorGame extends Phaser.Scene { }); } + // 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) { + const container = this.add.container(x, y).setDepth(DEPTH.popup + 1); + const tint = GEM_HEX[card.bonus]; + + // Background + const bg = this.add.graphics(); + bg.fillStyle(0x14110b, 1).fillRoundedRect(-CW / 2, -CH / 2, CW, CH, 10); + bg.fillStyle(tint, 0.22).fillRoundedRect(-CW / 2, -CH / 2, CW, CH, 10); + container.add(bg); + + // Art + if (this.hasArt) { + container.add( + this.add.image(0, 0, 'splendor-cards', cardFrame(card)).setDisplaySize(CW, CH) + ); + } + + // Border above art + const border = this.add.graphics(); + border.lineStyle(1.5, GEM_EDGE[card.bonus], 1).strokeRoundedRect(-CW / 2, -CH / 2, CW, CH, 10); + container.add(border); + + // Points — top-left + if (card.points > 0) { + container.add(this.add.text(-CW / 2 + 10, -CH / 2 + 2, String(card.points), { + fontFamily: 'Righteous', fontSize: '26px', + color: card.bonus === 'white' ? '#222' : '#fff', + })); + } + + // Gem bonus badge — top-right + const badgeG = this.add.graphics(); + badgeG.fillStyle(GEM_HEX[card.bonus], 1).fillCircle(CW / 2 - 22, -CH / 2 + 15, 12); + badgeG.lineStyle(2, GEM_EDGE[card.bonus], 1).strokeCircle(CW / 2 - 22, -CH / 2 + 15, 12); + container.add(badgeG); + if (this.hasGems) { + container.add( + this.add.image(CW / 2 - 22, -CH / 2 + 15, 'splendor-gems', gemFrame(card.bonus)) + .setDisplaySize(18, 18) + ); + } + + // Cost pips — lower-left, stacked + const costs = GEMS.filter((c) => (card.cost[c] ?? 0) > 0); + costs.forEach((c, i) => { + const pipY = CH / 2 - 22 - i * 30; + const pipG = this.add.graphics(); + pipG.fillStyle(GEM_HEX[c], 1).fillCircle(-CW / 2 + 20, pipY, 12); + pipG.lineStyle(2, GEM_EDGE[c], 1).strokeCircle(-CW / 2 + 20, pipY, 12); + container.add(pipG); + container.add(this.add.text(-CW / 2 + 20, pipY, String(card.cost[c]), { + fontFamily: 'Righteous', fontSize: '15px', + color: c === 'white' ? '#222' : '#fff', + }).setOrigin(0.5)); + }); + + return container; + } + + // ── reserve animation ─────────────────────────────────────────────────────── + animReserve(seat, card, srcX, srcY, onDone) { + const PW = 270, PH = 390; + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + + // DOM video portraits render above the canvas; hide them for the duration + 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._buildCardContainer(srcX, srcY, card); + + // Phase 1: fly to centre and grow + this.tweens.add({ + targets: container, + x: cx, y: cy, + scaleX: PW / CW, scaleY: PH / CH, + duration: 420, + ease: 'Cubic.easeOut', + onComplete: () => { + this._playReserveFireworks(cx, cy, card.bonus); + + // Phase 2: hold 1.2 s, then shrink to reserve-pill slot + this.time.delayedCall(1200, () => { + 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 ri = this.gs.players[seat].reserved.findIndex((c) => c.id === card.id); + const pillCX = PANEL_X + 150 + Math.max(0, ri) * 116 + 52; + const pillCY = panelY + h - 40 + 15; + + this.tweens.add({ targets: overlay, alpha: 0, duration: 300, + onComplete: () => { try { overlay.destroy(); } catch { /* */ } } }); + + this.tweens.add({ + targets: container, + x: pillCX, y: pillCY, + scale: 0.08, + alpha: 0, + duration: 360, + ease: 'Cubic.easeIn', + onComplete: () => { + for (const p of this.portraits) p?.show(); + container.removeAll(true); + container.destroy(); + onDone(); + }, + }); + }); + }, + }); + } + + _playReserveFireworks(cx, cy, bonus) { + // 5 distinct bursts staggered in time, each at a random position around the card + const tint = GEM_HEX[bonus]; + const edge = GEM_EDGE[bonus]; + const palettes = [ + [tint, edge, 0xffffff], + [0xffd700, 0xffbb00, 0xffffff], + [tint, 0xffffff, edge], + [0xff6644, 0xffaa00, 0xffd700], + [edge, tint, 0xaaffdd], + ]; + + const BURSTS = 5; + // Card at center is 270×390; place bursts in a ring around it + const RX = 195, RY = 240; // semi-axes of the ring (just outside card edges) + + for (let b = 0; b < BURSTS; b++) { + const delay = b * 190 + Math.random() * 70; + // Spread bursts evenly around the card with some jitter + const angle = (b / BURSTS) * Math.PI * 2 + (Math.random() - 0.5) * 0.7; + const rScale = 1.05 + Math.random() * 0.55; + const bx = cx + Math.cos(angle) * RX * rScale; + const by = cy + Math.sin(angle) * RY * rScale; + const palette = palettes[b % palettes.length]; + const sparkCount = 16 + Math.floor(Math.random() * 8); + + this.time.delayedCall(delay, () => { + // Central flash — expands and fades + const flash = this.add.circle(bx, by, 7, palette[0], 1).setDepth(DEPTH.popup + 2); + this.tweens.add({ + targets: flash, scaleX: 4.5, scaleY: 4.5, alpha: 0, + duration: 260, ease: 'Cubic.easeOut', + onComplete: () => { try { flash.destroy(); } catch { /* */ } }, + }); + + // Sparks radiate outward from the burst point + for (let i = 0; i < sparkCount; i++) { + const sparkAngle = (i / sparkCount) * Math.PI * 2 + (Math.random() - 0.5) * 0.3; + const speed = 90 + Math.random() * 170; + const size = 2.5 + Math.random() * 5; + const color = palette[Math.floor(Math.random() * palette.length)]; + const spark = this.add.circle(bx, by, size, color, 1).setDepth(DEPTH.popup + 2); + this.tweens.add({ + targets: spark, + x: bx + Math.cos(sparkAngle) * speed, + y: by + Math.sin(sparkAngle) * speed + speed * 0.28, // slight gravity droop + scaleX: 0.12, scaleY: 0.12, + alpha: 0, + duration: 600 + Math.random() * 480, + ease: 'Cubic.easeOut', + onComplete: () => { try { spark.destroy(); } catch { /* */ } }, + }); + } + }); + } + } + + // ── buy animation ──────────────────────────────────────────────────────────── + animBuy(seat, card, srcX, srcY, onDone) { + if (!this.hasArt) { onDone(); return; } + + const PW = 270, PH = 390; + 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._buildCardContainer(srcX, srcY, card); + + this.tweens.add({ + targets: container, + x: cx, y: cy, + scaleX: PW / CW, scaleY: PH / CH, + duration: 420, + ease: 'Cubic.easeOut', + onComplete: () => { + this._playReserveFireworks(cx, cy, card.bonus); + + this.time.delayedCall(1200, () => { + const bought = this.boughtCards[seat] ?? []; + const bi = bought.findIndex((c) => c.id === card.id); + const { cx: thumbCX, cy: thumbCY } = this.boughtCardThumbPos(seat, bi >= 0 ? bi : bought.length - 1); + + this.tweens.add({ targets: overlay, alpha: 0, duration: 300, + onComplete: () => { try { overlay.destroy(); } catch { /* */ } } }); + + this.tweens.add({ + targets: container, + x: thumbCX, y: thumbCY, + scaleX: THUMB_W / CW, scaleY: THUMB_H / CH, + alpha: 0, + duration: 360, + ease: 'Cubic.easeIn', + onComplete: () => { + for (const p of this.portraits) p?.show(); + container.removeAll(true); + container.destroy(); + onDone(); + }, + }); + }); + }, + }); + } + // ── human input ───────────────────────────────────────────────────────────── onTokenClick(color) { if (!this.isHumanTurn()) return; @@ -800,10 +1098,74 @@ export default class SplendorGame extends Phaser.Scene { applyHuman(action) { this.selectedCard = null; this.selection = []; + + if (this.hasArt && action.type === 'buy') { + const seat = this.humanSeat; + const card = this.findCard(action.cardId); + const boardPos = this.findCardBoardPos(action.cardId); + let srcX, srcY; + if (boardPos) { + srcX = boardPos.x; srcY = boardPos.y; + } else { + const n = this.gs.players.length, gap = 16; + const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n)); + const panelY = 76 + seat * (h + gap); + const ri = Math.max(0, this.gs.players[seat].reserved.findIndex((c) => c?.id === action.cardId)); + srcX = PANEL_X + 150 + ri * 116 + 52; srcY = panelY + h - 40 + 15; + } + + this.gs = applyAction(this.gs, action); + this.playActionSound(action); + if (this.gs.phase === 'discard' && this.gs.current === seat) { this.render(); return; } + + if (!this.boughtCards[seat]) this.boughtCards[seat] = []; + this.boughtCards[seat].push(card); + this.animatingBuy = { seat, cardId: card.id }; + this.busy = true; + this.render(); + + this.animBuy(seat, card, srcX, srcY, () => { + this.animatingBuy = null; + this.busy = false; + this.render(); + this.advance(); + }); + return; + } + + if (this.hasArt && (action.type === 'reserve' || action.type === 'reserveDeck')) { + const srcPos = action.type === 'reserve' + ? this.findCardBoardPos(action.cardId) + : { x: MARKET_X + DECK_W / 2, y: TIER_Y[action.tier] + CH / 2 }; + const srcX = srcPos?.x ?? GAME_WIDTH / 2; + const srcY = srcPos?.y ?? GAME_HEIGHT / 2; + + this.gs = applyAction(this.gs, action); + this.playActionSound(action); + if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) { + this.render(); + return; + } + + const reserved = this.gs.players[this.humanSeat].reserved; + const card = reserved[reserved.length - 1]; + this.animatingReserve = { seat: this.humanSeat, cardId: card.id }; + this.busy = true; + this.render(); + + this.animReserve(this.humanSeat, card, srcX, srcY, () => { + this.animatingReserve = null; + this.busy = false; + this.render(); + this.advance(); + }); + return; + } + this.gs = applyAction(this.gs, action); this.playActionSound(action); if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) { - this.render(); // hand over to the discard UI + this.render(); return; } this.render(); @@ -841,6 +1203,67 @@ export default class SplendorGame extends Phaser.Scene { return; } + if (this.hasArt && (action.type === 'reserve' || action.type === 'reserveDeck')) { + const srcPos = action.type === 'reserve' + ? this.findCardBoardPos(action.cardId) + : { x: MARKET_X + DECK_W / 2, y: TIER_Y[action.tier] + CH / 2 }; + const srcX = srcPos?.x ?? GAME_WIDTH / 2; + const srcY = srcPos?.y ?? GAME_HEIGHT / 2; + + this.gs = applyAction(this.gs, action); + this.playActionSound(action); + if (this.gs.phase === 'discard') { + this.gs = applyDiscard(this.gs, defaultDiscards(this.gs)); + } + + const reserved = this.gs.players[seat].reserved; + const card = reserved[reserved.length - 1]; + this.animatingReserve = { seat, cardId: card.id }; + this.render(); + + this.animReserve(seat, card, srcX, srcY, () => { + this.animatingReserve = null; + this.busy = false; + this.render(); + this.advance(); + }); + return; + } + + if (this.hasArt && action.type === 'buy') { + const card = this.findCard(action.cardId); + const boardPos = this.findCardBoardPos(action.cardId); + let srcX, srcY; + if (boardPos) { + srcX = boardPos.x; srcY = boardPos.y; + } else { + const n = this.gs.players.length, gap = 16; + const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n)); + const panelY = 76 + seat * (h + gap); + const ri = Math.max(0, this.gs.players[seat].reserved.findIndex((c) => c?.id === action.cardId)); + srcX = PANEL_X + 150 + ri * 116 + 52; srcY = panelY + h - 40 + 15; + } + + this.gs = applyAction(this.gs, action); + this.playActionSound(action); + if (this.gs.phase === 'discard') { + this.gs = applyDiscard(this.gs, defaultDiscards(this.gs)); + } + + if (!this.boughtCards[seat]) this.boughtCards[seat] = []; + this.boughtCards[seat].push(card); + this.animatingBuy = { seat, cardId: card.id }; + this.render(); + + this.animBuy(seat, card, srcX, srcY, () => { + this.animatingBuy = null; + this.busy = false; + this.render(); + this.advance(); + }); + return; + } + this.gs = applyAction(this.gs, action); this.playActionSound(action); if (this.gs.phase === 'discard') {