orbit/js/vignettes/VignetteHub.js

511 lines
19 KiB
JavaScript

import { config } from '../config/Config.js';
import {
vignettesEnabled,
vignettesForLocation,
vignetteAvailable,
vignetteAssets,
charEntry,
lineAssets,
} from './VignetteModel.js';
import { VignetteWindow } from '../ui/VignetteWindow.js';
/**
* VIGNETTE HUB — the scene manager for data/vignettes.json, the
* SurfaceScene's counterpart to GameScene's CommsHub (js/comms/CommsHub.js):
*
* - availability: a vignette is DUE for a location when it is bound to
* it (location 'bar' | 'shop' | …), unplayed (its once-ledger — the
* run's registry set 'vignettesPlayed', SaveData persists it), and
* its QUEST GATE holds (the scene builds the gate's facts from the
* sleeping GameScene: quest granted, unclaimed, step open — the
* scene it is a step of);
* - deferred assets: on demand — when a scene is armed, its character
* clip(s) + speech file(s) load through the scene loader (a 15 s stall
* valve releases the load — the scene plays whatever landed, the
* missing clip degrades to the NO SIGNAL plate, a missing voice to a
* text-only line);
* - the OPENING BEAT: the scene's window opens startDelayMs after the
* location's clip came up (the place reads first, then the scene
* begins) — if the player leaves the location before the beat lands
* (cancelPending), the scene is cancelled and re-arms on the next
* visit (the played ledger only marks on COMPLETION);
* - the SCENE: the first line decodes in over the speaker's face; NEXT
* advances (the voice cuts, the next face/line/voice take over — the
* same speaker's clip keeps rolling); the last line's DONE closes the
* window and returns to the looping clip alone;
* - the VOICE: each line's speech clip plays muted-video-only (the
* window's clip is ALWAYS muted — the voice is the audio, never the
* video); while it talks the other playing sounds (the surface hum)
* duck PER-SOUND (v4: `sound.setVolume` is the master — ducking it
* would quiet the voice too) and are restored when it ends;
* - the LOCK: while the scene holds, the scene's onLock(true) stands
* down the deck (NEXT/DONE is the only live control on the surface);
* on completion onLock(false) + onComplete(vig) hand the location
* back (the shop then raises its buy/sell view — the scene played
* BEFORE the player can touch it).
*
* The hub is the EAR — it plays the voice, owns the window, advances the
* lines. The VignetteWindow is the MOUTH (passive — it shows, and
* reports the NEXT press). The SURFACE SCENE is the throat (it arms the
* scene from the location entry, stands the deck down, and hands the
* location back on completion).
*
* The SURFACE scene constructs one (per visit to the surface — the hub
* dies with the scene; the ledger lives in the shared registry, so the
* next visit remembers), pumps `update(time)` in its update loop, calls
* `tryPlay(location)` when the player enters a location, `cancelPending()`
* when they leave before the beat, and reads `active` to stand down its
* own input.
*/
// The registry key of the run's played ledger (SaveData persists it —
// the same shape as the comms' 'commsPlayed').
const LEDGER_KEY = 'vignettesPlayed';
// How long a deferred asset pass may stall the load (then it releases —
// the scene plays whatever landed; the comms' valve).
const LOAD_VALVE_MS = 15000;
// How often the voice-completion poll ticks (v4 has no 'complete' event
// — the comms' recipe).
const VOICE_POLL_MS = 250;
export class VignetteHub {
/**
* @param {Phaser.Scene} scene the SurfaceScene (it owns the clock + loader)
* @param {{
* gate?: (vig: object) => object | null, the scene's gate facts from the
* sleeping GameScene: { granted, claimed, stepExists, stepDone } or
* null (the scene couldn't read the ledger) — the model treats a
* gated-but-unreadable scene as unavailable
* onLock?: (locked: boolean) => void, deck up / down (the scene stands
* the world down while the scene holds)
* onComplete?: (vig: object) => void, the scene finished (the ledger is
* marked; the scene hands the location back — the shop raises its view)
* }} o
*/
constructor(scene, o = {}) {
this.scene = scene;
this.cfg = config.section('vignettes', {});
this.enabled = vignettesEnabled(this.cfg);
this.startDelayMs = Math.max(0, Math.round(config.get('vignettes.window.startDelayMs') ?? 700));
this.voiceVolume = Math.max(0, Math.min(1, config.get('vignettes.audio.volume', 1.0)));
this.musicDuck = Math.max(0, Math.min(1, config.get('vignettes.audio.musicDuck', 0.3)));
this._gate = typeof o.gate === 'function' ? o.gate : null;
this._onLock = typeof o.onLock === 'function' ? o.onLock : null;
this._onComplete = typeof o.onComplete === 'function' ? o.onComplete : null;
this.played = this._playedSet();
this.current = null; // the scene in play (the normalized vignette)
this.lineIndex = 0;
this.window = null; // the live VignetteWindow
this.pending = null; // { vig, at, loading } — armed, not yet open
this._voiceKey = null;
this._voiceTimer = null;
this._ducked = false;
this._duckedSounds = null;
this._down = false; // destroyed — no more callbacks
scene.events.once('shutdown', () => this.destroy());
}
// ------------------------------------------------------------------
// the scene's handles
// ------------------------------------------------------------------
/** A scene is holding the surface (the window is open). */
get active() {
return this.window !== null;
}
/** A scene is armed (pending) or in play. */
get busy() {
return this.pending !== null || this.window !== null;
}
/**
* Arm the scene due for this location (the scene calls it when the
* player enters one — toggleBar 'bar', toggleShop 'shop'). Starts the
* deferred asset pass (if anything is missing), then the window opens
* `startDelayMs` later — the location's clip reads first.
*
* @returns {boolean} true when a scene was armed (the scene should
* defer the location's own UI — the shop's buy/sell view — until the
* scene completes)
*/
tryPlay(location) {
if (this._down || !this.enabled) return false;
if (this.busy) return false; // a scene already holds the surface
const vig = this._dueAt(location);
if (!vig) return false;
const pending = { vig, at: this.scene.time.now + this.startDelayMs, loading: false };
this.pending = pending;
this._startLoad(pending, vignetteAssets(this.cfg, vig));
return true;
}
/**
* Cancel the armed-but-not-yet-open scene (the scene calls it when the
* player leaves the location before the opening beat — LEAVE BAR /
* LEAVE SHOP). The load in flight may still complete — harmless (the
* cache is shared). The played ledger is untouched: the scene re-arms
* on the next visit (a conversation is a place you can return to).
*/
cancelPending() {
this.pending = null;
}
/**
* The pump (the scene calls it every frame while it's alive): opens a
* ready pending scene once its beat lands; drives the live window's
* decode + pulse.
*/
update(time) {
if (this._down) return;
if (this.window) {
this.window.update(time);
return;
}
const p = this.pending;
if (!p || p.loading) return; // the asset pass still runs
if (time < p.at) return; // the opening beat is still playing
this.pending = null;
this._play(p.vig);
}
/** Tear the hub down (scene shutdown / menu return). Idempotent. */
destroy() {
if (this._down) return;
this._down = true;
this._stopVoice();
this.pending = null;
if (this.window) {
try {
this.window.destroy();
} catch {
/* already torn down */
}
this.window = null;
}
this.current = null;
}
// ------------------------------------------------------------------
// the roster + the gate
// ------------------------------------------------------------------
/** The first vignette due for the location (roster order), or null. */
_dueAt(location) {
for (const vig of vignettesForLocation(this.cfg, location)) {
if (this._availableNow(vig)) return vig;
}
return null;
}
/**
* The model's availability against the live facts: the run's played
* ledger (this hub) + the gate's quest facts (the scene reads them from
* the sleeping GameScene — the model stays pure).
*/
_availableNow(vig) {
let gate = null;
if (vig.quest && this._gate) {
try {
gate = this._gate(vig);
} catch (e) {
console.warn('[vignettes] gate check failed — the scene stays unavailable', e);
gate = null;
}
}
return vignetteAvailable(vig, { played: this.played, gate });
}
// ------------------------------------------------------------------
// the deferred asset pass
// ------------------------------------------------------------------
/**
* Load whatever the scene's assets still need (the scene loader is
* deduping — a cached clip/speech is a no-op). If nothing is missing
* the pending scene is ready (the beat opens it). Otherwise the pass
* runs (complete → ready; the stall valve releases it either way —
* the scene plays whatever landed).
*/
_startLoad(pending, assets) {
const s = this.scene;
const missing = [];
for (const spec of assets.videos) if (!_hasVideo(s, spec.key)) missing.push({ kind: 'video', spec });
for (const spec of assets.audios) if (!_hasAudio(s, spec.key)) missing.push({ kind: 'audio', spec });
if (missing.length === 0) return; // ready — the beat opens the scene
pending.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 || this.pending !== pending) return;
done = true;
pending.loading = false;
if (valve) valve.remove(false);
s.load.off('complete', finish); // the other finish path — keep it clean
// Whatever landed, the scene plays (a missing clip degrades to the
// NO SIGNAL plate, a missing voice to a text-only line — the scene
// is never lost).
};
s.load.once('complete', finish);
s.load.start();
valve = s.time.delayedCall(LOAD_VALVE_MS, finish);
}
// ------------------------------------------------------------------
// the scene (window + lines)
// ------------------------------------------------------------------
/** Open the window on the first line: face in, text decoding, voice
* live, the deck stood down. */
_play(vig) {
const s = this.scene;
this.current = vig;
this.lineIndex = 0;
const line = vig.lines[0];
const char = charEntry(this.cfg, line.from) ?? {};
this.window = new VignetteWindow(s, {
label: char.label ?? line.from,
role: char.role ?? '',
line,
video: this._videoSpec(line),
last: vig.lines.length <= 1,
onNext: () => this._next(),
});
this.window.open();
this._lock(true);
this._speak(line);
}
/** The NEXT press (the window reports it): advance, or complete. */
_next() {
if (!this.current || !this.window) return;
const vig = this.current;
if (this.lineIndex >= vig.lines.length - 1) {
this._complete();
return;
}
this.lineIndex++;
const line = vig.lines[this.lineIndex];
this._stopVoice(); // the outgoing voice cuts (the comms' advance idiom)
const char = charEntry(this.cfg, line.from) ?? {};
this.window.setLine({
label: char.label ?? line.from,
role: char.role ?? '',
line,
video: this._videoSpec(line),
last: this.lineIndex === vig.lines.length - 1,
});
this._speak(line);
}
/**
* DONE (the last line's press): the scene is over. The ledger marks
* NOW (completing — the opposite of comms' mark-on-start: a
* conversation is a place, and the step's 'vignettePlayed' witness
* reads this mark). The window closes — the looping clip resumes as
* the location's backdrop — and the scene hands the surface back.
*/
_complete() {
if (!this.current) return;
const vig = this.current;
this._stopVoice();
const win = this.window;
this.window = null;
this.current = null;
this.played.add(vig.id);
this._lock(false);
win?.close(); // the fade-out; the clip keeps looping behind
if (this._onComplete) {
try {
this._onComplete(vig);
} catch (e) {
console.warn('[vignettes] onComplete hook failed', e);
}
}
}
/** The window's video spec for a line: the character's cached clip, or
* null (the window shows the NO SIGNAL plate). */
_videoSpec(line) {
const spec = lineAssets(this.cfg, line).video;
return spec && _hasVideo(this.scene, spec.key) ? { key: spec.key } : null;
}
// ------------------------------------------------------------------
// the voice (per line)
// ------------------------------------------------------------------
/**
* Play the line's speech clip (if it landed) at its own volume, ducking
* the other playing sounds (the surface hum) per-sound while it talks.
* A text-only line (no audio / it didn't land) is a valid line — the
* window just shows the words.
*/
_speak(line) {
const s = this.scene;
this._stopVoice(); // the outgoing line's voice (if any) cuts
const spec = lineAssets(this.cfg, line).audio;
const snd = s.sound;
if (!spec || !_hasAudio(s, spec.key) || !snd || typeof snd.play !== 'function') return;
this._voiceKey = spec.key;
this._duck(true);
if (this.window) this.window.setVoice(true);
try {
snd.play(spec.key, { volume: this.voiceVolume });
} catch (e) {
console.warn('[vignettes] the voice was refused by the engine', e);
this._voiceKey = null;
this._duck(false);
if (this.window) this.window.setVoice(false);
return;
}
// v4 has no 'complete' event — poll isPlaying (the comms' recipe)
// and end the duck when the voice stops (it does not gate the
// scene: NEXT is the only way forward).
this._voiceTimer = s.time.addEvent({
delay: VOICE_POLL_MS,
loop: true,
callback: () => {
if (this._down || this._voiceKey !== spec.key) return;
if (snd.isPlaying(spec.key) !== true) this._stopVoice();
},
});
}
/** Stop the current voice (the line advanced / the scene closed) and
* restore the ducked sounds exactly. */
_stopVoice() {
const s = this.scene;
if (this._voiceTimer) {
try {
this._voiceTimer.remove(false);
} catch {
/* already gone */
}
this._voiceTimer = null;
}
if (this._voiceKey && s.sound?.stopByKey) {
try {
s.sound.stopByKey(this._voiceKey);
} catch {
/* the engine may not know it */
}
}
this._voiceKey = null;
this._duck(false);
if (this.window) this.window.setVoice(false);
}
// ------------------------------------------------------------------
// the duck (per-sound — v4's setVolume IS the master)
// ------------------------------------------------------------------
/**
* Duck / restore the other playing sounds (the surface music) — the
* comms' recipe (CommsHub._duck): each one by a fraction of its OWN
* volume, the manager's master left untouched (v4: `sound.setVolume`
* IS the master — ducking it would quiet the voice too), restored
* exactly when the voice ends.
*
* v4 (Giedi) API, verified in lib/phaser.min.js: the manager has NO
* `keys` property (a `sounds` array + `get(key)` / `getAllPlaying()`) —
* and a body's `isPlaying` is a plain PROPERTY, not a method (the old
* `snd.keys` + `b.isPlaying()` loop iterated nothing and would have
* thrown the moment it found a body). `getAllPlaying()` is the source.
*/
_duck(on) {
const snd = this.scene && this.scene.sound;
if (!snd) return;
if (on && !this._ducked) {
this._ducked = true;
this._duckedSounds = [];
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 (a suspended context, headless).
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 * this.musicDuck);
} catch (e) {
console.warn('[vignettes] 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;
}
}
// ------------------------------------------------------------------
// the hooks
// ------------------------------------------------------------------
_lock(on) {
if (!this._onLock) return;
try {
this._onLock(on);
} catch (e) {
console.warn('[vignettes] onLock hook failed', e);
}
}
// ------------------------------------------------------------------
// the played ledger (registry — shared across scenes; SaveData saves it)
// ------------------------------------------------------------------
/**
* The run's played set, from the registry (shared across scenes — the
* scene reads it, the hub owns it, SaveData persists it). A ledger a
* save restored as an array becomes a Set here.
* @returns {Set<string>}
*/
_playedSet() {
const reg = this.scene.registry;
if (!reg) return new Set();
const cur = reg.get(LEDGER_KEY);
if (cur instanceof Set) return cur;
const set = new Set(Array.isArray(cur) ? cur.filter((id) => typeof id === 'string') : []);
reg.set(LEDGER_KEY, set);
return set;
}
}
// ----------------------------------------------------------------------
// small local helpers (the CommsHub video 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));
}