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:
parent
036298227f
commit
19620c10d4
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue