feat: add opponent pick speech and spectrum visualizer

- Add new `pick` audio clips for each opponent in `opponents.json` and corresponding MP3 files
- Implement a spectrum visualizer overlay for opponent portraits that activates during pick speech playback
- Integrate `SpeechQueue` to handle opponent pick audio with start/stop callbacks for the visualizer
- Add `setMenuMusicVolume` utility and adjust menu music volume during opponent selection
- Prevent speech/visualizer triggers during initial scene load and game start sequences
This commit is contained in:
Brian Fertig 2026-05-23 10:25:29 -06:00
parent 036298227f
commit 19620c10d4
8 changed files with 118 additions and 2 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -23,6 +23,9 @@
"cybro-upset-03", "cybro-upset-03",
"cybro-upset-04", "cybro-upset-04",
"cybro-upset-05" "cybro-upset-05"
],
"pick": [
"cybro-pick"
] ]
} }
}, },
@ -78,6 +81,9 @@
"victor-upset-03", "victor-upset-03",
"victor-upset-04", "victor-upset-04",
"victor-upset-05" "victor-upset-05"
],
"pick": [
"victor-pick"
] ]
} }
}, },
@ -104,6 +110,9 @@
"croc-upset-03", "croc-upset-03",
"croc-upset-04", "croc-upset-04",
"croc-upset-05" "croc-upset-05"
],
"pick": [
"croc-pick"
] ]
} }
}, },
@ -123,6 +132,9 @@
"upset": [ "upset": [
"mario-upset-01", "mario-upset-01",
"mario-upset-02" "mario-upset-02"
],
"pick": [
"mario-upset-01"
] ]
} }
}, },
@ -149,6 +161,9 @@
"ethel-upset-03", "ethel-upset-03",
"ethel-upset-04", "ethel-upset-04",
"ethel-upset-05" "ethel-upset-05"
],
"pick": [
"ethel-pick"
] ]
} }
}, },
@ -175,6 +190,9 @@
"jeff-upset-03", "jeff-upset-03",
"jeff-upset-04", "jeff-upset-04",
"jeff-upset-05" "jeff-upset-05"
],
"pick": [
"jeff-pick"
] ]
} }
}, },
@ -230,6 +248,9 @@
"fireball-upset-03", "fireball-upset-03",
"fireball-upset-04", "fireball-upset-04",
"fireball-upset-05" "fireball-upset-05"
],
"pick": [
"fireball-pick"
] ]
} }
}, },
@ -249,6 +270,9 @@
"upset": [ "upset": [
"bernie-upset-01", "bernie-upset-01",
"bernie-upset-02" "bernie-upset-02"
],
"pick": [
"bernie-happy-01"
] ]
} }
} }

View File

