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

View File

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

View File

@ -10,6 +10,7 @@ import {
} from './HoldemLogic.js'; } from './HoldemLogic.js';
import { chooseAction } from './HoldemAI.js'; import { chooseAction } from './HoldemAI.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, playChipBet, SFX } from '../../ui/Sounds.js';
// ── Layout constants ────────────────────────────────────────────────────────── // ── Layout constants ──────────────────────────────────────────────────────────
const CX = GAME_WIDTH / 2; // 960 const CX = GAME_WIDTH / 2; // 960
@ -416,6 +417,7 @@ export default class HoldemGame extends Phaser.Scene {
} }
this.foldedCardDisplays = {}; this.foldedCardDisplays = {};
this.gs = startHand(this.gs); this.gs = startHand(this.gs);
playSound(this, SFX.CARD_SHUFFLE);
if (this.gs.phase === 'game_over') { if (this.gs.phase === 'game_over') {
this.showGameOver(); this.showGameOver();
@ -538,6 +540,7 @@ export default class HoldemGame extends Phaser.Scene {
} }
animateFold(seat, onComplete) { animateFold(seat, onComplete) {
playSound(this, SFX.CARD_PLACE);
const sc = this.seatContainers[seat]; const sc = this.seatContainers[seat];
const { x: sx, y: sy } = SEAT_POS[seat]; const { x: sx, y: sy } = SEAT_POS[seat];
const { x: tx, y: ty } = this.betZone(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: sx, y: sy } = SEAT_POS[seat];
const { x: tx, y: ty } = this.betZone(seat); const { x: tx, y: ty } = this.betZone(seat);
playChipBet(this);
const CHIP_COLORS = [0xf0e8d0, 0x4a90d9, 0xe05c5c, 0x5cb85c, 0xf0a830]; const CHIP_COLORS = [0xf0e8d0, 0x4a90d9, 0xe05c5c, 0x5cb85c, 0xf0a830];
const allObjs = []; const allObjs = [];
let done = 0; let done = 0;
@ -788,6 +792,7 @@ export default class HoldemGame extends Phaser.Scene {
ease: 'Power2.easeOut', ease: 'Power2.easeOut',
onComplete: () => { onComplete: () => {
cardCont.destroy(); cardCont.destroy();
playSound(this, SFX.CARD_DEAL);
done++; done++;
if (done === sequence.length) onComplete(); if (done === sequence.length) onComplete();
}, },
@ -841,6 +846,7 @@ export default class HoldemGame extends Phaser.Scene {
this.renderCard(slot.cardObj, card, true); this.renderCard(slot.cardObj, card, true);
} }
slot.cardObj.setVisible(true).setScale(0, 1); slot.cardObj.setVisible(true).setScale(0, 1);
playSound(this, SFX.CARD_SHOW);
this.tweens.add({ this.tweens.add({
targets: slot.cardObj, targets: slot.cardObj,
@ -1031,6 +1037,7 @@ export default class HoldemGame extends Phaser.Scene {
// Compute winnings (resolveShowdown handles the fold-win case correctly) // Compute winnings (resolveShowdown handles the fold-win case correctly)
const winnings = resolveShowdown(players, community); 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 // Hide game-world portraits so their DOM videos don't bleed through the overlay
for (const p of this.portraits) p.hide?.(); 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', () => { const continueBtn = new Button(this, CX, 1042, 'Continue to Next Hand', () => {
this.cleanupSummary(); this.cleanupSummary();
this.dealNextHand(); this.dealNextHand();
}, { width: 320 }).setDepth(D.modal + 2).setAlpha(0); }, { width: 420, fontSize: 24 }).setDepth(D.modal + 2).setAlpha(0);
S.push(continueBtn); S.push(continueBtn);
this.tweens.add({ targets: continueBtn, alpha: 1, duration: 300, delay: 1100 }); 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, COLORS as PCOLORS, ENTRY, HOME_ENTRY, HOME_COL_LEN,
} from './ParchisiLogic.js'; } from './ParchisiLogic.js';
import { chooseMoves } from './ParchisiAI.js'; import { chooseMoves } from './ParchisiAI.js';
import { playSound, SFX } from '../../ui/Sounds.js';
// ── Layout constants ──────────────────────────────────────────────────────── // ── Layout constants ────────────────────────────────────────────────────────
const CELL = 50; const CELL = 50;
@ -327,6 +328,7 @@ export default class ParchisiGame extends Phaser.Scene {
} }
animateDiceRoll(finalValues, onComplete) { animateDiceRoll(finalValues, onComplete) {
playSound(this, SFX.DICE_ROLL);
this.diceContainers.forEach((c) => c.setAlpha(1)); this.diceContainers.forEach((c) => c.setAlpha(1));
let elapsed = 0; let elapsed = 0;
const totalMs = 650; const totalMs = 650;
@ -881,6 +883,7 @@ export default class ParchisiGame extends Phaser.Scene {
// ── Move execution + animation ──────────────────────────────────────────── // ── Move execution + animation ────────────────────────────────────────────
executeMove(move, onComplete) { executeMove(move, onComplete) {
playSound(this, SFX.PIECE_CLICK);
const obj = this.pawnObjs[move.player][move.pawnIdx]; const obj = this.pawnObjs[move.player][move.pawnIdx];
const from = this.locWorld(move.from, move.player); const from = this.locWorld(move.from, move.player);
const to = this.locWorld(move.to, 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 { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { auth } from '../../services/auth.js'; import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js'; import { api } from '../../services/api.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { PHASES, getPhase } from './PhaseSpec.js'; import { PHASES, getPhase } from './PhaseSpec.js';
import { import {
applyDrawFromDeck, applyDrawFromDeck,
@ -433,6 +434,7 @@ export default class Phase10Game extends Phaser.Scene {
const playerCount = this.slotForSeat.length; const playerCount = this.slotForSeat.length;
this.gs = createInitialState({ playerCount }); this.gs = createInitialState({ playerCount });
playSound(this, SFX.CARD_SHUFFLE);
this.renderAll(); this.renderAll();
this.setStatus('Your turn — draw from deck or discard.'); this.setStatus('Your turn — draw from deck or discard.');
this.maybeStartAITurn(); this.maybeStartAITurn();
@ -855,6 +857,7 @@ export default class Phase10Game extends Phaser.Scene {
} }
const next = applyDiscard(this.gs, handIdx); const next = applyDiscard(this.gs, handIdx);
if (next === this.gs) { this.setStatus("Can't discard right now."); return; } if (next === this.gs) { this.setStatus("Can't discard right now."); return; }
playSound(this, SFX.CARD_PLACE);
this.gs = next; this.gs = next;
this.renderAll(); this.renderAll();
this.afterTurnTransition(); this.afterTurnTransition();
@ -914,6 +917,7 @@ export default class Phase10Game extends Phaser.Scene {
this.clearHighlights(); this.clearHighlights();
const next = applyHit(this.gs, handIdx, target.targetSeat, target.groupIdx, target.position); const next = applyHit(this.gs, handIdx, target.targetSeat, target.groupIdx, target.position);
if (next === this.gs) { this.setStatus("Can't hit there."); return; } if (next === this.gs) { this.setStatus("Can't hit there."); return; }
playSound(this, SFX.CARD_PLACE);
this.gs = next; this.gs = next;
this.renderAll(); this.renderAll();
if (this.gs.roundPhase === 'roundOver' || this.gs.roundPhase === 'gameOver') { 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(); for (const o of items) o.destroy();
btn.destroy(); btn.destroy();
this.gs = startNextRound(this.gs); this.gs = startNextRound(this.gs);
playSound(this, SFX.CARD_SHUFFLE);
this.renderAll(); this.renderAll();
this.maybeStartAITurn(); this.maybeStartAITurn();
}, { width: 240, height: 48, fontSize: 20 }).setDepth(D.modal); }, { 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 { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { auth } from '../../services/auth.js'; import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js'; import { api } from '../../services/api.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { import {
BUILD_PILE_COUNT, BUILD_PILE_COUNT,
DISCARD_PILE_COUNT, DISCARD_PILE_COUNT,
@ -312,6 +313,7 @@ export default class SkipBoGame extends Phaser.Scene {
const playerCount = this.slotForSeat.length; const playerCount = this.slotForSeat.length;
this.gs = createInitialState({ playerCount }); this.gs = createInitialState({ playerCount });
playSound(this, SFX.CARD_SHUFFLE);
this.renderAll(); this.renderAll();
this.setStatus('Your turn'); this.setStatus('Your turn');
@ -706,6 +708,7 @@ export default class SkipBoGame extends Phaser.Scene {
const target = this.cardObjs.get(fromKey); const target = this.cardObjs.get(fromKey);
const dest = buildPilePos(action.buildIdx); const dest = buildPilePos(action.buildIdx);
if (!target) { done(); return; } if (!target) { done(); return; }
playSound(this, SFX.CARD_PLACE);
this.tweens.add({ this.tweens.add({
targets: target, targets: target,
x: dest.x, y: dest.y, scale: 1, 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 layout = slotLayout(this.slotForSeat[before.currentPlayer]);
const dest = layout.discards[discardIdx]; const dest = layout.discards[discardIdx];
if (!target) { done(); return; } if (!target) { done(); return; }
playSound(this, SFX.CARD_PLACE);
this.tweens.add({ this.tweens.add({
targets: target, x: dest.x, y: dest.y, targets: target, x: dest.x, y: dest.y,
duration: 240, ease: 'Cubic.easeOut', duration: 240, ease: 'Cubic.easeOut',

View File

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

View File

@ -34,17 +34,25 @@ export default class OpponentSelectScene extends Phaser.Scene {
async create() { async create() {
const cx = GAME_WIDTH / 2; 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', fontFamily: 'Righteous',
fontSize: '52px', fontSize: '52px',
color: COLORS.textHex, color: COLORS.textHex,
}).setOrigin(0.5); }).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', fontFamily: 'Righteous',
fontSize: '36px', fontSize: '36px',
color: COLORS.mutedHex, color: COLORS.mutedHex,
}).setOrigin(0.5); }).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 = []; let opponents = [];
try { try {
@ -59,20 +67,29 @@ export default class OpponentSelectScene extends Phaser.Scene {
} }
const min = this.gameDef.minOpponents ?? 1; const min = this.gameDef.minOpponents ?? 1;
this.startBtn = new Button(this, cx, 1048, 'Start Game', () => this.startGame(), { width: 280 }); new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu'), {
this.startBtn.setEnabled(min === 0);
new Button(this, cx, 978, 'Back', () => this.scene.start('GameMenu'), {
variant: 'ghost', variant: 'ghost',
width: 280, 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--) { for (let i = opponents.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
[opponents[i], opponents[j]] = [opponents[j], opponents[i]]; [opponents[i], opponents[j]] = [opponents[j], opponents[i]];
} }
this.buildOpponentGrid(opponents); 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 ?? [], this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf)); '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); 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') ?? {}; 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) {
const cbd = this.cache.json.get('card-backs') ?? {}; const cardBacks = this.cache.json.get('card-backs')?.cardBacks ?? [];
this.applyDefault('card-backs', cbd.default, 'selectedCardBack', 'cardBackTiles'); 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', 'cursor:pointer',
'box-sizing:border-box', 'box-sizing:border-box',
'user-select:none', 'user-select:none',
'transition:border-color 0.12s', 'transition:border 0.12s,background 0.15s,box-shadow 0.18s,color 0.12s',
].join(';'); ].join(';');
// Portrait wrapper — fixed size so canvas/video can be stacked inside // 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); info.appendChild(bio);
el.appendChild(portraitWrap); el.appendChild(portraitWrap);
el.appendChild(info); el.appendChild(info);
el._nameEl = name;
el.addEventListener('click', () => this.toggleOpponent(opp, el)); el.addEventListener('click', () => this.toggleOpponent(opp, el));
@ -254,7 +276,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
if (this.selected.has(opp.id)) { if (this.selected.has(opp.id)) {
this.selected.delete(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); const card = this.cards.find((c) => c.opp.id === opp.id);
if (card) { if (card) {
card.video.pause(); card.video.pause();
@ -268,14 +290,14 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.selected.delete(oldestId); this.selected.delete(oldestId);
const oldCard = this.cards.find((c) => c.opp.id === oldestId); const oldCard = this.cards.find((c) => c.opp.id === oldestId);
if (oldCard) { if (oldCard) {
oldCard.el.style.borderColor = COLORS.mutedHex; this.applyOpponentStyle(oldCard.el, false);
oldCard.video.pause(); oldCard.video.pause();
oldCard.video.style.display = 'none'; oldCard.video.style.display = 'none';
oldCard.canvas.style.display = 'block'; oldCard.canvas.style.display = 'block';
} }
} }
this.selected.add(opp.id); 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); const card = this.cards.find((c) => c.opp.id === opp.id);
if (card) { if (card) {
card.canvas.style.display = 'none'; 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) { buildOptionSection(label, labelY, items, selectedProp, tilesProp, onSelect, tileW = TILE_W, tileH = TILE_H, tileGap = TILE_GAP) {
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
this.add.text(cx, labelY, label, { const labelText = this.add.text(cx, labelY, label, {
fontFamily: '"Julius Sans One"', fontFamily: '"Julius Sans One"',
fontSize: '24px', fontSize: '24px',
color: COLORS.mutedHex, color: COLORS.mutedHex,
}).setOrigin(0.5); }).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 tileY = labelY + Math.max(82, Math.round(tileH / 2) + 18);
const totalW = items.length * tileW + (items.length - 1) * tileGap; 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) .setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true }); .setInteractive({ useHandCursor: true });
const overlay = this.add.rectangle(0, 0, tileW, tileH, COLORS.accent, 0);
const thumbW = tileW - 16; const thumbW = tileW - 16;
const thumbH = tileH - 30; const thumbH = tileH - 30;
let thumb; let thumb;
@ -338,13 +364,11 @@ export default class OpponentSelectScene extends Phaser.Scene {
color: COLORS.textHex, color: COLORS.textHex,
}).setOrigin(0.5); }).setOrigin(0.5);
container.add([bg, thumb, nameText]); container.add([bg, overlay, thumb, nameText]);
bg.on('pointerup', () => { bg.on('pointerup', () => {
this[selectedProp] = item; this[selectedProp] = item;
for (const t of this[tilesProp]) { this.highlightTile(tilesProp, item.id);
t.bg.setStrokeStyle(3, t.item.id === item.id ? COLORS.accent : COLORS.muted);
}
onSelect(item); onSelect(item);
}); });
bg.on('pointerover', () => { 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); 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) { 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; const def = items.find((i) => i.id === defaultId) ?? items[0] ?? null;
if (!def) return; if (!def) return;
this[selectedProp] = def; 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]) { 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, frameHeight: 420,
}); });
this.load.image('bg-menu', '/assets/images/background-menu.png'); 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.image('main-title', '/assets/images/main-title.png');
this.load.json('playfields', '/data/playfields.json'); this.load.json('playfields', '/data/playfields.json');
this.load.json('card-backs', '/data/card-backs.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() { 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();
}