471 lines
17 KiB
JavaScript
471 lines
17 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?: () => 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', 'researchComplete', …).
|
|
* 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.
|
|
*
|
|
* @returns {number} how many comms armed
|
|
*/
|
|
trigger(name) {
|
|
if (this._down || !this.enabled) return 0;
|
|
const due = commsForTrigger(this.cfg, name, this.played);
|
|
for (const def of due) {
|
|
this.queue.push({
|
|
def,
|
|
dueAt: this.scene.time.now + def.delayMs,
|
|
assets: commAssets(this.cfg, def),
|
|
loadingDone: false,
|
|
});
|
|
}
|
|
return due.length;
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
}
|