feat: add speech queue system and update audio configuration

- Implement `SpeechQueue` to manage opponent dialogue clips
- Add `speech` configuration to opponent data (Cy-Bro, Victor, Croc) with intro, happy, and upset states
- Trigger random speech clips in `Portrait.js` based on emotion and a 60% probability
- Play intro speech clips when opponent portraits are created
- Update `MusicPlayer` volume to 0.15 and clean up formatting/whitespace
This commit is contained in:
Brian Fertig 2026-05-22 16:35:46 -06:00
parent 4b0551c3cd
commit f6a4a3fb5d
40 changed files with 122 additions and 23 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4,7 +4,27 @@
"id": "cybro",
"spriteIndex": 0,
"name": "Cy-Bro",
"bio": "AI Bro sent from the future to show the superiority of the future."
"bio": "AI Bro sent from the future to show the superiority of the future.",
"speech": {
"intro": [
"cybro-intro-01",
"cybro-intro-02"
],
"happy": [
"cybro-happy-01",
"cybro-happy-02",
"cybro-happy-03",
"cybro-happy-04",
"cybro-happy-05"
],
"upset": [
"cybro-upset-01",
"cybro-upset-02",
"cybro-upset-03",
"cybro-upset-04",
"cybro-upset-05"
]
}
},
{
"id": "brad",
@ -16,13 +36,53 @@
"id": "victor",
"spriteIndex": 2,
"name": "Victor",
"bio": "Patient and calculating. Moves are made using ancient technology."
"bio": "Patient and calculating. Moves are made using ancient technology.",
"speech": {
"intro": [
"victor-intro-01",
"victor-intro-02"
],
"happy": [
"victor-happy-01",
"victor-happy-02",
"victor-happy-03",
"victor-happy-04",
"victor-happy-05"
],
"upset": [
"victor-upset-01",
"victor-upset-02",
"victor-upset-03",
"victor-upset-04",
"victor-upset-05"
]
}
},
{
"id": "croc",
"spriteIndex": 3,
"name": "Croc",
"bio": "Wassssuppp everybody! I'm here for the party!!"
"bio": "Wassssuppp everybody! I'm here for the party!!",
"speech": {
"intro": [
"croc-intro-01",
"croc-intro-02"
],
"happy": [
"croc-happy-01",
"croc-happy-02",
"croc-happy-03",
"croc-happy-04",
"croc-happy-05"
],
"upset": [
"croc-upset-01",
"croc-upset-02",
"croc-upset-03",
"croc-upset-04",
"croc-upset-05"
]
}
},
{
"id": "mario",

View File

@ -1,9 +1,9 @@
import * as Phaser from 'phaser';
import { COLORS, GAME_WIDTH } from '../config.js';
const BTN = 32;
const GAP = 6;
const PAD = 12;
const BTN = 32;
const GAP = 6;
const PAD = 12;
const DEPTH = 90;
function shuffle(arr) {
@ -17,25 +17,25 @@ function shuffle(arr) {
export class MusicPlayer {
constructor(scene, tracks) {
this.scene = scene;
this.scene = scene;
this.tracks = shuffle(tracks);
this.idx = 0;
this.muted = false;
this.idx = 0;
this.muted = false;
this._audio = null;
this._objs = [];
this._objs = [];
this._buildUI();
this._play(0);
scene.events.once('shutdown', () => this.destroy());
scene.events.once('destroy', () => this.destroy());
scene.events.once('destroy', () => this.destroy());
}
_buildUI() {
const s = this.scene;
const s = this.scene;
const muteX = GAME_WIDTH - PAD - BTN / 2;
const skipX = muteX - GAP - BTN;
const btnY = PAD + BTN / 2;
const btnY = PAD + BTN / 2;
const infoY = PAD + BTN + GAP + 8;
this._muteX = muteX;
@ -48,9 +48,9 @@ export class MusicPlayer {
Phaser.Geom.Rectangle.Contains,
);
skipBg.input.cursor = 'pointer';
skipBg.on('pointerover', () => this._drawBg(skipBg, skipX, btnY, true));
skipBg.on('pointerout', () => this._drawBg(skipBg, skipX, btnY, false));
skipBg.on('pointerdown', () => this.next());
skipBg.on('pointerover', () => this._drawBg(skipBg, skipX, btnY, true));
skipBg.on('pointerout', () => this._drawBg(skipBg, skipX, btnY, false));
skipBg.on('pointerdown', () => this.next());
const skipIcon = s.add.text(skipX, btnY, '⏭', {
fontFamily: 'sans-serif', fontSize: '17px', color: COLORS.textHex,
@ -63,9 +63,9 @@ export class MusicPlayer {
Phaser.Geom.Rectangle.Contains,
);
muteBg.input.cursor = 'pointer';
muteBg.on('pointerover', () => this._drawBg(muteBg, muteX, btnY, true));
muteBg.on('pointerout', () => this._drawBg(muteBg, muteX, btnY, false));
muteBg.on('pointerdown', () => this.toggleMute());
muteBg.on('pointerover', () => this._drawBg(muteBg, muteX, btnY, true));
muteBg.on('pointerout', () => this._drawBg(muteBg, muteX, btnY, false));
muteBg.on('pointerdown', () => this.toggleMute());
this._muteIcon = s.add.text(muteX, btnY, '♪', {
fontFamily: 'sans-serif', fontSize: '17px', color: COLORS.textHex,
@ -97,9 +97,9 @@ export class MusicPlayer {
}
const track = this.tracks[idx];
this._audio = new Audio(`/assets/music/${track.file}`);
this._audio.muted = this.muted;
this._audio.volume = 0.7;
this._audio.play().catch(() => {});
this._audio.muted = this.muted;
this._audio.volume = 0.15;
this._audio.play().catch(() => { });
this._audio.onended = () => this.next();
this._updateInfo();
}
@ -139,7 +139,7 @@ export class MusicPlayer {
this._audio.onended = null;
this._audio = null;
}
for (const o of this._objs) { try { o.destroy(); } catch (_) {} }
for (const o of this._objs) { try { o.destroy(); } catch (_) { } }
this._objs = [];
}
}

View File

@ -1,6 +1,7 @@
import { COLORS } from '../config.js';
import { auth } from '../services/auth.js';
import { api } from '../services/api.js';
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from './SpeechQueue.js';
// ── Shared portrait utility ───────────────────────────────────────────────────
// Both factories add elements directly to the scene at world coordinates so
@ -97,6 +98,11 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius,
videoEl.play().catch(returnToIdle);
videoEl.onended = returnToIdle;
videoEl.onerror = returnToIdle;
const speechClips = opponent?.speech?.[emotion];
if (speechClips?.length && Math.random() < 0.6) {
enqueueSpeech(speechClips[Math.floor(Math.random() * speechClips.length)]);
}
}
function hide() {
@ -114,10 +120,16 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius,
function destroy() {
videoEl.pause();
videoEl.src = '';
resetSpeechQueue();
}
scene.events.once('shutdown', destroy);
if (opponent?.speech?.intro?.length) {
const clips = opponent.speech.intro;
enqueueSpeech(clips[Math.floor(Math.random() * clips.length)]);
}
return { playEmotion, hide, show, destroy };
}

View File

@ -0,0 +1,27 @@
let _queue = [];
let _playing = false;
let _currentAudio = null;
const MAX = 4;
export function enqueue(filename) {
if (_queue.length + (_playing ? 1 : 0) >= MAX) return;
_queue.push(filename);
_playNext();
}
function _playNext() {
if (_playing || !_queue.length) return;
_playing = true;
_currentAudio = new Audio(`/assets/speech/${_queue.shift()}.mp3`);
_currentAudio.volume = 0.8;
const done = () => { _playing = false; _currentAudio = null; _playNext(); };
_currentAudio.onended = done;
_currentAudio.onerror = done;
_currentAudio.play().catch(done);
}
export function resetQueue() {
if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; }
_queue = [];
_playing = false;
}