diff --git a/assets/images/characters/OAC-close.png b/assets/images/characters/OAC-close.png new file mode 100644 index 0000000..ec94730 Binary files /dev/null and b/assets/images/characters/OAC-close.png differ diff --git a/assets/images/characters/OAC-wide.png b/assets/images/characters/OAC-wide.png new file mode 100644 index 0000000..d09c4fd Binary files /dev/null and b/assets/images/characters/OAC-wide.png differ diff --git a/assets/images/originals/ice-surface-02.png b/assets/images/originals/ice-surface-02.png new file mode 100644 index 0000000..40aebd1 Binary files /dev/null and b/assets/images/originals/ice-surface-02.png differ diff --git a/assets/speech/oac/intro-01.mp3 b/assets/speech/oac/intro-01.mp3 new file mode 100644 index 0000000..ace059f Binary files /dev/null and b/assets/speech/oac/intro-01.mp3 differ diff --git a/assets/speech/oac/intro-02.mp3 b/assets/speech/oac/intro-02.mp3 new file mode 100644 index 0000000..04c3cc5 Binary files /dev/null and b/assets/speech/oac/intro-02.mp3 differ diff --git a/assets/speech/oac/intro-03.mp3 b/assets/speech/oac/intro-03.mp3 new file mode 100644 index 0000000..7bbcb58 Binary files /dev/null and b/assets/speech/oac/intro-03.mp3 differ diff --git a/assets/speech/oac/intro-04.mp3 b/assets/speech/oac/intro-04.mp3 new file mode 100644 index 0000000..61cfd61 Binary files /dev/null and b/assets/speech/oac/intro-04.mp3 differ diff --git a/assets/videos/characters/oac.mp4 b/assets/videos/characters/oac.mp4 new file mode 100644 index 0000000..7cdda08 Binary files /dev/null and b/assets/videos/characters/oac.mp4 differ diff --git a/data/comms.json b/data/comms.json new file mode 100644 index 0000000..fde1055 --- /dev/null +++ b/data/comms.json @@ -0,0 +1,80 @@ +{ + "$comment": "COMMS — the characters of the ship speaking to the player (OAC first). Three comm types: 'ack' pauses the action and holds the box open (muted looping video + text + voice) until the player clicks ACKNOWLEDGE; 'visual' shows the box (video plays once + text + voice) and fades out when the voice ends; 'audio' is voice only — no box, no pause. Comms are EVENTS: each one binds a trigger (when) + a sender (who) + text + a speech clip (what). Trigger vocabulary: 'newGame' is live (the fresh-run welcome); the reservedTriggers list below is the proposed event set to discuss and assign comms to (researchComplete, researchNew, questNew, questComplete, discovery, landing, jump, buildComplete) — none of them fire yet. Assets are loaded ON DEMAND by the hub (js/comms/CommsHub.js) when a comm is due — never in a scene's preload, so the initial load stays lean.", + "enabled": true, + "audio": { + "$comment": "The comm'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 the voice talks, the other playing sounds (the game music) are ducked PER-SOUND by musicDuck (a fraction of each one's own volume) and restored exactly when the voice ends.", + "volume": 1.0, + "musicDuck": 0.3 + }, + "characters": { + "$comment": "The senders. label = the box's header; role = the small line beside it; video = the character clip (left panel of the box, ALWAYS played muted — the voice is the speech clip, never the video).", + "oac": { + "label": "O.A.C.", + "role": "ONBOARD A.I. COMPUTER", + "video": "assets/videos/characters/oac.mp4" + } + }, + "comms": [ + { + "$comment": "The fresh-run welcome: OAC introduces the ship. type 'ack' — the action pauses and the box holds (video loops silently) until ACKNOWLEDGE. delayMs beats the opening toast so the signal is clean.", + "id": "oac-intro", + "from": "oac", + "type": "ack", + "trigger": "newGame", + "text": "So you want to explore the galaxy do you?... Welcome to your ship... she's not much but she's got room to grow. I'm O.A.C. your Onboard A I Computer, and I'm here to help you get around.", + "audio": "assets/speech/oac/intro-01.mp3", + "once": true, + "delayMs": 1400 + } + ], + "reservedTriggers": [ + "researchComplete", + "researchNew", + "questNew", + "questComplete", + "discovery", + "landing", + "jump", + "buildComplete" + ], + "box": { + "$comment": "The wide comm box, docked just below the mineral bar (its right edge sticks to the bar's right edge; topPad px of padding below the bar). Left: the sender's clip in a bracketed feed panel (muted, contain-fit). Right: the comm text decoding in, and — for 'ack' only — the ACKNOWLEDGE button below it. The height grows to the content; long comms just get taller.", + "width": 560, + "topPad": 12, + "pad": 14, + "headerGap": 8, + "videoSize": 150, + "videoGap": 14, + "text": { + "fontSize": 13, + "lineHeight": 18, + "letterSpacing": 0.4 + }, + "button": { + "label": "ACKNOWLEDGE", + "gap": 10, + "fontSize": 11, + "paddingX": 16, + "paddingY": 7, + "letterSpacing": 2 + }, + "animation": { + "inMs": 260, + "outMs": 420, + "visualHoldMs": 350, + "$comment": "visualHoldMs = the beat the box holds after the voice ends (visual comms) before it fades." + }, + "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/data/manifest.json b/data/manifest.json index 15dbeeb..e224bea 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -27,6 +27,7 @@ "save.json", "mineralhud.json", "map.json", - "quests.json" + "quests.json", + "comms.json" ] } diff --git a/dev/cdp-probe.mjs b/dev/cdp-probe.mjs new file mode 100644 index 0000000..2621b18 --- /dev/null +++ b/dev/cdp-probe.mjs @@ -0,0 +1,58 @@ +// Dev: evaluate one expression in a page (brave CDP on :9333) and print +// the full result. Usage: node dev/cdp-probe.mjs [timeoutMs] +const [url, expr, timeoutMs = '20000'] = process.argv.slice(2); +if (!url || !expr) { + console.error('usage: node dev/cdp-probe.mjs [timeoutMs]'); + process.exit(2); +} +const CDP = '127.0.0.1:9333'; +const deadline = Date.now() + Number(timeoutMs); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +let target = await (await fetch(`http://${CDP}/json/list`)).json() + .then((ts) => ts.find((t) => t.type === 'page')); +if (!target) { + target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); +} +const ws = new WebSocket(target.webSocketDebuggerUrl); +let id = 0; +const pending = new Map(); +const send = (method, params = {}) => new Promise((res, rej) => { + const mid = ++id; + pending.set(mid, { res, rej }); + ws.send(JSON.stringify({ id: mid, method, params })); +}); +await new Promise((res, rej) => { + ws.onopen = res; + ws.onerror = (e) => rej(new Error('ws error: ' + (e?.message ?? '?'))); +}); +ws.onmessage = (m) => { + const msg = JSON.parse(m.data); + if (msg.id && pending.has(msg.id)) { + const { res, rej } = pending.get(msg.id); + pending.delete(msg.id); + msg.error ? rej(new Error(msg.error.message)) : res(msg.result); + } +}; + +await send('Page.enable'); +await send('Runtime.enable'); +await send('Page.navigate', { url }); +const loaded = new Promise((res) => setTimeout(res, 3000)); // let it settle +await loaded; + +const evaluate = async (e) => { + const r = await send('Runtime.evaluate', { expression: e, returnByValue: true, awaitPromise: true }); + if (r.exceptionDetails) throw new Error('page: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)); + return r.result?.value; +}; + +let out = null; +while (Date.now() < deadline) { + out = await evaluate(expr).catch(() => null); + if (out !== null && out !== undefined) break; + await sleep(300); +} +console.log(JSON.stringify(out, null, 2)); +ws.close(); +process.exit(0); diff --git a/dev/comms-hold.html b/dev/comms-hold.html new file mode 100644 index 0000000..75593ad --- /dev/null +++ b/dev/comms-hold.html @@ -0,0 +1,19 @@ + + + + + Orbit — dev: the comms intro self-check + + + + + + +
+ + + diff --git a/dev/comms-hold.mjs b/dev/comms-hold.mjs new file mode 100644 index 0000000..c105946 --- /dev/null +++ b/dev/comms-hold.mjs @@ -0,0 +1,59 @@ +/** + * Dev-only: HOLD the comms intro open (no ACK) for a clean screenshot of + * the box in its live state (video rolling, text decoded, button armed). + * node dev/cdp-shot.mjs "http://127.0.0.1:8090/dev/comms-hold.html" out.png \ + * "window.__commsHold" 60000 "document.getElementById('report')?.remove()" + */ +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'; + +const data = await ConfigLoader.load(); +config.init(data); +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; + +const report = document.createElement('pre'); +report.id = 'report'; +report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;margin:0;padding:8px 12px;font:12px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.9);border:1px solid #1b3a5a;max-width:50%;'; +document.body.appendChild(report); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const until = async (fn, ms = 30000) => { + const t0 = Date.now(); + for (;;) { + const v = fn(); + if (v) return v; + if (Date.now() - t0 > ms) return null; + await sleep(150); + } +}; + +setTimeout(async () => { + const s = () => game.scene.getScene('GameScene'); + const ready = await until(() => { + const sc = s(); + return sc && sc.ship && sc.comms && sc.comms.active && sc.comms.box?.video ? sc : null; + }); + if (!ready) { report.textContent = 'FAIL — comm never came up'; window.__commsHold = { ok: false }; return; } + // Let the text finish decoding + the clip settle, then signal. + await until(() => (s().comms.box._decFinished === true), 8000); + await sleep(700); + const sc = s(); + const box = sc.comms.box; + const el = box.video?.video; + window.__commsHold = { + ok: true, + box: { x: Math.round(box.x), y: Math.round(box.y), w: box.W, h: box.H }, + clip: el ? { paused: el.paused, muted: el.muted, t: el.currentTime?.toFixed(1) } : null, + voice: sc.comms._voiceKey ? sc.sound.isPlaying(sc.comms._voiceKey) : null, + }; + report.textContent = 'COMMS HOLD: box ' + box.W + '×' + box.H + + ' @ ' + Math.round(box.x) + ',' + Math.round(box.y) + + ' · clip ' + (el ? (el.paused ? 'PAUSED' : 'rolling') : '—') + + ' · voice ' + (sc.comms._voiceKey ? 'talking' : 'done'); +}, 600); diff --git a/dev/comms-intro.html b/dev/comms-intro.html new file mode 100644 index 0000000..9a83032 --- /dev/null +++ b/dev/comms-intro.html @@ -0,0 +1,19 @@ + + + + + Orbit — dev: the comms intro self-check + + + + + + +
+ + + diff --git a/dev/comms-intro.mjs b/dev/comms-intro.mjs new file mode 100644 index 0000000..f190b0f --- /dev/null +++ b/dev/comms-intro.mjs @@ -0,0 +1,159 @@ +/** + * Dev-only: the COMMS intro self-check — boots the game straight into + * the GameScene (a FRESH run, so the 'newGame' trigger arms OAC's + * welcome) and paints a plain-text report answering "did the ack comm + * actually happen?": + * + * 1. Did the comm ARM (trigger → queue) and PLAY (box + voice)? + * 2. Is the pause contract live (comms.paused, the ship parked)? + * 3. Is the clip muted + rolling (the video's element state)? + * 4. Does ACKNOWLEDGE actually end it (button press → finish → + * the box gone, the action free, the music volume restored)? + * + * node dev/slow-server.mjs 8080 # or any static server on the repo + * → open http://127.0.0.1:8080/dev/comms-intro.html + * + * Headless screenshot: + * node dev/cdp-shot.mjs "http://127.0.0.1:8080/dev/comms-intro.html" \ + * out.png "document.getElementById('report').textContent.includes('SELF-CHECK')" + */ +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'; + +const data = await ConfigLoader.load(); +config.init(data); + +const errors = []; +const origErr = console.error.bind(console); +console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); }; +if (typeof window !== 'undefined') { + window.addEventListener('error', (e) => errors.push(String(e.message))); + window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`)); +} + +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; + +const report = document.createElement('pre'); +report.id = 'report'; +report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:72%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;'; +document.body.appendChild(report); +const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); }; +setReport('COMMS SELF-CHECK RUNNING… (waiting for OAC’s welcome)'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const until = async (fn, ms = 30000) => { + const t0 = Date.now(); + for (;;) { + const v = fn(); + if (v) return v; + if (Date.now() - t0 > ms) return null; + await sleep(150); + } +}; + +const scene = () => game.scene.getScene('GameScene'); + +setTimeout(async () => { + const d = []; + try { + const s = scene(); + const ready = await until(() => (s && s.ship && s.comms ? s : null)); + if (!ready) { setReport(['FAIL — scene never became ready']); window.__commsResult = { ok: false }; return; } + + // 1) The welcome armed + played (the hub's deferred load may take a + // moment — the clip + the speech come in on demand). + const live = await until(() => (s.comms.active ? s : null)); + d.push(`arm+play: active=${s.comms.active} paused=${s.comms.paused} queued=${s.comms.queued}`); + if (!live) { setReport(['FAIL — the newGame comm never came up', ...d]); window.__commsResult = { ok: false, details: d }; return; } + + // The clip may take a beat to start (v4's autoplay retry — the + // browser unlocks muted autoplay on its own schedule); a real + // player has the whole voice to see it, so give it 4 s here. + const box = s.comms.box; + const clip = box?.video; + const el = clip?.video; + if (el) await until(() => el.paused === false, 4000); + const snd = s.sound; + const voiceKey = s.comms._voiceKey; + d.push(`box: ${box ? `${Math.round(box.x)},${Math.round(box.y)} ${box.W}×${box.H}` : '—'}`); + d.push(`clip: present=${!!clip} muted=${el ? el.muted : '—'} paused=${el ? el.paused : '—'} loop=${el ? el.loop : '—'} t=${el && Number.isFinite(el.currentTime) ? el.currentTime.toFixed(2) + 's' : '—'}`); + d.push(`voice: key=${voiceKey ?? '—'} playing=${voiceKey ? snd.isPlaying(voiceKey) : '—'} ducked=${s.comms._ducked}`); + d.push(`ship: paused-contract=${s.ship.body.velocity.length().toFixed(1)} state=${s.ship.state}`); + d.push(`text: "${String(box?.text?.text ?? '').slice(0, 60)}…"`); + + // Per-sound ducking (the volume contract): the VOICE's own sound + // stays at FULL volume (1.0); the music's own sound drops by + // musicDuck (0.6 → 0.18); the manager's MASTER is untouched. (The + // old bug: `sound.setVolume` IS the master in v4, so the voice was + // being quieted along with the music — that is why the comms + // sounded quiet.) Asserted on currentConfig.volume — the LOGICAL + // volume; the gain-node read lags in a suspended (headless) context. + const allPlaying = typeof snd.getAllPlaying === 'function' ? snd.getAllPlaying() : []; + const vs = allPlaying.find((x) => x.key === voiceKey); + const ms = allPlaying.find((x) => typeof x.key === 'string' && x.key.startsWith('music_')); + const musicVol = config.get('music.volume', 0.6); + const cfgVol = (x) => (x && x.currentConfig && typeof x.currentConfig.volume === 'number' ? x.currentConfig.volume : null); + d.push(`duck: voice.volume=${cfgVol(vs)} (expect 1.0) music.volume=${cfgVol(ms)} (expect ${(musicVol * 0.3).toFixed(2)}) master=${snd.masterVolumeNode && snd.masterVolumeNode.gain.value} (expect 1)`); + const duckOk = + s.comms._ducked === true && // the music duck was issued + cfgVol(vs) === 1.0 && // the voice is FULL volume + (cfgVol(ms) !== null && Math.abs(cfgVol(ms) - musicVol * 0.3) < 0.001); // music ducked per-sound + + const ok = + s.comms.active === true && + s.comms.paused === true && // ack: the action holds + !!box && + !!box.button && + !!clip && + !!el && + el.muted === true && // the video is silent (the voice is the speech) + el.loop === true && // the ack clip loops until acknowledged + el.paused === false && // …and it's rolling + voiceKey !== null && + snd.isPlaying(voiceKey) === true && // the speech is talking + duckOk && + s.ship.body.velocity.length() < 1; // the ship is parked + + // 2) ACKNOWLEDGE — press the box's button (its own pointerdown, the + // save-panel's modal idiom) and the comm must end: box gone, + // action free, the voice stopped. + const before = { paused: s.comms.paused, ducked: s.comms._ducked }; + box.button.panel.emit('pointerdown'); + await until(() => (s.comms.active === false ? s : null), 5000); + d.push(`ack: active=${s.comms.active} paused=${before.paused} → ${s.comms.paused} ducked=${before.ducked} → ${s.comms._ducked} voice=${voiceKey ? snd.isPlaying(voiceKey) : '—'}`); + // The duck must RESTORE: the music goes back to its own volume. + const msAfter = ms ? (snd.getAll(ms.key) ?? [])[0] : null; + d.push(`duck-restore: music.volume=${msAfter ? cfgVol(msAfter) : '—'} (expect ${musicVol})`); + const duckRestoreOk = !ms || (msAfter !== null && cfgVol(msAfter) !== null && Math.abs(cfgVol(msAfter) - musicVol) < 0.001); + // The comm must NOT replay: give the hub several frames to (wrongly) + // re-arm the same entry, then assert it stays finished. + await sleep(700); + const noReplay = s.comms.active === false && s.comms.queue.length === 0; + d.push(`no-replay: active=${s.comms.active} (expect false) queue=${s.comms.queue.length} (expect 0)`); + const ackOk = s.comms.active === false && s.comms.paused === false && s.comms._ducked === false && duckRestoreOk && (voiceKey ? snd.isPlaying(voiceKey) === false : true) && noReplay; + + // 3) The ledger: the welcome is PLAYED (a reload of this run would + // not re-hear it). + d.push(`ledger: played=${JSON.stringify(Array.from(s.registry.get('commsPlayed') ?? []))}`); + const playedOk = s.registry.get('commsPlayed')?.has('oac-intro') === true; + + // 4) The played filter: re-triggering arms nothing. + const armed = s.comms.trigger('newGame'); + d.push(`re-arm: armed=${armed} (expect 0 — the ledger holds)`); + const rearmOk = armed === 0; + + const allOk = ok && ackOk && playedOk && rearmOk; + const errLine = errors.length ? ['console errors:', ...errors.slice(0, 4)] : []; + window.__commsResult = { ok: allOk, details: [...d, ...errLine] }; + setReport(['COMMS SELF-CHECK ' + (allOk ? 'PASS ✔' : 'FAIL ✘'), '', ...d, ...errLine]); + } catch (err) { + window.__commsResult = { ok: false, details: ['EXCEPTION: ' + String(err?.stack ?? err)] }; + setReport(['COMMS SELF-CHECK FAIL ✘', String(err?.stack ?? err)]); + } +}, 600); diff --git a/dev/comms-types.html b/dev/comms-types.html new file mode 100644 index 0000000..112e532 --- /dev/null +++ b/dev/comms-types.html @@ -0,0 +1,19 @@ + + + + + Orbit — dev: the comms intro self-check + + + + + + +
+ + + diff --git a/dev/comms-types.mjs b/dev/comms-types.mjs new file mode 100644 index 0000000..0a6456a --- /dev/null +++ b/dev/comms-types.mjs @@ -0,0 +1,122 @@ +/** + * Dev-only: exercise the VISUAL + AUDIO comm paths in the live GameScene. + * The shipped roster is ack-only (the newGame welcome); this page overrides + * the comms config at boot so 'newGame' arms, in FIFO order: + * + * t-visual → box opens, clip plays ONCE (loop=false), NO ACK button, + * NO pause (comms.paused stays false, the ship is not parked), + * and the comm FINISHES ON ITS OWN when the voice ends + * (the isPlaying poll → visualHoldMs → fade). + * t-audio → NO box at all (comms.box is null), voice plays, no pause, + * finishes on its own. + * + * Headless note: the AudioContext is suspended, so the 12.6 s clip never + * reaches its natural end — the test stops the voice with stopByKey(), + * which is exactly what the hub's isPlaying poll sees as "the voice ended" + * (isPlaying → false). The hub must then finish the comm by itself. + * + * node dev/cdp-shot.mjs "http://127.0.0.1:8090/dev/comms-types.html" o.png \ + * "window.__commsTypes" 90000 + */ +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'; + +const data = await ConfigLoader.load(); + +// --- override: two newGame comms — one visual, one audio --------------- +const voice = 'assets/speech/oac/intro-01.mp3'; +data.comms = { + ...(data.comms ?? {}), + comms: [ + { + id: 't-visual', from: 'oac', type: 'visual', trigger: 'newGame', + text: 'A short visual comm — the action should keep running.', + audio: voice, once: true, + }, + { + id: 't-audio', from: 'oac', type: 'audio', trigger: 'newGame', + audio: voice, once: true, // second in the queue (FIFO behind the visual) + }, + ], +}; +config.init(data); + +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; + +const report = document.createElement('pre'); +report.id = 'report'; +report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;margin:0;padding:8px 12px;font:12px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.9);border:1px solid #1b3a5a;max-width:55%;white-space:pre-wrap;'; +document.body.appendChild(report); +const log = (m) => { report.textContent += m + '\n'; }; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const until = async (fn, ms = 20000) => { + const t0 = Date.now(); + for (;;) { + const v = fn(); + if (v) return v; + if (Date.now() - t0 > ms) return null; + await sleep(120); + } +}; + +setTimeout(async () => { + const d = []; + try { + const s = () => game.scene.getScene('GameScene'); + const ready = await until(() => { + const sc = s(); + return sc && sc.ship && sc.comms ? sc : null; + }); + if (!ready) throw new Error('scene never ready'); + + // ---- VISUAL ------------------------------------------------------- + const live = await until(() => (s().comms.active && s().comms.current?.id === 't-visual' ? s() : null)); + if (!live) throw new Error('visual comm never came up'); + const box = s().comms.box; + const el = box?.video?.video; + d.push(`visual: active=true paused=${s().comms.paused} (expect false) box=${!!box} button=${!!box?.button} (expect false)`); + d.push(`visual: clip loop=${el ? el.loop : '—'} (expect false) playing=${el ? el.paused === false : '—'}`); + d.push(`visual: shipFrozen=${s().ship.body.velocity.length() >= 1} (expect false — the comm did NOT park the ship)`); + const visualOk = + s().comms.paused === false && // NO pause (the visual contract) + !!box && + box.button === null && // no ACKNOWLEDGE button + !!el && + el.loop === false && // the clip plays ONCE + el.paused === false; + + // The voice is talking; end it the way the hub's poll sees an end. + const vKey = await until(() => (s().comms._voiceKey && s().sound.isPlaying(s().comms._voiceKey) ? s().comms._voiceKey : null)); + if (!vKey) throw new Error('visual voice never started'); + s().sound.stopByKey(vKey); + const visualDone = await until(() => (s().comms.current?.id !== 't-visual' ? true : null), 5000); + d.push(`visual: voice=${vKey} auto-finished=${!!visualDone} boxGone=${s().comms.box === null} paused=${s().comms.paused}`); + const ok = visualOk && !!visualDone && s().comms.box === null; + + // ---- AUDIO -------------------------------------------------------- + const aLive = await until(() => (s().comms.active && s().comms.current?.id === 't-audio' ? s() : null), 10000); + if (!aLive) throw new Error('audio comm never came up'); + const aNoBox = s().comms.box === null; + const aPaused = s().comms.paused; + const aVoiceKey = s().comms._voiceKey; + const aVoice = aVoiceKey ? s().sound.isPlaying(aVoiceKey) : null; + d.push(`audio: active=true box=${!!s().comms.box} (expect false) paused=${aPaused} (expect false) voice=${aVoice} (expect true)`); + if (aVoiceKey) s().sound.stopByKey(aVoiceKey); + const aDone = await until(() => (s().comms.current?.id !== 't-audio' ? true : null), 5000); + d.push(`audio: auto-finished=${!!aDone}`); + const okAll = ok && aNoBox && aPaused === false && aVoice === true && !!aDone; + + window.__commsTypes = { ok: okAll, details: d }; + console.log('[comms-types]', okAll ? 'PASS' : 'FAIL', d.join(' | ')); + } catch (err) { + window.__commsTypes = { ok: false, details: [...d, 'EXCEPTION: ' + String(err?.stack ?? err)] }; + console.error('[comms-types] FAIL', err); + } +}, 600); diff --git a/dev/comms.test.mjs b/dev/comms.test.mjs new file mode 100644 index 0000000..df3feab --- /dev/null +++ b/dev/comms.test.mjs @@ -0,0 +1,168 @@ +/** + * Comms model test (dev tool, run with Node — no browser): + * + * node dev/comms.test.mjs + * + * The pure rules of data/comms.json (js/comms/CommsModel.js) — the comm + * roster's contract: + * - the shipped config is VALID (enabled, OAC registered, the + * oac-intro comm wired: ack + newGame + the real assets); + * - the reserved triggers are RESERVED (no comm uses them yet); + * - normalizeComm drops what can't be shown (bad type / no trigger / + * no text for a box / no speech for an audio comm / unknown sender), + * and keeps the rest (once defaults true, delayMs clamped); + * - commsForTrigger returns the due comms in order, filtering the + * played `once` ones (and re-arming `once: false` ones); + * - asset keys are stable (per character for the clip, per file for + * the speech) and resolve to the shipped files. + */ +import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { readFileSync } 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; +let warnOn = true; +console.warn = (...a) => { + if (warnOn) origWarn(...a); +}; + +const { config } = await import(pathToFileURL(join(root, 'js/config/Config.js')).href); +const cfg = JSON.parse(readFileSync(join(root, 'data/comms.json'), 'utf8')); +config.init({ comms: cfg }); +const { + commsEnabled, + listComms, + commsForTrigger, + normalizeComm, + commAssets, + charEntry, + videoKeyFor, + audioKeyFor, + COMM_TYPES, +} = await import(pathToFileURL(join(root, 'js/comms/CommsModel.js')).href); + +// ---------------------------------------------------------------------- +// the shipped config (the contract the hub + box build on) +// ---------------------------------------------------------------------- + +check('comms.json is enabled', cfg.enabled === true); +check('COMM_TYPES = ack | visual | audio', JSON.stringify(COMM_TYPES) === JSON.stringify(['ack', 'visual', 'audio'])); + +const oac = charEntry(cfg, 'oac'); +check('OAC is registered (label + role + clip)', !!oac && oac.label === 'O.A.C.' && !!oac.role && oac.video === 'assets/videos/characters/oac.mp4'); + +const roster = listComms(cfg); +check('the roster holds exactly the shipped comms', roster.length === 1); +const intro = roster[0]; +check( + 'oac-intro: the fresh-run welcome (ack, newGame, OAC, once)', + !!intro && + intro.id === 'oac-intro' && + intro.from === 'oac' && + intro.type === 'ack' && + intro.trigger === 'newGame' && + intro.once === true, +); +check('oac-intro: the welcome text (the brief, verbatim)', intro?.text.includes('Welcome to your ship') && intro?.text.includes('O.A.C.')); +check('oac-intro: the parked speech clip', intro?.audio === 'assets/speech/oac/intro-01.mp3'); +check('oac-intro: its delay beats the opening toast', (intro?.delayMs ?? 0) >= 1000); + +check( + 'the reserved triggers are reserved (no comm uses them yet)', + ['researchComplete', 'researchNew', 'questNew', 'questComplete', 'discovery', 'landing', 'jump', 'buildComplete'].every( + (t) => !roster.some((c) => c.trigger === t), + ), +); + +// ---------------------------------------------------------------------- +// the deferred-load keys (stable + deduping) +// ---------------------------------------------------------------------- + +check('video key is per character', videoKeyFor('oac') === 'comms_vid_oac'); +check('audio key is per file (dedupes shared clips)', audioKeyFor('assets/speech/oac/intro-01.mp3') === 'comms_au_intro-01' && audioKeyFor('assets/speech/oac/intro-01.mp3') === audioKeyFor('./assets/speech/oac/intro-01.mp3')); +const assets = commAssets(cfg, intro); +check('oac-intro assets: the clip + the speech, keyed', assets.video?.key === 'comms_vid_oac' && assets.video.url === 'assets/videos/characters/oac.mp4' && assets.audio?.key === 'comms_au_intro-01'); + +// ---------------------------------------------------------------------- +// normalizeComm — the drops (what can't be shown doesn't ship) +// ---------------------------------------------------------------------- + +const base = { id: 't', from: 'oac', type: 'ack', trigger: 'newGame', text: 'hi', audio: 'assets/speech/oac/intro-01.mp3' }; + +// The drop checks below EXPECT the warnings — mute them (the checks +// verify the drops themselves). +warnOn = false; +check('a clean comm normalizes (defaults: once=true, delayMs=0)', (() => { + const c = normalizeComm({ ...base, id: 't2' }); + return !!c && c.once === true && c.delayMs === 0 && c.id === 't2'; +})()); +check('drop: unknown type', normalizeComm({ ...base, id: 'b1', type: 'hologram' }) === null); +check('drop: no trigger', normalizeComm({ ...base, id: 'b2', trigger: ' ' }) === null); +check('drop: ack/visual without text', normalizeComm({ ...base, id: 'b3', text: '' }) === null); +check('drop: audio comm without speech', normalizeComm({ ...base, id: 'b4', type: 'audio', audio: '' }) === null); +check('keep: an audio comm with no text (voice only)', (() => { + const c = normalizeComm({ ...base, id: 'b5', type: 'audio', text: '' }); + return !!c && c.text === '' && c.type === 'audio'; +})()); +check('keep: once:false survives (the author’s repeat)', normalizeComm({ ...base, id: 'b6', once: false })?.once === false); +check('delayMs clamps to >= 0', normalizeComm({ ...base, id: 'b7', delayMs: -50 })?.delayMs === 0); +warnOn = true; + +// unknown senders are dropped by the ROSTER (listComms), not normalize +const withGhost = { ...cfg, comms: [...cfg.comms, { ...base, id: 'ghost', from: 'wraith' }] }; +const ghostList = listComms(withGhost); +check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 1 && ghostList[0].id === 'oac-intro'); +const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] }); +check('drop: duplicate ids (first wins)', dupList.length === 1 && dupList[0].id === 'oac-intro'); +warnOn = true; + +// ---------------------------------------------------------------------- +// commsForTrigger — the due set (order + the played ledger) +// ---------------------------------------------------------------------- + +check('newGame arms the welcome on a fresh run', commsForTrigger(cfg, 'newGame').length === 1 && commsForTrigger(cfg, 'newGame')[0].id === 'oac-intro'); +check('a played `once` comm does not re-arm', commsForTrigger(cfg, 'newGame', new Set(['oac-intro'])).length === 0); +check('an unknown trigger arms nothing', commsForTrigger(cfg, 'launchParty').length === 0); +check('a blank trigger arms nothing', commsForTrigger(cfg, ' ').length === 0); + +// an `once: false` comm re-arms on every trigger (the repeat comm) +const repeatCfg = { + ...cfg, + comms: [{ id: 'chatter', from: 'oac', type: 'audio', trigger: 'landing', audio: 'assets/speech/oac/intro-01.mp3', once: false }], +}; +check('once:false re-arms after it played', commsForTrigger(repeatCfg, 'landing', new Set(['chatter'])).length === 1); + +// a future roster: several comms on one trigger keep config order +const multiCfg = { + ...cfg, + comms: [ + { id: 'a', from: 'oac', type: 'audio', trigger: 'researchComplete', audio: 'assets/speech/oac/research-complete.mp3' }, + { id: 'b', from: 'oac', type: 'audio', trigger: 'researchComplete', audio: 'assets/speech/oac/quest-new.mp3' }, + { id: 'c', from: 'oac', type: 'audio', trigger: 'questNew', audio: 'assets/speech/oac/quest-new.mp3' }, + ], +}; +check('a trigger’s comms arm in config order', JSON.stringify(commsForTrigger(multiCfg, 'researchComplete').map((c) => c.id)) === JSON.stringify(['a', 'b'])); + +// disabled master: the model still describes the roster (the hub is the +// one that honors `enabled`) — keep the two honest. +check('a config without the section reads as enabled (pre-comms saves)', commsEnabled({}) === true && commsEnabled({ enabled: false }) === false); + +// ---------------------------------------------------------------------- + +if (failures > 0) { + console.error(`\n${failures} comms-model check(s) failed`); + process.exit(1); +} +console.log('\nAll comms-model checks passed.'); diff --git a/dev/saves.test.mjs b/dev/saves.test.mjs index 558a645..b75513d 100644 --- a/dev/saves.test.mjs +++ b/dev/saves.test.mjs @@ -202,6 +202,9 @@ const makeStorage = (fail = false) => { scene.discovery.check(system.id, 0, 0, [{ id: 'pl:0', x: 500, y: 0, radius: 12 }]); check('setup: discovery has a hit', scene.discovery.isDiscovered(system.id, 'pl:0') === true); + // The comms played-ledger (the hub's registry Set) rides the save. + scene.registry.set('commsPlayed', new Set(['oac-intro', 'wraith-parting'])); + const rec = captureState(scene); check('capture: seed carried', rec.seed === SEED); check('capture: system identity carried', rec.currentSystemId === system.id && rec.systemName === system.name); @@ -211,6 +214,8 @@ const makeStorage = (fail = false) => { check('capture: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost'); check('capture: playtime carried', rec.playTimeMs === 123456); check('capture: depleted fields carried', rec.depletedClusters.includes(`${system.id}:c-depleted`)); + check('capture: the comms ledger is carried (record.comms.played)', + Array.isArray(rec.comms?.played) && rec.comms.played.includes('oac-intro') && rec.comms.played.includes('wraith-parting')); // A FRESH registry = a new browser session: prepareLoad rebuilds the // galaxy from the seed and restores the discovery state. @@ -229,6 +234,11 @@ const makeStorage = (fail = false) => { check('prepare: the live state is staged under the pending key', staged !== null && staged.ship.x === 123.5 && staged.playTimeMs === 123456); check('prepare: the hold rides along with the ship state', staged.ship.minerals === 37); + check('prepare: the comms ledger is restored (a Set, same ids)', + reg.get('commsPlayed') instanceof Set + && reg.get('commsPlayed').has('oac-intro') + && reg.get('commsPlayed').has('wraith-parting') + && reg.get('commsPlayed').size === 2); // A LEGACY record (pre-minerals) still loads — no field, the restore // side's `typeof === 'number'` guard keeps the ship at 0. @@ -254,6 +264,8 @@ const makeStorage = (fail = false) => { prepareLoad(regNoBuilds, makeRec()); // pre-build-system save check('prepare: a legacy record (no builds field) stages null builds', regNoBuilds.get(PENDING_RESTORE_KEY).builds === null); + check('prepare: a legacy record (no comms field) stages an empty ledger', + regNoBuilds.get('commsPlayed') instanceof Set && regNoBuilds.get('commsPlayed').size === 0); // consumeRestore: exactly once. const first = consumeRestore(reg); @@ -263,11 +275,13 @@ const makeStorage = (fail = false) => { // resetRunState: New Game clears the run-state seams. reg.set('discovery', reg.get('discovery')); + reg.set('commsPlayed', new Set(['oac-intro'])); reg.set(PENDING_RESTORE_KEY, { seed: SEED, ship: { x: 0, y: 0, heading: 0 } }); resetRunState(reg); check('reset: discovery cleared (fresh run)', reg.get('discovery') === null); check('reset: depleted fields cleared (fresh run)', reg.get('depletedClusters') === null); check('reset: staged restore cleared', reg.get(PENDING_RESTORE_KEY) === null); + check('reset: the comms ledger is cleared (the welcome arms again)', reg.get('commsPlayed') === null); } // prepareLoad must reject a record it can't trust. diff --git a/js/comms/CommsHub.js b/js/comms/CommsHub.js new file mode 100644 index 0000000..6a29716 --- /dev/null +++ b/js/comms/CommsHub.js @@ -0,0 +1,470 @@ +import { config } from '../config/Config.js'; +import { CommsBox } from '../ui/CommsBox.js'; +import { commsEnabled, commsForTrigger, commAssets, charEntry } from './CommsModel.js'; + +/** + * COMMS HUB — the scene-facing manager of data/comms.json. + * + * The hub owns a comm's life end-to-end: + * + * trigger(name) → the roster's due comms queue up (their delayMs beats + * whatever toast the event fired — the voice lands + * second, like a reply); + * pump(time) → the scene's update() calls it each frame; when the + * scene is FREE (the busy() contract) and the comm's + * assets are in, the next comm plays; + * play → the box (js/ui/CommsBox.js) opens under the mineral + * bar — the muted clip loops (ack) or runs once + * (visual) while the speech clip plays and the music + * ducks under it; + * finish → 'ack' waits for the ACKNOWLEDGE press (the action is + * frozen the whole time — the pause contract below); + * 'visual' holds a beat after the voice and fades; + * 'audio' is voice only — no box at all. + * + * DEFERRED ASSETS — the load is the COMM's, not the scene's (the point: + * the game can grow dozens of comms without preloading dozens of clips + * into every scene): when a comm is due, its character clip + speech + * file are queued on the scene's loader (`scene.load.video` / + * `scene.load.audio` → `start()` → 'complete') and played straight off + * the cache. The cache is shared game-wide, so the second scene to need + * OAC's clip gets it for free. + * + * THE PAUSE CONTRACT (ack comms only — visual/audio never pause; the + * player agreed to that: visual comms are the short ones): + * - input: the scene's handler swallows world clicks (GameScene gates + * on `comms.paused`) — the ACKNOWLEDGE button presses itself (its + * own pointerdown, the save-panel's modal idiom); + * - action: the scene's update() gates the ship's steering, the + * research/build ticks, and discovery on `comms.paused` — the world + * keeps breathing (starfield, tether, the clip), the action holds; + * - onPause hook: the scene parks the ship (`ship.stop()`) and drops + * the mining arm at pause start. + * + * PLAYED LEDGER (save-backed): the run's already-played comm ids live + * in the shared registry under 'commsPlayed' (a Set) — SaveData + * captures/restores it (record.comms.played), and a NEW GAME resets it + * (the welcome plays on every fresh run; a loaded run doesn't re-hear + * it). Marked PLAYED when the comm starts, so a comm the run never + * heard (abandoned mid-queue) can still arm on its next trigger. + * + * Config: data/comms.json (enabled, audio, characters, comms, box). + */ + +const PLAYED_KEY = 'commsPlayed'; +/** A deferred asset that hasn't landed in 15 s: play the comm anyway + * (NO SIGNAL plate / voice-less) — the queue never holds a comm forever. */ +const LOAD_VALVE_MS = 15000; + +export class CommsHub { + /** + * @param {Phaser.Scene} scene + * @param {{ + * busy?: () => boolean, the scene isn't free (a console is up, mining + * is live, a clip is in flight) — the comm waits + * anchor?: () => ({right:number, top:number}) | null, + * the mineral bar's lower-right (the box docks + * below it; null → the standard corner fallback) + * onPause?: (on: boolean) => void, the ack pause's scene-side effects + * }} [o] + */ + constructor(scene, o = {}) { + this.scene = scene; + this.cfg = config.section('comms', {}); + this.enabled = commsEnabled(this.cfg); + this.busy = typeof o.busy === 'function' ? o.busy : null; + this.anchor = typeof o.anchor === 'function' ? o.anchor : null; + this.onPause = typeof o.onPause === 'function' ? o.onPause : null; + + this.queue = []; // { def, dueAt, assets, loadingDone } + this.current = null; // the comm in play (its def) + this.box = null; // the live CommsBox (ack/visual comms) + this.played = this._playedSet(); + + this._loading = false; // a deferred asset pass is in flight + this._voiceKey = null; // the speech clip talking (or null) + this._voiceTimer = null; // the isPlaying poll (v4 has no complete event) + this._voiceDone = false; // this comm's voice has ended (or never existed) + this._ducked = false; + this._duckedSounds = null; // [{s, vol}] — the per-sound music duck (restore map) + this._paused = false; + this._endedAt = 0; // the last _finish (scene.time.now) — the ACK-click race guard + this._down = false; // torn down (scene shutdown) — all quiet + + scene.events.once('shutdown', () => this.destroy()); + } + + // ------------------------------------------------------------------ + // state the scene reads + // ------------------------------------------------------------------ + + /** True while an 'ack' comm holds the box open (the pause contract). */ + get paused() { + return this._paused; + } + + /** True while a comm is in play (box open or voice talking). */ + get active() { + return this.current !== null; + } + + /** How many comms are waiting behind the current one. */ + get queued() { + return this.queue.length; + } + + /** + * True if a comm just ended (the ACK press / the fade-out). The scene + * uses this as a RACE guard: the click that ACKNOWLEDGED (or the fade + * that closed a visual comm) is the comm's own — whatever the input + * dispatch order (button handler vs. scene handler), that click is + * never a world click. A deliberate NEXT action is always >200ms out. + */ + endedWithin(ms) { + return this._endedAt > 0 && (this.scene.time.now - this._endedAt) < ms; + } + + // ------------------------------------------------------------------ + // the scene's two calls + // ------------------------------------------------------------------ + + /** + * Arm the comms due for an event ('newGame', 'researchComplete', …). + * Each one queues with its delayMs and starts its deferred asset load + * immediately (while the event's toast is still up, the network is + * warm) — by the time the scene is free they are in. + * + * @returns {number} how many comms armed + */ + trigger(name) { + if (this._down || !this.enabled) return 0; + const due = commsForTrigger(this.cfg, name, this.played); + for (const def of due) { + this.queue.push({ + def, + dueAt: this.scene.time.now + def.delayMs, + assets: commAssets(this.cfg, def), + loadingDone: false, + }); + } + return due.length; + } + + /** + * Per-frame (the scene's update() drives it — the TimeClock is scene- + * driven in v4): step the live comm (the box's text decode) and, when + * idle, start the next due comm if the scene is free and the assets + * are in. + */ + pump(time) { + if (this._down) return; + if (this.current) { + this.box?.update(time); + return; + } + if (this._loading) return; // the asset pass re-pumps itself on 'complete' + const entry = this.queue[0]; + if (!entry) return; + if (time < entry.dueAt) return; + if (this.busy && this.busy()) return; // the console owns the screen — wait + this.queue.shift(); // COMMIT — a comm plays at most once (the played + // ledger then bars its next trigger; without this the entry would + // sit here and re-play every time the hub went idle) + this._start(entry); + } + + // ------------------------------------------------------------------ + // the comm's life + // ------------------------------------------------------------------ + + /** Start the entry: defer-load whatever's missing, then play. */ + _start(entry) { + const s = this.scene; + const missing = []; + if (entry.assets.video && !_hasVideo(s, entry.assets.video.key)) { + missing.push({ kind: 'video', spec: entry.assets.video }); + } + if (entry.assets.audio && !_hasAudio(s, entry.assets.audio.key)) { + missing.push({ kind: 'audio', spec: entry.assets.audio }); + } + if (missing.length === 0) { + this._play(entry); + return; + } + this._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) return; + done = true; + entry.loadingDone = true; + this._loading = false; + if (valve) { + valve.remove(false); + valve = null; + } + s.load.off('complete', finish); // the other finish path — keep it clean + // Whatever landed, the comm plays (a missing clip degrades to the + // NO SIGNAL plate / a voice-less box — the comm is never lost). + this._play(entry); + }; + s.load.once('complete', finish); + s.load.start(); + // The stall valve (the project's idiom — a clip that never starts + // must not hold the player): the load must resolve on its own. + valve = s.time.delayedCall(LOAD_VALVE_MS, finish); + } + + /** The comm is in play: the box (ack/visual) + the voice + the pause. */ + _play(entry) { + const s = this.scene; + const def = entry.def; + this.current = def; + this._voiceDone = false; + this.played.add(def.id); // the run has heard it (or is hearing it) + // The ack comm freezes the action FIRST (before the box slides in, + // before the voice) — the ship is already parked as the box lands. + // Visual/audio comms never pause (the agreed contract — they are the + // short ones). + if (def.type === 'ack') this._setPaused(true); + + if (def.type !== 'audio') { + const char = charEntry(this.cfg, def.from) ?? {}; + const video = entry.assets.video && _hasVideo(s, entry.assets.video.key) + ? { key: entry.assets.video.key } + : null; + this.box = new CommsBox(s, this._anchor(), { + label: char.label ?? def.from, + role: char.role ?? '', + text: def.text, + loop: def.type === 'ack', // the clip loops silently until acknowledged + video, + showAck: def.type === 'ack', + onAck: () => this._ack(), + }); + this.box.open(); + } + this._speak(entry); + } + + /** The comm's voice (the speech clip), with the music ducked under it. */ + _speak(entry) { + const s = this.scene; + const spec = entry.assets.audio; + const snd = s.sound; + if (!spec || !_hasAudio(s, spec.key) || !snd || typeof snd.play !== 'function') { + // Voice-less comm (missing clip, or no sound manager — headless): + // the comm still shows; the finish path just runs early. + this._voiceKey = null; + this._voiceEnded(); + return; + } + this._voiceKey = spec.key; + this._voiceDone = false; + this._duck(true); + this.box?.setVoice(true); + // The comm's voice is the comm's CONTENT (not an SFX — it plays even + // with sfx.enabled off; the cache/manager guards above are the + // failure path, per the project's sound rule). + try { + snd.play(spec.key, { volume: config.get('comms.audio.volume', 1.0) }); + } catch (e) { + // The engine refused (autoplay policy, the manager mid-shutdown): + // the comm still shows — finish on the voice-less path, never hang. + console.warn('[comms] voice refused by the engine', e); + this._voiceKey = null; + this._voiceEnded(); + return; + } + // v4 (Giedi) has no 'complete' event on the sound manager (checked — + // see Music.js) — poll isPlaying() on the shuffle's time base. + this._voiceTimer = s.time.addEvent({ + delay: 250, + loop: true, + callback: () => { + if (this._down || !this.current || this._voiceKey !== spec.key) return; + if (snd.isPlaying(spec.key) !== true) { + this._voiceKey = null; + this._voiceEnded(); + } + }, + }); + } + + /** The voice ended: ack waits for the button; the rest fade out. */ + _voiceEnded() { + if (this._voiceDone || !this.current) return; + this._voiceDone = true; + this._stopVoice(); + this.box?.setVoice(false); + this._duck(false); + const def = this.current; + if (def.type === 'ack') return; // the box holds (video looping) until ACKNOWLEDGE + const hold = + def.type === 'visual' + ? Math.max(0, config.get('comms.box.animation.visualHoldMs', 350)) + : 0; + this.scene.time.delayedCall(hold, () => { + if (this.current === def) this._finish(); + }); + } + + /** The ACKNOWLEDGE press (the box's button — the scene is the voice). */ + _ack() { + if (!this.current || this.current.type !== 'ack') return; + this._finish(); + } + + /** The comm is over: tear down, unpause, hand the next one its turn. */ + _finish() { + if (!this.current) return; + this._stopVoice(); + this._duck(false); + this._setPaused(false); + this._endedAt = this.scene.time.now; // the ACK-click race guard (the scene swallows it) + const box = this.box; + this.box = null; + this.current = null; + this._voiceDone = true; + if (box) box.close(); // the fade-out; the hub moves on (the next pump) + } + + /** Tear everything down (scene shutdown / destroy). Idempotent. */ + destroy() { + if (this._down) return; + this._down = true; + this._stopVoice(); + this._duck(false); + this._setPaused(false); + this.queue.length = 0; + this.current = null; + if (this.box) { + this.box.destroy(); + this.box = null; + } + } + + // ------------------------------------------------------------------ + // internals + // ------------------------------------------------------------------ + + _stopVoice() { + if (this._voiceTimer) { + this._voiceTimer.remove(false); + this._voiceTimer = null; + } + if (this._voiceKey) { + const snd = this.scene.sound; + if (snd && typeof snd.stopByKey === 'function') { + try { + snd.stopByKey(this._voiceKey); + } catch { + /* the manager is gone (shutdown) — fine */ + } + } + this._voiceKey = null; + } + } + + _duck(on) { + const snd = this.scene && this.scene.sound; + if (!snd) return; + if (on && !this._ducked) { + // The duck is PER-SOUND, never the manager's master: `sound.setVolume` + // is the MASTER volume node (v4), and it would quiet the comm's OWN + // voice along with the music — the voice is the point. Music is + // played per-sound at music.volume (Music.js), so lower the other + // playing sounds' own volume by the factor and restore it exactly. + this._ducked = true; + this._duckedSounds = []; + const f = config.get('comms.audio.musicDuck', 0.3); + 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 in a suspended context (headless tabs), + // so the restore map is keyed off the config, not the node. + 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 * f); + } catch (e) { + console.warn('[comms] 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; + } + } + _setPaused(on) { + const want = on === true; + if (want === this._paused) return; + this._paused = want; + if (this.onPause) { + try { + this.onPause(want); + } catch (e) { + console.warn('[comms] pause hook failed', e); + } + } + } + + /** The box's dock: the mineral bar's lower-right, or the standard corner. */ + _anchor() { + if (this.anchor) { + try { + const a = this.anchor(); + if (a && Number.isFinite(a.right) && Number.isFinite(a.top)) return a; + } catch { + /* the hud is gone (shutdown race) — fall through */ + } + } + const W = this.scene.scale?.width ?? 1280; + return { right: W - 16, top: 47 }; // the hud's standard geometry + } + + /** + * The run's played ledger (the shared registry — SaveData captures it + * and a new game resets it). A record predating comms has none → a + * fresh Set (the welcome plays; old saves load). + */ + _playedSet() { + const reg = this.scene.registry; + if (!reg) return new Set(); + const cur = reg.get(PLAYED_KEY); + if (cur instanceof Set) return cur; + const set = new Set(Array.isArray(cur) ? cur.filter((id) => typeof id === 'string') : []); + reg.set(PLAYED_KEY, set); + return set; + } +} + +// ---------------------------------------------------------------------- +// the v4 cache guards (the Sfx.js/ResearchWindow 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/comms/CommsModel.js b/js/comms/CommsModel.js new file mode 100644 index 0000000..9e49052 --- /dev/null +++ b/js/comms/CommsModel.js @@ -0,0 +1,178 @@ +/** + * COMMS MODEL — the pure, Node-testable rules of data/comms.json. + * + * A comm is an EVENT with a sender: { id, from, type, trigger, text, + * audio, once, delayMs }. This module normalizes the config (dropping — + * with a console warning — anything that can't be shown) and answers + * the two questions the hub asks: + * + * - `commsForTrigger(cfg, trigger, played)` — which comms are DUE for + * an event (already-played `once` comms don't re-fire; a fresh run's + * empty played set is what lets the new-game welcome play); + * - `commAssets(cfg, comm)` — the deferred-load spec: the sender's + * character clip (by character id, so every comm from OAC shares one + * cached clip) and the speech clip (by file name, so a clip shared + * by several comms loads once). + * + * Pure: it takes the `comms` config SECTION (the object from + * data/comms.json) — never the `config` singleton — so the dev tests + * (dev/comms.test.mjs) can feed it plain JSON. + * + * import { config } from '../config/Config.js'; + * const cfg = config.section('comms', {}); + * const due = commsForTrigger(cfg, 'newGame', playedSet); + */ + +export const COMM_TYPES = ['ack', 'visual', 'audio']; + +/** The scene-loader cache key for a character's clip: comms_vid_. */ +export function videoKeyFor(charId) { + return `comms_vid_${String(charId)}`; +} + +/** + * The scene-loader cache key for a speech clip, derived from the FILE + * (two comms sharing a clip share one key; 'assets/speech/oac/intro-01.mp3' + * → 'comms_au_intro-01'). + */ +export function audioKeyFor(file) { + const base = String(file ?? '') + .split('/') + .pop() + .replace(/\.[a-z0-9]+$/i, ''); + const safe = base.replace(/[^a-z0-9_-]+/gi, '-'); + return `comms_au_${safe || 'speech'}`; +} + +/** @returns {boolean} the master switch (a config predating comms defaults on). */ +export function commsEnabled(cfg) { + return cfg?.enabled !== false; +} + +/** + * The sender's entry (characters.) or null (a comm with an unknown + * sender is dropped by listComms — the box 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 comm entry: returns a clean object, or null when the + * entry can't be shown (with a console warning — the rest of the list + * still plays). + * + * Rules: + * - type in ack|visual|audio (else the comm is unplayable); + * - trigger: a non-empty string (a comm that can't fire is dead weight); + * - from: must resolve to a character (the box needs its face); + * - text: required for ack|visual (a box with no words is noise) — + * optional for 'audio' (voice only); + * - audio: required for 'audio' (voice is the whole comm) — optional + * for ack|visual (a text-only comm is a valid comm); + * - once defaults true (comms don't repeat unless the author says so); + * - delayMs defaults 0 (clamped to >= 0). + */ +export function normalizeComm(raw) { + if (!raw || typeof raw !== 'object') return null; + const id = String(raw.id ?? '').trim(); + if (!id) return null; + const type = String(raw.type ?? '').trim(); + if (!COMM_TYPES.includes(type)) { + console.warn(`[comms] "${id}" has unknown type "${type}" (expected ${COMM_TYPES.join(' | ')}) — dropped`); + return null; + } + const trigger = String(raw.trigger ?? '').trim(); + if (!trigger) { + console.warn(`[comms] "${id}" has no trigger — dropped`); + return null; + } + const from = String(raw.from ?? '').trim(); + const text = String(raw.text ?? '').trim(); + const audio = String(raw.audio ?? '').trim(); + if (!text && type !== 'audio') { + console.warn(`[comms] "${id}" (${type}) has no text — dropped`); + return null; + } + if (!audio && type === 'audio') { + console.warn(`[comms] "${id}" (audio) has no speech clip — dropped`); + return null; + } + return { + id, + from, + type, + trigger, + text, + audio: audio || null, + once: raw.once !== false, + delayMs: Math.max(0, Number(raw.delayMs) || 0), + }; +} + +/** + * The playable roster: the normalized comms in config order, minus the + * ones whose sender has no character entry (warned per id). + * @returns {Array} + */ +export function listComms(cfg) { + const list = Array.isArray(cfg?.comms) ? cfg.comms : []; + const out = []; + const seen = new Set(); + for (const raw of list) { + const comm = normalizeComm(raw); + if (!comm) continue; + if (!charEntry(cfg, comm.from)) { + console.warn(`[comms] "${comm.id}" speaks for unknown sender "${comm.from}" — dropped`); + continue; + } + if (seen.has(comm.id)) { + console.warn(`[comms] duplicate id "${comm.id}" — the first entry wins`); + continue; + } + seen.add(comm.id); + out.push(comm); + } + return out; +} + +/** + * The comm's deferred-load spec. The character clip is keyed BY CHARACTER + * (every OAC comm shares one 185 KB clip); the speech clip BY FILE (a clip + * reused across comms loads once). + * + * @returns {{video: {key:string,url:string}|null, audio: {key:string,url:string}|null}} + */ +export function commAssets(cfg, comm) { + const entry = charEntry(cfg, comm.from); + const videoUrl = String(entry?.video ?? '').trim(); + const audioUrl = String(comm.audio ?? '').trim(); + return { + video: videoUrl ? { key: videoKeyFor(comm.from), url: videoUrl } : null, + audio: audioUrl ? { key: audioKeyFor(audioUrl), url: audioUrl } : null, + }; +} + +/** + * The comms due for a trigger: the roster entries bound to it, in config + * order, minus the ones already played (`once` comms the run has heard; + * `once: false` comms re-arm on every trigger — the author's repeat). + * + * @param {object} cfg the comms config section + * @param {string} trigger e.g. 'newGame' + * @param {Set} [played] the run's already-played ids (a save's ledger) + * @returns {Array} the normalized comms + */ +export function commsForTrigger(cfg, trigger, played = new Set()) { + const name = String(trigger ?? '').trim(); + if (!name) return []; + return listComms(cfg).filter((c) => c.trigger === name && !(c.once && played.has(c.id))); +} + +/** The set of trigger names the roster uses (the dev tests + docs). */ +export function triggersUsed(cfg) { + return new Set(listComms(cfg).map((c) => c.trigger)); +} diff --git a/js/save/SaveData.js b/js/save/SaveData.js index e3328df..646fbc9 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -120,6 +120,13 @@ export function captureState(scene, now) { // A save predating quests has no field; the restore stages null and // the starter quest is granted onto the fresh ledger (old saves load). quests: scene.questState ? scene.questState.toJSON() : null, + // Comms — the run's ALREADY-PLAYED ids (data/comms.json: the `once` + // comms the run has heard, so a load doesn't re-fire them and a new + // game re-plays the welcome). The ledger lives in the registry + // ('commsPlayed' — js/comms/CommsHub.js owns it). A save predating + // comms has no field; the restore stages an empty set (old saves + // load). + comms: { played: Array.from(scene.registry.get('commsPlayed') ?? []) }, // 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. @@ -231,6 +238,17 @@ export function prepareLoad(registry, record) { 'depletedClusters', new Set(Array.isArray(record.depletedClusters) ? record.depletedClusters : []), ); + // The run's COMMS played-ledger (a save predating comms has no field → + // an empty set — old saves load; a fresh run's welcome arms on its own + // 'newGame' trigger in GameScene.create()). + registry.set( + 'commsPlayed', + new Set( + Array.isArray(record.comms?.played) + ? record.comms.played.filter((id) => typeof id === 'string') + : [], + ), + ); registry.set(PENDING_RESTORE_KEY, { ship: record.ship, tethers: Array.isArray(record.tethers) ? record.tethers : [], @@ -267,6 +285,7 @@ export function resetRunState(registry) { registry.set('quests', null); registry.set('totalMined', null); registry.set('depletedClusters', null); + registry.set('commsPlayed', null); // the welcome arms again on the new run registry.set(PENDING_RESTORE_KEY, null); } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index dc2b2b3..88e0d18 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -34,6 +34,7 @@ import { MenuSubBar } from '../ui/MenuSubBar.js'; import { SavePanel } from '../ui/SavePanel.js'; import { SaveManager } from '../save/SaveManager.js'; import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.js'; +import { CommsHub } from '../comms/CommsHub.js'; import { TetherField } from '../tether/TetherField.js'; import { Mining } from '../mining/Mining.js'; import { MiningPopup } from '../ui/MiningPopup.js'; @@ -348,6 +349,12 @@ export class GameScene extends Phaser.Scene { // playtime) in the registry (js/save/SaveData.js) — the ship and the // tether field don't exist yet, so apply it once they do (below). this._pendingRestore = consumeRestore(this.registry); + // A FRESH run (no staged restore) is what the 'newGame' comms fire + // for (the welcome plays on a new game, never on a load) — and a + // jump-cut restart (scene.restart, no resetRunState) is NOT one: its + // played ledger (the registry) already holds the welcome's id, so + // the trigger below re-arms nothing (the model's played filter). + this._freshRun = this._pendingRestore === null; this.systemRecord = this.galaxy.currentSystem(); this.systemContent = this.galaxy.ensureContent(this.systemRecord.id); // DEPLETED FIELDS — asteroid clusters the run has mined to nothing: @@ -758,6 +765,46 @@ export class GameScene extends Phaser.Scene { this.mineralHud = new MineralHud(this); this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage); + // ---- COMMS (the characters speak to the player — data/comms.json) -- + // The hub (js/comms/CommsHub.js) owns the comms' lives: the queue + // (events arm them; pump() below plays the next due one), the + // DEFERRED asset loads (the comm's character clip + speech file load + // when the comm is due — never in preload, so a big comm roster + // doesn't bloat the initial load), the voice (with the music ducked + // under it), and the ACK pause contract (below: update() gates the + // action on comms.paused; the input seams gate on it too). The box + // itself (js/ui/CommsBox.js) docks just below the mineral bar — the + // hud's lower-right is the anchor. + this.comms = new CommsHub(this, { + // The scene isn't free while any of these owns the screen — the + // comm waits (re-pumped each frame) until the screen is clear. + busy: () => + (this.savePanel && this.savePanel.isOpen) || + (this.researchWindow && this.researchWindow.isOpen) || + (this.mapWindow && this.mapWindow.isOpen) || + (this.questWindow && this.questWindow.isOpen) || + (this.commsPanel && this.commsPanel.isOpen) || + (this.miningPopup && this.miningPopup.isOpen) || + (this.menuSubBar && this.menuSubBar.isOpen) || + (this.mining && this.mining.isActive) || + (this.scanPulse && this.scanPulse.busy) || + !!this._jumping, + anchor: () => { + const hud = this.mineralHud; + if (!hud) return null; + return { right: this.scale.width - hud.pad, top: hud.barY + hud.barH }; + }, + // The ack pause's scene-side effects: the action holds — the ship + // parks dead (target + throttle cleared, velocity zeroed) and the + // mining arm drops. Resuming needs no work (the ship stays parked; + // the player's next click steers it again). + onPause: (on) => { + if (!on) return; + this.ship.stop(); + if (this.mining && this.mining.isActive) this.mining.stop(); + }, + }); + // Hint (pinned just ABOVE the command deck, not under it) this.hint = this.add .text(this.scale.width / 2, this.scale.height - deckReserve - (deckReserve ? 20 : 26), config.get('game.hintText', ''), { @@ -1069,6 +1116,12 @@ export class GameScene extends Phaser.Scene { // A jump is in flight (between the clip's end and the restart): the // old scene is already gone — swallow anything that lands in the gap. if (this._jumping) return; + // An ACK comm is up — it owns the whole screen (the box + its + // ACKNOWLEDGE button press itself; the world holds still — the + // CommsHub's pause contract; no skip, the player reads + clicks). + // _commInputBlocked also swallows the click that ACKNOWLEDGED (the + // endedWithin race window) — that click is the comm's own. + if (this._commInputBlocked()) return; // The save pop-up is MODAL — while it's up it owns all input // (its scrim / cards / dialog eat the click; the world stays put). if (this.savePanel && this.savePanel.isOpen) return; @@ -1276,6 +1329,16 @@ export class GameScene extends Phaser.Scene { // to the world camera. this.systemEffects = new SystemEffects(this); this.systemEffects.apply(this.systemRecord.type); + + // ---- COMMS: the fresh-run welcome ---------------------------------- + // The 'newGame' trigger (data/comms.json) arms the run's opening + // comm — OAC introducing the ship (ack: the action pauses, the box + // holds, the player reads + clicks ACKNOWLEDGE). It fires on a FRESH + // start only (above: _freshRun) — a LOAD's played ledger already + // holds its id, so a resumed run doesn't re-hear the welcome. The + // hub's delayMs beats the opening toast, and its deferred load pulls + // OAC's clip + speech on demand (the scene's preload stays lean). + if (this._freshRun) this.comms.trigger('newGame'); } /** @@ -1653,10 +1716,19 @@ export class GameScene extends Phaser.Scene { // fire. (Same quirk as MenuScene.update; verified Sept 2026.) this.time.update(_time, delta); this.tweens.update(); + this.comms?.pump(_time); // comms: queue → play (and its pause contract) + // The ACK pause contract (the CommsHub froze the action): the ship + // holds station, the research/build ticks wait (their deadlines are + // time-based — a project that finishes while the box holds completes + // on the first tick after, its toast landing when the action resumes), + // and discovery is off (the ship isn't moving). The world KEEPS + // breathing — starfield, tether, the comm's own clip — only the + // player's action is paused. + const _commsPaused = this.comms?.paused === true; this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle this.systemEffects?.update(_time); // the star's character (wave phase + star's screen UV) this.questTracker?.update(_time); // the tracker: live checklist + priority - this.ship.update(_time, delta); + if (!_commsPaused) this.ship.update(_time, delta); // Session time (saved with the game) — capped so a backgrounded tab // can't fast-forward it. this.playTimeMs = (this.playTimeMs ?? 0) + Math.min(delta, 100); @@ -1667,7 +1739,9 @@ export class GameScene extends Phaser.Scene { this.commsPanel?.update(_time); // the name decode, the bar draw-in, the cursor blink, the flicker // Research: tick the in-flight project (time-based; tick() reports any // completion and already unlocked it — the scene applies effects/SFX). - const _researchDone = this.researchState?.tick(_time) ?? null; + // Paused by an ACK comm (above) — the tick resumes after, and the + // completed project's effects land then (the toast, the deck bar). + const _researchDone = !_commsPaused && this.researchState ? this.researchState.tick(_time) : null; if (_researchDone?.length) { for (const d of _researchDone) this._completeResearch(d.category, d.id); } @@ -1679,7 +1753,7 @@ export class GameScene extends Phaser.Scene { // player took off mid-build (game-loop clock — see beginBuild); when // its deadline passes HERE we apply the effect (the world change is // world state: the tether range outlives the stay). - if (this.buildState) { + if (this.buildState && !_commsPaused) { const _buildDone = this.buildState.tick(this.game.loop.now); if (_buildDone.length) { for (const c of _buildDone) this.completeBuild(c.planet, c.build); @@ -1708,7 +1782,7 @@ export class GameScene extends Phaser.Scene { this.tetherField.draw(_time); // the barrier (on-screen dots only) this.actionBar?.update(_time, delta); // the deck's living details this._checkDestinationReached(); // last leg done? clear the destination (REACHED) - this.updateDiscovery(_time, delta); // last: sees this frame's final camera view + if (!_commsPaused) this.updateDiscovery(_time, delta); // last: sees this frame's final camera view } /** @@ -1986,6 +2060,7 @@ export class GameScene extends Phaser.Scene { * click wins). */ autopilotTo(id) { + if (this._commInputBlocked()) return; // the comm owns the screen (the ship holds) // While the sub-bar is open (or this very click just closed it — the // scene's handler and the chip's own listener race on event order), // the chip's click is the menu-dismiss, not a navigation command. @@ -2768,12 +2843,27 @@ export class GameScene extends Phaser.Scene { }); } + /** + * True while the ACK comm owns the input — it's up (paused), or it just + * ended on a click (the endedWithin race window, so the click that + * ACKNOWLEDGED never leaks into a world/deck/ESC action). The scene's + * input seams gate on this; the pause's action-freeze gates on + * comms.paused alone (strict). + */ + _commInputBlocked() { + const c = this.comms; + return !!(c && (c.paused || c.endedWithin(200))); + } + /** * The deck's button presses (js/ui/ActionBar.js → onAction). * 'menu' is the save system's door (menuAction below); the other * slots are still seams for the player's loop. */ deckAction(id) { + // The ACK comm owns the input (no deck while the box holds — and the + // click that ACKNOWLEDGED is the comm's, never the deck's). + if (this._commInputBlocked()) return; // The research console (full-screen, depth 80) — the deck's main feature. if (id === 'research') { if (this.researchWindow && this.researchWindow.isOpen) { @@ -4088,6 +4178,7 @@ export class GameScene extends Phaser.Scene { /** ESC: topmost open thing first — confirm dialog → pop-up → research → mining → sub-bar. */ escAction() { + if (this._commInputBlocked()) return; // the comm owns the input (no skip) if (this.savePanel) { if (this.savePanel.confirm.isOpen) { this.savePanel.confirm.cancel(); diff --git a/js/ui/CommsBox.js b/js/ui/CommsBox.js new file mode 100644 index 0000000..5341814 --- /dev/null +++ b/js/ui/CommsBox.js @@ -0,0 +1,483 @@ +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'; + +/** + * COMMS BOX — the wide box a comm shows in, docked just below the mineral + * bar (upper-right): the sender's clip in a bracketed feed panel on the + * LEFT, the comm text decoding in on the RIGHT, and — for 'ack' comms — + * the ACKNOWLEDGE button below the text. + * + * ┌──────────────────────────────────────────────────────┐ + * │ ● O.A.C. // ONBOARD A.I. COMPUTER │ + * │ ┌──────┐ So you want to explore the galaxy do │ + * │ │ O A C │ you?... Welcome to your ship... she's │ + * │ │ clip │ not much but she's got room to grow. [ACK]│ + * │ └──────┘ │ + * └──────────────────────────────────────────────────────┘ + * + * The box is a PASSIVE view (the project rule): the CommsHub + * (js/comms/CommsHub.js) owns the comm's life — it builds a box per + * comm, drives `open()` / `update(time)` / `setVoice(on)` / `close()`, + * and tears it down. The box never plays the voice, never touches the + * scene's action; it only shows the comm and reports the button press + * (`onAck` — the hub is the ear, the scene is the voice). + * + * The clip is ALWAYS muted: the voice is the comm's speech clip, and + * the video is the sender's face (the project's video rule — the + * Research/Build feeds, the landing stage). 'ack' loops it silently + * while the box holds; 'visual' plays it once. + * + * v4 quirks honored (see the ResearchWindow/SurfaceScene notes): + * - 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 (v4's play() is a no-op then). + * + * Config: data/comms.json → box. + */ +export class CommsBox extends Phaser.GameObjects.Container { + /** + * @param {Phaser.Scene} scene + * @param {{right:number, top:number}} anchor the mineral bar's lower-right + * (the box's right edge sticks to anchor.right, its top sits + * `box.topPad` below anchor.top) + * @param {{ + * label: string, the sender's display name (the header) + * role: string, the small line beside it + * text: string, the comm's words (decode in) + * loop: boolean, the clip loops silently (ack) or plays once (visual) + * video: {key:string}|null, the sender's cached clip (null → NO SIGNAL plate) + * showAck: boolean, the ACKNOWLEDGE button (ack comms) + * onAck: () => void, the button press (the hub finishes the comm) + * }} spec + */ + constructor(scene, anchor, spec) { + super(scene, 0, 0); + this.scene.add.existing(this); + this.setScrollFactor(0); // UI — pinned to the screen, not the world + this.setDepth(55); // above the mineral bar (30) and the deck (50); under the full-screen consoles (80) + + const boxCfg = config.section('comms.box', {}); + const colors = boxCfg.colors ?? {}; + const textCfg = boxCfg.text ?? {}; + const buttonCfg = boxCfg.button ?? {}; + const anim = boxCfg.animation ?? {}; + + this.W = Math.max(320, boxCfg.width ?? 560); + this.pad = boxCfg.pad ?? 14; + this.topPad = boxCfg.topPad ?? 12; + this.videoSize = Math.max(80, boxCfg.videoSize ?? 150); + this.videoGap = boxCfg.videoGap ?? 14; + this.headerH = 20; + + // Palette (data/comms.json → box.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 = boxCfg.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.inMs = Math.max(0, anim.inMs ?? 260); + this.outMs = Math.max(0, anim.outMs ?? 420); + + this.onAck = typeof spec.onAck === 'function' ? spec.onAck : null; + this.showAck = spec.showAck === true; + this.loopVideo = spec.loop === true; // the clip loops silently (ack) or plays once (visual) + this.videoSpec = spec.video && spec.video.key ? spec.video : null; + this.voiceOn = false; + this._textValue = String(spec.text ?? ''); + + const fam = fontStack('body'); + + // ---- measure the text (its height sizes the box) ------------------ + const textMaxW = Math.max(120, this.W - 2 * this.pad - this.videoSize - this.videoGap); + this.text = scene.add + .text(0, 0, spec.text, { + 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); + const textW = this.text.width; + const textH = this.text.height; + + // ---- the button (ack comms) — measured too (it adds a row) --------- + this.button = null; + let buttonH = 0; + const buttonLabel = String(buttonCfg.label ?? 'ACKNOWLEDGE'); + if (this.showAck) { + // Measure first (a throwaway) so the box height is final before the + // real one is built at its slot. + const probe = new Phaser.GameObjects.Text(scene, 0, 0, buttonLabel.toUpperCase(), { + fontFamily: fam, + fontSize: `${buttonCfg.fontSize ?? 11}px`, + }); + buttonH = probe.height + (buttonCfg.paddingY ?? 7) * 2; + probe.destroy(); + } + + const contentTop = this.pad + this.headerH + (boxCfg.headerGap ?? 8); + const rightColH = textH + (this.showAck ? (buttonCfg.gap ?? 10) + buttonH : 0); + this.H = contentTop + Math.max(this.videoSize, rightColH) + this.pad; + + // ---- placement: just below the mineral bar, right edge aligned ----- + this.finalX = anchor.right - this.W; + this.finalY = anchor.top + this.topPad; + this.setPosition(this.finalX, this.finalY - 16); // starts 16px high — it slides in + this.setAlpha(0); + + this._buildBackplate(); + this._buildHeader(spec.label, spec.role); + this._buildVideoPanel(contentTop); + 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 + if (this.showAck) { + this.button = new MenuButton( + scene, + 0, 0, // repositioned below (right-aligned in the text column) + buttonLabel, + () => this._onAckPress(), + { + fontSize: buttonCfg.fontSize ?? 11, + paddingX: buttonCfg.paddingX ?? 16, + paddingY: buttonCfg.paddingY ?? 7, + letterSpacing: buttonCfg.letterSpacing ?? 2, + upper: true, + screenFixed: true, // the box lives in a scrolling-cam scene + }, + ); + // Right-aligned in the text column: the action anchors the box's + // lower-right (the menu's button idiom, the console's balance). + const textX = this.pad + this.videoSize + this.videoGap; + const textMaxW = Math.max(120, this.W - 2 * 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, + ); + this.add(this.button); + } + + // The decode-in (the console's type-out): the comm's words pull + // themselves out of static over the canonical window. + this._dec = null; + this._decT0 = null; + this._decFinished = false; + } + + // ------------------------------------------------------------------ + // the chrome + // ------------------------------------------------------------------ + + _buildBackplate() { + const g = this.scene.add.graphics().setScrollFactor(0); + 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); + this.backplate = g; + this.add(g); + } + + _buildHeader(label, role) { + const fam = fontStack('body'); + // The voice indicator — amber while the comm'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; + const 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); + name.setPosition(dx, this.pad + 8); + this.add(name); + const 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); + roleText.setPosition(dx + name.width + 10, this.pad + 8); + this.add(roleText); + this.dot.setPosition(this.pad + 3.5, this.pad + 8); + // A faint hairline under the header, across the box 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); + } + + _buildVideoPanel(contentTop) { + const s = this.scene; + const vx = this.pad; + const vy = contentTop; + const vs = this.videoSize; + + // The feed frame: bracket corners + the side ticks (the + // ResearchWindow archive-feed idiom). + 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); + + const cx = vx + vs / 2; + const cy = vy + vs / 2; + + this.video = null; + const key = this.videoSpec?.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 sender's face — muted. The voice is the comm's + // speech clip (the hub plays it); 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(this.loopVideo); + // Fire it straight away (the SurfaceScene landing-clip idiom): if + // the browser's autoplay policy holds it, v4 retries internally + // until it's allowed — and the game only runs after a gesture + // (the menu click), so muted autoplay is permitted from the start. + 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 box's container + this.video = v; + } else { + this._noSignal(cx, cy, vx, vy, vs); + } + } + + _noSignal(cx, cy, vx, vy, vs) { + const s = this.scene; + 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.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.add(ns); + } + + // ------------------------------------------------------------------ + // the hub's handles + // ------------------------------------------------------------------ + + /** The comm's words decoding in (the hub calls this once, at open). */ + beginText() { + const dur = decodeDur(this._textValue.length); + this._decT0 = this.scene.time.now; + this._dec = new ScrambleDecode(this._textValue, this._decT0, dur); + this.text.setText(''); + } + + /** Per-frame (the hub pumps it): the text's decode. */ + update(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 begins the text). */ + 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 box 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; + } + if (this.video) { + _destroyVideo(this.video); + this.video = null; + } + this.button?.destroy(); + this.button = null; + this.dot?.destroy(); + this.dot = null; + this.text?.destroy(); + this.text = null; + this.backplate?.destroy(); + this.backplate = null; + super.destroy(); + } + + _onAckPress() { + if (this._closing) return; + // The press plays its own tick (the MenuButton calls the scene's + // voice); the hub is the ear — it finishes the comm from here. + if (this.onAck) this.onAck(); + } +} + +// ---------------------------------------------------------------------- +// small local helpers (the ResearchWindow/SurfaceScene 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 */ + } +}