112 lines
3.6 KiB
JavaScript
112 lines
3.6 KiB
JavaScript
import { enqueue as enqueueSpeech } from './SpeechQueue.js';
|
||
|
||
// ── Opponent conversation coordinator ─────────────────────────────────────────
|
||
// Global, per-scene system: when two opponents that list each other (via the
|
||
// `convo` array in opponents.json) are both present in the same game, they can
|
||
// have a recorded conversation. Each eligible directed pair (owner→match) gets
|
||
// its own independent random 1–5 min one-shot timer and plays once per match.
|
||
// Conversations always queue (bypassing the speech-queue cap) and play
|
||
// sequentially. While playing, both portraits show an orange ring + speech
|
||
// visualizer for the clip's duration.
|
||
//
|
||
// This rides on the shared Portrait factory, so it applies to every game with
|
||
// no per-game wiring.
|
||
|
||
const DEBUG_CONVOS = true;
|
||
|
||
const MIN_MS = 20_000;
|
||
const MAX_MS = 200_000;
|
||
const ORANGE_RGB = [255, 122, 0];
|
||
|
||
function rand(min, max) {
|
||
return min + Math.random() * (max - min);
|
||
}
|
||
|
||
class ConvoManager {
|
||
constructor(scene) {
|
||
this.scene = scene;
|
||
this.entries = [];
|
||
this.armed = false;
|
||
this._armTimer = null;
|
||
}
|
||
|
||
register(entry) {
|
||
this.entries.push(entry);
|
||
// Portraits register synchronously during scene `create`; debounce arming
|
||
// so we only schedule timers once every portrait has registered.
|
||
if (!this.armed) {
|
||
this._armTimer?.remove();
|
||
this._armTimer = this.scene.time.delayedCall(0, () => this._armAll());
|
||
}
|
||
return () => this._unregister(entry);
|
||
}
|
||
|
||
_unregister(entry) {
|
||
const i = this.entries.indexOf(entry);
|
||
if (i >= 0) this.entries.splice(i, 1);
|
||
}
|
||
|
||
_armAll() {
|
||
if (this.armed) return;
|
||
this.armed = true;
|
||
this._armTimer = null;
|
||
|
||
const seen = new Set();
|
||
const debugMatches = [];
|
||
for (const a of this.entries) {
|
||
const convo = a.opponent?.convo;
|
||
if (!Array.isArray(convo)) continue;
|
||
for (const matchId of convo) {
|
||
const b = this.entries.find(e => e !== a && e.opponent?.id === matchId);
|
||
if (!b) continue;
|
||
const key = `${a.opponent.id}->${b.opponent.id}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
const delay = rand(MIN_MS, MAX_MS);
|
||
if (DEBUG_CONVOS) debugMatches.push({ pair: key, file: `convo/${a.opponent.id}-${b.opponent.id}.mp3`, delayMs: delay, playsAt: `${(delay / 1000).toFixed(1)}s` });
|
||
this.scene.time.delayedCall(delay, () => this._playConvo(a, b));
|
||
}
|
||
}
|
||
if (DEBUG_CONVOS) {
|
||
if (debugMatches.length) {
|
||
console.log('[ConvoManager] Scheduled conversations:', debugMatches);
|
||
} else {
|
||
console.log('[ConvoManager] No conversation matches found among present opponents:', this.entries.map(e => e.opponent?.id));
|
||
}
|
||
}
|
||
}
|
||
|
||
_playConvo(a, b) {
|
||
if (!a.isAlive() || !b.isAlive()) return;
|
||
enqueueSpeech(`convo/${a.opponent.id}-${b.opponent.id}`, {
|
||
onStart: () => {
|
||
a.startSpeaking(ORANGE_RGB); b.startSpeaking(ORANGE_RGB);
|
||
a.showRing(); b.showRing();
|
||
},
|
||
onEnd: () => {
|
||
a.stopSpeaking(); b.stopSpeaking();
|
||
a.hideRing(); b.hideRing();
|
||
},
|
||
}, { force: true });
|
||
}
|
||
|
||
destroy() {
|
||
this._armTimer?.remove();
|
||
this._armTimer = null;
|
||
this.entries = [];
|
||
}
|
||
}
|
||
|
||
export function registerConvoPortrait(scene, entry) {
|
||
let mgr = scene.__convoManager;
|
||
if (!mgr) {
|
||
mgr = new ConvoManager(scene);
|
||
scene.__convoManager = mgr;
|
||
scene.events.once('shutdown', () => {
|
||
mgr.destroy();
|
||
if (scene.__convoManager === mgr) scene.__convoManager = null;
|
||
});
|
||
}
|
||
return mgr.register(entry);
|
||
}
|