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?: () => boolean, the scene isn't free (a console is up, mining * is live, a clip is in flight) — the comm waits * 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()) return; // the console owns the screen — 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)); }