285 lines
11 KiB
JavaScript
285 lines
11 KiB
JavaScript
/**
|
|
* VIGNETTE MODEL — the pure, Node-testable rules of data/vignettes.json.
|
|
*
|
|
* A vignette is a DIALOGUE SCENE that plays on a planet's surface (the
|
|
* SurfaceScene), triggered by the world's location buttons (the EXPLORE
|
|
* drawer's Bar, the SHOP deck slot): a sequence of lines, each with a
|
|
* speaker + text + an optional speech clip, played in a window centered
|
|
* on the screen over the location's looping clip (js/ui/VignetteWindow.js).
|
|
*
|
|
* { id, location, quest?, step?, once, lines: [{ from, text, audio? }] }
|
|
*
|
|
* This module normalizes the config (dropping — with a console warning —
|
|
* anything that can't be shown) and answers the questions the hub asks:
|
|
*
|
|
* - `vignettesForLocation(cfg, location)` — which scenes are bound to a
|
|
* world slot ('bar', 'shop', …), in config order;
|
|
* - `vignetteAvailable(vig, state)` — is this one playable NOW, against
|
|
* a state snapshot the scene builds from the sleeping GameScene (the
|
|
* run's played ledger + the gate's quest facts — see below);
|
|
* - `lineAssets(cfg, line)` / `vignetteAssets(cfg, vig)` — the
|
|
* deferred-load specs: the speaker's PLANET clip (by character id, so
|
|
* every line from Carol shares one cached clip) and each speech clip
|
|
* (by file, so a clip shared by several lines loads once).
|
|
*
|
|
* THE GATE (vignettes are quest steps): a vignette names the quest it is
|
|
* a step of (`quest` + the step's index `step`). It is AVAILABLE while
|
|
* that quest is granted and unclaimed and the step is still OPEN — and
|
|
* finishing the scene is the step's WITNESS (GameScene._questCheckEval's
|
|
* 'vignettePlayed' check reads the run's played ledger — the same shape
|
|
* as the comms' trestleTalked/trestleMet). A vignette with no gate plays
|
|
* on every visit (until its ledger mark).
|
|
*
|
|
* Pure: it takes the `vignettes` config SECTION (the object from
|
|
* data/vignettes.json) — never the `config` singleton — so the dev tests
|
|
* (dev/vignettes.test.mjs) can feed it plain JSON.
|
|
*
|
|
* import { config } from '../config/Config.js';
|
|
* const cfg = config.section('vignettes', {});
|
|
* const scenes = vignettesForLocation(cfg, 'bar');
|
|
*/
|
|
|
|
/** The scene-loader cache key for a character's PLANET clip: vign_vid_<id>. */
|
|
export function videoKeyFor(charId) {
|
|
return `vign_vid_${String(charId)}`;
|
|
}
|
|
|
|
/**
|
|
* A character's PLANET clip URL — the planet cut of the comms' space cut:
|
|
* assets/videos/characters/planet-<id>.mp4 (planet-trestle.mp4 vs the
|
|
* comms' trestle.mp4). A character entry may override it with its own
|
|
* `video` (lineAssets applies the override).
|
|
*/
|
|
export function videoUrlFor(charId) {
|
|
return `assets/videos/characters/planet-${String(charId)}.mp4`;
|
|
}
|
|
|
|
/**
|
|
* The scene-loader cache key for a speech clip, derived from the FILE
|
|
* (the comms' audioKeyFor recipe, the vignettes' prefix): the key is the
|
|
* folder + basename (two lines sharing a clip share one key; the folder
|
|
* is part of the key because TWO CHARACTERS may both ship an
|
|
* 'intro-01.mp3' — the basename alone would make Carol speak Trestle's
|
|
* clip (the loader would see the key as already loaded)).
|
|
*/
|
|
export function audioKeyFor(file) {
|
|
const parts = String(file ?? '')
|
|
.split('/')
|
|
.filter(Boolean);
|
|
const base = (parts.pop() ?? '').replace(/\.[a-z0-9]+$/i, '');
|
|
const dir = parts.length ? parts[parts.length - 1] : null;
|
|
const safe = `${dir ? dir + '-' : ''}${base}`.replace(/[^a-z0-9_-]+/gi, '-');
|
|
return `vign_au_${safe || 'speech'}`;
|
|
}
|
|
|
|
/** @returns {boolean} the master switch (a config predating vignettes defaults on). */
|
|
export function vignettesEnabled(cfg) {
|
|
return cfg?.enabled !== false;
|
|
}
|
|
|
|
/**
|
|
* The speaker's entry (characters.<from>) or null (a line from an unknown
|
|
* speaker makes its vignette unplayable — the window has no face to put
|
|
* on it).
|
|
*/
|
|
export function charEntry(cfg, from) {
|
|
const chars = cfg?.characters;
|
|
if (!chars || typeof chars !== 'object' || from == null) return null;
|
|
const entry = chars[from];
|
|
return entry && typeof entry === 'object' ? entry : null;
|
|
}
|
|
|
|
/**
|
|
* Normalize one line: a clean object, or null when it can't be spoken
|
|
* (with a console warning).
|
|
*
|
|
* Rules:
|
|
* - from: must name a speaker (the caller checks the character entry —
|
|
* a faceless line makes the whole scene unplayable);
|
|
* - text: required (a line with no words is noise);
|
|
* - audio: an optional speech clip (a text-only line is a valid line).
|
|
*/
|
|
export function normalizeLine(raw) {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const from = String(raw.from ?? '').trim();
|
|
if (!from) {
|
|
console.warn('[vignettes] a line has no speaker — dropped');
|
|
return null;
|
|
}
|
|
const text = String(raw.text ?? '').trim();
|
|
if (!text) {
|
|
console.warn(`[vignettes] a line from "${from}" has no text — dropped`);
|
|
return null;
|
|
}
|
|
const audio = String(raw.audio ?? '').trim();
|
|
return { from, text, audio: audio || null };
|
|
}
|
|
|
|
/**
|
|
* Normalize one vignette entry: returns a clean object, or null when the
|
|
* scene can't be shown (with a console warning — the rest of the roster
|
|
* still plays).
|
|
*
|
|
* Rules:
|
|
* - id: required (the played ledger + the quest's 'vignettePlayed'
|
|
* witness both key off it);
|
|
* - location: a non-empty string, lowercased (the world slot that
|
|
* triggers it — 'bar', 'shop', …);
|
|
* - lines: at least ONE playable line with a known speaker (a half
|
|
* scene is worse than no scene — one bad line drops the whole
|
|
* vignette, warned per line);
|
|
* - quest: optional (no gate = the scene plays on every visit); when
|
|
* present it must name a non-empty quest id;
|
|
* - step: the gate's step index (default 0, clamped to >= 0);
|
|
* - once defaults true (a scene plays once per run; `once: false`
|
|
* re-arms on every visit — the author's repeat).
|
|
*
|
|
* @param {object} raw the config entry (data/vignettes.json → vignettes[])
|
|
* @param {object} [cfg] the vignettes config section (for the characters
|
|
* table — the line speaker check; plain JSON in the dev tests)
|
|
*/
|
|
export function normalizeVignette(raw, cfg) {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const id = String(raw.id ?? '').trim();
|
|
if (!id) {
|
|
console.warn('[vignettes] a scene has no id — dropped');
|
|
return null;
|
|
}
|
|
const location = String(raw.location ?? '').trim().toLowerCase();
|
|
if (!location) {
|
|
console.warn(`[vignettes] "${id}" has no location — dropped`);
|
|
return null;
|
|
}
|
|
const quest = String(raw.quest ?? '').trim();
|
|
const step = Math.max(0, Math.round(Number(raw.step) || 0));
|
|
const rawLines = Array.isArray(raw.lines) ? raw.lines : [];
|
|
if (rawLines.length === 0) {
|
|
console.warn(`[vignettes] "${id}" has no lines — dropped`);
|
|
return null;
|
|
}
|
|
const lines = [];
|
|
for (const rl of rawLines) {
|
|
const line = normalizeLine(rl);
|
|
if (!line) return null; // one bad line drops the whole scene (warned above)
|
|
if (!charEntry(cfg, line.from)) {
|
|
console.warn(`[vignettes] "${id}" has a line for unknown speaker "${line.from}" — dropped`);
|
|
return null;
|
|
}
|
|
lines.push(line);
|
|
}
|
|
return {
|
|
id,
|
|
location,
|
|
quest: quest || null,
|
|
step: quest ? step : null,
|
|
once: raw.once !== false,
|
|
lines,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The playable roster: the normalized vignettes in config order, minus
|
|
* the unplayable ones (warned per id). The characters table rides in
|
|
* `cfg.characters` (data/vignettes.json).
|
|
* @returns {Array<object>}
|
|
*/
|
|
export function listVignettes(cfg) {
|
|
const list = Array.isArray(cfg?.vignettes) ? cfg.vignettes : [];
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const raw of list) {
|
|
if (typeof raw === 'string' || raw == null) continue; // $comment strings, etc.
|
|
const vig = normalizeVignette(raw, cfg);
|
|
if (!vig) continue;
|
|
if (seen.has(vig.id)) {
|
|
console.warn(`[vignettes] duplicate id "${vig.id}" — the first entry wins`);
|
|
continue;
|
|
}
|
|
seen.add(vig.id);
|
|
out.push(vig);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The roster's scenes bound to a location, in config order.
|
|
* @param {string} location the world slot id ('bar', 'shop', … — case-insensitive)
|
|
* @returns {Array<object>} the normalized vignettes
|
|
*/
|
|
export function vignettesForLocation(cfg, location) {
|
|
const name = String(location ?? '').trim().toLowerCase();
|
|
if (!name) return [];
|
|
return listVignettes(cfg).filter((v) => v.location === name);
|
|
}
|
|
|
|
/**
|
|
* Is the scene playable NOW? Pure against a state snapshot the scene
|
|
* builds (the hub adds the run's played ledger; the scene supplies the
|
|
* gate's facts from the sleeping GameScene):
|
|
*
|
|
* state = {
|
|
* played: Set, the run's already-played ids (registry 'vignettesPlayed')
|
|
* gate: null | { null = no gate (an ungated scene) / the scene
|
|
* granted: boolean, couldn't read the ledger — a gated scene
|
|
* claimed: boolean, then stays UNAVAILABLE (a gate it can't
|
|
* stepExists: boolean, verify must not open a scene)
|
|
* stepDone: boolean,
|
|
* },
|
|
* }
|
|
*
|
|
* Available = not already played (for `once` scenes) AND (no gate, or
|
|
* the gate is held: the quest is granted, unclaimed, its step exists and
|
|
* is still open).
|
|
*
|
|
* @returns {boolean}
|
|
*/
|
|
export function vignetteAvailable(vig, state = {}) {
|
|
if (!vig) return false;
|
|
const played = state.played instanceof Set ? state.played : new Set();
|
|
if (vig.once && played.has(vig.id)) return false;
|
|
if (!vig.quest) return true; // ungated — the location plays it (once not played)
|
|
const gate = state.gate;
|
|
if (!gate || typeof gate !== 'object') return false; // gated + unreadable → unavailable
|
|
return (
|
|
gate.granted === true &&
|
|
gate.claimed !== true &&
|
|
gate.stepExists === true &&
|
|
gate.stepDone !== true
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The line's deferred-load spec. The character clip is keyed BY CHARACTER
|
|
* (every Carol line shares one cached clip — her planet cut); the speech
|
|
* clip BY FILE (a clip reused across lines loads once).
|
|
*
|
|
* @returns {{video: {key:string,url:string}|null, audio: {key:string,url:string}|null}}
|
|
*/
|
|
export function lineAssets(cfg, line) {
|
|
const entry = charEntry(cfg, line.from);
|
|
const videoUrl = String(entry?.video ?? '').trim() || videoUrlFor(line.from);
|
|
const audioUrl = String(line?.audio ?? '').trim();
|
|
return {
|
|
video: { key: videoKeyFor(line.from), url: videoUrl },
|
|
audio: audioUrl ? { key: audioKeyFor(audioUrl), url: audioUrl } : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The vignette's full deferred-load spec (the hub's one load pass): every
|
|
* distinct character clip + speech file its lines need, deduped by cache
|
|
* key (a two-beat Carol scene fetches her clip once).
|
|
*
|
|
* @returns {{videos: Array<{key:string,url:string}>, audios: Array<{key:string,url:string}>}}
|
|
*/
|
|
export function vignetteAssets(cfg, vig) {
|
|
const videos = new Map();
|
|
const audios = new Map();
|
|
for (const line of vig.lines ?? []) {
|
|
const a = lineAssets(cfg, line);
|
|
if (a.video) videos.set(a.video.key, a.video);
|
|
if (a.audio) audios.set(a.audio.key, a.audio);
|
|
}
|
|
return { videos: [...videos.values()], audios: [...audios.values()] };
|
|
}
|