@ -1,7 +1,8 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js'; import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js';
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../ui/SpeechQueue.js';
// Option tile dimensions — playfield rows // Option tile dimensions — playfield rows
const TILE_W = 190; const TILE_W = 190;
@ -30,10 +31,12 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.playfieldTiles = []; this.playfieldTiles = [];
this.selectedCardBack = null; this.selectedCardBack = null;
this.cardBackTiles = []; this.cardBackTiles = [];
this._initializing = false;
} }
async create() { async create() {
playMenuMusic(); playMenuMusic();
setMenuMusicVolume(0.25);
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const bgKey = this.gameDef.category === 'casino' ? 'bg-casino' : 'bg-room'; const bgKey = this.gameDef.category === 'casino' ? 'bg-casino' : 'bg-room';
@ -90,7 +93,15 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.buildOpponentGrid(opponents); this.buildOpponentGrid(opponents);
const max = this.gameDef.maxOpponents ?? 1; const max = this.gameDef.maxOpponents ?? 1;
this._initializing = true;
this.cards.slice(0, max).forEach(({ opp, el }) => this.toggleOpponent(opp, el)); this.cards.slice(0, max).forEach(({ opp, el }) => this.toggleOpponent(opp, el));
this._initializing = false;
this.events.once('shutdown', () => {
resetSpeechQueue();
this.cards.forEach(c => c.destroyViz?.());
if (!this._startingGame) setMenuMusicVolume(0.6);
});
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));
@ -228,8 +239,71 @@ export default class OpponentSelectScene extends Phaser.Scene {
canvas.style.display = 'block'; canvas.style.display = 'block';
}); });
// Spectrum visualizer canvas — overlays portrait when pick audio plays
const vizCanvas = document.createElement('canvas');
vizCanvas.width = portraitSize;
vizCanvas.height = portraitSize;
vizCanvas.style.cssText = `position:absolute;top:0;left:0;width:${portraitSize}px;height:${portraitSize}px;border-radius:50%;pointer-events:none;opacity:0;transition:opacity 0.2s ease;`;
const vizCtx = vizCanvas.getContext('2d');
const vsz = portraitSize;
const BAR_COUNT = 6;
const bars = Array.from({ length: BAR_COUNT }, () => ({ cur: 0.1, target: 0.5 }));
let vizActive = false;
let rafId = null;
let retargetTimer = null;
function _vizFrame() {
if (!vizActive) { rafId = null; vizCtx.clearRect(0, 0, vsz, vsz); return; }
vizCtx.clearRect(0, 0, vsz, vsz);
const gy = vsz * 0.58;
const grad = vizCtx.createLinearGradient(0, gy, 0, vsz);
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(1, 'rgba(0,0,0,0.38)');
vizCtx.fillStyle = grad;
vizCtx.fillRect(0, gy, vsz, vsz - gy);
const barW = vsz * 0.072;
const gap = vsz * 0.03;
const totalW = BAR_COUNT * barW + (BAR_COUNT - 1) * gap;
const startX = (vsz - totalW) / 2;
const maxH = vsz * 0.36;
const baseY = vsz * 0.88;
bars.forEach((bar, i) => {
bar.cur += (bar.target - bar.cur) * 0.14;
const h = Math.max(bar.cur * maxH, vsz * 0.04);
const x = startX + i * (barW + gap);
vizCtx.shadowColor = 'rgba(0,200,255,0.7)';
vizCtx.shadowBlur = 5;
vizCtx.fillStyle = 'rgba(0,200,255,0.88)';
vizCtx.beginPath();
vizCtx.roundRect(x, baseY - h, barW, h, 2);
vizCtx.fill();
});
vizCtx.shadowBlur = 0;
rafId = requestAnimationFrame(_vizFrame);
}
function startViz() {
vizActive = true;
vizCanvas.style.opacity = '1';
if (!retargetTimer) retargetTimer = setInterval(() => { bars.forEach(b => { b.target = 0.2 + Math.random() * 0.8; }); }, 120);
if (!rafId) rafId = requestAnimationFrame(_vizFrame);
}
function stopViz() {
vizActive = false;
vizCanvas.style.opacity = '0';
clearInterval(retargetTimer);
retargetTimer = null;
}
function destroyViz() {
stopViz();
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
}
portraitWrap.appendChild(canvas); portraitWrap.appendChild(canvas);
portraitWrap.appendChild(video); portraitWrap.appendChild(video);
portraitWrap.appendChild(vizCanvas);
// Text block // Text block
const info = document.createElement('div'); const info = document.createElement('div');
@ -268,7 +342,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
el.addEventListener('click', () => this.toggleOpponent(opp, el)); el.addEventListener('click', () => this.toggleOpponent(opp, el));
this.cards.push({ opp, el, canvas, video }); this.cards.push({ opp, el, canvas, video, startViz, stopViz, destroyViz });
return el; return el;
} }
@ -281,10 +355,12 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.applyOpponentStyle(el, false); 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.stopViz();
card.video.pause(); card.video.pause();
card.video.style.display = 'none'; card.video.style.display = 'none';
card.canvas.style.display = 'block'; card.canvas.style.display = 'block';
} }
resetSpeechQueue();
} else { } else {
// If already at max, bump the oldest selection out first // If already at max, bump the oldest selection out first
if (this.selected.size >= max) { if (this.selected.size >= max) {
@ -292,6 +368,7 @@ 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.stopViz();
this.applyOpponentStyle(oldCard.el, false); this.applyOpponentStyle(oldCard.el, false);
oldCard.video.pause(); oldCard.video.pause();
oldCard.video.style.display = 'none'; oldCard.video.style.display = 'none';
@ -309,6 +386,16 @@ export default class OpponentSelectScene extends Phaser.Scene {
card.canvas.style.display = 'block'; card.canvas.style.display = 'block';
}); });
} }
if (!this._initializing) {
const pickClips = opp.speech?.pick;
if (pickClips?.length) {
resetSpeechQueue();
enqueueSpeech(pickClips[Math.floor(Math.random() * pickClips.length)], {
onStart: () => this.cards.find(c => c.opp.id === opp.id)?.startViz(),
onEnd: () => this.cards.find(c => c.opp.id === opp.id)?.stopViz(),
});
}
}
} }
this.startBtn.setEnabled(this.selected.size >= min); this.startBtn.setEnabled(this.selected.size >= min);
} }
@ -422,6 +509,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
startGame() { startGame() {
if (this.selected.size === 0) return; if (this.selected.size === 0) return;
this._startingGame = true;
stopMenuMusic(); stopMenuMusic();
const opponents = this.cards const opponents = this.cards
.filter(({ opp }) => this.selected.has(opp.id)) .filter(({ opp }) => this.selected.has(opp.id))

View File

@ -18,3 +18,7 @@ export function playMenuMusic() {
export function stopMenuMusic() { export function stopMenuMusic() {
if (_audio) _audio.pause(); if (_audio) _audio.pause();
} }
export function setMenuMusicVolume(vol) {
_getAudio().volume = vol;
}