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-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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -18,3 +18,7 @@ export function playMenuMusic() {
|
|||
export function stopMenuMusic() {
|
||||
if (_audio) _audio.pause();
|
||||
}
|
||||
|
||||
export function setMenuMusicVolume(vol) {
|
||||
_getAudio().volume = vol;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue