orbit/js/comms/CommsHub.js

483 lines
18 KiB
JavaScript

import { config } from '../config/Config.js';
import { CommsBox } from '../ui/CommsBox.js';
import { commsEnabled, commsForTrigger, commAssets, charEntry } from './CommsModel.js';
/**
* COMMS HUB — the scene-facing manager of data/comms.json.
*
* The hub owns a comm's life end-to-end:
*
* trigger(name) → the roster's due comms queue up (their delayMs beats
* whatever toast the event fired — the voice lands
* second, like a reply);
* pump(time) → the scene's update() calls it each frame; when the
* scene is FREE (the busy() contract) and the comm's
* assets are in, the next comm plays;
* play → the box (js/ui/CommsBox.js) opens under the mineral
* bar — the muted clip loops (ack) or runs once
* (visual) while the speech clip plays and the music
* ducks under it;
* finish → 'ack' waits for the ACKNOWLEDGE press (the action is
* frozen the whole time — the pause contract below);
* 'visual' holds a beat after the voice and fades;
* 'audio' is voice only — no box at all.
*
* DEFERRED ASSETS — the load is the COMM's, not the scene's (the point:
* the game can grow dozens of comms without preloading dozens of clips
* into every scene): when a comm is due, its character clip + speech
* file are queued on the scene's loader (`scene.load.video` /
* `scene.load.audio` → `start()` → 'complete') and played straight off
* the cache. The cache is shared game-wide, so the second scene to need
* OAC's clip gets it for free.
*
* THE PAUSE CONTRACT (ack comms only — visual/audio never pause; the
* player agreed to that: visual comms are the short ones):
* - input: the scene's handler swallows world clicks (GameScene gates
* on `comms.paused`) — the ACKNOWLEDGE button presses itself (its
* own pointerdown, the save-panel's modal idiom);
* - action: the scene's update() gates the ship's steering, the
* research/build ticks, and discovery on `comms.paused` — the world
* keeps breathing (starfield, tether, the clip), the action holds;
* - onPause hook: the scene parks the ship (`ship.stop()`) and drops
* the mining arm at pause start.
*
* PLAYED LEDGER (save-backed): the run's already-played comm ids live
* in the shared registry under 'commsPlayed' (a Set) — SaveData
* captures/restores it (record.comms.played), and a NEW GAME resets it
* (the welcome plays on every fresh run; a loaded run doesn't re-hear
* it). Marked PLAYED when the comm starts, so a comm the run never
* heard (abandoned mid-queue) can still arm on its next trigger.
*
* Config: data/comms.json (enabled, audio, characters, comms, box).
*/
const PLAYED_KEY = 'commsPlayed';
/** A deferred asset that hasn't landed in 15 s: play the comm anyway
* (NO SIGNAL plate / voice-less) — the queue never holds a comm forever. */
const LOAD_VALVE_MS = 15000;
export class CommsHub {
/**
* @param {Phaser.Scene} scene
* @param {{
* busy?: (commType?: string) => boolean,
* the scene isn't free for this comm type — the
* comm waits (the scene may relax the hold for
* 'audio' comms: voice over an in-progress
* action like mining is fine)
* anchor?: () => ({right:number, top:number}) | null,
* the mineral bar's lower-right (the box docks
* below it; null → the standard corner fallback)
* onPause?: (on: boolean) => void, the ack pause's scene-side effects
* }} [o]
*/
constructor(scene, o = {}) {
this.scene = scene;
this.cfg = config.section('comms', {});
this.enabled = commsEnabled(this.cfg);
this.busy = typeof o.busy === 'function' ? o.busy : null;
this.anchor = typeof o.anchor === 'function' ? o.anchor : null;
this.onPause = typeof o.onPause === 'function' ? o.onPause : null;
this.queue = []; // { def, dueAt, assets, loadingDone }
this.current = null; // the comm in play (its def)
this.box = null; // the live CommsBox (ack/visual comms)
this.played = this._playedSet();
this._loading = false; // a deferred asset pass is in flight
this._voiceKey = null; // the speech clip talking (or null)
this._voiceTimer = null; // the isPlaying poll (v4 has no complete event)
this._voiceDone = false; // this comm's voice has ended (or never existed)
this._ducked = false;
this._duckedSounds = null; // [{s, vol}] — the per-sound music duck (restore map)
this._paused = false;
this._endedAt = 0; // the last _finish (scene.time.now) — the ACK-click race guard
this._down = false; // torn down (scene shutdown) — all quiet
scene.events.once('shutdown', () => this.destroy());
}
// ------------------------------------------------------------------
// state the scene reads
// ------------------------------------------------------------------
/** True while an 'ack' comm holds the box open (the pause contract). */
get paused() {
return this._paused;
}
/** True while a comm is in play (box open or voice talking). */
get active() {
return this.current !== null;
}
/** How many comms are waiting behind the current one. */
get queued() {
return this.queue.length;
}
/**
* True if a comm just ended (the ACK press / the fade-out). The scene
* uses this as a RACE guard: the click that ACKNOWLEDGED (or the fade
* that closed a visual comm) is the comm's own — whatever the input
* dispatch order (button handler vs. scene handler), that click is
* never a world click. A deliberate NEXT action is always >200ms out.
*/
endedWithin(ms) {
return this._endedAt > 0 && (this.scene.time.now - this._endedAt) < ms;
}
// ------------------------------------------------------------------
// the scene's two calls
// ------------------------------------------------------------------
/**
* Arm the comms due for an event ('newGame', 'firstScan', …).
* Each one queues with its delayMs and starts its deferred asset load
* immediately (while the event's toast is still up, the network is
* warm) — by the time the scene is free they are in.
* IDEMPOTENT: a comm already in play or already queued is not armed
* twice (a double click on the button that fired the event must not
* double the comm — the played ledger only knows once it has STARTED).
*
* @returns {number} how many comms armed
*/
trigger(name) {
if (this._down || !this.enabled) return 0;
const inFlight = new Set();
if (this.current) inFlight.add(this.current.id);
for (const e of this.queue) inFlight.add(e.def.id);
let armed = 0;
for (const def of commsForTrigger(this.cfg, name, this.played)) {
if (inFlight.has(def.id)) continue; // already queued / in play
this.queue.push({
def,
dueAt: this.scene.time.now + def.delayMs,
assets: commAssets(this.cfg, def),
loadingDone: false,
});
inFlight.add(def.id);
armed++;
}
return armed;
}
/**
* Per-frame (the scene's update() drives it — the TimeClock is scene-
* driven in v4): step the live comm (the box's text decode) and, when
* idle, start the next due comm if the scene is free and the assets
* are in.
*/
pump(time) {
if (this._down) return;
if (this.current) {
this.box?.update(time);
return;
}
if (this._loading) return; // the asset pass re-pumps itself on 'complete'
const entry = this.queue[0];
if (!entry) return;
if (time < entry.dueAt) return;
if (this.busy && this.busy(entry.def.type)) return; // the screen owns this comm type — wait
this.queue.shift(); // COMMIT — a comm plays at most once (the played
// ledger then bars its next trigger; without this the entry would
// sit here and re-play every time the hub went idle)
this._start(entry);
}
// ------------------------------------------------------------------
// the comm's life
// ------------------------------------------------------------------
/** Start the entry: defer-load whatever's missing, then play. */
_start(entry) {
const s = this.scene;
const missing = [];
if (entry.assets.video && !_hasVideo(s, entry.assets.video.key)) {
missing.push({ kind: 'video', spec: entry.assets.video });
}
if (entry.assets.audio && !_hasAudio(s, entry.assets.audio.key)) {
missing.push({ kind: 'audio', spec: entry.assets.audio });
}
if (missing.length === 0) {
this._play(entry);
return;
}
this._loading = true;
for (const { kind, spec } of missing) {
if (kind === 'video') s.load.video(spec.key, spec.url);
else s.load.audio(spec.key, spec.url);
}
let done = false;
let valve = null;
const finish = () => {
if (done || this._down) return;
done = true;
entry.loadingDone = true;
this._loading = false;
if (valve) {
valve.remove(false);
valve = null;
}
s.load.off('complete', finish); // the other finish path — keep it clean
// Whatever landed, the comm plays (a missing clip degrades to the
// NO SIGNAL plate / a voice-less box — the comm is never lost).
this._play(entry);
};
s.load.once('complete', finish);
s.load.start();
// The stall valve (the project's idiom — a clip that never starts
// must not hold the player): the load must resolve on its own.
valve = s.time.delayedCall(LOAD_VALVE_MS, finish);
}
/** The comm is in play: the box (ack/visual) + the voice + the pause. */
_play(entry) {
const s = this.scene;
const def = entry.def;
this.current = def;
this._voiceDone = false;
this.played.add(def.id); // the run has heard it (or is hearing it)
// The ack comm freezes the action FIRST (before the box slides in,
// before the voice) — the ship is already parked as the box lands.
// Visual/audio comms never pause (the agreed contract — they are the
// short ones).
if (def.type === 'ack') this._setPaused(true);
if (def.type !== 'audio') {
const char = charEntry(this.cfg, def.from) ?? {};
const video = entry.assets.video && _hasVideo(s, entry.assets.video.key)
? { key: entry.assets.video.key }
: null;
this.box = new CommsBox(s, this._anchor(), {
label: char.label ?? def.from,
role: char.role ?? '',
text: def.text,
loop: def.type === 'ack', // the clip loops silently until acknowledged
video,
showAck: def.type === 'ack',
onAck: () => this._ack(),
});
this.box.open();
}
this._speak(entry);
}
/** The comm's voice (the speech clip), with the music ducked under it. */
_speak(entry) {
const s = this.scene;
const spec = entry.assets.audio;
const snd = s.sound;
if (!spec || !_hasAudio(s, spec.key) || !snd || typeof snd.play !== 'function') {
// Voice-less comm (missing clip, or no sound manager — headless):
// the comm still shows; the finish path just runs early.
this._voiceKey = null;
this._voiceEnded();
return;
}
this._voiceKey = spec.key;
this._voiceDone = false;
this._duck(true);
this.box?.setVoice(true);
// The comm's voice is the comm's CONTENT (not an SFX — it plays even
// with sfx.enabled off; the cache/manager guards above are the
// failure path, per the project's sound rule).
try {
snd.play(spec.key, { volume: config.get('comms.audio.volume', 1.0) });
} catch (e) {
// The engine refused (autoplay policy, the manager mid-shutdown):
// the comm still shows — finish on the voice-less path, never hang.
console.warn('[comms] voice refused by the engine', e);
this._voiceKey = null;
this._voiceEnded();
return;
}
// v4 (Giedi) has no 'complete' event on the sound manager (checked —
// see Music.js) — poll isPlaying() on the shuffle's time base.
this._voiceTimer = s.time.addEvent({
delay: 250,
loop: true,
callback: () => {
if (this._down || !this.current || this._voiceKey !== spec.key) return;
if (snd.isPlaying(spec.key) !== true) {
this._voiceKey = null;
this._voiceEnded();
}
},
});
}
/** The voice ended: ack waits for the button; the rest fade out. */
_voiceEnded() {
if (this._voiceDone || !this.current) return;
this._voiceDone = true;
this._stopVoice();
this.box?.setVoice(false);
this._duck(false);
const def = this.current;
if (def.type === 'ack') return; // the box holds (video looping) until ACKNOWLEDGE
const hold =
def.type === 'visual'
? Math.max(0, config.get('comms.box.animation.visualHoldMs', 350))
: 0;
this.scene.time.delayedCall(hold, () => {
if (this.current === def) this._finish();
});
}
/** The ACKNOWLEDGE press (the box's button — the scene is the voice). */
_ack() {
if (!this.current || this.current.type !== 'ack') return;
this._finish();
}
/** The comm is over: tear down, unpause, hand the next one its turn. */
_finish() {
if (!this.current) return;
this._stopVoice();
this._duck(false);
this._setPaused(false);
this._endedAt = this.scene.time.now; // the ACK-click race guard (the scene swallows it)
const box = this.box;
this.box = null;
this.current = null;
this._voiceDone = true;
if (box) box.close(); // the fade-out; the hub moves on (the next pump)
}
/** Tear everything down (scene shutdown / destroy). Idempotent. */
destroy() {
if (this._down) return;
this._down = true;
this._stopVoice();
this._duck(false);
this._setPaused(false);
this.queue.length = 0;
this.current = null;
if (this.box) {
this.box.destroy();
this.box = null;
}
}
// ------------------------------------------------------------------
// internals
// ------------------------------------------------------------------
_stopVoice() {
if (this._voiceTimer) {
this._voiceTimer.remove(false);
this._voiceTimer = null;
}
if (this._voiceKey) {
const snd = this.scene.sound;
if (snd && typeof snd.stopByKey === 'function') {
try {
snd.stopByKey(this._voiceKey);
} catch {
/* the manager is gone (shutdown) — fine */
}
}
this._voiceKey = null;
}
}
_duck(on) {
const snd = this.scene && this.scene.sound;
if (!snd) return;
if (on && !this._ducked) {
// The duck is PER-SOUND, never the manager's master: `sound.setVolume`
// is the MASTER volume node (v4), and it would quiet the comm's OWN
// voice along with the music — the voice is the point. Music is
// played per-sound at music.volume (Music.js), so lower the other
// playing sounds' own volume by the factor and restore it exactly.
this._ducked = true;
this._duckedSounds = [];
const f = config.get('comms.audio.musicDuck', 0.3);
const playing = typeof snd.getAllPlaying === 'function' ? snd.getAllPlaying() : [];
for (const s of playing) {
if (!s || s.key === this._voiceKey) continue; // the voice is exempt
if (typeof s.setVolume !== 'function') continue;
// The LOGICAL volume (currentConfig) is the source of truth — the
// gain node's read can lag in a suspended context (headless tabs),
// so the restore map is keyed off the config, not the node.
const vol = s.currentConfig && typeof s.currentConfig.volume === 'number'
? s.currentConfig.volume
: s.volume;
if (typeof vol !== 'number') continue;
this._duckedSounds.push({ s, vol });
try {
s.setVolume(vol * f);
} catch (e) {
console.warn('[comms] duck failed', e);
}
}
} else if (!on && this._ducked) {
this._ducked = false;
for (const { s, vol } of this._duckedSounds) {
try {
s.setVolume(vol);
} catch {
/* it ended mid-duck (a track rolled on) — fine */
}
}
this._duckedSounds = null;
}
}
_setPaused(on) {
const want = on === true;
if (want === this._paused) return;
this._paused = want;
if (this.onPause) {
try {
this.onPause(want);
} catch (e) {
console.warn('[comms] pause hook failed', e);
}
}
}
/** The box's dock: the mineral bar's lower-right, or the standard corner. */
_anchor() {
if (this.anchor) {
try {
const a = this.anchor();
if (a && Number.isFinite(a.right) && Number.isFinite(a.top)) return a;
} catch {
/* the hud is gone (shutdown race) — fall through */
}
}
const W = this.scene.scale?.width ?? 1280;
return { right: W - 16, top: 47 }; // the hud's standard geometry
}
/**
* The run's played ledger (the shared registry — SaveData captures it
* and a new game resets it). A record predating comms has none → a
* fresh Set (the welcome plays; old saves load).
*/
_playedSet() {
const reg = this.scene.registry;
if (!reg) return new Set();
const cur = reg.get(PLAYED_KEY);
if (cur instanceof Set) return cur;
const set = new Set(Array.isArray(cur) ? cur.filter((id) => typeof id === 'string') : []);
reg.set(PLAYED_KEY, set);
return set;
}
}
// ----------------------------------------------------------------------
// the v4 cache guards (the Sfx.js/ResearchWindow idiom)
// ----------------------------------------------------------------------
function _hasVideo(scene, key) {
const c = scene.cache?.video;
return !!(c && typeof c.has === 'function' && c.has(key));
}
function _hasAudio(scene, key) {
const c = scene.cache?.audio;
return !!(c && typeof c.has === 'function' && c.has(key));
}