Compare commits

...

4 Commits

Author SHA1 Message Date
Brian Fertig 25ccdb5da9 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
2026-06-04 22:32:33 -06:00
Brian Fertig aa6fce0f6c feat(splendor): improve reserve card hover preview, dynamic control bar, and sound effects
- Add 500ms hover-intent preview for reserve cards, temporarily hiding player portraits
- Dynamically calculate control bar panel height based on player count
- Adjust reserve button positioning and increase control bar background opacity
- Reposition turn label and remove redundant win condition subtitle
- Integrate audio feedback for card interactions (ui-attach) and achievement animations (firework)
2026-06-04 21:11:04 -06:00
Brian Fertig 6abcbfa32b 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
2026-06-04 20:02:42 -06:00
Brian Fertig 5eb5c71ebf refine ui rendering and gem token layout
- Add `regMaskedImg` helper for rounded-rectangle image masking
- Update card, noble, and deck rendering to layer masks and borders correctly
- Adjust gem token sizing, spacing, and positioning for improved alignment
- Standardize visual constants and improve overall UI polish
2026-06-04 19:21:17 -06:00
6 changed files with 685 additions and 47 deletions

Binary file not shown.

BIN
public/assets/fx/gem-01.mp3 Normal file

Binary file not shown.

BIN
public/assets/fx/gem-02.mp3 Normal file

Binary file not shown.

Binary file not shown.

View File

