diff --git a/assets/images/originals/arid-planet-02.png b/assets/images/originals/arid-planet-02.png new file mode 100644 index 0000000..ed8243f Binary files /dev/null and b/assets/images/originals/arid-planet-02.png differ diff --git a/assets/speech/carol/carol-q2-01.mp3 b/assets/speech/carol/carol-q2-01.mp3 deleted file mode 100644 index 07f14fa..0000000 Binary files a/assets/speech/carol/carol-q2-01.mp3 and /dev/null differ diff --git a/assets/speech/carol/carol-q2-02.mp3 b/assets/speech/carol/carol-q2-02.mp3 deleted file mode 100644 index 1cb6f7b..0000000 Binary files a/assets/speech/carol/carol-q2-02.mp3 and /dev/null differ diff --git a/assets/speech/carol/carol-q2-03.mp3 b/assets/speech/carol/carol-q2-03.mp3 deleted file mode 100644 index 1d14e47..0000000 Binary files a/assets/speech/carol/carol-q2-03.mp3 and /dev/null differ diff --git a/assets/speech/carol/carol-q2-04.mp3 b/assets/speech/carol/carol-q2-04.mp3 deleted file mode 100644 index 7bf524d..0000000 Binary files a/assets/speech/carol/carol-q2-04.mp3 and /dev/null differ diff --git a/assets/speech/carol/carol-q3-01.mp3 b/assets/speech/carol/carol-q3-01.mp3 new file mode 100644 index 0000000..ec2e280 Binary files /dev/null and b/assets/speech/carol/carol-q3-01.mp3 differ diff --git a/assets/speech/carol/carol-q3-02.mp3 b/assets/speech/carol/carol-q3-02.mp3 new file mode 100644 index 0000000..f189374 Binary files /dev/null and b/assets/speech/carol/carol-q3-02.mp3 differ diff --git a/assets/speech/carol/carol-q3-03.mp3 b/assets/speech/carol/carol-q3-03.mp3 new file mode 100644 index 0000000..9044db7 Binary files /dev/null and b/assets/speech/carol/carol-q3-03.mp3 differ diff --git a/assets/speech/carol/carol-q3-04.mp3 b/assets/speech/carol/carol-q3-04.mp3 new file mode 100644 index 0000000..c98d0b0 Binary files /dev/null and b/assets/speech/carol/carol-q3-04.mp3 differ diff --git a/assets/speech/trestle/trestle-q3-01.mp3 b/assets/speech/trestle/trestle-q3-01.mp3 new file mode 100644 index 0000000..1adcd80 Binary files /dev/null and b/assets/speech/trestle/trestle-q3-01.mp3 differ diff --git a/assets/videos/arid-land-01.mp4 b/assets/videos/arid-land-01.mp4 new file mode 100644 index 0000000..abe3930 Binary files /dev/null and b/assets/videos/arid-land-01.mp4 differ diff --git a/assets/videos/arid-surface-01.mp4 b/assets/videos/arid-surface-01.mp4 new file mode 100644 index 0000000..1dc5130 Binary files /dev/null and b/assets/videos/arid-surface-01.mp4 differ diff --git a/assets/videos/arid-takeoff-01.mp4 b/assets/videos/arid-takeoff-01.mp4 new file mode 100644 index 0000000..7de9624 Binary files /dev/null and b/assets/videos/arid-takeoff-01.mp4 differ diff --git a/data/landing.json b/data/landing.json index ec5ff24..399a044 100644 --- a/data/landing.json +++ b/data/landing.json @@ -73,7 +73,7 @@ { "land": "lava-land-01.mp4", "surface": "lava-surface-01.mp4", "takeoff": "lava-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }, { "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "lava-shop-02.mp4" }, { "land": "lava-land-03.mp4", "surface": "lava-surface-03.mp4", "takeoff": "lava-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }, - { "land": "ice-land-01.mp4", "surface": "lava-surface-01.mp4", "takeoff": "lava-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }, + { "land": "arid-land-01.mp4", "surface": "arid-surface-01.mp4", "takeoff": "arid-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }, { "land": "ice-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" }, { "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }, { "land": "capital-land-neutral.mp4", "surface": "capital-surface-neutral.mp4", "takeoff": "capital-takeoff-neutral.mp4", "shop": "ice-shop-01.mp4", "bar": "capital-bar-neutral.mp4" }, diff --git a/data/manifest.json b/data/manifest.json index c634a39..c2f723b 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -31,6 +31,7 @@ "map.json", "quests.json", "comms.json", + "vignettes.json", "goods.json", "economy.json", "shopui.json" diff --git a/data/quests.json b/data/quests.json index 55d09d6..15158a1 100644 --- a/data/quests.json +++ b/data/quests.json @@ -148,14 +148,14 @@ "title": "Special Delivery", "description": "The mail routes are dead, and a sealed package that must reach the home world is sitting at the neutral capital's bar. Carol is paying for the run, and Trestle will see it off — the only question is whether your ship can hold the cargo. Meet the two of them at the bar, upgrade the hold, and deliver the package to the home world.", "issuer": "Carol", - "_comment": "The run's THIRD quest (the 'Leaving the Nest' follow-up — the bar at the neutral capital). It GRANTS the moment 'Leaving the Nest' is COMPLETED AND CLAIMED (unlocks.claimed — the same chain mechanic as leaving_the_nest). The three checks are witnessed by the run's STORY ledger (registry 'story', saved as record.story — GameScene._recordLanding / onSurfaceDeparture): step 1 'capitalLanded' — a real landing on the NEUTRAL CAPITAL WORLD (startLanding — the bar; the shop gate lifts with it, SurfaceScene reads GameScene.isShopUnlocked, and the SPECIAL PACKAGE — packageGood 'special-package', data/goods.json, sheet frame 114, tradeable:false so no shop touches it — lands in the hold, GameScene._handoffSpecialPackage); step 2 'cargoUpgraded' — a cargo-bay SHIP build installed (data/builds.json → effects.cargo.bays — the record is ship-scoped, so any world's build counts); step 3 'packageDelivered' — the delivery's proof (story.delivered, MONOTONIC): a real launch off the capital (SurfaceScene.finishTakeoff → GameScene.onSurfaceDeparture, which also sets the destination back to the home world) followed by a landing ON the home world (the proof is recorded at that landing and stands — a later landing elsewhere can't un-deliver it). Reward: 200 credits (claimQuest's credit seam — the same as 'Leaving the Nest').", + "_comment": "The run's THIRD quest (the 'Leaving the Nest' follow-up — the bar at the neutral capital). It GRANTS the moment 'Leaving the Nest' is COMPLETED AND CLAIMED (unlocks.claimed — the same chain mechanic as leaving_the_nest). The three checks: step 1 'vignettePlayed' (check.id 'special_delivery_bar_meeting' — the bar's dialogue scene in data/vignettes.json: Trestle + Carol, the run's played ledger registry 'vignettesPlayed' is the witness — VignetteHub marks it when the scene's DONE press completes it; the gate — the quest granted, unclaimed, this step open — is what plays it when the bar is entered). The CAPITAL LANDING ITSELF still runs the story ledger (registry 'story', saved as record.story — GameScene._recordLanding): the shop gate lifts (SurfaceScene reads GameScene.isShopUnlocked) and the SPECIAL PACKAGE — packageGood 'special-package', data/goods.json, sheet frame 114, tradeable:false so no shop touches it — lands in the hold (GameScene._handoffSpecialPackage); step 2 'cargoUpgraded' — a cargo-bay SHIP build installed (data/builds.json → effects.cargo.bays — the record is ship-scoped, so any world's build counts); step 3 'packageDelivered' — the delivery's proof (story.delivered, MONOTONIC): a real launch off the capital (SurfaceScene.finishTakeoff → GameScene.onSurfaceDeparture, which also sets the destination back to the home world) followed by a landing ON the home world (the proof is recorded at that landing and stands — a later landing elsewhere can't un-deliver it). Reward: 200 credits (claimQuest's credit seam — the same as 'Leaving the Nest').", "unlocks": { "_comment": "The grant condition: the listed quests' rewards CLAIMED — the run's chain (leaving_the_nest paid out → this one grants).", "claimed": ["leaving_the_nest"] }, "packageGood": "special-package", "checks": [ - { "type": "capitalLanded", "label": "Meet with Trestle and Carol at the neutral capital's bar" }, + { "type": "vignettePlayed", "id": "special_delivery_bar_meeting", "label": "Meet with Trestle and Carol at the neutral capital's bar" }, { "type": "cargoUpgraded", "label": "Upgrade Ship to hold Cargo" }, { "type": "packageDelivered", "label": "Deliver Package for Carol" } ], diff --git a/data/vignettes.json b/data/vignettes.json new file mode 100644 index 0000000..f3adc53 --- /dev/null +++ b/data/vignettes.json @@ -0,0 +1,107 @@ +{ + "$comment": "VIGNETTES — dialogue scenes played ON A PLANET (SurfaceScene), triggered by the world's location buttons: the EXPLORE drawer's service points (the neutral capital's Bar) and the SHOP (deck slot). A vignette is a sequence of LINES — each with a speaker (a character), text, and a speech clip — played in a window centered on the screen (js/ui/VignetteWindow.js) OVER the location's looping clip, which keeps playing in the background. NEXT advances the lines; the LAST line's button (DONE) closes the window and returns to the looping clip alone. Vignettes are GATED BY QUEST STEPS: each entry names the quest that holds it + the index of the step it is — it is available while that quest is granted, unclaimed, and the step still open (js/vignettes/VignetteModel.js → vignetteAvailable, the scene builds the gate's facts from the sleeping GameScene). Finishing it is the step's WITNESS (GameScene._questCheckEval's 'vignettePlayed' check reads the run's played ledger — the same shape as the comms' trestleTalked/trestleMet witnesses). A speaker's face is the PLANET cut of their clip — assets/videos/characters/planet-.mp4 (the comms' space cut is .mp4) — and is ALWAYS played muted (the voice is the line's speech clip, never the video). Assets (the character clip + each line's speech) load ON DEMAND by the hub (js/vignettes/VignetteHub.js) when a vignette is armed — never in a scene's preload, so the initial load stays lean. The played ledger marks when the scene COMPLETES (the opposite of comms' mark-on-start): a conversation is a place you can return to — leave mid-scene and it re-arms on the next visit to the location.", + "enabled": true, + "audio": { + "$comment": "A line's voice plays at its OWN volume (volume) — the manager's master is left untouched (in v4 `sound.setVolume` IS the master, so ducking it would quiet the voice too). While a voice talks, the other playing sounds (the surface hum) are ducked PER-SOUND by musicDuck (a fraction of each one's own volume) and restored exactly when the voice ends — the comms' recipe (data/comms.json → audio).", + "volume": 1.0, + "musicDuck": 0.3 + }, + "characters": { + "$comment": "The speakers. label = the window's header; role = the small line beside it. `video` is optional: the speaker's PLANET clip (the window's left panel), defaulting to assets/videos/characters/planet-.mp4 — the planet cut of the comms' space cut (planet-trestle.mp4 vs trestle.mp4, assets/videos/characters/).", + "trestle": { + "label": "TRESTLE", + "role": "OUT-OF-SYSTEM MENTOR" + }, + "carol": { + "label": "CAROL", + "role": "COURIER" + } + }, + "vignettes": [ + { + "id": "special_delivery_bar_meeting", + "location": "bar", + "quest": "special_delivery", + "step": 0, + "$comment": "'Special Delivery' step 1 — the neutral capital's bar: Trestle's welcome (trestle-q3-01) + Carol's four beats (carol-q3-01..04). It plays when the quest is held and the bar is entered on the capital — the package already joined the hold on the landing itself (GameScene._recordLanding / _handoffSpecialPackage), so Carol's 'the cargo has been loaded' line is the truth the moment the scene opens. COMPLETING it (the DONE press) marks the step: the quest's check is 'vignettePlayed' (check.id = this id) and reads the run's played ledger (registry 'vignettesPlayed' — VignetteHub marks it on completion, SaveData persists it).", + "lines": [ + { + "from": "trestle", + "text": "Hey Kid. Thanks for meeting us here. Meet Carol, she's part of a team that.. well, let's discuss that later. Right now Carol has a package that needs delivering, and you need training on how to trade cargo.", + "audio": "assets/speech/trestle/trestle-q3-01.mp3" + }, + { + "from": "carol", + "text": "It's nice to meet you. Trestle speaks highly of you. He thinks you might be able to help me with a delivery I need to make.", + "audio": "assets/speech/carol/carol-q3-01.mp3" + }, + { + "from": "carol", + "text": "That ship of yours is.. well, lets say there is room for improvement. I hope you don't mind, I've asked some of my men to build out 4 cargo bays inside of it. That should give us what we need to get you transporting.", + "audio": "assets/speech/carol/carol-q3-02.mp3" + }, + { + "from": "carol", + "text": "I've also seen to it that the cargo I need transported has been loaded. This should be a quick job for you. I can't go back to your home star system, for reasons I can't devuldge, but just bring this package back to the merchant on your home world and sell it to him.", + "audio": "assets/speech/carol/carol-q3-03.mp3" + }, + { + "from": "carol", + "text": "After you've done that, launch back into space and I'll contact you. Get this job done and there might be a future for you in this galaxy afterall. We'll talk more later.", + "audio": "assets/speech/carol/carol-q3-04.mp3" + } + ] + } + ], + "$vignettes_comment": "The playable scenes — each one: `id` (the played ledger's key + the quest's 'vignettePlayed' witness), `location` (the world slot that triggers it — 'bar' the neutral capital's BAR service, 'shop' the SHOP deck slot; case-insensitive), `quest` + `step` (the gate: the quest it is a step of, and the index of that step in the quest's checks — available while the quest is granted, unclaimed, and the step is not yet done; omit both for an ungated scene), `once` (default true — a scene plays once per run; once:false re-arms on every visit), and `lines` (the dialogue, in order — each line: `from` a speaker id, `text` the words, `audio` the speech clip file (optional — a text-only line is valid)). The first scene ships: the bar's Trestle + Carol meeting (special_delivery_bar_meeting — 'Special Delivery' step 1; assets/speech/trestle/trestle-q3-01.mp3 + assets/speech/carol/carol-q3-01..04.mp3).", + "window": { + "$comment": "The centered window (js/ui/VignetteWindow.js) — the comms box's centered twin: on the left the speaker's clip in a bracketed feed panel (muted, looping while the line holds — a NO SIGNAL plate if the clip never lands), on the right the line's text decoding in, with the NEXT button below it — the primary action in amber (the same attention pulse as the comms' ACKNOWLEDGE), reading DONE on the last line. videoSize 256 = the planet cut at FULL size (the clip is 256×256 — the face is never downscaled); width 800 leaves a 502px text column (800 − 2·14 pad − 256 feed − 14 gap). The window SIZES TO THE LINE IN PLAY (textHeightFor → _rebuildLayout): a later line taller than the first grows the window — backplate, button, pulse, re-center — so the words never run into the button or the window's edge. The SCRIM dims the world behind the window so it owns the moment — the looping clip behind it keeps moving (the location is the set). startDelayMs = the beat between the location's clip coming up and the window opening: the place reads first, then the scene begins.", + "width": 800, + "pad": 14, + "headerGap": 8, + "videoSize": 256, + "videoGap": 14, + "startDelayMs": 700, + "text": { + "fontSize": 13, + "lineHeight": 18, + "letterSpacing": 0.4 + }, + "button": { + "label": "NEXT", + "doneLabel": "DONE", + "$gap": "the row of space between the line's text and the NEXT/DONE button — one full line-height (18) on top of the old 10: the words and the button read as separate rows, never crowded", + "gap": 28, + "fontSize": 11, + "paddingX": 16, + "paddingY": 7, + "letterSpacing": 2 + }, + "pulse": { + "$comment": "The NEXT/DONE button's attention pulse: the button wears the window's amber, an amber halo breathes under it, and two staggered sonar rings expand out — the one live control on the surface is unmistakable while the scene holds.", + "enabled": true, + "color": "#ffd84d", + "periodMs": 1300 + }, + "animation": { + "inMs": 260, + "outMs": 420 + }, + "scrim": { + "color": "#02040a", + "alpha": 0.4 + }, + "colors": { + "panel": "#0a1120", + "panelAlpha": 0.94, + "border": "#22405f", + "borderAlpha": 0.9, + "notch": 10, + "neon": "#00e5ff", + "ink": "#eaf6ff", + "dim": "#7d92c4", + "faint": "#3d4c74", + "amber": "#ffc94d" + } + } +} diff --git a/dev/quests.test.mjs b/dev/quests.test.mjs index 2e88447..ff44793 100644 --- a/dev/quests.test.mjs +++ b/dev/quests.test.mjs @@ -129,7 +129,7 @@ check('starter: reward fits the ship\u2019s hold', // The requirement types the scene evaluates (GameScene._questCheckEval) // — a typo here would be a checklist row that never completes. -const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld', 'systemCharted', 'gatesActivated', 'trestleTalked', 'trestleMet', 'capitalLanded', 'cargoUpgraded', 'packageDelivered']); +const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld', 'systemCharted', 'gatesActivated', 'trestleTalked', 'trestleMet', 'capitalLanded', 'cargoUpgraded', 'packageDelivered', 'vignettePlayed']); check('starter: every check type is a known live check', (starter?.checks ?? []).every((c) => CHECK_TYPES.has(c.type))); @@ -204,8 +204,17 @@ for (const c of starter?.checks ?? []) { ])); check('delivery: every check type is a known live check', (q?.checks ?? []).every((c) => CHECK_TYPES.has(c.type))); const byType = Object.fromEntries((q?.checks ?? []).map((c) => [c.type, c])); - check('delivery: the three check types (capitalLanded, cargoUpgraded, packageDelivered)', - ['capitalLanded', 'cargoUpgraded', 'packageDelivered'].every((t) => !!byType?.[t])); + check('delivery: the three check types (vignettePlayed, cargoUpgraded, packageDelivered)', + ['vignettePlayed', 'cargoUpgraded', 'packageDelivered'].every((t) => !!byType?.[t])); + // The bar step's witness — the VIGNETTE scene (data/vignettes.json): its id + // resolves to a scene bound to the bar, so the scene's DONE press is what + // completes the step (VignetteHub marks the ledger; _questCheckEval reads it). + check('delivery: the bar step names a shipped bar scene (vignettes.json → id + location bar)', (() => { + const vcfg = read('vignettes.json'); + const id = byType?.vignettePlayed?.id; + const scene = (vcfg?.vignettes ?? []).find((v) => v?.id === id); + return typeof id === 'string' && id !== '' && !!scene && String(scene.location).toLowerCase() === 'bar'; + })()); check('delivery: the reward is 200 credits', Number.isInteger(q?.reward?.credits) && q.reward.credits === 200); // The package good — real, on the sheet's spare cell, and NON-TRADEABLE // (no shop stocks or buys it; the SELL grid refuses to queue it). diff --git a/dev/vignette-flow.html b/dev/vignette-flow.html new file mode 100644 index 0000000..1c9c7df --- /dev/null +++ b/dev/vignette-flow.html @@ -0,0 +1,20 @@ + + + + + Orbit — dev vignette flow test + + + + + + +
+ + + diff --git a/dev/vignette-flow.mjs b/dev/vignette-flow.mjs new file mode 100644 index 0000000..9a8affa --- /dev/null +++ b/dev/vignette-flow.mjs @@ -0,0 +1,360 @@ +/** + * Dev-only VIGNETTE flow test: boots straight into the GameScene (smoke + * style), lands on the home world, then drives the SHIPPED bar scene — + * 'Special Delivery' step 1 (data/vignettes.json → special_delivery_bar_meeting) + * — end-to-end. The dialogue, the voices, and the quest gate are the + * authored ones; the test injects only the one thing the home world lacks + * by design — a BAR clip (the bar service normally rides the neutral + * capital's frame, where the scene's home is). + * + * node dev/server.mjs 8124 + * node dev/cdp-firefox.mjs http://127.0.0.1:8124/dev/vignette-flow.html \ + * 'return window.__VIGNETTE_TEST__;' + * + * Steps: + * 1. boot + the fixtures (the bar clip on the home frame; the quest + * granted — 'Leaving the Nest' is claimed in the story chain, which + * the smoke boot does not walk) + * 2. land (the real entry) → force the surface stage + * 3. the hub is live + the scene is DUE (the gate's facts hold: the + * quest's step 0 is the 'vignettePlayed' witness — open until the + * scene completes) + * 4. enter the bar — the scene ARMS (pending), not open yet (the + * opening beat: the location's clip reads first) + * 5. the beat lands — the window opens on TRESTLE's line (the deck + * stood down); the feed is the FULL 256×256 planet cut and the + * window sizes itself to the line (button below the text, inside + * the window — the probe measurement matches the engine's live one) + * 6. NEXT ×4 — each of Carol's four beats (the face stays CAROL; the + * voice is the line's own clip, ducking the hum), verbatim words, + * and the window re-sizes to each (taller) line + * 7. the last line reads DONE; the press completes: the ledger marks + * (completing), the quest's bar step stands DONE (the witness), + * the quest is not yet claimable (its other steps still stand), + * the bar's own lock returns — and LEAVE BAR, which the scene's + * lock had disabled, re-arms (the press leaves the bar), and the + * music duck is restored (it was ducked per-sound under every voice) + * 8. re-enter the bar — the scene stays spent (the once-ledger), a + * plain visit with no window + */ +import Phaser from '../js/vendor/phaser.js'; +import { config } from '../js/config/Config.js'; +import { ConfigLoader } from '../js/config/ConfigLoader.js'; +import { createGameConfig } from '../js/config/GameConfig.js'; +import { GameScene } from '../js/scenes/GameScene.js'; +import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; +import { musicKey, musicVolumeFor } from '../js/utils/Music.js'; + +window.__VIGNETTE_ERRORS__ = []; +window.addEventListener('error', (e) => { + window.__VIGNETTE_ERRORS__ = (window.__VIGNETTE_ERRORS__ ?? []).concat(String(e?.message ?? e)); +}); + +const fail = (msg, extra = {}) => { + window.__VIGNETTE_TEST__ = { + pass: false, + error: String(msg), + steps: window.__STEPS__ ?? [], + errors: window.__VIGNETTE_ERRORS__, + diag: extra, + }; + console.error('[vignette-flow] FAIL:', msg, extra); + throw new Error(String(msg)); +}; +window.__FLOW_STARTED__ = true; // module graph loaded — for debugging +const step = (name, cond, extra = {}) => { + if (!cond) fail(`${name}: ${JSON.stringify(extra).slice(0, 300)}`, extra); + window.__STEPS__ = (window.__STEPS__ ?? []).concat(name); +}; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const T0 = Date.now(); +const trace = (m) => { + window.__VIG_TRACE__ = (window.__VIG_TRACE__ ?? []).concat([`${((Date.now() - T0) / 1000).toFixed(1)}s ${m}`]); +}; +window.__VIG_TRACE__ = []; + +// Headless boots are flaky (a fresh profile can stall a fetch); the config +// load gets a few independent attempts, each time-boxed. +const withTimeout = (p, ms) => Promise.race([ + p, + new Promise((_, rej) => setTimeout(() => rej(new Error('config load timeout')), ms)), +]); +const data = await (async () => { + let last; + for (let i = 0; i < 4; i++) { + try { + return await withTimeout(ConfigLoader.load(), 25000); + } catch (e) { + last = e; + trace(`config-retry-${i + 1}: ${e?.message ?? e}`); + await new Promise((r) => setTimeout(r, 1500)); + } + } + throw last ?? new Error('config load failed'); +})(); +const ID = 'special_delivery_bar_meeting'; + +(async () => { + trace('iife-start'); + config.init(data); + const gameConfig = createGameConfig(); + gameConfig.scene = [GameScene, SurfaceScene]; + const game = new Phaser.Game(gameConfig); + window.game = game; + trace('game-created'); + + // 1 — wait for the flight scene to be live (v4 boots asynchronously — + // headless boots can be slow; a real-time deadline, not a poll count). + let gs = null; + const bootDeadline = Date.now() + 150000; + while (Date.now() < bootDeadline) { + gs = game.scene?.getScene?.('GameScene') ?? null; + if (gs && gs.ship) break; + await sleep(100); + } + if (!gs || !gs.ship) fail('GameScene never came up', { gs: String(gs), trace: window.__VIG_TRACE__ }); + trace('boot-found'); + step('boot', true, { frame: gs.planet?.sheetFrame }); + + // The fixtures — the home world's missing pieces: a BAR clip (the bar + // service is a NEUTRAL CAPITAL thing, this is its test bed) + the + // drawer's BAR item (normally a capitalNeutral-clause item — the test + // world is terran) + the quest grant (the story chain's 'Leaving the + // Nest' claim, which the smoke boot does not walk). The drawer's BAR + // button is what LEAVE BAR lives on — the button the scene's lock has + // to re-arm on DONE. + const frame = gs.planet.sheetFrame; + const videos = config.get('landing.videos') ?? []; + videos[frame] = videos[frame] ?? {}; + videos[frame].bar = 'capital-bar-neutral.mp4'; + const exploreItems = config.get('actionbar.surface.explore.items'); + let barItem = null; + if (Array.isArray(exploreItems)) { + barItem = exploreItems.find((i) => i && i.id === 'bar') ?? null; + if (barItem) barItem.where = null; // this test bed is a bar world, full stop + } + gs.questState?.give?.('special_delivery'); + const scene = (config.get('vignettes')?.vignettes ?? []).find((v) => v?.id === ID); + step( + 'fixtures', + !!videos[frame]?.bar && + !!barItem && + gs.questState?.isGranted?.('special_delivery') === true && + !!scene && + scene.lines?.length === 5, + { frame, granted: gs.questState?.isGranted?.('special_delivery'), scene: !!scene, barItem: !!barItem }, + ); + + // 2 — the real entry + the surface stage (the test does not wait out + // the landing clip). + const target = gs.commsTargetFor(gs.planet); + gs.startLanding(target); + await sleep(400); + const ss = game.scene.getScene('SurfaceScene'); + if (!ss) fail('SurfaceScene never launched'); + ss.startSurface(); // idempotent + await sleep(300); + step('surface-stage', ss.phase === 'surface', { phase: ss.phase }); + trace('surface-stage'); + + // 3 — the hub (the scene's vignette manager) is live + the scene is DUE. + const hub = ss.vignetteHub; + step('hub', !!hub && hub.enabled === true, { hub: !!hub, enabled: hub?.enabled }); + const roster = hub?.cfg?.vignettes ?? null; + const dueDiag = (() => { + const raw = Array.isArray(roster) ? roster : null; + if (!raw || raw.length === 0) return { roster: 'empty/missing' }; + const first = raw[0]; + let gate = 'n/a'; + try { + gate = hub._gate(first); + } catch (e) { + gate = `threw: ${e?.message ?? e}`; + } + const g = ss.gameScene; + return { + roster: raw.map((v) => v.id), + firstQuest: first.quest, + gate, + gameScene: g ? { questState: !!g.questState, snap: typeof g.questSnapshot, granted: g.questState?.isGranted?.('special_delivery') } : null, + }; + })(); + step('due', hub?._dueAt?.('bar')?.id === ID, { due: hub?._dueAt?.('bar')?.id ?? null, ...dueDiag }); + trace('due-ok'); + + // 4 — enter the bar: the scene ARMS (pending) — not open yet (the + // opening beat: the location's clip reads first). + ss.toggleBar(); + step('bar-mode', ss.barMode === true, { barMode: ss.barMode }); + step('armed', hub.busy === true, { pending: !!hub.pending, active: hub.active }); + await sleep(150); + step('beat-holds', hub.active === false, { active: hub.active }); + + // 5 — the beat lands (the assets are local files): the window opens on + // Trestle's line (the deck stood down — the window's NEXT is the only + // live control). + const openDeadline = Date.now() + 60000; + while (Date.now() < openDeadline) { + if (hub.active) break; + await sleep(100); + } + step('scene-open', hub.active === true, { pending: !!hub.pending, busy: hub.busy }); + trace('scene-open'); + const win = hub.window; + step('line-0-trestle', + win?.name?.text === 'TRESTLE' && win?._textValue === scene.lines[0].text && win?.button?.labelText?.text === 'NEXT', + { name: win?.name?.text, label: win?.button?.labelText?.text, text: (win?._textValue ?? '').slice(0, 60) }); + step('voice-0', hub._voiceKey !== null, { voiceKey: hub._voiceKey }); + + // THE NEW GEOMETRY: the feed is the FULL planet cut (256×256, no + // downscale) in a wider window (the text column is 502px). + step('feed-256', + win?.videoSize === 256 && win?.W >= 800 && win?.textMaxW >= 400, + { W: win?.W, videoSize: win?.videoSize, textMaxW: win?.textMaxW }); + + // THE WINDOW SIZES TO THE LINE: for every line, H = the layout formula + // (feed row vs. text+button row, whichever is taller), the button seats + // below that line's OWN text (not the first line's), and it stays inside + // the window. One source of truth: the window's own textHeightFor probe. + const gap0 = config.get('vignettes.window.button.gap', 10); + const layoutOf = (line) => { + const th = win.textHeightFor(line.text); + return { + th, + H: win._contentTop() + Math.max(win.videoSize, th + gap0 + win.buttonH) + win.pad, + buttonTop: win._contentTop() + th + gap0, + }; + }; + const fitsLine = (i) => { + const e = layoutOf(scene.lines[i]); + return Math.abs(win.H - e.H) < 0.01 && + win.button.y - win.button.height / 2 >= e.buttonTop - 0.01 && + win.button.y + win.button.height / 2 <= win.H - 1; + }; + step('layout-line-0', fitsLine(0), { H: win.H, expected: layoutOf(scene.lines[0]).H, btnTop: win.button.y - win.button.height / 2 }); + + // The PROBE (the layout's source of truth) must match the engine's + // LIVE measurement of the same line once the decode has landed (the + // decode caps at 880ms). + const decDeadline = Date.now() + 3000; + while (Date.now() < decDeadline && !win?._decFinished) await sleep(50); + step('decode-landed', win?._decFinished === true, { decFinished: win?._decFinished }); + step( + 'probe-matches-live', + win?.textHeightFor !== undefined && Math.abs(win.text.height - win.textHeightFor(win._textValue)) <= 1.5, + { live: win?.text?.height, probe: win?.textHeightFor?.(win?._textValue) }, + ); + // THE DUCK (the comms' behavior): while the voice talks, the surface + // loop is ducked PER-SOUND to its own volume × musicDuck — and back + // when the voice ends. + const mKey = musicKey(`music.frames.${frame}`); + const mBase = musicVolumeFor(`frames.${frame}`); + const mDuck = config.get('vignettes.audio.musicDuck', 0.3); + const mEps = Math.max(1e-6, mBase * 0.005); // the WebAudio gain is float32 — allow its quantization + const mBody = () => (ss.sound?.getAllPlaying?.() || []).find((b) => b && b.key === mKey); + step( + 'duck-under-voice', + (() => { + const b = mBody(); + return !!b && typeof b.volume === 'number' && b.volume > 0 && + Math.abs(b.volume - mBase * mDuck) <= mEps; + })(), + { music: mBody()?.volume ?? null, base: mBase, duck: mDuck, key: mKey }, + ); + step( + 'deck-locked', + (ss.actionBar?.slots ?? []).filter((s) => s.live).every((s) => s.disabled === true) === true, + { slots: (ss.actionBar?.slots ?? []).map((s) => `${s.id}:${s.disabled}`) }, + ); + + // 6 — the four Carol beats: the NEXT press each time (the face stays + // CAROL from line two on; each voice is the line's own clip). + const press = (w) => w.button.panel.emit('pointerdown'); + for (let i = 1; i < scene.lines.length; i++) { + press(win); + await sleep(200); + const last = i === scene.lines.length - 1; + step(`line-${i}-carol`, + hub.lineIndex === i && + win?.name?.text === 'CAROL' && + win?._textValue === scene.lines[i].text && + win?.button?.labelText?.text === (last ? 'DONE' : 'NEXT') && + hub._voiceKey !== null, + { + lineIndex: hub.lineIndex, + name: win?.name?.text, + label: win?.button?.labelText?.text, + text: (win?._textValue ?? '').slice(0, 60), + voiceKey: hub._voiceKey, + }); + // THE WINDOW RE-SIZED TO THIS LINE (a taller line must not run into + // the button or the window's edge — the old first-line height is gone). + step(`layout-line-${i}`, fitsLine(i), { H: win.H, expected: layoutOf(scene.lines[i]).H, btnTop: win.button.y - win.button.height / 2 }); + } + + // 7 — DONE: the scene completes. The ledger marks NOW (completing), + // and the quest's bar step — the 'vignettePlayed' witness keyed on + // this scene's id — stands DONE. The quest itself is not yet claimable + // (the hold + delivery still stand). + press(win); + await sleep(200); + step('done', hub.active === false && hub.window === null, { active: hub.active }); + step( + 'duck-restored', + (() => { + const b = mBody(); + return !!b && Math.abs((b.volume ?? -1) - mBase) <= mEps; + })(), + { music: mBody()?.volume ?? null, base: mBase }, + ); + step('ledger', hub.played.has(ID) === true, { played: [...hub.played] }); + step('registry', ss.registry?.get?.('vignettesPlayed')?.has?.(ID) === true); + const quest = (gs.questSnapshot()?.quests ?? []).find((q) => q.id === 'special_delivery'); + step( + 'quest-step-done', + quest?.checks?.[0]?.done === true && quest?.complete === false && quest?.claimed === false, + { + checks: quest?.checks?.map((c) => c.done), + complete: quest?.complete, + claimed: quest?.claimed, + }, + ); + step('gate-consumed', hub._dueAt('bar') === null, { due: hub?._dueAt?.('bar')?.id ?? null }); + step('bar-still-mode', ss.barMode === true, { barMode: ss.barMode }); + + // THE WAY-OUT COMES BACK: the scene's lock disabled LEAVE BAR (the + // drawer's bar item) along with everything else; DONE must re-enable + // it — the bar's OWN lock returns (deck stays standby, LEAVE BAR is + // the one live control on the surface again). + const barBtn = ss.exploreSubBar?.buttons?.find((b) => b.id === 'bar'); + step( + 'wayout-back', + !!barBtn && barBtn.btn.disabled === false, + { + barDisabled: barBtn?.btn?.disabled ?? 'n/a', + buttons: (ss.exploreSubBar?.buttons ?? []).map((b) => b.id), + }, + ); + + // And LEAVE BAR still WORKS — the press leaves the bar (previously + // the dead button swallowed the click and stranded the player). + barBtn?.btn?.panel?.emit('pointerdown'); + await sleep(250); + step('wayout-leaves', ss.barMode === false, { barMode: ss.barMode }); + + // 8 — re-enter the bar: the scene stays spent (the once-ledger; the + // quest step stands done behind it) — a normal bar visit, no window. + ss.toggleBar(); + await sleep(250); + step( + 'spent-on-revisit', + ss.barMode === true && hub.active === false && hub.busy === false, + { barMode: ss.barMode, active: hub.active, busy: hub.busy }, + ); + ss.leaveBar(); + + window.__VIGNETTE_TEST__ = { pass: true, steps: window.__STEPS__ ?? [], id: ID, errors: window.__VIGNETTE_ERRORS__ }; +})().catch((e) => { + fail(e?.message ?? String(e), {}); +}); diff --git a/dev/vignettes.test.mjs b/dev/vignettes.test.mjs new file mode 100644 index 0000000..5455f49 --- /dev/null +++ b/dev/vignettes.test.mjs @@ -0,0 +1,331 @@ +/** + * Vignettes model test (dev tool, run with Node — no browser): + * + * node dev/vignettes.test.mjs + * + * The pure rules of data/vignettes.json (js/vignettes/VignetteModel.js) — + * the planet dialogue scenes' contract: + * - the shipped config is VALID (enabled, the speakers registered with + * their planet cuts — planet-trestle.mp4 / planet-carol.mp4 — and the + * roster well-formed — empty until the first scene is authored); + * - normalizeLine / normalizeVignette drop what can't be shown (no + * speaker / no text / unknown speaker / no id / no location / no + * lines) and keep the rest (audio optional, once defaults true, + * the gate's quest+step pair normalizes); + * - vignettesForLocation returns the scenes bound to a world slot + * ('bar' | 'shop'), in config order; + * - vignetteAvailable is the availability rule: unplayed, the gate + * held (quest granted, unclaimed, the named step exists and is open) + * — a gate the scene couldn't read stays CLOSED, a `once: false` + * scene re-arms after it played; + * - asset keys are stable (per character for the planet clip, per file + * for the speech — a clip shared by several lines loads once) and + * resolve to the shipped files. + */ +import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { readFileSync, existsSync } from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); + +let failures = 0; +const check = (label, cond) => { + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); + if (!cond) failures++; +}; + +// Silence the model's drop warnings during the negative tests (they are +// the EXPECTED behavior; the checks below verify the drops). +const origWarn = console.warn; +console.warn = () => {}; + +const { + vignettesEnabled, + charEntry, + normalizeLine, + normalizeVignette, + listVignettes, + vignettesForLocation, + vignetteAvailable, + lineAssets, + vignetteAssets, + videoKeyFor, + videoUrlFor, + audioKeyFor, +} = await import(pathToFileURL(join(root, 'js/vignettes/VignetteModel.js')).href); + +const cfg = JSON.parse(readFileSync(join(root, 'data/vignettes.json'), 'utf8')); + +// ---------------------------------------------------------------------- +// the shipped config (the contract the hub + window build on) +// ---------------------------------------------------------------------- + +check('vignettes.json is enabled', cfg.enabled === true); +check( + 'the audio recipe ships (own volume + a per-sound music duck)', + typeof cfg.audio?.volume === 'number' && typeof cfg.audio?.musicDuck === 'number', +); + +const trestle = charEntry(cfg, 'trestle'); +check('Trestle is registered (label + role)', !!trestle && trestle.label === 'TRESTLE' && !!trestle.role); +const carol = charEntry(cfg, 'carol'); +check('Carol is registered (label + role)', !!carol && carol.label === 'CAROL' && !!carol.role); + +// The default planet cuts — the speakers' faces (the comms' space cuts +// are the .mp4 pair; the planet cuts ride alongside). +check( + 'the default planet cuts ship (trestle + carol)', + existsSync(join(root, 'assets/videos/characters/planet-trestle.mp4')) && + existsSync(join(root, 'assets/videos/characters/planet-carol.mp4')), +); + +// The roster ships its FIRST scene — the bar's Trestle + Carol meeting +// ('Special Delivery' step 1; the quest's check is the 'vignettePlayed' +// witness keyed on the scene's id). +const roster = listVignettes(cfg); +check('the roster: exactly one scene ships (the first meeting)', roster.length === 1); +const first = roster[0]; +check( + 'the first scene: the bar, gated on special_delivery step 0 (once)', + first?.id === 'special_delivery_bar_meeting' && + first?.location === 'bar' && + first?.quest === 'special_delivery' && + first?.step === 0 && + first?.once === true, +); +check( + 'the first scene: five lines — Trestle opens, Carol carries the four beats', + first?.lines?.length === 5 && + JSON.stringify(first?.lines.map((l) => l.from)) === JSON.stringify(['trestle', 'carol', 'carol', 'carol', 'carol']), +); +check( + 'the first scene: every line carries its speech clip (the voices ship on disk)', + (first?.lines ?? []).every((l) => typeof l.audio === 'string' && l.audio.length > 0 && existsSync(join(root, l.audio))), +); +check( + "the first scene: its gate is the quest's bar step (vignettePlayed → this id)", + (() => { + const quests = JSON.parse(readFileSync(join(root, 'data/quests.json'), 'utf8')); + const q = (quests.quests ?? []).find((x) => x.id === first?.quest); + const c = (q?.checks ?? [])[first?.step]; + return c?.type === 'vignettePlayed' && c?.id === first?.id; + })(), +); +check("location: the bar's scenes (the first one)", JSON.stringify(vignettesForLocation(cfg, 'bar').map((v) => v.id)) === JSON.stringify(['special_delivery_bar_meeting'])); +check('location: the shop has no scenes yet', vignettesForLocation(cfg, 'shop').length === 0); + +// The window's contract (the hub + window read it): the labels + the +// opening beat. +// The window's geometry contract (the user's ask): the speaker's face at +// FULL size (the 256×256 planet cut — never downscaled) + a text column +// wide enough that the shipped lines wrap comfortably. +const winCfg = cfg.window ?? {}; +check('the window ships the full planet cut (videoSize 256 — the face is never downscaled)', winCfg.videoSize === 256); +check( + 'the window is wide: the feed + a usable text column (≥320px of words — 502px ships)', + (() => { + const col = (winCfg.width ?? 0) - 2 * (winCfg.pad ?? 0) - (winCfg.videoSize ?? 0) - (winCfg.videoGap ?? 0); + return col >= 320; + })(), +); +check('the window ships NEXT + DONE labels', cfg.window?.button?.label === 'NEXT' && cfg.window?.button?.doneLabel === 'DONE'); +check('the opening beat ships (a positive delay)', Number(cfg.window?.startDelayMs) >= 0); +check('the scrim ships (a dimming + its alpha)', !!cfg.window?.scrim?.color && Number(cfg.window?.scrim?.alpha) >= 0); + +// ---------------------------------------------------------------------- +// normalization +// ---------------------------------------------------------------------- + +const chars = { trestle: { label: 'TRESTLE' }, carol: { label: 'CAROL' } }; +const mkCfg = (vignettes) => ({ enabled: true, characters: chars, vignettes }); + +check('a line: the words + the speaker (audio optional)', (() => { + const l = normalizeLine({ from: 'carol', text: 'Hey kid.', audio: 'assets/speech/carol/carol-q2-01.mp3' }); + return l?.from === 'carol' && l?.text === 'Hey kid.' && l?.audio === 'assets/speech/carol/carol-q2-01.mp3'; +})()); +check('a line: a text-only line is valid', normalizeLine({ from: 'carol', text: 'Hey.' })?.audio === null); +check('a line: no speaker is dropped', normalizeLine({ text: 'Words.' }) === null); +check('a line: no words is dropped', normalizeLine({ from: 'carol' }) === null); +check('a line: a non-object is dropped', normalizeLine('carol: words.') === null); + +const good = normalizeVignette( + { + id: 'bar-trestle-carol', + location: 'Bar', // case-insensitive + quest: 'special_delivery', + step: 1, + lines: [ + { from: 'trestle', text: 'One more stop before the nest.' }, + { from: 'carol', text: 'You made it to the capital, kid.' }, + ], + }, + mkCfg([]), +); +check( + 'a scene: id + location (lowercased) + the gate pair + the lines, in order', + !!good && + good.id === 'bar-trestle-carol' && + good.location === 'bar' && + good.quest === 'special_delivery' && + good.step === 1 && + good.once === true && + good.lines.length === 2 && + good.lines[0].from === 'trestle' && + good.lines[1].from === 'carol', +); +check('a scene: no gate = ungated (quest + step both null)', (() => { + const v = normalizeVignette({ id: 'chatter', location: 'bar', lines: [{ from: 'carol', text: 'Hi.' }] }, mkCfg([])); + return v?.quest === null && v?.step === null; +})()); +check('a scene: `once: false` re-arms (the author’s repeat)', (() => { + const v = normalizeVignette( + { id: 'repeat', location: 'shop', lines: [{ from: 'trestle', text: 'Back again.' }], once: false }, + mkCfg([]), + ); + return v?.once === false; +})()); +check('a scene: a step without a quest is dropped (a gate needs its quest)', (() => { + const v = normalizeVignette({ id: 'orphan', location: 'bar', step: 2, lines: [{ from: 'carol', text: 'Hi.' }] }, mkCfg([])); + return v?.quest === null && v?.step === null; +})()); +check('a scene: no id is dropped', normalizeVignette({ location: 'bar', lines: [{ from: 'carol', text: 'Hi.' }] }, mkCfg([])) === null); +check('a scene: no location is dropped', normalizeVignette({ id: 'x', lines: [{ from: 'carol', text: 'Hi.' }] }, mkCfg([])) === null); +check('a scene: no lines is dropped', normalizeVignette({ id: 'x', location: 'bar' }, mkCfg([])) === null); +check('a scene: an unknown speaker drops the whole scene', (() => { + const v = normalizeVignette( + { id: 'ghost', location: 'bar', lines: [{ from: 'carol', text: 'Hi.' }, { from: 'no-one', text: '??' }] }, + mkCfg([]), + ); + return v === null; +})()); +check('a scene: one bad line drops the whole scene (warned)', (() => { + const v = normalizeVignette( + { id: 'broken', location: 'bar', lines: [{ from: 'carol' }] }, + mkCfg([]), + ); + return v === null; +})()); +check('the roster: duplicates keep the first entry', (() => { + const c = mkCfg([ + { id: 'dup', location: 'bar', lines: [{ from: 'carol', text: 'One.' }] }, + { id: 'dup', location: 'bar', lines: [{ from: 'carol', text: 'Two.' }] }, + ]); + const roster = listVignettes(c); + return roster.length === 1 && roster[0].lines[0].text === 'One.'; +})()); +check('the roster: a $comment string is skipped', (() => { + const c = { + enabled: true, + characters: chars, + vignettes: ['$comment', { id: 'real', location: 'bar', lines: [{ from: 'carol', text: 'Hi.' }] }], + }; + return listVignettes(c).length === 1 && listVignettes(c)[0].id === 'real'; +})()); + +// ---------------------------------------------------------------------- +// the location binding (Explore's Bar / the deck's Shop) +// ---------------------------------------------------------------------- + +const twoLoc = mkCfg([ + { id: 'bar-a', location: 'bar', lines: [{ from: 'carol', text: 'Bar one.' }] }, + { id: 'shop-a', location: 'shop', lines: [{ from: 'trestle', text: 'Shop one.' }] }, + { id: 'bar-b', location: 'bar', lines: [{ from: 'trestle', text: 'Bar two.' }] }, +]); +check('location: the bar’s scenes, in config order', JSON.stringify(vignettesForLocation(twoLoc, 'bar').map((v) => v.id)) === JSON.stringify(['bar-a', 'bar-b'])); +check('location: the shop’s scenes', JSON.stringify(vignettesForLocation(twoLoc, 'SHOP').map((v) => v.id)) === JSON.stringify(['shop-a'])); +check('location: an empty location yields nothing', vignettesForLocation(twoLoc, ' ').length === 0); + +// ---------------------------------------------------------------------- +// availability (the gate — the scene is a quest step) +// ---------------------------------------------------------------------- + +const gated = { + id: 'bar-trestle-carol', + location: 'bar', + quest: 'special_delivery', + step: 0, + once: true, + lines: [{ from: 'trestle', text: 'One.' }], +}; +const openGate = { granted: true, claimed: false, stepExists: true, stepDone: false }; +const played = () => new Set(); + +check('available: ungated + unplayed → plays', vignetteAvailable({ ...gated, quest: null, step: null }, { played: played(), gate: null }) === true); +check('available: ungated + played → done (once)', vignetteAvailable({ ...gated, quest: null, step: null }, { played: new Set(['bar-trestle-carol']), gate: null }) === false); +check('available: once:false re-arms after it played', vignetteAvailable({ ...gated, quest: null, step: null, once: false }, { played: new Set(['bar-trestle-carol']), gate: null }) === true); +check('available: the gate held (granted, unclaimed, step open) → plays', vignetteAvailable(gated, { played: played(), gate: openGate }) === true); +check('available: the quest not granted → stays closed', vignetteAvailable(gated, { played: played(), gate: { ...openGate, granted: false } }) === false); +check('available: the quest claimed → done (the step paid out)', vignetteAvailable(gated, { played: played(), gate: { ...openGate, claimed: true } }) === false); +check('available: the step already done → done (the witness stands)', vignetteAvailable(gated, { played: played(), gate: { ...openGate, stepDone: true } }) === false); +check('available: a missing step (miswired gate) → stays closed', vignetteAvailable(gated, { played: played(), gate: { ...openGate, stepExists: false } }) === false); +check('available: a gate the scene couldn’t read → stays closed', vignetteAvailable(gated, { played: played(), gate: null }) === false); +check('available: completed (played) + gate open → done (the ledger marks on completion)', vignetteAvailable(gated, { played: new Set(['bar-trestle-carol']), gate: openGate }) === false); + +// ---------------------------------------------------------------------- +// the deferred-load specs (keys + urls) +// ---------------------------------------------------------------------- + +check('video: the key is stable per character', videoKeyFor('carol') === 'vign_vid_carol'); +check( + 'video: the default is the PLANET cut (assets/videos/characters/planet-.mp4)', + videoUrlFor('carol') === 'assets/videos/characters/planet-carol.mp4', +); +check('video: a character entry may override its clip', (() => { + const c = { characters: { carol: { label: 'CAROL', video: 'assets/videos/characters/carol-alt.mp4' } } }; + const a = lineAssets(c, { from: 'carol', text: 'x', audio: null }); + return a.video?.url === 'assets/videos/characters/carol-alt.mp4' && a.video?.key === 'vign_vid_carol'; +})()); +check('video: the default cut ships on disk', existsSync(join(root, videoUrlFor('trestle'))) && existsSync(join(root, videoUrlFor('carol')))); +check('speech: the key is folder-scoped (two characters may share a basename)', audioKeyFor('assets/speech/carol/carol-q2-01.mp3') !== audioKeyFor('assets/speech/trestle/carol-q2-01.mp3')); +check('speech: the same file across lines shares one key', audioKeyFor('assets/speech/carol/carol-q2-01.mp3') === audioKeyFor('assets/speech/carol/carol-q2-01.mp3')); +check('speech: a text-only line has no audio spec', lineAssets({ characters: chars }, { from: 'carol', text: 'x', audio: null }).audio === null); + +check( + 'a scene’s asset pass: dedupes by key (a two-beat Carol scene fetches her clip once)', + (() => { + const c = mkCfg([]); + const vig = normalizeVignette( + { + id: 'multi', + location: 'bar', + lines: [ + { from: 'carol', text: 'One.', audio: 'assets/speech/carol/carol-q2-01.mp3' }, + { from: 'carol', text: 'Two.', audio: 'assets/speech/carol/carol-q2-01.mp3' }, + { from: 'trestle', text: 'Three.' }, + ], + }, + c, + ); + const a = vignetteAssets(c, vig); + return a.videos.length === 2 && a.audios.length === 1 && a.videos.every((v) => v.url.startsWith('assets/videos/characters/planet-')); + })(), +); + +// ---------------------------------------------------------------------- +// the enable switch (a config predating vignettes reads as enabled) +// ---------------------------------------------------------------------- + +check('a config without the section reads as enabled (pre-vignettes saves)', vignettesEnabled({}) === true && vignettesEnabled({ enabled: false }) === false); + +// ---------------------------------------------------------------------- +// the hub's duck (v4's Sound API — the music must ACTUALLY duck; +// this is a source-shape regression guard: the old `snd.keys` + +// `isPlaying()` loop iterated nothing in the v4 "Giedi" build) +// ---------------------------------------------------------------------- + +const hubSrc = readFileSync(join(root, 'js/vignettes/VignetteHub.js'), 'utf8'); +const duckSrc = (hubSrc.match(/_duck\(on\) \{[\s\S]*?\n \}/) ?? [''])[0]; +check('duck: reads the v4 manager (getAllPlaying — no `keys` property exists)', /getAllPlaying/.test(duckSrc) && !/\.keys/.test(duckSrc)); +check('duck: treats a body isPlaying as data, never a method call', !/\.isPlaying\(/.test(duckSrc)); +check('duck: exempts the voice, ducks by the musicDuck factor, restores its own volume', /_voiceKey/.test(duckSrc) && /this\.musicDuck/.test(duckSrc) && /s\.setVolume\(vol\)/.test(duckSrc)); + +// ---------------------------------------------------------------------- + +console.warn = origWarn; +if (failures > 0) { + console.error(`\n${failures} vignette-model check(s) failed`); + process.exit(1); +} +console.log('\nAll vignette-model checks passed.'); diff --git a/js/save/SaveData.js b/js/save/SaveData.js index ae68a87..34ab70f 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -148,6 +148,17 @@ export function captureState(scene, now) { // comms has no field; the restore stages an empty set (old saves // load). comms: { played: Array.from(scene.registry.get('commsPlayed') ?? []) }, + // Vignettes — the run's ALREADY-COMPLETED scene ids (data/vignettes.json: + // the `once` scenes the run has finished, so a load doesn't re-play them + // and a new game re-plays them). Marked on COMPLETION (the opposite of + // comms' mark-on-start — a conversation is a place: leave mid-scene and + // it re-arms on the next visit), which is also the step's WITNESS — the + // 'vignettePlayed' quest check (GameScene._questCheckEval) reads this + // same ledger. The ledger lives in the registry + // ('vignettesPlayed' — js/vignettes/VignetteHub.js owns it). A save + // predating vignettes has no field; the restore stages an empty set + // (old saves load). + vignettes: { played: Array.from(scene.registry.get('vignettesPlayed') ?? []) }, // Lifetime minerals mined from asteroids (the 'Mine 200 Minerals' // requirement's input — the hold is cargo, this is the record). // A save predating it has no field; the restore stages 0. @@ -299,6 +310,17 @@ export function prepareLoad(registry, record) { : [], ), ); + // The run's VIGNETTES played-ledger (a save predating vignettes has no + // field → an empty set — old saves load; a fresh run's scenes arm on + // their quest gates in data/vignettes.json). + registry.set( + 'vignettesPlayed', + new Set( + Array.isArray(record.vignettes?.played) + ? record.vignettes.played.filter((id) => typeof id === 'string') + : [], + ), + ); registry.set(PENDING_RESTORE_KEY, { ship: record.ship, tethers: Array.isArray(record.tethers) ? record.tethers : [], @@ -337,6 +359,7 @@ export function resetRunState(registry) { registry.set('depletedClusters', null); registry.set('story', null); // the bar, the handoff, the leg — all fresh registry.set('commsPlayed', null); // the welcome arms again on the new run + registry.set('vignettesPlayed', null); // the scenes re-arm on the new run registry.set(PENDING_RESTORE_KEY, null); } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 8a10a9c..90e855b 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -3613,6 +3613,10 @@ export class GameScene extends Phaser.Scene { * build with effects.cargo.bays, data/builds.json) * packageDelivered— the delivery's proof holds (this.story.delivered — * the home landing that followed the capital's launch) + * vignettePlayed — the named VIGNETTE scene completed (registry + * 'vignettesPlayed' — VignetteHub marks it when the + * scene ENDS; data/vignettes.json's scene is the step + * this check is the witness of) */ _questCheckEval(check) { const label = String(check?.label ?? ''); @@ -3717,6 +3721,20 @@ export class GameScene extends Phaser.Scene { const done = this.story?.delivered === true; return { label, done, detail: done ? '1/1' : '0/1' }; } + if (type === 'vignettePlayed') { + // A VIGNETTE scene (data/vignettes.json) completed: the run's + // vignette played-ledger (registry 'vignettesPlayed' — VignetteHub + // marks it when the scene ENDS; SaveData saves/restores it) is the + // witness — the scene named by check.id is the one the step is + // about (the bar's Trestle + Carol meeting for 'Special Delivery'). + // The scene plays over the location's looping clip (the window is + // the moment; the clip keeps moving beneath it) and its DONE press + // is what sets this — the conversation IS the step. + const id = String(check.id ?? '').trim(); + const played = this._vignettesPlayed(); + const done = id !== '' && played.has(id); + return { label, done, detail: done ? 'SEEN' : 'PENDING' }; + } return { label, done: false, detail: '??' }; } @@ -3754,6 +3772,19 @@ export class GameScene extends Phaser.Scene { return cur instanceof Set ? cur : new Set(); } + /** + * The run's vignette played-ledger (registry 'vignettesPlayed' — + * VignetteHub owns it, SaveData captures/restores it under + * record.vignettes.played). Vignettes mark on COMPLETION (the opposite + * of comms' mark-on-start — a scene left unfinished re-arms), so this + * is also the 'vignettePlayed' check's progress record. + * @returns {Set} + */ + _vignettesPlayed() { + const cur = this.registry?.get?.('vignettesPlayed'); + return cur instanceof Set ? cur : new Set(); + } + /** * A quest check's system: 'home' → the home system (the nest); a * literal id otherwise (future quests may name a specific system). diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index 1d4689d..7cb3b36 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -7,6 +7,7 @@ import { playSfxOn } from '../utils/Sfx.js'; import { playMusicOn, musicKey, stopMusicOn } from '../utils/Music.js'; import { ActionBar } from '../ui/ActionBar.js'; import { MenuSubBar } from '../ui/MenuSubBar.js'; +import { VignetteHub } from '../vignettes/VignetteHub.js'; import { resolveItems, worldContext } from '../ui/ExploreBar.js'; import { SavePanel } from '../ui/SavePanel.js'; import { SaveManager } from '../save/SaveManager.js'; @@ -108,6 +109,8 @@ export class SurfaceScene extends Phaser.Scene { this.actionBar = null; this.menuSubBar = null; this.exploreSubBar = null; // the EXPLORE drawer (the deck's full-width service bar) + this.vignetteHub = null; // the planet's dialogue scenes (data/vignettes.json) + this.vignetteLocation = null; // the location a scene is holding (bar | shop) this.savePanel = null; this.saveManager = null; this.landKey = null; @@ -318,6 +321,14 @@ export class SurfaceScene extends Phaser.Scene { this.leaveShop(); return; } + if (this.vignetteHub?.busy) { + // A scene is pending or open (the bar's) — the shop's entry waits + // for its DONE (the scene plays before the player can touch + // buy/sell, and one location at a time). + this.consoleNote('ONE THING AT A TIME'); + this.playSfx('ui_close'); + return; + } if (!this.hasVideo(this.shopKey)) { this.consoleNote('NO SHOP ON THIS WORLD'); return; @@ -349,6 +360,12 @@ export class SurfaceScene extends Phaser.Scene { this.shopMode = true; this.actionBar?.setLabel('shop', this.shopLabel(true)); // the slot now reads LEAVE SHOP this.setDeckLocked(true); // the shop is up — the deck's other slots go standby + // The SHOP's scene, if one is due (data/vignettes.json, location + // 'shop'): it plays BEFORE the player can touch buy/sell — the trade + // view defers to the scene's completion (_vignetteComplete raises it + // then). No scene due → the trade view comes up now (the current + // behavior). + if (this._tryStartVignette('shop')) return; this.buildShopView(); // the buy/sell UI over the clip (js/ui/shop/ShopView.js) } @@ -361,6 +378,7 @@ export class SurfaceScene extends Phaser.Scene { * painted over the surface loop — must be hidden, not just paused. */ leaveShop() { + if (this.vignetteHub?.active) return; // the scene holds the shop — DONE is the only way out this.shopMode = false; this.destroyShopView(); // tear down the buy/sell UI before restoring the surface this.shopVideo?.pause(); @@ -368,6 +386,11 @@ export class SurfaceScene extends Phaser.Scene { this.surfaceVideo?.resume(); // resumes where it paused (no surface clip → the plate) this.actionBar?.setLabel('shop', this.shopLabel(false)); // the slot is back to SHOP this.setDeckLocked(false); // the shop is down — the deck's other slots come back + // The scene, if armed-but-not-yet-open (the player left before the + // opening beat), is cancelled — it re-arms on the next visit to the + // shop (the played ledger marks on completion only, so nothing is + // lost). + this.vignetteHub?.cancelPending(); } /** @@ -428,6 +451,13 @@ export class SurfaceScene extends Phaser.Scene { this.leaveBar(); return; } + if (this.vignetteHub?.busy) { + // A scene is pending or open (the shop's) — the bar's entry waits + // for its DONE (one location at a time). + this.consoleNote('ONE THING AT A TIME'); + this.playSfx('ui_close'); + return; + } if (!this.hasVideo(this.barKey)) { this.consoleNote('NO BAR ON THIS WORLD'); return; @@ -459,6 +489,13 @@ export class SurfaceScene extends Phaser.Scene { this.barMode = true; this.exploreSubBar?.setLabel('bar', this.barLabel(true)); // the slot now reads LEAVE BAR this.setBarLocked(true); // the bar is up — every OTHER button goes standby + // The BAR's scene, if one is due (data/vignettes.json, location + // 'bar'): the bar's clip reads first, the window opens over it after + // the opening beat. While the scene holds, the deck + drawer stand + // down (the window's NEXT/DONE is the only live control); when it + // completes, the bar's own lock returns (LEAVE BAR live again) — the + // scene's ledger mark being the step's witness. + this._tryStartVignette('bar'); } /** @@ -470,12 +507,18 @@ export class SurfaceScene extends Phaser.Scene { * painted over the surface loop — must be hidden, not just paused. */ leaveBar() { + if (this.vignetteHub?.active) return; // the scene holds the bar — DONE is the only way out this.barMode = false; this.barVideo?.pause(); this.barVideo?.setVisible(false); // its frozen last frame would cover the surface this.surfaceVideo?.resume(); // resumes where it paused (no surface clip → the plate) this.exploreSubBar?.setLabel('bar', this.barLabel(false)); // the slot is back to BAR this.setBarLocked(false); // the bar is down — the deck + drawer come back + // The scene, if armed-but-not-yet-open (the player left before the + // opening beat), is cancelled — it re-arms on the next visit to the + // bar (the played ledger marks on completion only, so nothing is + // lost). + this.vignetteHub?.cancelPending(); } /** @@ -526,6 +569,110 @@ export class SurfaceScene extends Phaser.Scene { return inBar ? String(active) : String(base); } + // ------------------------------------------------------------------ + // Stage 2d — the planet's VIGNETTES (data/vignettes.json — the + // dialogue scenes that play on the surface: the window over the + // location's looping clip, NEXT/DONE, the quest-step witness) + // ------------------------------------------------------------------ + + /** + * Arm the vignette due for a location (the scene's hub — it loads the + * scene's assets on demand and opens the window after the opening + * beat). Returns true when a scene now holds the surface: the caller + * defers the location's own UI (the shop's buy/sell view) until the + * scene completes (_vignetteComplete). + */ + _tryStartVignette(location) { + const hub = this.vignetteHub; + if (!hub || !hub.enabled || hub.busy) return false; + this.vignetteLocation = location; + return hub.tryPlay(location); + } + + /** + * The gate's facts (the hub asks; the model applies them): the scene's + * quest, read from the sleeping GameScene — granted, unclaimed, and + * the named step still open. A gate the scene can't read (no ledger, + * no such step) stays CLOSED — a miswired gate must not open a scene. + */ + _vignetteGate(vig) { + if (!vig.quest) return null; + // Resolve lazily (the create-time capture can predate the scene + // manager's registration on a slow boot — the click is what matters). + const g = this.gameScene ?? this.scene?.get?.('GameScene') ?? null; + if (!g?.questState || typeof g.questSnapshot !== 'function') return null; + const closed = { granted: false, claimed: false, stepExists: false, stepDone: false }; + if (g.questState.isGranted(vig.quest) !== true) return closed; + const quest = (g.questSnapshot().quests ?? []).find((q) => q.id === vig.quest); + if (!quest) return closed; + const step = Array.isArray(quest.checks) ? quest.checks[vig.step ?? 0] : null; + return { + granted: true, + claimed: quest.claimed === true, + stepExists: step != null, + stepDone: step?.done === true, + }; + } + + /** + * The deck's lock while a scene holds (the hub's onLock): the window's + * NEXT/DONE is the only live control on the surface — every deck slot + * (including the location's way-out) and every drawer item go standby. + * On completion the location's own lock returns (LEAVE BAR / LEAVE + * SHOP live again) — the scene played ON TOP of the location, not + * instead of it. The way-out comes back FIRST on the restore: the + * `on` path disabled it along with everything else, and the location + * restore below intentionally SKIPS it (it owns the location's lock — + * setBarLocked/setDeckLocked never touch their own way-out) — so + * without this line LEAVE BAR / LEAVE SHOP would stay dead after DONE. + */ + _vignetteLock(on) { + const deck = this.actionBar; + const drawer = this.exploreSubBar; + if (on) { + this.menuSubBar?.close?.(); // one drawer at a time — the window's up now + if (deck && Array.isArray(deck.slots)) { + for (const s of deck.slots) { + if (s.live) deck.setSlotEnabled(s.id, false); + } + } + if (drawer && Array.isArray(drawer.buttons)) { + for (const b of drawer.buttons) drawer.setDisabled(b.id, true); + } + } else { + // The way-out first (see the doc above) — LEAVE BAR over the bar, + // LEAVE SHOP over the shop — so it is live when the location's own + // lock returns. + if (this.barMode && drawer && Array.isArray(drawer.buttons)) { + if (drawer.buttons.some((b) => b.id === 'bar')) drawer.setDisabled('bar', false); + } else if (this.shopMode && deck && Array.isArray(deck.slots)) { + const shop = deck.slots.find((s) => s.id === 'shop'); + if (shop && shop.live) deck.setSlotEnabled('shop', true); + } + if (this.barMode) this.setBarLocked(true); + else if (this.shopMode) this.setDeckLocked(true); + else { + this.setBarLocked(false); + this.setDeckLocked(false); + if (drawer && Array.isArray(drawer.buttons)) { + for (const b of drawer.buttons) drawer.setDisabled(b.id, false); + } + } + } + } + + /** + * The scene completed (the hub's onComplete — the played ledger is + * already marked, which is the step's 'vignettePlayed' witness): the + * location resumes. The bar is the loop again; the shop raises its + * buy/sell view now — it deferred from entry while the scene held + * (the scene plays before the player can touch buy/sell). + */ + _vignetteComplete(vig) { + if (this.shopMode) this.buildShopView(); + this.vignetteLocation = null; + } + /** * The BUY/SELL UI over the shop clip (js/ui/shop/ShopView.js). Built once * per shop visit (a fresh session each time you land — the session-local @@ -985,6 +1132,20 @@ export class SurfaceScene extends Phaser.Scene { }); } + // The VIGNETTE hub (data/vignettes.json — the planet's dialogue + // scenes, the comms hub's surface twin): it arms the scene due for a + // location when the player enters one (toggleBar 'bar' / toggleShop + // 'shop'), loads the scene's assets on demand, opens the window over + // the location's looping clip after the opening beat, and hands the + // surface back on DONE (the scene's ledger mark is the step's + // witness). It dies with this scene — the played ledger lives in the + // shared registry, so the next visit remembers. + this.vignetteHub = new VignetteHub(this, { + gate: (vig) => this._vignetteGate(vig), + onLock: (on) => this._vignetteLock(on), + onComplete: (vig) => this._vignetteComplete(vig), + }); + // The BUILD console (the deck's BUILD slot) — the passive view over // gameScene.buildState (the run's build records + the single // in-progress build): the same split as the research console (state @@ -1255,6 +1416,9 @@ export class SurfaceScene extends Phaser.Scene { /** ESC: build console → quests console → set-destination → confirm dialog → save pop-up → explore drawer → sub-bar. */ escAction() { + // A scene holding the surface owns ESC too — NEXT/DONE is the only + // way out (the scene plays before the player can touch buy/sell). + if (this.vignetteHub?.active) return; if (this.barMode) return; // the drawer's LEAVE BAR is the only way out of the bar if (this.buildWindow?.isOpen) { this.buildWindow.close(); @@ -1394,6 +1558,11 @@ export class SurfaceScene extends Phaser.Scene { } onPointerDown(pointer) { + // A scene holding the surface (the window open) owns the clicks — + // NEXT/DONE is the only live control; the surface's own click + // bookkeeping (the drawers' dismiss, the double-click skip) stands + // down (the window's button handles its own press). + if (this.vignetteHub?.active) return; // Double-click (two presses inside the window) — the rest of the // in-flight one-shot clip is skipped: a landing in flight lands now, // a takeoff in flight leaves now (a no-op on the deck, where no clip @@ -1530,6 +1699,7 @@ export class SurfaceScene extends Phaser.Scene { this.exploreSubBar?.update(time, delta); this.savePanel?.update(time); this._setDestDialog?.update(time); // the SET DESTINATION confirm + this.vignetteHub?.update(time); // a scene's opening beat / decode / pulse this.updateHud(time); // the upper-left name/type decode (surface stage only) // The build console: tick the single in-progress build (one at a @@ -1579,6 +1749,9 @@ export class SurfaceScene extends Phaser.Scene { this._setDest = null; this.buildWindow?.destroy(); this.buildWindow = null; + this.vignetteHub?.destroy(); // the scene + its pending beat die with the surface + this.vignetteHub = null; + this.vignetteLocation = null; this.actionBar?.destroy(); this.menuSubBar?.destroy(); this.exploreSubBar?.destroy(); diff --git a/js/ui/VignetteWindow.js b/js/ui/VignetteWindow.js new file mode 100644 index 0000000..b55c6ad --- /dev/null +++ b/js/ui/VignetteWindow.js @@ -0,0 +1,761 @@ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { toColor, toCss } from '../utils/Color.js'; +import { fontStack } from '../utils/Theme.js'; +import { ScrambleDecode, decodeDur } from '../utils/Decode.js'; +import { CyberShape } from './CyberShape.js'; +import { MenuButton } from './MenuButton.js'; + +/** + * VIGNETTE WINDOW — the centered dialogue window a vignette plays in + * (js/vignettes/VignetteHub.js owns its life): the speaker's PLANET clip + * in a bracketed feed panel on the LEFT, the line's text decoding in on + * the RIGHT, and — on every line — the NEXT button below the text + * (amber, the ACK pulse; the LAST line's button reads DONE and the hub + * closes the window). A scrim dims the world behind it so the window + * owns the moment — the location's looping clip keeps moving beneath + * (that is the set). + * + * ┌──────────────────────────────────────────────────────┐ + * │ ● TRESTLE // OUT-OF-SYSTEM MENTOR │ + * │ ┌──────┐ Hey kid. You made it to the capital — │ + * │ │ carol│ Carol's at the bar, she's been waiting… │ + * │ │ clip │ [NEXT] │ + * │ └──────┘ │ + * └──────────────────────────────────────────────────────┘ + * + * The window is a PASSIVE view (the project rule): the VignetteHub + * builds it, drives `open()` / `setLine(spec)` / `update(time)` / + * `setVoice(on)` / `close()`, and tears it down. It never plays the + * voice, never touches the scene's action; it only shows the line and + * reports the button press (`onNext` — the hub is the ear, the scene is + * the voice). + * + * The clip is ALWAYS muted (the comms' video rule — the voice is the + * line's speech clip, and the video is the speaker's face): it loops + * silently while the line holds. When the NEXT line's speaker is a + * different character, `setLine` swaps the face in place (the clip is + * cached game-wide, so the swap is a fresh element off the cache). + * + * The window SIZES TO THE LINE IN PLAY (textHeightFor → _rebuildLayout): + * a later line taller than the first grows the window in place — the + * backplate re-draws, the NEXT/DONE button seats below the NEW text (not + * the first line's), the pulse follows, and the window re-centers — so + * the words never run into the button or the window's edge. + * + * v4 quirks honored (the CommsBox/SurfaceScene video idiom): + * - add.video(x, y, key) — the key is LAST; + * - the element is muted via el.muted (setVolume(0) alone isn't the + * browser's autoplay check); + * - the bookkeeping size is a placeholder until the first presented + * frame — fit now with the best known dimensions, refit on + * 'created', keep the clip hidden until ready (no black flash); + * - resume() not play() after a pause. + * + * Config: data/vignettes.json → window. + */ +export class VignetteWindow extends Phaser.GameObjects.Container { + /** + * @param {Phaser.Scene} scene the SurfaceScene (screen-fixed — it pins + * itself to the screen, scrollFactor 0) + * @param {{ + * label: string, the speaker's display name (the header) + * role: string, the small line beside it + * line: {text: string},the first line's words (decode in) + * video: {key:string}|null, the speaker's cached planet clip (null → NO SIGNAL plate) + * last: boolean, the last line of the scene (the button reads DONE) + * onNext: () => void, the button press (the hub advances / completes) + * }} spec + */ + constructor(scene, spec) { + super(scene, 0, 0); + this.scene.add.existing(this); + this.setScrollFactor(0); // UI — pinned to the screen, not the world + this.setDepth(110); // above the shop view (100) — the window owns the moment + + const winCfg = config.section('vignettes.window', {}); + const colors = winCfg.colors ?? {}; + const textCfg = winCfg.text ?? {}; + const buttonCfg = winCfg.button ?? {}; + const anim = winCfg.animation ?? {}; + const scrimCfg = winCfg.scrim ?? {}; + + this.W = Math.max(320, winCfg.width ?? 560); + this.pad = winCfg.pad ?? 14; + this.videoSize = Math.max(80, winCfg.videoSize ?? 150); + this.videoGap = winCfg.videoGap ?? 14; + this.headerH = 20; + + // Palette (data/vignettes.json → window.colors, theme underneath). + this.panelInt = toColor(colors.panel, 0x0a1120); + this.panelAlpha = Math.max(0, Math.min(1, colors.panelAlpha ?? 0.94)); + this.borderInt = toColor(colors.border, 0x22405f); + this.borderAlpha = colors.borderAlpha ?? 0.9; + this.notch = winCfg.notch ?? Math.min(14, 20); + this.neonInt = toColor(colors.neon, 0x00e5ff); + this.inkCss = toCss(toColor(colors.ink, 0xeaf6ff)); + this.dimCss = toCss(toColor(colors.dim, 0x7d92c4)); + this.faintCss = toCss(toColor(colors.faint, 0x3d4c74)); + this.amberInt = toColor(colors.amber, 0xffc94d); + this.nextLabel = String(buttonCfg.label ?? 'NEXT').toUpperCase(); + this.doneLabel = String(buttonCfg.doneLabel ?? 'DONE').toUpperCase(); + + this.inMs = Math.max(0, anim.inMs ?? 260); + this.outMs = Math.max(0, anim.outMs ?? 420); + + this.onNext = typeof spec.onNext === 'function' ? spec.onNext : null; + this.lastLine = spec.last === true; + this.voiceOn = false; + this._textValue = String(spec.line?.text ?? spec.text ?? ''); + this._videoKey = undefined; // the face in the feed (setLine's swap key — undefined until the first _setVideo, so a first line with no clip still gets its NO SIGNAL plate) + + // The button pulse (window.pulse config) — the one live control on + // the surface wears the same attention cue as the comms' ACKNOWLEDGE. + const pulseCfg = config.section('vignettes.window.pulse', {}); + this._pulseOn = pulseCfg.enabled !== false; + this._pulseColor = toColor(pulseCfg.color, 0xffd84d); + this._pulsePeriod = Math.max(600, pulseCfg.periodMs ?? 1300); + this._halo = null; + this._ringA = null; + this._ringB = null; + + const fam = fontStack('body'); + + // ---- measure the text (its height sizes the window) --------------- + const textMaxW = Math.max(120, this.W - 2 * this.pad - this.videoSize - this.videoGap); + this.textMaxW = textMaxW; // the column (text wrap + the button's right seat) + this.text = scene.add + .text(0, 0, this._textValue, { + fontFamily: fam, + fontSize: `${textCfg.fontSize ?? 13}px`, + lineHeight: { value: textCfg.lineHeight ?? 18 }, + color: this.inkCss, + letterSpacing: textCfg.letterSpacing ?? 0.4, + wordWrap: { width: textMaxW, useAdvancedWrap: true }, + }) + .setOrigin(0, 0) + .setScrollFactor(0); + // The wrapped height (the PROBE — the same measurement setLine re-runs + // per line, so the window and its tests share one source of truth). + const textH = this.textHeightFor(this._textValue); + + // ---- the button (every line) — measured too (it adds a row) -------- + const buttonLabel = this.lastLine ? this.doneLabel : this.nextLabel; + const probe = new Phaser.GameObjects.Text(scene, 0, 0, buttonLabel, { + fontFamily: fam, + fontSize: `${buttonCfg.fontSize ?? 11}px`, + }); + const buttonH = probe.height + (buttonCfg.paddingY ?? 7) * 2; + probe.destroy(); + this.buttonH = buttonH; // the layout math (constructor + setLine) reads it + + const contentTop = this.pad + this.headerH + (winCfg.headerGap ?? 8); + this.H = contentTop + Math.max(this.videoSize, textH + (buttonCfg.gap ?? 10) + buttonH) + this.pad; + + // ---- placement: dead center — it slides in from 16px high --------- + const cx = (scene.scale?.width ?? 1280) / 2; + const cy = (scene.scale?.height ?? 720) / 2; + this.finalX = cx - this.W / 2; + this.finalY = cy - this.H / 2; + this.setPosition(this.finalX, this.finalY - 16); + this.setAlpha(0); + + this._buildScrim(scrimCfg); + this._buildBackplate(); + this._buildHeader(spec.label, spec.role); + this.text.setPosition(this.pad + this.videoSize + this.videoGap, contentTop); + this.add(this.text); // v4: a directly-built Text is off the display list until added + // The button wears the window's AMBER — the primary action reads + // against the cyan UI (the pulse below makes it unmistakable). + const amberCss = toCss(this.amberInt); + this.button = new MenuButton( + scene, + 0, 0, // repositioned below (right-aligned in the text column) + buttonLabel, + () => this._onNextPress(), + { + fontSize: buttonCfg.fontSize ?? 11, + paddingX: buttonCfg.paddingX ?? 16, + paddingY: buttonCfg.paddingY ?? 7, + letterSpacing: buttonCfg.letterSpacing ?? 2, + upper: true, + screenFixed: true, // the window lives in a scrolling-cam scene + bgColor: '#181104', // warm dark panel (the window's amber family) + hoverColor: '#2f2409', // hover warms it + borderColor: amberCss, // the window's amber — the primary-action edge + textColor: '#ffe9b8', // warm ink on the amber + }, + ); + // Right-aligned in the text column: the action anchors the window's + // lower-right (the menu's button idiom, the console's balance). + const textX = this.pad + this.videoSize + this.videoGap; + this.button.setPosition( + textX + Math.min(this.button.width, textMaxW) - this.button.width / 2, + contentTop + textH + (buttonCfg.gap ?? 10) + buttonH / 2, + ); + if (this._pulseOn) this._buildPulse(); // the halo + rings go BEHIND the button + this.add(this.button); + this._setVideo(spec.video); + + // The decode-in (the console's type-out): the line's words pull + // themselves out of static over the canonical window. + this._dec = null; + this._decT0 = null; + this._decFinished = false; + } + + // ------------------------------------------------------------------ + // the chrome + // ------------------------------------------------------------------ + + /** + * The SCRIM — the world behind the window goes dim (the ConfirmOverlay + * idiom), but unlike the confirm it is a pure backdrop: NOT interactive + * (a click through it lands on the surface, and the scene's + * pointerdown guard stands it down while the scene holds — NEXT/DONE is + * the only live control). + */ + _buildScrim(scrimCfg) { + const W = this.scene.scale?.width ?? 1280; + const H = this.scene.scale?.height ?? 720; + const color = toColor(scrimCfg.color ?? '#02040a', 0x02040a); + this.scrim = this.scene.add + .rectangle(W / 2, H / 2, W, H, color, Math.max(0, Math.min(1, scrimCfg.alpha ?? 0.4))) + .setScrollFactor(0); + this.add(this.scrim); + } + + _buildBackplate() { + const g = this.scene.add.graphics().setScrollFactor(0); + this.backplate = g; + this.add(g); + this._drawBackplate(); + } + + /** (Re)draw the backplate at the window's current W × H — a TALLER line + * re-sizes the window (setLine → _rebuildLayout) and the plate follows. */ + _drawBackplate() { + const g = this.backplate; + g.clear(); + CyberShape.draw(g, this.W, this.H, { + notch: this.notch, + fill: this.panelInt, + fillAlpha: this.panelAlpha, + stroke: this.borderInt, + strokeAlpha: this.borderAlpha, + lineWidth: 1.5, + glow: this.neonInt, + glowAlpha: 0.14, + }); + g.setPosition(this.W / 2, this.H / 2); + } + + _buildHeader(label, role) { + const fam = fontStack('body'); + // The voice indicator — amber while the line's voice is talking + // (setVoice drives its pulse), faint when the voice is done. + this.dot = this.scene.add.circle(0, 0, 3.5, this.amberInt, 0.35).setScrollFactor(0); + this.add(this.dot); + const dx = this.pad + 3.5 + 5; + this.name = this.scene.add + .text(0, 0, String(label ?? '').toUpperCase(), { + fontFamily: fam, + fontSize: '11px', + color: this.inkCss, + letterSpacing: 2, + }) + .setOrigin(0, 0.5) + .setScrollFactor(0); + this.name.setPosition(dx, this.pad + 8); + this.add(this.name); + this.roleText = this.scene.add + .text(0, 0, String(role ?? '').toUpperCase(), { + fontFamily: fam, + fontSize: '9px', + color: this.faintCss, + letterSpacing: 1.5, + }) + .setOrigin(0, 0.5) + .setScrollFactor(0); + this.roleText.setPosition(dx + this.name.width + 10, this.pad + 8); + this.add(this.roleText); + this.dot.setPosition(this.pad + 3.5, this.pad + 8); + // A faint hairline under the header, across the window width. + const line = this.scene.add.graphics().setScrollFactor(0); + line.lineStyle(1, this.borderInt, 0.45); + line.lineBetween(this.pad, this.pad + this.headerH, this.W - this.pad, this.pad + this.headerH); + this.add(line); + } + + // ------------------------------------------------------------------ + // the feed (the speaker's face) + // ------------------------------------------------------------------ + + /** + * The feed panel: the bracket frame (static), then either the speaker's + * planet clip (muted, looping, contain-fit, hidden until ready) or the + * NO SIGNAL plate (a clip the stall valve couldn't land). `setLine` + * re-enters here when the NEXT line's speaker changes (the clip is + * cached game-wide — the swap is a fresh element off the cache). + */ + _setVideo(spec) { + const s = this.scene; + const key = spec && spec.key ? String(spec.key) : null; + + // The feed frame: bracket corners + the side ticks (the + // ResearchWindow archive-feed idiom) — built once, every face under it. + if (!this._feedFrame) { + const s = this.scene; + const vx = this.pad; + const vy = this._contentTop(); + const vs = this.videoSize; + const frame = s.add.graphics().setScrollFactor(0); + frame.clear(); + _brackets(frame, vx - 5, vy - 5, vs + 10, vs + 10, { length: 12, color: this.neonInt, alpha: 0.7 }); + frame.lineStyle(1, this.borderInt, 0.55); + frame.lineBetween(vx - 5, vy + vs / 2, vx - 1, vy + vs / 2); + frame.lineBetween(vx + vs + 1, vy + vs / 2, vx + vs + 5, vy + vs / 2); + this.add(frame); + this._feedFrame = frame; + } + + // Same face still in the feed — keep it rolling (the comms idiom). + if (key === this._videoKey) return; + this._destroyFace(); + + const vx = this.pad; + const vy = this._contentTop(); + const vs = this.videoSize; + const cx = vx + vs / 2; + const cy = vy + vs / 2; + this._videoKey = key; + + if (key && _hasVideo(s, key)) { + // v4: add.video(x, y, key) — the key is the LAST argument (the + // factory creates AND displays the element). + const v = s.add.video(0, 0, key); + v.setOrigin(0.5); + v.setScrollFactor(0); + // The feed is the speaker's face — muted (the project's video rule: + // the voice is the line's speech clip, never the video). v4's + // setVolume() only sets el.volume, so mute the element too (the + // autoplay-policy check). + v.setVolume(0); + if (v.video) v.video.muted = true; + v.setLoop(true); // the face loops silently while the line holds + // Fire it straight away (the SurfaceScene idiom): v4 retries + // internally until autoplay is allowed, and the game only runs + // after a gesture (the landing click), so muted autoplay is fine. + v.play(); + // Fit: contain (the face is never cropped), refit on 'created' + // (the true dimensions), hidden until ready (no black flash). + const fit = (vv, iw = 0, ih = 0) => { + const el = vv.video; + const vw = iw || (el && (el.videoWidth || el.width)) || (vv.frame && vv.frame.realWidth) || 256; + const vh = ih || (el && (el.videoHeight || el.height)) || (vv.frame && vv.frame.realHeight) || 256; + const sc = Math.min(vs / vw, vs / vh); + vv.setPosition(cx, cy); + vv.setScale(sc); + }; + fit(v); + const ready = (vv, w, h) => { + if (vv !== v || !vv.active) return; + fit(vv, w, h); + vv.setVisible(true); + if (!vv.video?.paused) return; // already rolling (v4 autoplay retry) + vv.play(); // the first start raced 'created' — nudge it again + }; + v.on('created', ready); + if (v.video && v.video.readyState >= 1) ready(v, 0, 0); + this.add(v); // into the window's container + this.video = v; + } else { + this._noSignal(cx, cy); + } + } + + _contentTop() { + return this.pad + this.headerH + (config.section('vignettes.window', {}).headerGap ?? 8); + } + + /** Tear the current face down (clip + plate) — a swap or destroy. */ + _destroyFace() { + if (this.video) { + _destroyVideo(this.video); + this.video = null; + } + this._plate?.destroy(); + this._plate = null; + this._plateText?.destroy(); + this._plateText = null; + } + + _noSignal(cx, cy) { + const s = this.scene; + const vs = this.videoSize; + const ph = s.add.graphics().setScrollFactor(0); + ph.clear(); + CyberShape.draw(ph, vs, vs, { + notch: 6, + fill: 0x050a12, + fillAlpha: 0.9, + stroke: this.borderInt, + strokeAlpha: 0.5, + lineWidth: 1, + }); + ph.setPosition(cx, cy); + this._plate = ph; + this.add(ph); + const ns = s.add + .text(cx, cy, 'NO SIGNAL', { + fontFamily: fontStack('header'), + fontSize: '12px', + color: this.faintCss, + letterSpacing: 3, + }) + .setOrigin(0.5) + .setScrollFactor(0); + this._plateText = ns; + this.add(ns); + } + + // ------------------------------------------------------------------ + // the button pulse (the one-live-control cue) + // ------------------------------------------------------------------ + + /** The amber halo (breathing) + two staggered sonar rings (expanding + * out from the button), behind it in the container. Sized off the + * button's final geometry — called after its position is set. */ + _buildPulse() { + const s = this.scene; + const bw = this.button.width; + const bh = this.button.height; + const notch = this.button.style?.notch ?? 8; + + // The halo — a soft amber fill hugging the button (additive: light + // from behind, not a solid slab). + const halo = s.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD); + halo.clear(); + CyberShape.draw(halo, bw + 8, bh + 8, { + notch: notch + 2, + fill: this._pulseColor, + fillAlpha: 1, // the alpha is the breathing (update drives it) + }); + halo.setPosition(this.button.x, this.button.y); + this._halo = halo; + + // The sonar rings — a plain outline a touch larger than the button; + // update() scales/fades them on a half-period stagger, so one is + // always leaving while the other sets. + const mkRing = () => { + const ring = s.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD); + ring.clear(); + CyberShape.draw(ring, bw + 12, bh + 12, { + notch: notch + 3, + stroke: this._pulseColor, + strokeAlpha: 1, + lineWidth: 2.5, + }); + ring.setPosition(this.button.x, this.button.y); + return ring; + }; + this._ringA = mkRing(); + this._ringB = mkRing(); + + this.add(this._halo); + this.add(this._ringA); + this.add(this._ringB); + } + + /** Per-frame (the hub pumps while the scene is in play): the pulse. A + * pure function of `time` — nothing to stop, and the hub stops + * pumping when the window closes. */ + _pulse(time) { + if (!this._pulseOn || !this._ringA || !this._halo) return; + const P = this._pulsePeriod; + const ring = (r, off) => { + const u = ((time + off) % P) / P; // 0..1 — the ring's age this cycle + // Never fully gone: the floor keeps the button framed so the cue + // reads even in the "between waves" moment. + r.setScale(1 + 0.3 * u).setAlpha(0.18 + (1 - u) * 0.67); + }; + ring(this._ringA, 0); + ring(this._ringB, P / 2); + // The halo breathes once per cycle — the button's own soft glow. + const b = 0.5 + 0.5 * Math.sin(((time % P) / P) * Math.PI * 2); + this._halo.setAlpha(0.12 + 0.26 * b); + } + + // ------------------------------------------------------------------ + // layout (the window sizes to the line in play — a taller line must + // never run into the button or the window's edge) + // ------------------------------------------------------------------ + + /** + * The wrapped height of `value` in the window's text style (a PROBE + * text with the same font/size/line-height/wrap as the live line — this + * build's setText measures synchronously, so a never-added probe is + * exact). The layout math (constructor + setLine) and the dev flow test + * all read THIS — one source of truth. + * @returns {number} + */ + textHeightFor(value) { + const textCfg = config.section('vignettes.window.text', {}); + const probe = new Phaser.GameObjects.Text(this.scene, 0, 0, String(value ?? ''), { + fontFamily: fontStack('body'), + fontSize: `${textCfg.fontSize ?? 13}px`, + lineHeight: { value: textCfg.lineHeight ?? 18 }, + letterSpacing: textCfg.letterSpacing ?? 0.4, + wordWrap: { width: this.textMaxW, useAdvancedWrap: true }, + }); + const h = probe.height; + probe.destroy(); + return h; + } + + /** + * Re-size the window to the line just set (setLine): the backplate + * grows, the NEXT/DONE button seats below the NEW text (not the first + * line's), the pulse follows the button, and the window re-centers — + * anchored top, it grows down. The live text is untouched (its decode + * starts a tick later, so the measurement never renders). + */ + _rebuildLayout(value) { + const winCfg = config.section('vignettes.window', {}); + const buttonCfg = winCfg.button ?? {}; + const contentTop = this._contentTop(); + const th = this.textHeightFor(value); + const gap = buttonCfg.gap ?? 10; + this.H = contentTop + Math.max(this.videoSize, th + gap + this.buttonH) + this.pad; + this._stopContainerTweens(); // no slide/fade fighting the re-center + this._drawBackplate(); + const textX = this.pad + this.videoSize + this.videoGap; + this.button.setPosition( + textX + Math.min(this.button.width, this.textMaxW) - this.button.width / 2, + contentTop + th + gap + this.buttonH / 2, + ); + this._reseatPulse(); + this.finalX = (this.scene.scale?.width ?? 1280) / 2 - this.W / 2; + this.finalY = (this.scene.scale?.height ?? 720) / 2 - this.H / 2; + this.setPosition(this.finalX, this.finalY); + } + + /** The pulse follows the button (a re-layout moves it). */ + _reseatPulse() { + if (!this._halo) return; + this._halo.setPosition(this.button.x, this.button.y); + this._ringA?.setPosition(this.button.x, this.button.y); + this._ringB?.setPosition(this.button.x, this.button.y); + } + + /** Kill the window's own tweens (a fast NEXT press mid-slide-in). */ + _stopContainerTweens() { + const tweens = this.scene.tweens?.getTweensOf?.(this) ?? []; + for (const t of tweens) { + try { + t.stop(); + } catch { + /* already done */ + } + } + } + + // ------------------------------------------------------------------ + // the hub's handles + // ------------------------------------------------------------------ + + /** + * The line's words decoding in (the hub calls this at open + setLine). + */ + beginText() { + const dur = decodeDur(this._textValue.length); + this._decT0 = this.scene.time.now; + this._dec = new ScrambleDecode(this._textValue, this._decT0, dur); + this._decFinished = false; + this.text.setText(''); + } + + /** + * The NEXT line (the hub advances): the header relabels (the new + * speaker), the face swaps when the speaker does (the same clip keeps + * rolling), the text re-decodes, and the button relabels NEXT → DONE + * on the last line. + * + * @param {{ + * label: string, role: string, + * line: {text: string}, + * video: {key:string}|null, + * last: boolean, + * }} spec + */ + setLine(spec) { + if (spec.label !== undefined) this.name.setText(String(spec.label ?? '').toUpperCase()); + if (spec.role !== undefined) { + const roleStr = String(spec.role ?? '').toUpperCase(); + this.roleText.setText(roleStr); + // Re-seat the role line beside the (possibly new) name. + this.roleText.setPosition(this.pad + 3.5 + 5 + this.name.width + 10, this.pad + 8); + } + this._textValue = String(spec.line?.text ?? spec.text ?? ''); + if (spec.video !== undefined) this._setVideo(spec.video); + if (spec.last !== undefined) { + this.lastLine = spec.last === true; + this.button.setLabel(this.lastLine ? this.doneLabel : this.nextLabel); + } + // Size the window to THIS line (it may be taller than the first — + // the button and the backplate follow; the old first-line height is + // gone), then let the words decode in. + this._rebuildLayout(this._textValue); + this.beginText(); + } + + /** Per-frame (the hub pumps it): the button pulse, then the text's decode. */ + update(time) { + this._pulse(time); + if (!this._dec || this._decFinished) return; + if (!this._dec.started(time)) return; + this.text.setText(this._dec.display(time)); + if (this._dec.finished(time)) { + this._decFinished = true; + this.text.setText(this._textValue); // land exactly + } + } + + /** The voice is live / done — the header dot pulses while it talks. */ + setVoice(on) { + this.voiceOn = !!on; + if (this._dotTween) { + this._dotTween.stop(); + this._dotTween = null; + } + if (this.voiceOn) { + this.dot.setFillStyle(this.amberInt, 1); + this._dotTween = this.scene.tweens.add({ + targets: this.dot, + scale: 1.5, + duration: 420, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut', + }); + } else { + this.dot.setFillStyle(this.amberInt, 0.35).setScale(1); + } + } + + /** The slide-in (the hub opens it, then the first line decodes). */ + open() { + this.beginText(); + this.scene.tweens.add({ + targets: this, + y: this.finalY, + alpha: 1, + duration: this.inMs, + ease: 'Cubic.easeOut', + }); + } + + /** The fade-out → destroy. `cb` runs after the teardown (or now). */ + close(cb) { + if (this._closing) { + if (typeof cb === 'function') cb(); + return; + } + this._closing = true; + if (typeof cb === 'function') { + this.scene.tweens.add({ + targets: this, + y: this.finalY + 10, + alpha: 0, + duration: this.outMs, + ease: 'Cubic.easeIn', + onComplete: () => { + this.destroy(); + cb(); + }, + }); + } else { + this.destroy(); + } + } + + /** Tear the window down (the hub's shutdown / close path). */ + destroy() { + if (this._closing === 'done') return; + this._closing = 'done'; + if (this._dotTween) { + this._dotTween.stop(); + this._dotTween = null; + } + this._destroyFace(); + this._feedFrame?.destroy(); + this._feedFrame = null; + this._halo?.destroy(); + this._halo = null; + this._ringA?.destroy(); + this._ringA = null; + this._ringB?.destroy(); + this._ringB = null; + this.button?.destroy(); + this.button = null; + this.dot?.destroy(); + this.dot = null; + this.name?.destroy(); + this.name = null; + this.roleText?.destroy(); + this.roleText = null; + this.text?.destroy(); + this.text = null; + this.backplate?.destroy(); + this.backplate = null; + this.scrim?.destroy(); + this.scrim = null; + super.destroy(); + } + + _onNextPress() { + if (this._closing) return; + // The press plays its own tick (the MenuButton calls the scene's + // voice); the hub is the ear — it advances the scene from here. + if (this.onNext) this.onNext(); + } +} + +// ---------------------------------------------------------------------- +// small local helpers (the CommsBox video idiom) +// ---------------------------------------------------------------------- + +function _hasVideo(scene, key) { + const c = scene.cache?.video; + return !!(c && typeof c.has === 'function' && c.has(key)); +} + +/** Bracket corners framing a rect (the viewfinder chrome). */ +function _brackets(g, x, y, w, h, o = {}) { + const L = o.length ?? 12; + g.lineStyle(o.lineWidth ?? 2, o.color ?? 0x00e5ff, o.alpha ?? 0.7); + g.lineBetween(x, y + L, x, y); + g.lineBetween(x, y, x + L, y); + g.lineBetween(x + w - L, y, x + w, y); + g.lineBetween(x + w, y, x + w, y + L); + g.lineBetween(x + w, y + h - L, x + w, y + h); + g.lineBetween(x + w, y + h, x + w - L, y + h); + g.lineBetween(x + L, y + h, x, y + h); + g.lineBetween(x, y + h, x, y + h - L); +} + +/** Destroy a video element the project-safe way (the SurfaceScene idiom). */ +function _destroyVideo(v) { + if (!v) return; + try { + v.off(); + if (typeof v.stop === 'function') v.stop(false); + } catch { + /* the element may already be gone */ + } + try { + v.destroy(); + } catch { + /* already destroyed */ + } +} diff --git a/js/vignettes/VignetteHub.js b/js/vignettes/VignetteHub.js new file mode 100644 index 0000000..4575408 --- /dev/null +++ b/js/vignettes/VignetteHub.js @@ -0,0 +1,510 @@ +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)); +} diff --git a/js/vignettes/VignetteModel.js b/js/vignettes/VignetteModel.js new file mode 100644 index 0000000..fe5e004 --- /dev/null +++ b/js/vignettes/VignetteModel.js @@ -0,0 +1,284 @@ +/** + * 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_. */ +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-.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.) 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} + */ +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} 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()] }; +}