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)
This commit is contained in:
Brian Fertig 2026-06-04 21:11:04 -06:00
parent 6abcbfa32b
commit aa6fce0f6c
5 changed files with 33 additions and 8 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

@ -64,6 +64,7 @@ export default class SplendorGame extends Phaser.Scene {
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
}
create() {
@ -147,6 +148,7 @@ export default class SplendorGame extends Phaser.Scene {
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 = [];
}
@ -538,6 +540,22 @@ export default class SplendorGame extends Phaser.Scene {
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
@ -575,9 +593,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;
@ -617,10 +639,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' });
@ -659,12 +681,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 ─────────────────────────────────────────────────────────────────
@ -850,6 +869,8 @@ export default class SplendorGame extends Phaser.Scene {
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,
@ -896,6 +917,8 @@ export default class SplendorGame extends Phaser.Scene {
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({
@ -958,6 +981,8 @@ export default class SplendorGame extends Phaser.Scene {
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,