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} */ _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)); }