feat: add sound effects, backgrounds, and enhance OpponentSelect UI

- Introduce `Sounds.js` utility and load new audio assets (dice, cards, chips, casino).
- Play appropriate sound effects in Backgammon, Blackjack, Holdem, Parchisi, Phase10, SkipBo, and Yatzi.
- Add `background-casino.png` and `background-room.png` to PreloadScene.
- Update OpponentSelectScene with new backgrounds, styled title/subtitle pills, and improved Start/Back buttons.
- Enhance opponent selection with gradient backgrounds and shadows via `applyOpponentStyle`.
- Implement `highlightTile` for better visual feedback in option selectors.
- Randomly select a card back for card games in OpponentSelectScene.
This commit is contained in:
Brian Fertig 2026-05-17 12:34:46 -06:00
parent 792fd35fd4
commit fcfff62a15
15 changed files with 141 additions and 21 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

@ -8,6 +8,7 @@ import {
applyMove, endTurn, hasAnyMove, computePipCount,
} from './BackgammonLogic.js';
import { chooseMoves } from './BackgammonAI.js';
import { playSound, SFX } from '../../ui/Sounds.js';
// ── Layout constants ──────────────────────────────────────────────────────────
const BX = 300; // board outer left
@ -656,6 +657,7 @@ export default class BackgammonGame extends Phaser.Scene {
}
animateDiceRoll(finalValues, onComplete) {
playSound(this, SFX.DICE_ROLL);
this.diceContainers.forEach((c) => c.setAlpha(1));
let elapsed = 0;
const totalMs = 700;

View File

@ -4,6 +4,7 @@ import { Button } from '../../ui/Button.js';
import { Modal } from '../../ui/Modal.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { api } from '../../services/api.js';
import { playSound, playChipBet, SFX } from '../../ui/Sounds.js';
import {
buildShoe, handValue, isBlackjack, isBust, canDouble, canSplit,
createInitialState, prepareRound, applyBet, dealAllCards,
@ -392,6 +393,7 @@ export default class BlackjackGame extends Phaser.Scene {
startNewRound() {
if (this.shoe.length < 52) this.shoe = buildShoe();
playSound(this, SFX.CARD_SHUFFLE);
this.pendingBet = 0;
this.bustedSeats = new Set();
this.gs = prepareRound(this.gs);
@ -656,6 +658,7 @@ export default class BlackjackGame extends Phaser.Scene {
// ── Animations ────────────────────────────────────────────────────────────
animateBets(onComplete) {
this.renderBetAreas();
playChipBet(this);
let done = 0;
const active = this.gs.players.filter(p => p.active);
if (active.length === 0) { onComplete(); return; }
@ -724,6 +727,7 @@ export default class BlackjackGame extends Phaser.Scene {
targets: flying, x: tx, y: ty, duration: 180, ease: 'Power2',
onComplete: () => {
flying.destroy();
playSound(this, SFX.CARD_DEAL);
// Reveal the card at destination
if (entry.type === 'dealer') {
const cont = this.dealerCardGraphics[entry.cardIdx];
@ -742,6 +746,7 @@ export default class BlackjackGame extends Phaser.Scene {
animateDealerReveal(onComplete) {
const holeCard = this.dealerCardGraphics[1];
if (!holeCard) { this.gs.dealer.revealed = true; this.renderAll(); onComplete(); return; }
playSound(this, SFX.CARD_SHOW);
// Flip: scale X 1→0, redraw face-up, scale X 0→1
this.tweens.add({
@ -778,6 +783,7 @@ export default class BlackjackGame extends Phaser.Scene {
targets: flying, x: tx, y: ty, duration: 200, ease: 'Power2',
onComplete: () => {
flying.destroy();
playSound(this, SFX.CARD_PLACE);
// Reveal the card face-up at the destination
const cardConts = this.cardGraphics[seat] ?? [];
const targetCont = cardConts[cardIdx];
@ -800,6 +806,10 @@ export default class BlackjackGame extends Phaser.Scene {
const isWin = result === 'win' || result === 'blackjack';
const isLose = result === 'lose' || result === 'bust';
if (result === 'blackjack') playSound(this, SFX.CASINO_BLACKJACK);
else if (result === 'win') playSound(this, SFX.CASINO_WIN);
else if (result === 'lose' || result === 'bust') playSound(this, SFX.CASINO_LOSE);
if (seat > 0 && this.portraits[seat]) {
this.portraits[seat].playEmotion?.(isWin ? 'happy' : 'upset');
}

View File

@ -10,6 +10,7 @@ import {
} from './HoldemLogic.js';
import { chooseAction } from './HoldemAI.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, playChipBet, SFX } from '../../ui/Sounds.js';
// ── Layout constants ──────────────────────────────────────────────────────────
const CX = GAME_WIDTH / 2; // 960
@ -416,6 +417,7 @@ export default class HoldemGame extends Phaser.Scene {
}
this.foldedCardDisplays = {};
this.gs = startHand(this.gs);
playSound(this, SFX.CARD_SHUFFLE);
if (this.gs.phase === 'game_over') {
this.showGameOver();
@ -538,6 +540,7 @@ export default class HoldemGame extends Phaser.Scene {
}
animateFold(seat, onComplete) {
playSound(this, SFX.CARD_PLACE);
const sc = this.seatContainers[seat];
const { x: sx, y: sy } = SEAT_POS[seat];
const { x: tx, y: ty } = this.betZone(seat);
@ -588,6 +591,7 @@ export default class HoldemGame extends Phaser.Scene {
const { x: sx, y: sy } = SEAT_POS[seat];
const { x: tx, y: ty } = this.betZone(seat);
playChipBet(this);
const CHIP_COLORS = [0xf0e8d0, 0x4a90d9, 0xe05c5c, 0x5cb85c, 0xf0a830];
const allObjs = [];
let done = 0;
@ -788,6 +792,7 @@ export default class HoldemGame extends Phaser.Scene {
ease: 'Power2.easeOut',
onComplete: () => {
cardCont.destroy();
playSound(this, SFX.CARD_DEAL);
done++;
if (done === sequence.length) onComplete();
},
@ -841,6 +846,7 @@ export default class HoldemGame extends Phaser.Scene {
this.renderCard(slot.cardObj, card, true);
}
slot.cardObj.setVisible(true).setScale(0, 1);
playSound(this, SFX.CARD_SHOW);
this.tweens.add({
targets: slot.cardObj,
@ -1031,6 +1037,7 @@ export default class HoldemGame extends Phaser.Scene {
// Compute winnings (resolveShowdown handles the fold-win case correctly)
const winnings = resolveShowdown(players, community);
if ((winnings.get(0) ?? 0) > 0) playSound(this, SFX.CASINO_WIN);
// Hide game-world portraits so their DOM videos don't bleed through the overlay
for (const p of this.portraits) p.hide?.();
@ -1082,7 +1089,7 @@ export default class HoldemGame extends Phaser.Scene {
const continueBtn = new Button(this, CX, 1042, 'Continue to Next Hand', () => {
this.cleanupSummary();
this.dealNextHand();
}, { width: 320 }).setDepth(D.modal + 2).setAlpha(0);
}, { width: 420, fontSize: 24 }).setDepth(D.modal + 2).setAlpha(0);
S.push(continueBtn);
this.tweens.add({ targets: continueBtn, alpha: 1, duration: 300, delay: 1100 });
}

View File

@ -9,6 +9,7 @@ import {
COLORS as PCOLORS, ENTRY, HOME_ENTRY, HOME_COL_LEN,
} from './ParchisiLogic.js';
import { chooseMoves } from './ParchisiAI.js';
import { playSound, SFX } from '../../ui/Sounds.js';
// ── Layout constants ────────────────────────────────────────────────────────
const CELL = 50;
@ -327,6 +328,7 @@ export default class ParchisiGame extends Phaser.Scene {
}
animateDiceRoll(finalValues, onComplete) {
playSound(this, SFX.DICE_ROLL);
this.diceContainers.forEach((c) => c.setAlpha(1));
let elapsed = 0;
const totalMs = 650;
@ -881,6 +883,7 @@ export default class ParchisiGame extends Phaser.Scene {
// ── Move execution + animation ────────────────────────────────────────────
executeMove(move, onComplete) {
playSound(this, SFX.PIECE_CLICK);
const obj = this.pawnObjs[move.player][move.pawnIdx];
const from = this.locWorld(move.from, move.player);
const to = this.locWorld(move.to, move.player);

View File

@ -4,6 +4,7 @@ import { Button } from '../../ui/Button.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { PHASES, getPhase } from './PhaseSpec.js';
import {
applyDrawFromDeck,
@ -433,6 +434,7 @@ export default class Phase10Game extends Phaser.Scene {
const playerCount = this.slotForSeat.length;
this.gs = createInitialState({ playerCount });
playSound(this, SFX.CARD_SHUFFLE);
this.renderAll();
this.setStatus('Your turn — draw from deck or discard.');
this.maybeStartAITurn();
@ -855,6 +857,7 @@ export default class Phase10Game extends Phaser.Scene {
}
const next = applyDiscard(this.gs, handIdx);
if (next === this.gs) { this.setStatus("Can't discard right now."); return; }
playSound(this, SFX.CARD_PLACE);
this.gs = next;
this.renderAll();
this.afterTurnTransition();
@ -914,6 +917,7 @@ export default class Phase10Game extends Phaser.Scene {
this.clearHighlights();
const next = applyHit(this.gs, handIdx, target.targetSeat, target.groupIdx, target.position);
if (next === this.gs) { this.setStatus("Can't hit there."); return; }
playSound(this, SFX.CARD_PLACE);
this.gs = next;
this.renderAll();
if (this.gs.roundPhase === 'roundOver' || this.gs.roundPhase === 'gameOver') {
@ -1047,6 +1051,7 @@ export default class Phase10Game extends Phaser.Scene {
for (const o of items) o.destroy();
btn.destroy();
this.gs = startNextRound(this.gs);
playSound(this, SFX.CARD_SHUFFLE);
this.renderAll();
this.maybeStartAITurn();
}, { width: 240, height: 48, fontSize: 20 }).setDepth(D.modal);

View File

@ -4,6 +4,7 @@ import { Button } from '../../ui/Button.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import {
BUILD_PILE_COUNT,
DISCARD_PILE_COUNT,
@ -312,6 +313,7 @@ export default class SkipBoGame extends Phaser.Scene {
const playerCount = this.slotForSeat.length;
this.gs = createInitialState({ playerCount });
playSound(this, SFX.CARD_SHUFFLE);
this.renderAll();
this.setStatus('Your turn');
@ -706,6 +708,7 @@ export default class SkipBoGame extends Phaser.Scene {
const target = this.cardObjs.get(fromKey);
const dest = buildPilePos(action.buildIdx);
if (!target) { done(); return; }
playSound(this, SFX.CARD_PLACE);
this.tweens.add({
targets: target,
x: dest.x, y: dest.y, scale: 1,
@ -721,6 +724,7 @@ export default class SkipBoGame extends Phaser.Scene {
const layout = slotLayout(this.slotForSeat[before.currentPlayer]);
const dest = layout.discards[discardIdx];
if (!target) { done(); return; }
playSound(this, SFX.CARD_PLACE);
this.tweens.add({
targets: target, x: dest.x, y: dest.y,
duration: 240, ease: 'Cubic.easeOut',

View File

@ -11,6 +11,7 @@ import {
getWinners,
} from './YatziLogic.js';
import { chooseDiceToHold, chooseCategory, shouldKeepRolling } from './YatziAI.js';
import { playSound, SFX } from '../../ui/Sounds.js';
// ─── Layout ───────────────────────────────────────────────────────────────
const LEFT_CX = 480;
@ -361,6 +362,7 @@ export default class YatziGame extends Phaser.Scene {
}
async animateRoll(targetDice) {
playSound(this, SFX.DICE_ROLL);
this.animating = true;
return new Promise((resolve) => {
this.tweens.addCounter({
@ -507,6 +509,7 @@ export default class YatziGame extends Phaser.Scene {
const before = this.gs;
const after = commitScore(before, cat);
if (after === before) return;
playSound(this, SFX.PENCIL_WRITE);
this.gs = after;
if (after.lastBonusGained) this.showBonusToast();
this.cellHoverGfx?.clear();

View File

@ -34,17 +34,25 @@ export default class OpponentSelectScene extends Phaser.Scene {
async create() {
const cx = GAME_WIDTH / 2;
this.add.text(cx, 60, this.gameDef.name, {
const bgKey = this.gameDef.category === 'casino' ? 'bg-casino' : 'bg-room';
this.add.image(cx, GAME_HEIGHT / 2, bgKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1);
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(-1);
const titleText = this.add.text(cx, 60, this.gameDef.name, {
fontFamily: 'Righteous',
fontSize: '52px',
color: COLORS.textHex,
}).setOrigin(0.5);
const titlePill = this.add.rectangle(cx, 60, titleText.width + 48, titleText.height + 20, 0x000000, 0.72);
this.children.moveBelow(titlePill, titleText);
this.add.text(cx, 122, 'Choose your opponent', {
const subtitleText = this.add.text(cx, 122, 'Choose your opponent', {
fontFamily: 'Righteous',
fontSize: '36px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
const subtitlePill = this.add.rectangle(cx, 122, subtitleText.width + 48, subtitleText.height + 20, 0x000000, 0.72);
this.children.moveBelow(subtitlePill, subtitleText);
let opponents = [];
try {
@ -59,20 +67,29 @@ export default class OpponentSelectScene extends Phaser.Scene {
}
const min = this.gameDef.minOpponents ?? 1;
this.startBtn = new Button(this, cx, 1048, 'Start Game', () => this.startGame(), { width: 280 });
this.startBtn.setEnabled(min === 0);
new Button(this, cx, 978, 'Back', () => this.scene.start('GameMenu'), {
new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu'), {
variant: 'ghost',
width: 280,
});
this.startBtn = new Button(this, cx + 150, 1013, 'Start Game', () => this.startGame(), {
width: 280,
bg: COLORS.accent,
bgHover: COLORS.gold,
textColor: COLORS.textDarkHex,
textHoverColor: COLORS.textDarkHex,
});
this.startBtn.setEnabled(min === 0);
for (let i = opponents.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[opponents[i], opponents[j]] = [opponents[j], opponents[i]];
}
this.buildOpponentGrid(opponents);
const max = this.gameDef.maxOpponents ?? 1;
this.cards.slice(0, max).forEach(({ opp, el }) => this.toggleOpponent(opp, el));
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
@ -82,12 +99,16 @@ export default class OpponentSelectScene extends Phaser.Scene {
CARD_TILE_W, CARD_TILE_H, CARD_TILE_GAP);
}
// Apply defaults
// Apply playfield default; card back is chosen randomly
const pfd = this.cache.json.get('playfields') ?? {};
this.applyDefault('playfields', pfd.default, 'selectedPlayfield', 'playfieldTiles');
if (this.gameDef.cardGame) {
const cbd = this.cache.json.get('card-backs') ?? {};
this.applyDefault('card-backs', cbd.default, 'selectedCardBack', 'cardBackTiles');
const cardBacks = this.cache.json.get('card-backs')?.cardBacks ?? [];
if (cardBacks.length > 0) {
const randomCb = cardBacks[Math.floor(Math.random() * cardBacks.length)];
this.selectedCardBack = randomCb;
this.highlightTile('cardBackTiles', randomCb.id);
}
}
}
@ -153,7 +174,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
'cursor:pointer',
'box-sizing:border-box',
'user-select:none',
'transition:border-color 0.12s',
'transition:border 0.12s,background 0.15s,box-shadow 0.18s,color 0.12s',
].join(';');
// Portrait wrapper — fixed size so canvas/video can be stacked inside
@ -241,6 +262,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
info.appendChild(bio);
el.appendChild(portraitWrap);
el.appendChild(info);
el._nameEl = name;
el.addEventListener('click', () => this.toggleOpponent(opp, el));
@ -254,7 +276,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
if (this.selected.has(opp.id)) {
this.selected.delete(opp.id);
el.style.borderColor = COLORS.mutedHex;
this.applyOpponentStyle(el, false);
const card = this.cards.find((c) => c.opp.id === opp.id);
if (card) {
card.video.pause();
@ -268,14 +290,14 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.selected.delete(oldestId);
const oldCard = this.cards.find((c) => c.opp.id === oldestId);
if (oldCard) {
oldCard.el.style.borderColor = COLORS.mutedHex;
this.applyOpponentStyle(oldCard.el, false);
oldCard.video.pause();
oldCard.video.style.display = 'none';
oldCard.canvas.style.display = 'block';
}
}
this.selected.add(opp.id);
el.style.borderColor = COLORS.accentHex;
this.applyOpponentStyle(el, true);
const card = this.cards.find((c) => c.opp.id === opp.id);
if (card) {
card.canvas.style.display = 'none';
@ -293,11 +315,13 @@ export default class OpponentSelectScene extends Phaser.Scene {
buildOptionSection(label, labelY, items, selectedProp, tilesProp, onSelect, tileW = TILE_W, tileH = TILE_H, tileGap = TILE_GAP) {
const cx = GAME_WIDTH / 2;
this.add.text(cx, labelY, label, {
const labelText = this.add.text(cx, labelY, label, {
fontFamily: '"Julius Sans One"',
fontSize: '24px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
const labelPill = this.add.rectangle(cx, labelY, labelText.width + 40, labelText.height + 18, 0x000000, 0.72);
this.children.moveBelow(labelPill, labelText);
const tileY = labelY + Math.max(82, Math.round(tileH / 2) + 18);
const totalW = items.length * tileW + (items.length - 1) * tileGap;
@ -317,6 +341,8 @@ export default class OpponentSelectScene extends Phaser.Scene {
.setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const overlay = this.add.rectangle(0, 0, tileW, tileH, COLORS.accent, 0);
const thumbW = tileW - 16;
const thumbH = tileH - 30;
let thumb;
@ -338,13 +364,11 @@ export default class OpponentSelectScene extends Phaser.Scene {
color: COLORS.textHex,
}).setOrigin(0.5);
container.add([bg, thumb, nameText]);
container.add([bg, overlay, thumb, nameText]);
bg.on('pointerup', () => {
this[selectedProp] = item;
for (const t of this[tilesProp]) {
t.bg.setStrokeStyle(3, t.item.id === item.id ? COLORS.accent : COLORS.muted);
}
this.highlightTile(tilesProp, item.id);
onSelect(item);
});
bg.on('pointerover', () => {
@ -354,7 +378,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
if (this[selectedProp]?.id !== item.id) bg.setStrokeStyle(3, COLORS.muted);
});
this[tilesProp].push({ item, bg });
this[tilesProp].push({ item, bg, overlay, nameText });
}
applyDefault(cacheKey, defaultId, selectedProp, tilesProp) {
@ -363,8 +387,29 @@ export default class OpponentSelectScene extends Phaser.Scene {
const def = items.find((i) => i.id === defaultId) ?? items[0] ?? null;
if (!def) return;
this[selectedProp] = def;
this.highlightTile(tilesProp, def.id);
}
applyOpponentStyle(el, selected) {
if (selected) {
el.style.border = `3px solid ${COLORS.accentHex}`;
el.style.background = `linear-gradient(rgba(200,168,75,0.22),rgba(200,168,75,0.22)),${COLORS.panelHex}`;
el.style.boxShadow = `0 0 0 2px rgba(200,168,75,0.22), 0 0 24px rgba(200,168,75,0.42)`;
if (el._nameEl) el._nameEl.style.color = COLORS.accentHex;
} else {
el.style.border = `2px solid ${COLORS.mutedHex}`;
el.style.background = COLORS.panelHex;
el.style.boxShadow = 'none';
if (el._nameEl) el._nameEl.style.color = COLORS.textHex;
}
}
highlightTile(tilesProp, selectedId) {
for (const t of this[tilesProp]) {
t.bg.setStrokeStyle(3, t.item.id === def.id ? COLORS.accent : COLORS.muted);
const sel = t.item.id === selectedId;
t.overlay.setFillStyle(COLORS.accent, sel ? 0.22 : 0);
t.bg.setStrokeStyle(sel ? 4 : 3, sel ? COLORS.accent : COLORS.muted);
t.nameText.setColor(sel ? COLORS.accentHex : COLORS.textHex);
}
}

View File

@ -31,9 +31,23 @@ export default class PreloadScene extends Phaser.Scene {
frameHeight: 420,
});
this.load.image('bg-menu', '/assets/images/background-menu.png');
this.load.image('bg-room', '/assets/images/background-room.png');
this.load.image('bg-casino', '/assets/images/background-casino.png');
this.load.image('main-title', '/assets/images/main-title.png');
this.load.json('playfields', '/data/playfields.json');
this.load.json('card-backs', '/data/card-backs.json');
this.load.audio('sfx-card-deal', '/assets/fx/card-deal.mp3');
this.load.audio('sfx-card-place', '/assets/fx/card-place.mp3');
this.load.audio('sfx-card-show', '/assets/fx/card-show.mp3');
this.load.audio('sfx-card-shuffle', '/assets/fx/card-shuffle.mp3');
this.load.audio('sfx-casino-blackjack', '/assets/fx/casino-blackjack.mp3');
this.load.audio('sfx-casino-lose', '/assets/fx/casino-lose.mp3');
this.load.audio('sfx-casino-win', '/assets/fx/casino-win.mp3');
this.load.audio('sfx-chip-bet', '/assets/fx/chip-bet.mp3');
this.load.audio('sfx-dice-roll', '/assets/fx/dice-roll.mp3');
this.load.audio('sfx-pencil-write', '/assets/fx/pencil-write.mp3');
this.load.audio('sfx-piece-click', '/assets/fx/piece-click.mp3');
}
async create() {

27
public/src/ui/Sounds.js Normal file
View File

@ -0,0 +1,27 @@
// Sound effect keys — must match the keys loaded in PreloadScene.
export const SFX = {
CARD_DEAL: 'sfx-card-deal',
CARD_PLACE: 'sfx-card-place',
CARD_SHOW: 'sfx-card-show',
CARD_SHUFFLE: 'sfx-card-shuffle',
CASINO_BLACKJACK: 'sfx-casino-blackjack',
CASINO_LOSE: 'sfx-casino-lose',
CASINO_WIN: 'sfx-casino-win',
CHIP_BET: 'sfx-chip-bet',
DICE_ROLL: 'sfx-dice-roll',
PENCIL_WRITE: 'sfx-pencil-write',
PIECE_CLICK: 'sfx-piece-click',
};
export function playSound(scene, key) {
try { scene.sound.play(key); } catch (_) {}
}
// chip-bet is limited to one simultaneous instance.
let _chipBetSound = null;
export function playChipBet(scene) {
if (_chipBetSound?.isPlaying) return;
_chipBetSound?.destroy();
_chipBetSound = scene.sound.add(SFX.CHIP_BET);
_chipBetSound.play();
}