511 lines
19 KiB
JavaScript
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));
|
|
}
|