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

View File

@ -1,7 +1,8 @@
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.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
const TILE_W = 190;
@ -30,10 +31,12 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.playfieldTiles = [];
this.selectedCardBack = null;
this.cardBackTiles = [];
this._initializing = false;
}
async create() {
playMenuMusic();
setMenuMusicVolume(0.25);
const cx = GAME_WIDTH / 2;
const bgKey = this.gameDef.category === 'casino' ? 'bg-casino' : 'bg-room';
@ -90,7 +93,15 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.buildOpponentGrid(opponents);
const max = this.gameDef.maxOpponents ?? 1;
this._initializing = true;
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 ?? [],
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
@ -228,8 +239,71 @@ export default class OpponentSelectScene extends Phaser.Scene {
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(video);
portraitWrap.appendChild(vizCanvas);
// Text block
const info = document.createElement('div');
@ -268,7 +342,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
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;
}
@ -281,10 +355,12 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.applyOpponentStyle(el, false);
const card = this.cards.find((c) => c.opp.id === opp.id);
if (card) {
card.stopViz();
card.video.pause();
card.video.style.display = 'none';
card.canvas.style.display = 'block';
}
resetSpeechQueue();
} else {
// If already at max, bump the oldest selection out first
if (this.selected.size >= max) {
@ -292,6 +368,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.selected.delete(oldestId);
const oldCard = this.cards.find((c) => c.opp.id === oldestId);
if (oldCard) {
oldCard.stopViz();
this.applyOpponentStyle(oldCard.el, false);
oldCard.video.pause();
oldCard.video.style.display = 'none';
@ -309,6 +386,16 @@ export default class OpponentSelectScene extends Phaser.Scene {
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);
}
@ -422,6 +509,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
startGame() {
if (this.selected.size === 0) return;
this._startingGame = true;
stopMenuMusic();
const opponents = this.cards
.filter(({ opp }) => this.selected.has(opp.id))

View File

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