@ -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;
@ -32,6 +33,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 +59,15 @@ 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
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() {
@ -94,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);
}
});
}
@ -114,15 +132,26 @@ 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);
}
// ── render: rebuild all dynamic visuals from this.gs ─────────────────────────
reg(obj) { this.dyn.push(obj); return obj; }
regMaskedImg(x, y, w, h, key, frame, depth, r = 10) {
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff).fillRoundedRect(x, y, w, h, r);
this.dyn.push({ destroy: () => maskG.destroy() });
return this.reg(
this.add.image(x + w / 2, y + h / 2, key, frame)
.setDisplaySize(w, h).setMask(maskG.createGeometryMask()).setDepth(depth)
);
}
clearDyn() {
this.clearPreview();
if (this._thumbHoverTimer) { this._thumbHoverTimer.remove(); this._thumbHoverTimer = null; }
for (const o of this.dyn) { try { o.destroy(); } catch { /* noop */ } }
this.dyn = [];
}
@ -132,6 +161,76 @@ 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;
}
// 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;
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;
@ -153,12 +252,16 @@ export default class SplendorGame extends Phaser.Scene {
if (!this.hasArt) {
g.fillStyle(tint, 0.9).fillRoundedRect(px, py, pw, 65, { tl: 10, tr: 10, bl: 0, br: 0 });
}
g.lineStyle(3, GEM_EDGE[card.bonus], 1).strokeRoundedRect(px, py, pw, ph, 10);
if (this.hasArt) {
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff).fillRoundedRect(px, py, pw, ph, 10);
this.preview.push({ destroy: () => maskG.destroy() });
rp(this.add.image(px + pw / 2, py + ph / 2, 'splendor-cards', cardFrame(card))
.setDisplaySize(pw, ph).setDepth(DEPTH.popup));
.setDisplaySize(pw, ph).setMask(maskG.createGeometryMask()).setDepth(DEPTH.popup));
}
// Border drawn above art
rp(this.add.graphics().setDepth(DEPTH.popup + 1))
.lineStyle(3, GEM_EDGE[card.bonus], 1).strokeRoundedRect(px, py, pw, ph, 10);
// Points
if (card.points > 0) {
@ -206,12 +309,13 @@ export default class SplendorGame extends Phaser.Scene {
this.gs.nobles.forEach((n, i) => {
const x = MARKET_X + i * (NOBLE + 14);
const g = this.reg(this.add.graphics().setDepth(DEPTH.card));
g.fillStyle(0x000000, 0.45).fillRoundedRect(x + 3, NOBLE_Y + 4, NOBLE, NOBLE, 10);
g.fillStyle(0x2b2620, 1).fillRoundedRect(x, NOBLE_Y, NOBLE, NOBLE, 10);
g.lineStyle(2, COLORS.accent, 0.8).strokeRoundedRect(x, NOBLE_Y, NOBLE, NOBLE, 10);
if (this.hasArt) {
this.reg(this.add.image(x + NOBLE / 2, NOBLE_Y + NOBLE / 2, 'splendor-cards', nobleFrame(n))
.setDisplaySize(NOBLE, NOBLE).setDepth(DEPTH.card + 1));
this.regMaskedImg(x, NOBLE_Y, NOBLE, NOBLE, 'splendor-cards', nobleFrame(n), DEPTH.card + 1);
}
this.reg(this.add.graphics().setDepth(DEPTH.card + 2))
.lineStyle(1.5, COLORS.accent, 0.8).strokeRoundedRect(x, NOBLE_Y, NOBLE, NOBLE, 10);
// prestige (always drawn over art)
this.reg(this.add.text(x + 8, NOBLE_Y + 4, '3', {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.goldHex,
@ -238,13 +342,15 @@ export default class SplendorGame extends Phaser.Scene {
// deck pile
const deckN = this.gs.decks[tier].length;
const dg = this.reg(this.add.graphics().setDepth(DEPTH.card));
dg.fillStyle(0x000000, 0.45).fillRoundedRect(MARKET_X + 3, y + 4, DECK_W, CH, 10);
dg.fillStyle(tier === 3 ? 0x2a3550 : tier === 2 ? 0x3a4a2a : 0x4a3a2a, 1)
.fillRoundedRect(MARKET_X, y, DECK_W, CH, 10);
dg.lineStyle(2, 0x000000, 0.6).strokeRoundedRect(MARKET_X, y, DECK_W, CH, 10);
if (deckN > 0 && this.hasArt) {
this.reg(this.add.image(MARKET_X + DECK_W / 2, y + CH / 2, 'splendor-cards', deckBackFrame(tier))
.setDisplaySize(DECK_W, CH).setDepth(DEPTH.card + 1));
this.regMaskedImg(MARKET_X, y, DECK_W, CH, 'splendor-cards', deckBackFrame(tier), DEPTH.card + 1);
}
this.reg(this.add.graphics().setDepth(DEPTH.card + 2))
.lineStyle(1.5, tier === 3 ? 0x4a6090 : tier === 2 ? 0x5a7040 : 0x705030, 0.8)
.strokeRoundedRect(MARKET_X, y, DECK_W, CH, 10);
this.reg(this.add.text(MARKET_X + DECK_W / 2, y + 30, '★'.repeat(tier), {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.card + 2));
@ -274,18 +380,20 @@ export default class SplendorGame extends Phaser.Scene {
// Card background — always draw the tinted rect (provides border shape and colour identity)
const g = this.reg(this.add.graphics().setDepth(DEPTH.card + 1));
const tint = GEM_HEX[card.bonus];
g.fillStyle(0x000000, 0.45).fillRoundedRect(x + 3, y + 4, CW, CH, 10);
g.fillStyle(0x14110b, 1).fillRoundedRect(x, y, CW, CH, 10);
g.fillStyle(tint, 0.20).fillRoundedRect(x, y, CW, CH, 10);
if (!this.hasArt) {
g.fillStyle(tint, 0.9).fillRoundedRect(x, y, CW, 30, { tl: 10, tr: 10, bl: 0, br: 0 });
}
g.lineStyle(2, GEM_EDGE[card.bonus], 1).strokeRoundedRect(x, y, CW, CH, 10);
// Spritesheet background art drawn over the tinted rect when available
// Spritesheet art clipped to rounded rect
if (this.hasArt) {
this.reg(this.add.image(x + CW / 2, y + CH / 2, 'splendor-cards', cardFrame(card))
.setDisplaySize(CW, CH).setDepth(DEPTH.card + 1));
this.regMaskedImg(x, y, CW, CH, 'splendor-cards', cardFrame(card), DEPTH.card + 1);
}
// Border drawn on top of art
this.reg(this.add.graphics().setDepth(DEPTH.card + 2))
.lineStyle(1.5, GEM_EDGE[card.bonus], 1).strokeRoundedRect(x, y, CW, CH, 10);
// Vector overlays — always drawn on top of whatever background is present
if (card.points > 0) {
@ -366,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));
}
});
@ -399,9 +512,10 @@ export default class SplendorGame extends Phaser.Scene {
// gem cells: cards (bonus) over tokens — compact, right-aligned
const order = [...GEMS, GOLD];
const gemR = 14;
const gemR = 10;
const totalR = 14;
const gemSpacing = 36;
const gemStartX = x + w - 16 - gemR - (order.length - 1) * gemSpacing;
const gemStartX = x + w - 16 - totalR - (order.length - 1) * gemSpacing;
order.forEach((color, ci) => {
const ccx = gemStartX + ci * gemSpacing;
const ccy = y + 70;
@ -409,14 +523,14 @@ export default class SplendorGame extends Phaser.Scene {
const toks = p.tokens[color] ?? 0;
const cg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
cg.fillStyle(GEM_HEX[color], 1).fillCircle(ccx, ccy, gemR);
cg.lineStyle(2, GEM_EDGE[color], 1).strokeCircle(ccx, ccy, gemR);
cg.lineStyle(1.5, GEM_EDGE[color], 1).strokeCircle(ccx, ccy, gemR);
if (color !== GOLD) {
this.reg(this.add.text(ccx, ccy, String(cards), {
fontFamily: 'Righteous', fontSize: '14px',
fontFamily: 'Righteous', fontSize: '12px',
color: color === 'white' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
}
this.reg(this.add.text(ccx, ccy + 24, `${toks}`, {
this.reg(this.add.text(ccx, ccy + 22, `${toks}`, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
// discard interactivity (human, over limit)
@ -432,22 +546,85 @@ 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 - gemR - 2, divY, gemStartX + (order.length - 1) * gemSpacing + gemR + 2, divY);
.lineBetween(gemStartX - totalR - 2, divY, gemStartX + (order.length - 1) * gemSpacing + totalR + 2, divY);
order.forEach((color, ci) => {
const ccx = gemStartX + ci * gemSpacing;
const total = (color === GOLD ? 0 : (p.bonuses[color] ?? 0)) + (p.tokens[color] ?? 0);
const tg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
tg.fillStyle(GEM_HEX[color], total > 0 ? 0.8 : 0.2).fillCircle(ccx, totalCY, 10);
tg.lineStyle(1.5, GEM_EDGE[color], 1).strokeCircle(ccx, totalCY, 10);
tg.fillStyle(GEM_HEX[color], total > 0 ? 0.8 : 0.2).fillCircle(ccx, totalCY, totalR);
tg.lineStyle(2, GEM_EDGE[color], 1).strokeCircle(ccx, totalCY, totalR);
this.reg(this.add.text(ccx, totalCY, String(total), {
fontFamily: 'Righteous', fontSize: '12px',
fontFamily: 'Righteous', fontSize: '14px',
color: color === 'white' ? '#222' : '#fff',
}).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;
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));
}
// 500 ms hover-intent preview
const tz = this.reg(
this.add.zone(tx + THUMB_W / 2, ty + THUMB_H / 2, THUMB_W, THUMB_H)
.setInteractive().setDepth(DEPTH.ui + 5)
);
tz.on('pointerover', () => {
this._thumbHoverTimer = this.time.delayedCall(500, () => {
for (const p of this.portraits) p?.hide();
this.showCardPreview(bc, tx + THUMB_W / 2, ty + THUMB_H);
});
});
tz.on('pointerout', () => {
if (this._thumbHoverTimer) { this._thumbHoverTimer.remove(); this._thumbHoverTimer = null; }
this.clearPreview();
for (const p of this.portraits) p?.show();
});
});
// reserved cards
const rN = p.reserved.length;
this.reg(this.add.text(x + 16, y + h - 30, `Reserved: ${rN}/${MAX_RESERVED}`, {
@ -456,6 +633,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));
@ -482,9 +660,13 @@ export default class SplendorGame extends Phaser.Scene {
// ── bottom control bar ──────────────────────────────────────────────────────
drawControlBar() {
const x = MARKET_X, y = CONTROL_Y, w = 920, h = GAME_HEIGHT - y - 24;
const n = this.gs?.players?.length ?? 4;
const panelGap = 16;
const panelH = Math.min(220, Math.floor((GAME_HEIGHT - 90 - panelGap * (n - 1)) / n));
const lastPanelBottom = 76 + (n - 1) * (panelH + panelGap) + panelH;
const x = MARKET_X, y = CONTROL_Y, w = 920, h = Math.max(120, lastPanelBottom - y);
const g = this.reg(this.add.graphics().setDepth(DEPTH.ui));
g.fillStyle(0x000000, 0.3).fillRoundedRect(x, y, w, h, 12);
g.fillStyle(0x000000, 0.55).fillRoundedRect(x, y, w, h, 12);
g.lineStyle(1, COLORS.accent, 0.4).strokeRoundedRect(x, y, w, h, 12);
if (isGameOver(this.gs)) return;
@ -524,10 +706,10 @@ export default class SplendorGame extends Phaser.Scene {
bx += 170;
}
if (source === 'board' && human.reserved.length < MAX_RESERVED) {
this.addButton(bx + 80, y + 70, 'Reserve (+1 gold)',
this.addButton(bx + 115, y + 70, 'Reserve (+1 gold)',
() => this.applyHuman({ type: 'reserve', cardId: card.id, tier: card.tier }),
{ width: 230, height: 46, variant: 'ghost' });
bx += 250;
bx += 285;
}
this.addButton(bx + 70, y + 70, 'Cancel', () => { this.selectedCard = null; this.render(); },
{ width: 130, height: 46, variant: 'ghost' });
@ -566,12 +748,9 @@ export default class SplendorGame extends Phaser.Scene {
drawTurnLabel() {
const txt = isGameOver(this.gs) ? 'Game over' : `${this.pname(this.gs.current)}'s turn`;
this.reg(this.add.text(BANK_X, GAME_HEIGHT - 70, txt, {
this.reg(this.add.text(BANK_X, GAME_HEIGHT - 48, txt, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.ui + 1));
this.reg(this.add.text(BANK_X, GAME_HEIGHT - 40, `First to ${WIN_POINTS} prestige wins`, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.ui + 1));
}
// ── helpers ─────────────────────────────────────────────────────────────────
@ -586,9 +765,11 @@ export default class SplendorGame extends Phaser.Scene {
const gap = 16;
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
const order = [...GEMS, GOLD];
const cellW = (PANEL_W - 32) / order.length;
const totalR = 14;
const gemSpacing = 36;
const gemStartX = PANEL_X + PANEL_W - 16 - totalR - (order.length - 1) * gemSpacing;
const ci = order.indexOf(color);
return { x: PANEL_X + 16 + cellW * ci + cellW / 2, y: 76 + seat * (h + gap) + 70 };
return { x: gemStartX + ci * gemSpacing, y: 76 + seat * (h + gap) + 70 };
}
humanGemPos(color) { return this.playerGemPos(this.humanSeat, color); }
@ -600,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));
@ -626,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) {
@ -653,10 +840,311 @@ 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) {
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 { /* */ } } });
try { const a = new Audio('/assets/fx/ui-attach.mp3'); a.volume = 0.8; a.play(); } 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, () => {
try { const fw = new Audio('/assets/fx/firework.mp3'); fw.volume = 0.7; fw.play(); } catch { /* */ }
// 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 { /* */ } } });
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,
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;
@ -698,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;
@ -728,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) {
@ -778,10 +1272,74 @@ export default class SplendorGame extends Phaser.Scene {
applyHuman(action) {
this.selectedCard = null;
this.selection = [];
this.gs = applyAction(this.gs, action);
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._applyAction(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._applyAction(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._applyAction(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();
@ -790,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.
@ -819,7 +1391,73 @@ export default class SplendorGame extends Phaser.Scene {
return;
}
this.gs = applyAction(this.gs, action);
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._applyAction(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._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 };
this.render();
this.animBuy(seat, card, srcX, srcY, () => {
this.animatingBuy = null;
this.busy = false;
this.render();
this.advance();
});
return;
}
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));
@ -834,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) {

View File

@ -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)];