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
This commit is contained in:
Brian Fertig 2026-06-04 22:32:33 -06:00
parent aa6fce0f6c
commit 25ccdb5da9
2 changed files with 183 additions and 15 deletions

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 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 TIER_Y = { 3: 168, 2: 168 + CH + 26, 1: 168 + (CH + 26) * 2 };
const NOBLE = 96, NOBLE_Y = 56; 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; 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.animatingBuy = null; // { seat, cardId } suppresses thumbnail during animation
this.boughtCards = {}; // seat → card[] — persists purchased cards for display this.boughtCards = {}; // seat → card[] — persists purchased cards for display
this._thumbHoverTimer = null; // pending 500 ms intent timer for thumbnail preview 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() { create() {
@ -109,7 +112,7 @@ export default class SplendorGame extends Phaser.Scene {
this.portraits[idx] = createPlayerPortrait(this, px, py, portraitR, DEPTH.ui + 1, 'SplendorGame'); this.portraits[idx] = createPlayerPortrait(this, px, py, portraitR, DEPTH.ui + 1, 'SplendorGame');
} else { } else {
const opp = this.opponents[idx - 1]; 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, fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(DEPTH.ui); }).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); { variant: 'ghost', width: 140, height: 42, fontSize: 18 }).setDepth(DEPTH.ui);
} }
@ -180,6 +183,42 @@ export default class SplendorGame extends Phaser.Scene {
return null; 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) { boughtCardThumbPos(seat, idx) {
const n = this.gs.players.length; const n = this.gs.players.length;
const gap = 16; const gap = 16;
@ -435,8 +474,13 @@ export default class SplendorGame extends Phaser.Scene {
}).setOrigin(1, 0).setDepth(DEPTH.card + 3)); }).setOrigin(1, 0).setDepth(DEPTH.card + 3));
} }
if (this.isHumanTurn() && color !== GOLD && n > 0) { 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) 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)); zone.on('pointerdown', () => this.onTokenClick(color));
} }
}); });
@ -502,7 +546,7 @@ export default class SplendorGame extends Phaser.Scene {
// divider + total row (card bonuses + tokens) // divider + total row (card bonuses + tokens)
const ccy = y + 70; const ccy = y + 70;
const divY = ccy + 38; const divY = ccy + 38;
const totalCY = ccy + 53; const totalCY = ccy + 57;
const divG = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1)); const divG = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
divG.lineStyle(1, COLORS.accent, 0.35) divG.lineStyle(1, COLORS.accent, 0.35)
.lineBetween(gemStartX - totalR - 2, divY, gemStartX + (order.length - 1) * gemSpacing + totalR + 2, divY); .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)); }).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) // bought card thumbnails (right of portrait, left of gems, wrapping into rows)
(this.boughtCards[idx] ?? []).forEach((bc, bi) => { (this.boughtCards[idx] ?? []).forEach((bc, bi) => {
if (this.animatingBuy?.seat === idx && this.animatingBuy?.cardId === bc.id) return; if (this.animatingBuy?.seat === idx && this.animatingBuy?.cardId === bc.id) return;
@ -714,7 +781,7 @@ export default class SplendorGame extends Phaser.Scene {
: action.colors; : action.colors;
const preTokens = { ...this.gs.players[seat].tokens }; const preTokens = { ...this.gs.players[seat].tokens };
this.gs = applyAction(this.gs, action); this._applyAction(action);
this.playActionSound(action); this.playActionSound(action);
if (this.gs.phase === 'discard') { if (this.gs.phase === 'discard') {
this.gs = applyDiscard(this.gs, defaultDiscards(this.gs)); this.gs = applyDiscard(this.gs, defaultDiscards(this.gs));
@ -740,6 +807,12 @@ export default class SplendorGame extends Phaser.Scene {
duration: 380, duration: 380,
delay: i * 90, delay: i * 90,
ease: 'Cubic.easeOut', 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: () => { onComplete: () => {
gem.destroy(); gem.destroy();
if (--pending === 0) { if (--pending === 0) {
@ -767,10 +840,80 @@ export default class SplendorGame extends Phaser.Scene {
displayWidth: 32, displayHeight: 32, displayWidth: 32, displayHeight: 32,
duration: 300, duration: 300,
ease: 'Cubic.easeOut', 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(), 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. // 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). // All draw coords are in container-local space (0,0 = card centre).
_buildCardContainer(x, y, card) { _buildCardContainer(x, y, card) {
@ -1043,7 +1186,7 @@ export default class SplendorGame extends Phaser.Scene {
// Save token state before and after applying the action // Save token state before and after applying the action
const preTokens = { ...this.gs.players[this.humanSeat].tokens }; const preTokens = { ...this.gs.players[this.humanSeat].tokens };
this.gs = applyAction(this.gs, action); this._applyAction(action);
this.playActionSound(action); this.playActionSound(action);
const postTokens = this.gs.players[this.humanSeat].tokens; const postTokens = this.gs.players[this.humanSeat].tokens;
@ -1073,6 +1216,12 @@ export default class SplendorGame extends Phaser.Scene {
duration: 380, duration: 380,
delay: i * 90, delay: i * 90,
ease: 'Cubic.easeOut', 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: () => { onComplete: () => {
gem.destroy(); gem.destroy();
if (--pending === 0) { 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; srcX = PANEL_X + 150 + ri * 116 + 52; srcY = panelY + h - 40 + 15;
} }
this.gs = applyAction(this.gs, action); this._applyAction(action);
this.playActionSound(action); this.playActionSound(action);
if (this.gs.phase === 'discard' && this.gs.current === seat) { this.render(); return; } 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 srcX = srcPos?.x ?? GAME_WIDTH / 2;
const srcY = srcPos?.y ?? GAME_HEIGHT / 2; const srcY = srcPos?.y ?? GAME_HEIGHT / 2;
this.gs = applyAction(this.gs, action); this._applyAction(action);
this.playActionSound(action); this.playActionSound(action);
if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) { if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) {
this.render(); this.render();
@ -1187,7 +1336,7 @@ export default class SplendorGame extends Phaser.Scene {
return; return;
} }
this.gs = applyAction(this.gs, action); this._applyAction(action);
this.playActionSound(action); this.playActionSound(action);
if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) { if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) {
this.render(); this.render();
@ -1199,6 +1348,20 @@ export default class SplendorGame extends Phaser.Scene {
// ── turn loop ─────────────────────────────────────────────────────────────── // ── turn loop ───────────────────────────────────────────────────────────────
advance() { 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 (isGameOver(this.gs)) { this.render(); this.onGameOver(); return; }
if (this.gs.phase === 'discard') { if (this.gs.phase === 'discard') {
// Only the human ever parks here interactively; AI auto-discards inline. // 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 srcX = srcPos?.x ?? GAME_WIDTH / 2;
const srcY = srcPos?.y ?? GAME_HEIGHT / 2; const srcY = srcPos?.y ?? GAME_HEIGHT / 2;
this.gs = applyAction(this.gs, action); this._applyAction(action);
this.playActionSound(action); this.playActionSound(action);
if (this.gs.phase === 'discard') { if (this.gs.phase === 'discard') {
this.gs = applyDiscard(this.gs, defaultDiscards(this.gs)); 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; srcX = PANEL_X + 150 + ri * 116 + 52; srcY = panelY + h - 40 + 15;
} }
this.gs = applyAction(this.gs, action); this._applyAction(action);
this.playActionSound(action); this.playActionSound(action);
if (this.gs.phase === 'discard') { if (this.gs.phase === 'discard') {
this.gs = applyDiscard(this.gs, defaultDiscards(this.gs)); this.gs = applyDiscard(this.gs, defaultDiscards(this.gs));
} }
if (card.points > 0) this.portraits[seat]?.playEmotion('happy');
if (!this.boughtCards[seat]) this.boughtCards[seat] = []; if (!this.boughtCards[seat]) this.boughtCards[seat] = [];
this.boughtCards[seat].push(card); this.boughtCards[seat].push(card);
this.animatingBuy = { seat, cardId: card.id }; this.animatingBuy = { seat, cardId: card.id };
@ -1289,7 +1453,11 @@ export default class SplendorGame extends Phaser.Scene {
return; 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); this.playActionSound(action);
if (this.gs.phase === 'discard') { if (this.gs.phase === 'discard') {
this.gs = applyDiscard(this.gs, defaultDiscards(this.gs)); this.gs = applyDiscard(this.gs, defaultDiscards(this.gs));
@ -1304,7 +1472,7 @@ export default class SplendorGame extends Phaser.Scene {
if (!action) return; if (!action) return;
if (action.type === 'buy') playSound(this, SFX.PURCHASE); 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 === '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) { showTurnBanner(text) {

View File

@ -139,7 +139,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf)); '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 ?? [], this.buildOptionSection('Card Back', 798, this.cache.json.get('card-backs')?.cardBacks ?? [],
'selectedCardBack', 'cardBackTiles', (cb) => this.selectCardBack(cb), 'selectedCardBack', 'cardBackTiles', (cb) => this.selectCardBack(cb),
CARD_TILE_W, CARD_TILE_H, CARD_TILE_GAP); 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') ?? {}; const pfd = this.cache.json.get('playfields') ?? {};
this.applyDefault('playfields', pfd.default, 'selectedPlayfield', 'playfieldTiles'); 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 ?? []; const cardBacks = this.cache.json.get('card-backs')?.cardBacks ?? [];
if (cardBacks.length > 0) { if (cardBacks.length > 0) {
const randomCb = cardBacks[Math.floor(Math.random() * cardBacks.length)]; const randomCb = cardBacks[Math.floor(Math.random() * cardBacks.length)];