diff --git a/assets/fx/discovery.mp3 b/assets/fx/discovery.mp3 new file mode 100644 index 0000000..0c43ece Binary files /dev/null and b/assets/fx/discovery.mp3 differ diff --git a/assets/fx/system-scan.mp3 b/assets/fx/system-scan.mp3 new file mode 100644 index 0000000..df65215 Binary files /dev/null and b/assets/fx/system-scan.mp3 differ diff --git a/assets/fx/type-construct.mp3 b/assets/fx/type-construct.mp3 new file mode 100644 index 0000000..4f02823 Binary files /dev/null and b/assets/fx/type-construct.mp3 differ diff --git a/assets/fx/type-deconstruct.mp3 b/assets/fx/type-deconstruct.mp3 new file mode 100644 index 0000000..c669266 Binary files /dev/null and b/assets/fx/type-deconstruct.mp3 differ diff --git a/data/manifest.json b/data/manifest.json index 8723228..fb3c98a 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -13,6 +13,7 @@ "naming.json", "research.json", "builds.json", - "actionbar.json" + "actionbar.json", + "sfx.json" ] } diff --git a/data/sfx.json b/data/sfx.json new file mode 100644 index 0000000..c9bdf1c --- /dev/null +++ b/data/sfx.json @@ -0,0 +1,8 @@ +{ + "_comment": "Sound effects. enabled = master switch (also skips the load). volume is 0..1. construct = the dossier typing in; deconstruct = the dossier deconstructing; discovery = a new object coming into view. (system-scan.mp3 is unassigned — drop a key like \"scan\" here and wire it in GameScene when used.)", + "enabled": true, + "volume": 0.55, + "construct": "assets/fx/type-construct.mp3", + "deconstruct": "assets/fx/type-deconstruct.mp3", + "discovery": "assets/fx/discovery.mp3" +} diff --git a/dev/decode.test.mjs b/dev/decode.test.mjs index 50ff183..f15c067 100644 --- a/dev/decode.test.mjs +++ b/dev/decode.test.mjs @@ -11,6 +11,8 @@ * - unrevealed slots are drawn from the decode alphabet; * - the reveal never goes backwards over time; * - display length is stable through the window; + * - reverse mode deconstructs (value → static → '') with the shrinking + * prefix never growing back; * - decodeDur floors short lines, caps long ones, grows in between; * - the default window is the menu's 620 ms. */ @@ -40,8 +42,13 @@ check('exact target after the window', dec.display(T0 + dur + 1) === VALUE); check('started()/finished() agree with display()', dec.started(T0 - 1) === false && dec.finished(T0 + dur) === true); check('empty target stays empty', new ScrambleDecode('', T0, dur).display(T0 + dur / 2) === ''); -// Through the window: length stable, prefix exact, rest from the alphabet, -// and the reveal monotonically non-decreasing. +// Through the window: length stable, the guaranteed-revealed prefix exact +// (per the documented curve), the rest from the decode alphabet, and the +// guaranteed reveal monotonically non-decreasing. (Slots that scramble to +// a coincidental match with the target are indistinguishable — and fine, +// so the checks are keyed on the curve, not on the measured common prefix.) +const forwardReveal = (t) => + Math.floor(Math.max(0, Math.min(1, ((t - T0) / dur) * 1.15)) * VALUE.length); let lenOk = true; let prefixOk = true; let charsOk = true; @@ -49,24 +56,54 @@ let monotonic = true; let prev = 0; for (let t = T0; t <= T0 + dur; t += 16) { const s = dec.display(t); + const r = forwardReveal(t); if (s.length !== VALUE.length) lenOk = false; - let n = 0; - while (n < VALUE.length && s[n] === VALUE[n]) n++; - if (s.slice(0, n) !== VALUE.slice(0, n)) prefixOk = false; // revealed part is exact - for (let i = n; i < s.length; i++) if (DECODE_CHARS.indexOf(s[i]) === -1) charsOk = false; - if (n < prev) monotonic = false; - prev = n; + for (let i = 0; i < r; i++) if (s[i] !== VALUE[i]) prefixOk = false; // guaranteed part is exact + for (let i = r; i < s.length; i++) { + if (s[i] !== VALUE[i] && DECODE_CHARS.indexOf(s[i]) === -1) charsOk = false; + } + if (r < prev) monotonic = false; + prev = r; } check('display length stays constant through the window', lenOk); -check('revealed prefix always matches the target', prefixOk); +check('the guaranteed-revealed prefix is always exact', prefixOk); check('unrevealed slots come from the decode alphabet', charsOk); -check('reveal never goes backwards', monotonic); +check('the guaranteed reveal never goes backwards', monotonic); // decodeDur: floored, capped, growing in between. check('decodeDur floors short lines', decodeDur(0) >= 420 && decodeDur(1) >= 420); check('decodeDur caps long lines', decodeDur(500) <= 880); check('decodeDur grows with length (in range)', decodeDur(4) < decodeDur(12) && decodeDur(12) < decodeDur(40)); +// --- Reverse (deconstruct): the same reveal played backwards -------------- +const rdec = new ScrambleDecode(VALUE, T0, dur, true); +check('reverse holds the value before the start time', rdec.display(T0 - 1) === VALUE); +check('reverse empties out after the window', rdec.display(T0 + dur + 1) === ''); +check('reverse started()/finished() agree with display()', rdec.started(T0 - 1) === false && rdec.finished(T0 + dur) === true); + +const reverseReveal = (t) => + Math.floor(Math.max(0, Math.min(1, (1 - (t - T0) / dur) * 1.15)) * VALUE.length); +let rLenOk = true; +let rPrefixOk = true; +let rCharsOk = true; +let rMonotonic = true; +let rPrev = VALUE.length; +for (let t = T0; t <= T0 + dur; t += 16) { + const s = rdec.display(t); + const r = reverseReveal(t); + if (s.length !== VALUE.length) rLenOk = false; + for (let i = 0; i < r; i++) if (s[i] !== VALUE[i]) rPrefixOk = false; // still-stable part is exact + for (let i = r; i < s.length; i++) { + if (s[i] !== VALUE[i] && DECODE_CHARS.indexOf(s[i]) === -1) rCharsOk = false; + } + if (r > rPrev) rMonotonic = false; + rPrev = r; +} +check('reverse: display length stays constant through the window', rLenOk); +check('reverse: the still-stable prefix is always exact', rPrefixOk); +check('reverse: unrevealed slots come from the decode alphabet', rCharsOk); +check('reverse: the stable prefix only ever shrinks', rMonotonic); + // The default window is the menu's 620 ms (the canonical decode timing). check('default window is the menu’s 620 ms', new ScrambleDecode('x', 0).dur === 620); diff --git a/dev/system-hud.test.mjs b/dev/system-hud.test.mjs index 13d2f56..748b141 100644 --- a/dev/system-hud.test.mjs +++ b/dev/system-hud.test.mjs @@ -3,19 +3,30 @@ * * node dev/system-hud.test.mjs * - * Runs the REAL GameScene.createSystemHud()/updateHudDecode() + * Runs the REAL GameScene.createSystemHud()/updateHud()/toggleHud() * (js/scenes/GameScene.js) against the real galaxy + SystemReport data and - * a stubbed scene, then asserts the arrival-decode behavior — the same - * "decode" scramble the menu uses on the Galaxy Seed (js/utils/Decode.js): - * - the dossier renders the name first, then the data lines, in the same - * layout as before (y positions, hudEndY); - * - every line starts EMPTY; - * - the system name begins decoding before any line below it (stagger); - * - while a line decodes: length stays the target's, the revealed prefix - * is exact, the unrevealed tail comes from the decode alphabet; - * - once a line lands it never changes again; - * - at the end every line is exactly the report string and the decode - * state self-clears (hudDecode === null). + * a stubbed scene, and asserts the full dossier lifecycle: + * - arrival: the name decodes in FIRST, then the data lines below it + * (same scramble as the menu's Galaxy Seed, js/utils/Decode.js), in + * the same layout as before (y positions, hudEndY); + * - while a line decodes: length stays the target's, the revealed + * prefix is exact, the unrevealed tail comes from the decode + * alphabet; a settled line never changes again; + * - the state caret (▾) sits right of the name, starts hidden, and + * appears once the name has landed (expanded state = pointing down); + * - the details are OPEN by default; the dossier folds itself 10 s + * after arrival — deconstructing in REVERSE build order (seed line + * first … subtitle last) — then the caret swings down→right; + * - clicking the name (toggleHud) re-opens (caret down, lines type in + * forward) and re-closes (lines erase in reverse, caret right); + * - a toggle mid-animation is ignored; a manual toggle cancels the + * one-shot auto-fold (a re-opened dossier stays open past 10 s); + * - hudTitleContains covers the name and the caret beside it, and + * nothing else; + * - SFX (data/sfx.json): the construct sound plays when the dossier + * types in (arrival AND manual re-open), the deconstruct sound when it + * folds (auto-fold AND manual re-close), the discovery sound when a + * new object is found; sfx.enabled=false loads and plays nothing. */ import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; @@ -68,109 +79,451 @@ const galaxy = Galaxy.create('decode-hud-test'); const rec = galaxy.currentSystem(); const content = galaxy.ensureContent(rec.id); const report = formatSystemReport(content); -const expected = [ - report.title, +const detailValues = [ report.subtitle, ...report.settlements.map((s) => s.text), report.summary, `seed ${galaxy.seed}`, ]; -console.log(`dossier under test:\n${expected.map((s) => ' ' + s).join('\n')}\n`); +console.log(`dossier under test:\n ${report.title}\n${detailValues.map((s) => ' ' + s).join('\n')}\n`); -// --- Stub scene: just what createSystemHud()/updateHudDecode() touch ------- -const texts = []; +// --- Stub scene: what createSystemHud()/updateHud()/toggleHud() touch ----- class FakeText { constructor(x, y, str) { this.x = x; this.y = y; this.text = str; this.depth = 0; + this.alpha = 1; + this.angle = 0; + } + get width() { + return this.text.length * 9; // deterministic fake metrics + } + get height() { + return 19; + } + setText(s) { + this.text = s; + return this; + } + setOrigin() { + return this; + } + setScrollFactor() { + return this; + } + setDepth(d) { + this.depth = d; + return this; + } + setAlpha(a) { + this.alpha = a; + return this; + } + setPosition(x, y) { + this.x = x; + this.y = y; + return this; + } + destroy() { + this.destroyed = true; + return this; } - setText(s) { this.text = s; return this; } - setOrigin() { return this; } - setScrollFactor() { return this; } - setDepth(d) { this.depth = d; return this; } } -const scene = { - time: { now: 0 }, - add: { text: (x, y, str) => { const t = new FakeText(x, y, str); texts.push(t); return t; } }, - systemRecord: rec, - systemContent: content, - galaxy, -}; +function makeScene() { + const texts = []; + // Plain object with the REAL GameScene prototype BEHIND it (updateHud + // calls showCaret/rotateCaret/start* on `this` — prototype chain, since + // class methods are non-enumerable and Object.assign skips them). + const scene = Object.assign( + Object.create(GameScene.prototype), + { + texts, + time: { now: 0 }, + add: { + text: (x, y, str) => { + const t = new FakeText(x, y, str); + texts.push(t); + return t; + }, + }, + // Tweens run INSTANTLY in the harness: land the final value now. + tweens: { + add(opts) { + const targets = Array.isArray(opts.targets) ? opts.targets : [opts.targets]; + for (const [k, v] of Object.entries(opts)) { + if (k === 'targets' || k === 'duration' || k === 'ease' || k === 'onComplete') continue; + for (const tg of targets) tg[k] = v; + } + if (typeof opts.onComplete === 'function') opts.onComplete(); + return {}; + }, + }, + systemRecord: rec, + systemContent: content, + galaxy, + }, + ); + return scene; +} + +const STEP = 16; +const step = (scene, from, to) => { + for (let t = from; t <= to; t += STEP) GameScene.prototype.updateHud.call(scene, t); +}; +const detailFinal = (scene) => scene.hudDetail.every((d) => d.text.text === d.value); +const detailEmpty = (scene) => scene.hudDetail.every((d) => d.text.text === ''); + +// =========================================================================== +// 1) Arrival: layout, decode order, caret, open-by-default +// =========================================================================== +const scene = makeScene(); GameScene.prototype.createSystemHud.call(scene); -// --- Layout: same lines, same positions as before the decode --------------- -check('one text per dossier line (name, subtitle, settlements, summary, seed)', texts.length === expected.length); -check('every line starts empty', texts.every((t) => t.text === '')); -check('all lines pinned left/top (16, 14+)', texts.every((t) => t.x === 16 && t.y >= 14)); +const titleW = report.title.length * 9; +const titleH = 19; + +check('title + caret + one text per detail line', scene.texts.length === 2 + detailValues.length); +check('the name starts empty (it decodes in)', scene.hudTitle.text === ''); +check('details start empty (open by default, not pre-printed)', scene.hudDetail.every((d) => d.text.text === '')); +check('the caret is the down triangle ▾, hidden at first', scene.hudCaret.text === '\u25be' && scene.hudCaret.alpha === 0); +check( + 'the caret sits just right of the FINISHED name', + scene.hudCaret.x === 16 + titleW + 10 && scene.hudCaret.y === 14 + titleH / 2, +); +check('starts open-by-default: phase constructing, auto-fold armed, t0 unanchored', + scene.hudPhase === 'constructing' && scene.autoCollapseArmed === true && scene.hudArrivalT0 === null && + scene.hudTimeline.t0 === null); +check('arrival timeline: name first, then the detail lines', + scene.hudTimeline.mode === 'arrive' && + scene.hudTimeline.lines.length === 1 + detailValues.length && + scene.hudTimeline.lines[0].isTitle === true && + scene.hudTimeline.lines[0].value === report.title); + +// Layout: same lines and y positions as the original dossier. { - // Expected y walk: title @14 (+26, it's 17px), subtitle @40 (+20), - // +2, then one +20 per line (each line steps the cursor on, seed last), - // hudEndY = final y + 6 — exactly the original layout. let y = 14; - const ys = [y]; + const ys = [y]; // title y += 26; ys.push(y); // subtitle y += 20; y += 2; - for (let i = 0; i < report.settlements.length; i++) { ys.push(y); y += 20; } + for (let i = 0; i < report.settlements.length; i++) { + ys.push(y); + y += 20; + } ys.push(y); // summary y += 20; ys.push(y); // seed y += 20; - check('y layout unchanged by the decode', texts.every((t, i) => t.y === ys[i])); - check('hudEndY unchanged by the decode', scene.hudEndY === y + 6); + check('y layout unchanged by the decode', [scene.hudTitle, ...scene.hudDetail.map((d) => d.text)].every((t, i) => t.y === ys[i])); + check('hudEndY unchanged (tether readout keeps its slot)', scene.hudEndY === y + 6); } -check('decode state is live (t0 unanchored until the first frame)', scene.hudDecode !== null && scene.hudDecode.t0 === null); -// --- Simulate the arrival: frames at 16 ms from a 1000 ms "first frame" ---- -// "Settled" is read from the scene's own flag (dec.finished is the real -// signal — a mid-decode full-string match is a coincidence, not settlement). +// The toggle hit-area: the name plus the caret beside it — and nothing else. +const contains = (px, py) => GameScene.prototype.hudTitleContains.call(scene, px, py); +check('hudTitleContains: on the name', contains(20, 20) === true); +check('hudTitleContains: on the caret beside the name', contains(16 + titleW + 15, 23) === true); +check('hudTitleContains: not far away', contains(500, 300) === false); +check('hudTitleContains: not below the dossier', contains(16, 200) === false); + +// Simulate frames from the first update frame (T0), asserting the decode +// contract on every line. "Settled" is read from the scene's own flag — +// a mid-decode full-string match is a coincidence, not settlement. const T0 = 1000; -const firstNonEmpty = new Array(expected.length).fill(null); -let prefixOk = true; -let tailAlphabetOk = true; +const lines = [ + { text: scene.hudTitle, target: report.title }, + ...scene.hudDetail.map((d) => ({ text: d.text, target: d.value })), +]; +const firstNonEmpty = new Array(lines.length).fill(null); let lengthOk = true; -let settledContractOk = true; - -for (let t = T0; t <= T0 + 4000; t += 16) { - GameScene.prototype.updateHudDecode.call(scene, t); - const hd = scene.hudDecode; - texts.forEach((text, i) => { - const target = expected[i]; +let tailOk = true; +let settledOk = true; +for (let t = T0; t <= T0 + 4000; t += STEP) { + GameScene.prototype.updateHud.call(scene, t); + const tl = scene.hudTimeline; + for (let i = 0; i < lines.length; i++) { + const { text, target } = lines[i]; if (firstNonEmpty[i] === null && text.text !== '') firstNonEmpty[i] = t; - if (text.text === '') return; + if (text.text === '') continue; if (text.text.length !== target.length) lengthOk = false; - // Once the scene has settled the line, it is final: exact, and only at - // or after the end of its decode window (dec.t0 + dec.dur). - if (hd && hd.lines[i].settled) { - if (text.text !== target || t < hd.lines[i].dec.t0 + hd.lines[i].dec.dur) settledContractOk = false; - return; + if (tl && tl.lines[i].settled) { + if (text.text !== target || t < tl.lines[i].dec.t0 + tl.lines[i].dec.dur) settledOk = false; + continue; } - if (text.text === target) return; // mid-decode coincidence — passes the checks below - // Decoding: revealed prefix exact, tail from the decode alphabet. + if (text.text === target) continue; // mid-decode coincidence let n = 0; while (n < target.length && text.text[n] === target[n]) n++; - if (text.text.slice(0, n) !== target.slice(0, n)) prefixOk = false; - for (let k = n; k < text.text.length; k++) if (DECODE_CHARS.indexOf(text.text[k]) === -1) tailAlphabetOk = false; - }); + for (let k = n; k < text.text.length; k++) if (DECODE_CHARS.indexOf(text.text[k]) === -1) tailOk = false; + } +} +check('first frame: every line still empty (the beat of arrival)', + lines.every((_, i) => firstNonEmpty[i] === null || firstNonEmpty[i] > T0)); +check('the system name starts decoding first', + firstNonEmpty[0] !== null && firstNonEmpty.every((t, i) => (t ?? Infinity) >= firstNonEmpty[0])); +check('decode order is top-to-bottom', + firstNonEmpty.every((t, i) => i === 0 || (t ?? Infinity) >= (firstNonEmpty[i - 1] ?? Infinity))); +check('display length stays the target’s through the window', lengthOk); +check('unrevealed tail always from the decode alphabet', tailOk); +check('every line lands as exactly the report string', lines.every((l, i) => l.text.text === (i === 0 ? report.title : detailValues[i - 1]))); +check('a settled line is final (exact, and only after its window)', settledOk); +check('the arrival timeline self-clears', scene.hudTimeline === null); +check('arrived OPEN: phase expanded', scene.hudPhase === 'expanded'); +check('the caret appeared once the name landed (pointing down)', + scene.hudCaretShown === true && scene.hudCaret.alpha === 1 && scene.hudCaret.angle === 0); + +// =========================================================================== +// 2) The one-shot auto-fold at 10 s: reverse deconstruction + caret→right +// =========================================================================== +let collapseSeen = null; +for (let t = T0 + 4000; t <= T0 + 13000; t += STEP) { + GameScene.prototype.updateHud.call(scene, t); + if (!collapseSeen && scene.hudTimeline && scene.hudTimeline.mode === 'collapse') { + collapseSeen = { + t, + order: scene.hudTimeline.lines.map((l) => l.text), + t0s: scene.hudTimeline.lines.map((l) => l.dec.t0), + reverse: scene.hudTimeline.lines.map((l) => l.dec.reverse), + }; + } +} +check('the auto-fold fired at 10 s after arrival', + collapseSeen !== null && collapseSeen.t >= T0 + 10000 && collapseSeen.t <= T0 + 10000 + STEP); +check('deconstruction runs in REVERSE build order (seed → … → subtitle)', + collapseSeen !== null && + collapseSeen.order[0] === scene.hudDetail[scene.hudDetail.length - 1].text && + collapseSeen.order[1] === scene.hudDetail[scene.hudDetail.length - 2].text && + collapseSeen.order[collapseSeen.order.length - 1] === scene.hudDetail[0].text); +check('deconstruction lines start staggered, each playing reverse', + collapseSeen !== null && + collapseSeen.reverse.every(Boolean) && + collapseSeen.t0s.every((v, i) => i === 0 || v > collapseSeen.t0s[i - 1])); +check('after the fold: details gone, name intact', + detailEmpty(scene) && scene.hudTitle.text === report.title); +check('folded: phase collapsed', scene.hudPhase === 'collapsed'); +check('the caret swung down→right after the last line left', scene.hudCaret.angle === -90); +check('the auto-fold is one-shot (no longer armed)', scene.autoCollapseArmed === false); + +// =========================================================================== +// 3) Player toggle: re-open (caret down, lines type in forward), re-close +// =========================================================================== +scene.time.now = T0 + 13000; +GameScene.prototype.toggleHud.call(scene); +check('toggle while folded starts a FORWARD rebuild', + scene.hudPhase === 'constructing' && scene.hudTimeline.mode === 'expand'); +{ + const tl = scene.hudTimeline; + check('rebuild order is build order (subtitle → … → seed)', + tl.lines.length === detailValues.length && + tl.lines[0].text === scene.hudDetail[0].text && + tl.lines[tl.lines.length - 1].text === scene.hudDetail[scene.hudDetail.length - 1].text && + tl.lines.every((l, i) => l.text === scene.hudDetail[i].text)); + check('rebuild lines are forward decodes, staggered', + tl.lines.every((l) => l.dec.reverse === false) && + tl.lines.map((l) => l.dec.t0).every((v, i) => i === 0 || v > tl.lines[i - 1].dec.t0)); +} +check('the caret swung back down for the re-open', scene.hudCaret.angle === 0); +step(scene, T0 + 13000, T0 + 16000); +check('re-opened: every line back to exactly the report string', detailFinal(scene)); +check('re-opened: phase expanded again', scene.hudPhase === 'expanded'); + +scene.time.now = T0 + 16000; +GameScene.prototype.toggleHud.call(scene); +check('toggle while open starts the REVERSE deconstruction again', + scene.hudPhase === 'collapsing' && scene.hudTimeline.mode === 'collapse' && + scene.hudTimeline.lines[0].text === scene.hudDetail[scene.hudDetail.length - 1].text); +const collapseWhileOpen = scene.hudTimeline; +GameScene.prototype.toggleHud.call(scene); // mid-animation: must be ignored +check('a toggle mid-animation is ignored (same timeline, still collapsing)', + scene.hudTimeline === collapseWhileOpen && scene.hudPhase === 'collapsing'); +step(scene, T0 + 16000, T0 + 19000); +check('folded again: details empty, caret right, phase collapsed', + detailEmpty(scene) && scene.hudCaret.angle === -90 && scene.hudPhase === 'collapsed'); + +// =========================================================================== +// 4) A manual toggle CANCELS the one-shot auto-fold +// =========================================================================== +const scene2 = makeScene(); +GameScene.prototype.createSystemHud.call(scene2); +step(scene2, T0, T0 + 4000); // arrive, open by default +check('fresh dossier arrived open', scene2.hudPhase === 'expanded' && detailFinal(scene2)); + +// The player folds it at 8 s, then re-opens just before the 10 s mark — +// the re-opened dossier must STAY open past 10 s (no auto-fold). +scene2.time.now = T0 + 8000; +GameScene.prototype.toggleHud.call(scene2); +let foldStart = null; +let reOpenAt = null; +for (let t = T0 + 8000; t <= T0 + 13000; t += STEP) { + if (foldStart === null && scene2.hudPhase === 'collapsing') foldStart = t; + if (reOpenAt === null && scene2.hudPhase === 'collapsed') { + scene2.time.now = t; + GameScene.prototype.toggleHud.call(scene2); + reOpenAt = t; + } + GameScene.prototype.updateHud.call(scene2, t); +} +check("the player's 8 s fold ran (not the auto-fold)", foldStart !== null && foldStart >= T0 + 8000 && foldStart <= T0 + 8000 + STEP); +check('the re-open really ran across the 10 s mark', reOpenAt !== null && reOpenAt + 1600 > T0 + 10000); +check('a manual toggle cancels the auto-fold — still OPEN past 10 s', + scene2.hudPhase === 'expanded' && detailFinal(scene2) && scene2.hudCaret.angle === 0); +check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false); + +// =========================================================================== +// 5) Tether readout: bottom-right above the MENU button, deck-aligned +// =========================================================================== +{ + scene.tetherHudTexts = []; + scene.tetherField = { + tethers: [ + { level: 1, radius: 5120, label: 'Terra' }, + { level: 2, radius: 10240, label: 'Kethral' }, + ], + }; + const BW = 190; + const BH = 66; + const barX = 20; + const barW = 1160; // 20..1180 + const barH = 92; + const barY = 708; // 800 - 12 - 92 (the deck's strip) + const pad = 16; + const slotW = (barW - pad * 2) / 6; + const menuX = barX + pad + slotW * 5.5; // sixth slot centre (the MENU button) + const menu = { id: 'menu', slot: { x: menuX, y: barY + barH / 2 } }; + scene.actionBar = { + rect: { x: barX, y: barY, w: barW, h: barH }, // the deck panel strip + style: { bw: BW, bh: BH }, + slots: [menu], + }; + + GameScene.prototype.refreshTetherHud.call(scene); + const right = barX + barW; // the deck panel's right edge + const buttonTop = barY + barH / 2 - BH / 2; + const lines = scene.tetherHudTexts; + check('one line per tether', lines.length === 2); + check('the lines read level · range · anchor', + lines[0].text === 'TETHER LV 1 · RANGE 5.1K PX · TERRA' && + lines[1].text === 'TETHER LV 2 · RANGE 10.2K PX · KETHRAL'); + check('right-aligned to the deck (bottom bar) right edge', + lines.every((t) => Math.abs(t.x + t.width - right) < 0.001)); + check('the block sits above the button with 20 px of padding over its top', + Math.abs(lines[1].y + lines[1].height - (buttonTop - 20)) < 0.001); + check('lines stack UPWARD from the button (bottom-anchored)', + lines[0].y < lines[1].y && lines[1].y < buttonTop && lines[0].y < buttonTop); + + // A third tether appears: the block stays anchored to the button — the + // new line takes the bottom slot (closest to the button) and the old + // lines step up by exactly one line (19 px fake height + 2 px gap). + const before = lines.map((t) => ({ x: t.x, y: t.y })); + scene.tetherField.tethers.push({ level: 1, radius: 2560, label: null }); + GameScene.prototype.refreshTetherHud.call(scene); + const after = scene.tetherHudTexts; + check('added tether: new line takes the bottom slot, old lines step up', + after.length === 3 && + Math.abs(after[2].y + after[2].height - (buttonTop - 20)) < 0.001 && + Math.abs(after[0].y - (before[0].y - 21)) < 0.001 && + Math.abs(after[1].y - (before[1].y - 21)) < 0.001 && + Math.abs(after[2].x + after[2].width - right) < 0.001 && + after[2].text === 'TETHER LV 1 · RANGE 2.6K PX'); + + // No deck (disabled) → the old left-column fallback under the dossier. + scene.actionBar = null; + GameScene.prototype.refreshTetherHud.call(scene); + check('no deck → falls back to the left column under the dossier', + scene.tetherHudTexts[0].x === 16 && scene.tetherHudTexts[0].y === scene.hudEndY); } -check('first frame: every line still empty (the beat of arrival)', texts.every((t, i) => firstNonEmpty[i] === null || firstNonEmpty[i] > T0)); -check('the system name starts decoding first', firstNonEmpty[0] !== null && firstNonEmpty.every((t, i) => (t ?? Infinity) >= firstNonEmpty[0])); -check('decode order is top-to-bottom', firstNonEmpty.every((t, i) => i === 0 || (t ?? Infinity) >= (firstNonEmpty[i - 1] ?? Infinity))); -check('display length stays the target’s through the window', lengthOk); -check('revealed prefix always exact, tail from the decode alphabet', prefixOk && tailAlphabetOk); -check('every line lands as exactly the report string', texts.every((t, i) => t.text === expected[i])); -check('a settled line is final (exact, and only after its window)', settledContractOk); -check('decode state self-clears after arrival', scene.hudDecode === null); -check('updateHudDecode is a no-op once cleared', (() => { - const snap = texts.map((t) => t.text); - GameScene.prototype.updateHudDecode.call(scene, T0 + 5000); - return texts.every((t, i) => t.text === snap[i]); -})()); +// =========================================================================== +// 6) SFX: construct / deconstruct / discovery (data/sfx.json) +// =========================================================================== +{ + const sfxJson = config.section('sfx'); + const s = makeScene(); + s.played = []; + s.audio = { sfx_construct: true, sfx_deconstruct: true, sfx_discovery: true }; // the real preload() puts them here + s.audioQueued = []; + s.sound = { play: (key, cfg) => s.played.push({ key, ...(cfg ?? {}) }) }; + s.cache = { hasAudio: (key) => s.audio[key] === true }; + s.load = { + audio: (key, url) => s.audioQueued.push([key, url]), + spritesheet: () => {}, + image: () => {}, + }; + s.scale = { width: 1280, height: 720 }; + s.time.delayedCall = (_ms, fn) => { fn(); return {}; }; // toast lifetime: instant in the harness + s.add.circle = (x, y, radius, fill, alpha) => ({ + x, y, radius, fill, alpha, + setStrokeStyle() { return this; }, + setDepth() { return this; }, + destroy() { this.destroyed = true; return this; }, + }); + + // preload: the three configured SFX at their configured paths. + GameScene.prototype.preload.call(s); + const queuedKey = (key) => s.audioQueued.find((e) => e[0] === key); + check('preload loads the three configured SFX', + s.audioQueued.length === 3 && + queuedKey('sfx_construct')?.[1] === sfxJson.construct && + queuedKey('sfx_deconstruct')?.[1] === sfxJson.deconstruct && + queuedKey('sfx_discovery')?.[1] === sfxJson.discovery); + check('the configured SFX files exist in the repo', + [sfxJson.construct, sfxJson.deconstruct, sfxJson.discovery].every( + (p) => fs.existsSync(join(__dirname, '..', p)), + )); + + // arrival → the construct SFX (once, at the configured volume). + GameScene.prototype.createSystemHud.call(s); + GameScene.prototype.updateHud.call(s, 0); + check('arrival types the dossier in with the construct SFX', + s.played.length === 1 && s.played[0].key === 'sfx_construct' && s.played[0].volume === sfxJson.volume); + + // the 10 s auto-fold → the deconstruct SFX. + s.played = []; + GameScene.prototype.updateHud.call(s, 10000); + check('the 10 s auto-fold deconstructs with the deconstruct SFX', + s.played.length === 1 && s.played[0].key === 'sfx_deconstruct'); + GameScene.prototype.updateHud.call(s, 12000); + check('... and the dossier ends folded', s.hudPhase === 'collapsed' && detailEmpty(s)); + + // manual re-open → construct again; manual re-close → deconstruct again. + s.played = []; + s.time.now = 12000; + GameScene.prototype.toggleHud.call(s); + check('a manual re-open types the dossier back in with the construct SFX', + s.played.length === 1 && s.played[0].key === 'sfx_construct'); + step(s, 12000, 14000); // the re-open settles + s.played = []; + s.time.now = 14000; + GameScene.prototype.toggleHud.call(s); + check('a manual re-close deconstructs with the deconstruct SFX', + s.played.length === 1 && s.played[0].key === 'sfx_deconstruct'); + + // discovery → the discovery SFX, with the toast still firing. + const obj = { x: 100, y: 100, radius: 512, name: 'Kethral', typeLabel: 'planet' }; + s.played = []; + GameScene.prototype.celebrateDiscovery.call(s, obj); + check('a new discovery plays the discovery SFX (exactly once)', + s.played.length === 1 && s.played[0].key === 'sfx_discovery' && s.played[0].volume === sfxJson.volume); + check('the DISCOVERED toast still appears alongside the sound', + s.texts.some((t) => t.text === 'DISCOVERED — KETHRAL · PLANET')); + + // guards: a missing asset or the master switch off → never plays, never throws. + s.played = []; + s.audio = {}; // e.g. the load was skipped + GameScene.prototype.celebrateDiscovery.call(s, obj); + check('a missing asset never plays (and never throws)', s.played.length === 0); + + s.audio = { sfx_discovery: true }; + config.init({ ...config.data, sfx: { ...sfxJson, enabled: false } }); + s.played = []; + GameScene.prototype.celebrateDiscovery.call(s, obj); + check('sfx.enabled=false → nothing plays', s.played.length === 0); + s.audioQueued = []; + GameScene.prototype.preload.call(s); + check('sfx.enabled=false → preload loads no audio', s.audioQueued.length === 0); +} if (failures > 0) { console.error(`\n${failures} system-hud test(s) FAILED`); diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index de6cd93..1d6461b 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -19,6 +19,17 @@ const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const HEADER_FONT = () => fontStack('header', FONT_FALLBACK); const BODY_FONT = () => fontStack('body', FONT_FALLBACK); +/** + * The dossier's decode pacing (the shared decode effect, per line): + * a beat of arrival, then each line starts a little after the last. + */ +const HUD_LEAD_IN = 250; // ms before the dossier starts typing in +const HUD_STAGGER = 140; // ms between the start of each line +const HUD_EXPAND_LEAD = 120; // ms before re-opened details start streaming in + +/** The open-by-default dossier folds itself 10 s after arrival (unless the player toggles it first). */ +const HUD_AUTO_COLLAPSE_MS = 10000; + /** 5120 → "5.1K", 640 → "640" — compact range readout for the HUD. */ function fmtRange(v) { if (v >= 1000) { @@ -39,6 +50,10 @@ function fmtRange(v) { * (this.compass) pointing the way back — and clicking its name tag * autopilots the ship there (GameScene.autopilotTo: it targets the * keep-out rim on the side the ship is approaching from). + * + * The system dossier (top-left) opens by default on arrival and folds + * itself after 10 s; clicking the system NAME (or the caret beside it) + * toggles the detail lines open/closed — see createSystemHud(). */ export class GameScene extends Phaser.Scene { constructor() { @@ -85,6 +100,14 @@ export class GameScene extends Phaser.Scene { }, ); } + + // Sound effects (data/sfx.json → enabled). Skipped entirely when the + // master switch is off — no load cost, no files fetched. + if (config.get('sfx.enabled', true)) { + this.load.audio('sfx_construct', config.get('sfx.construct', 'assets/fx/type-construct.mp3')); + this.load.audio('sfx_deconstruct', config.get('sfx.deconstruct', 'assets/fx/type-deconstruct.mp3')); + this.load.audio('sfx_discovery', config.get('sfx.discovery', 'assets/fx/discovery.mp3')); + } } create() { @@ -242,6 +265,10 @@ export class GameScene extends Phaser.Scene { }) : null; + // The tether readout anchors above the deck's MENU button — re-render + // it now that the deck exists (the boot pass had nothing to anchor to). + this.refreshTetherHud(); + // 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', ''), { @@ -254,8 +281,10 @@ export class GameScene extends Phaser.Scene { .setScrollFactor(0); // UI: pinned to the screen, not the world // Input: click = fly there. A click ON the command deck is deck - // business, and a click on a compass name tag is an autopilot (it - // already retargeted the ship) — neither is a fly-here. + // business, a click on a compass name tag is an autopilot (it + // already retargeted the ship), and a click on the system NAME (or + // the caret beside it) toggles the dossier open/closed — none of + // those is a fly-here. // A click inside a planet clamps to that planet's keep-out rim — the // ship can stop at the clearance, never inside. (Worlds don't // overlap, so sequential clamping is exact.) A click BEYOND the @@ -264,6 +293,10 @@ export class GameScene extends Phaser.Scene { this.input.on('pointerdown', (pointer) => { if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return; if (this.compass.contains(pointer.x, pointer.y)) return; + if (this.hudTitleContains(pointer.x, pointer.y)) { + this.toggleHud(); // the system name toggles the dossier details + return; + } let aim = { x: pointer.worldX, y: pointer.worldY }; for (const s of this.solids) { aim = s.aimPoint(aim.x, aim.y, this.ship.radius); @@ -324,9 +357,25 @@ export class GameScene extends Phaser.Scene { * The dossier types itself in with the menu's decode scramble * (js/utils/Decode.js): the system name first, then each data line below * it, staggered — the console acquiring a signal as the ship arrives. - * It runs every time the scene is created: the first start and every new - * solar system. updateHudDecode drives the reveal (anchored to the first - * frame after create — the scene's TimeClock is still stale in create). + * A small caret sits to the RIGHT of the name — pointing DOWN while the + * details are open, RIGHT while they're folded — and the name (or the + * caret) is the click target that toggles them (toggleHud): + * + * arrive ──► OPEN by default (name decodes, caret fades in below it… + * beside it), details type in + * │ after 10 s (HUD_AUTO_COLLAPSE_MS) — OR a click on the name + * ▼ + * details DECONSTRUCT in reverse build order (seed line + * first … subtitle last), then the caret swings down→right + * │ + * ▼ a click on the name (or the caret) + * the caret swings right→down, the details type back in + * + * Any manual toggle cancels the one-shot auto-fold — the player took + * control of the dossier. updateHud drives it all (anchored to the + * first frame after create — the scene's TimeClock is still stale in + * create()). It runs every time the scene is created: the first start + * and every new solar system. * * The system's CONTENTS were ensured in create() (ensureGalaxy + * ensureContent) — the first touch of the lazy level-2 generation. @@ -338,32 +387,59 @@ export class GameScene extends Phaser.Scene { const fam = BODY_FONT(); const famHeader = HEADER_FONT(); const current = this.systemRecord; - - // Decode timeline (relative; anchored on the first update frame): - // a beat of arrival, then the lines type in one after another. - const LEAD_IN = 250; // ms before the signal starts - const STAGGER = 140; // ms between the start of each line - let delay = LEAD_IN; - const lines = []; + const X = 16; let y = 14; - const line = (value, style) => { - const t = this.add - .text(16, y, '', style) - .setOrigin(0, 0) - .setScrollFactor(0) // UI: pinned to the screen, not the world - .setDepth(30); - lines.push({ text: t, value, delay, dur: decodeDur(value.length) }); - delay += STAGGER; - y += 20 + (style.fontSize === '17px' ? 6 : 0); - }; - line(report.title, { + // --- The name: the dossier's anchor and its toggle ----------------- + const titleStyle = { fontFamily: famHeader, fontSize: '17px', // v4 quirk: text colors must be CSS strings (see toCss, utils/Color.js). color: toCss(this.galaxy.typeDefs?.[current.type]?.theme?.color ?? '#9fb4e8'), letterSpacing: 2, - }); + }; + const title = this.add + .text(X, y, report.title, titleStyle) + .setOrigin(0, 0) + .setScrollFactor(0) // UI: pinned to the screen, not the world + .setDepth(30); + const titleW = title.width; // measure the FINISHED name once… + const titleH = title.height; + title.setText(''); // …then let the decode type it out + this.hudTitle = title; + + // The state caret — a small triangle to the right of the name: + // pointing down (▾) = details open, right = folded. It fades in once + // the name has landed and swings with the state (rotateCaret). + const caret = this.add + .text(X + titleW + 10, y + titleH / 2, '\u25be', { + fontFamily: fam, + fontSize: '14px', + color: '#8fa0c9', + }) + .setOrigin(0.5) + .setScrollFactor(0) // UI: pinned to the screen, not the world + .setDepth(30) + .setAlpha(0); + this.hudCaret = caret; + this.hudCaretShown = false; + + // Click target: the name plus the caret beside it (screen space — the + // dossier is pinned UI, so local space == screen space). + this.hudTitleRect = { x: X - 4, y: y - 4, w: titleW + 24, h: titleH + 8 }; + y += 26; + + // --- The details (open by default): what's already there ------------ + const detail = []; + const line = (value, style) => { + const t = this.add + .text(X, y, '', style) + .setOrigin(0, 0) + .setScrollFactor(0) // UI: pinned to the screen, not the world + .setDepth(30); + detail.push({ text: t, value }); + y += 20; + }; line(report.subtitle, { fontFamily: fam, fontSize: '12px', color: '#8fa0c9', letterSpacing: 1 }); y += 2; for (const s of report.settlements) { @@ -371,68 +447,228 @@ export class GameScene extends Phaser.Scene { } line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' }); line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' }); - this.hudDecode = { t0: null, lines }; - this.hudEndY = y + 6; // the tether readout (refreshTetherHud) continues below + this.hudDetail = detail; + this.hudEndY = y + 6; // bottom of the dossier block (tether readout's fallback anchor) + + // --- State + the arrival timeline (anchored on the first frame) ----- + this.hudPhase = 'constructing'; // 'constructing' | 'expanded' | 'collapsing' | 'collapsed' + this.autoCollapseArmed = true; // the one-shot 10 s auto-fold + this.hudArrivalT0 = null; + this.hudTimeline = { + mode: 'arrive', // the name first, then the data below it + t0: null, + lines: [ + { text: title, value: report.title, isTitle: true }, + ...detail, + ].map((ln, i) => ({ + ...ln, + delay: HUD_LEAD_IN + i * HUD_STAGGER, + dur: decodeDur(ln.value.length), + settled: false, + })), + }; } /** - * The dossier's decode (js/utils/Decode.js) — the same scramble as the - * menu's Galaxy Seed: each line reveals left-to-right over its own - * window, the unrevealed slots churning through the alphabet. Lines - * start staggered (name first, then the data below it). Self-clears once - * every line has landed, so update() pays nothing after arrival. + * The dossier's lifecycle, driven every frame (updateHud is called from + * update() with the engine's loop time — the scene's TimeClock is the + * only clock that runs, see the v4 quirk in update()): + * - the active timeline (arrive / expand / collapse) reveals or + * erases its lines one by one with the shared decode scramble; + * - on arrival the name lands first and the state caret fades in + * beside it; + * - on a collapse the LAST line built erases first (seed → … → + * subtitle), then the caret swings down→right; + * - the one-shot 10 s auto-fold fires once (cancelled by any manual + * toggle). */ - updateHudDecode(time) { - const hd = this.hudDecode; - if (!hd) return; - if (hd.t0 === null) { - // First frame after create(): anchor the timeline here (this is the - // same time base update() receives; the scene's time.now is stale). - hd.t0 = time; - for (const ln of hd.lines) ln.dec = new ScrambleDecode(ln.value, hd.t0 + ln.delay, ln.dur); + updateHud(time) { + if (this.hudArrivalT0 === null) { + // First frame after create(): anchor the arrival timeline here + // (this is the same time base update() receives; the scene's + // time.now is still stale). + this.hudArrivalT0 = time; + this.playSfx('construct'); // the dossier starts typing in + const tl = this.hudTimeline; + tl.t0 = time; + for (const ln of tl.lines) ln.dec = new ScrambleDecode(ln.value, time + ln.delay, ln.dur); } - let done = true; - for (const ln of hd.lines) { - if (ln.settled) continue; - if (!ln.dec.started(time)) { - done = false; - continue; + + const tl = this.hudTimeline; + if (tl) { + let done = true; + for (const ln of tl.lines) { + if (ln.settled) continue; + if (!ln.dec.started(time)) { + done = false; + continue; + } + ln.text.setText(ln.dec.display(time)); + if (ln.dec.finished(time)) { + ln.settled = true; + if (ln.isTitle) this.showCaret(); // the name has landed — the state is legible + } else done = false; + } + if (done) { + this.hudTimeline = null; + if (tl.mode === 'collapse') { + this.hudPhase = 'collapsed'; + this.rotateCaret(true); // details gone → caret swings to point right + } else { + this.hudPhase = 'expanded'; + } } - ln.text.setText(ln.dec.display(time)); - if (ln.dec.finished(time)) ln.settled = true; - else done = false; } - if (done) this.hudDecode = null; + + // The open-by-default dossier folds itself 10 s after arrival — + // unless the player already toggled it (autoCollapseArmed). + if ( + this.autoCollapseArmed && + this.hudPhase === 'expanded' && + !this.hudTimeline && + time >= this.hudArrivalT0 + HUD_AUTO_COLLAPSE_MS + ) { + this.autoCollapseArmed = false; + this.startCollapse(time); + } + } + + /** The state caret fades in once the name has landed (arrival only). */ + showCaret() { + if (this.hudCaretShown) return; + this.hudCaretShown = true; + this.tweens.add({ targets: this.hudCaret, alpha: 1, duration: 160, ease: 'Sine.easeOut' }); + } + + /** The caret swings with the state: down (0) = open, right (−90°) = folded. */ + rotateCaret(toRight) { + this.tweens.add({ + targets: this.hudCaret, + angle: toRight ? -90 : 0, + duration: 220, + ease: 'Sine.easeInOut', + }); } /** - * Under the dossier: one line per tether — level, range, anchor. Rebuilt + * Re-open the details: the caret swings back down first, then the lines + * type back in in build order (subtitle → … → seed) — the same decode as + * arrival, just without the name (it never left). + */ + startExpand(t) { + this.hudPhase = 'constructing'; + this.playSfx('construct'); // the details type back in + this.rotateCaret(false); + this.hudTimeline = { + mode: 'expand', + lines: this.hudDetail.map((ln, i) => ({ + text: ln.text, + value: ln.value, + settled: false, + dec: new ScrambleDecode(ln.value, t + HUD_EXPAND_LEAD + i * HUD_STAGGER, decodeDur(ln.value.length)), + })), + }; + } + + /** + * Fold the details: the lines DECONSTRUCT in reverse build order (seed + * line erases first … subtitle last) — the same scramble played + * backwards — and the caret swings down→right once the last one is gone + * (updateHud, when the timeline completes). + */ + startCollapse(t) { + this.hudPhase = 'collapsing'; + this.playSfx('deconstruct'); // the details deconstruct + this.hudTimeline = { + mode: 'collapse', + lines: [...this.hudDetail].reverse().map((ln, i) => ({ + text: ln.text, + value: ln.value, + settled: false, + // reverse = true: value → shrinking prefix → static → '' + dec: new ScrambleDecode(ln.value, t + i * HUD_STAGGER, decodeDur(ln.value.length), true), + })), + }; + } + + /** + * The player clicked the system name (or its caret): toggle the details. + * Ignored mid-animation — let the current construct/deconstruct finish. + * A manual toggle also cancels the one-shot auto-fold. + */ + toggleHud() { + if (this.hudTimeline) return; + this.autoCollapseArmed = false; + if (this.hudPhase === 'expanded') this.startCollapse(this.time.now); + else if (this.hudPhase === 'collapsed') this.startExpand(this.time.now); + } + + /** Is (px,py) on the dossier's toggle target — the name or the caret beside it? */ + hudTitleContains(px, py) { + const r = this.hudTitleRect; + return !!r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h; + } + + /** + * Tether readout: one line per tether — level, range, anchor. Rebuilt * whenever the field changes (TetherField.onChange), so a future * "tether upgrade" build shows up here for free. + * + * Sits in the BOTTOM-RIGHT, stacked just above the command deck's MENU + * button, right-aligned to the DECK's right edge. The block is + * bottom-anchored, so when more tethers are added they grow UPWARD, away + * from the button. If the deck is disabled there's no button to anchor + * to — the readout falls back to the left column under the dossier + * (hudEndY). */ refreshTetherHud() { - if (this.hudEndY === undefined || !this.tetherField) return; + if (!this.tetherField) return; for (const g of this.tetherHudTexts) g.destroy(); this.tetherHudTexts = []; const fam = BODY_FONT(); const neon = toCss(themeColor('neon', 0x00e5ff)); - let y = this.hudEndY; - for (const t of this.tetherField.tethers) { + + // Build first so the real widths/heights are known, then place. + const made = this.tetherField.tethers.map((t) => { const label = t.label ? ` · ${String(t.label).toUpperCase()}` : ''; - this.tetherHudTexts.push( - this.add - .text(16, y, `TETHER LV ${t.level} · RANGE ${fmtRange(t.radius)} PX${label}`, { - fontFamily: fam, - fontSize: '12px', - color: neon, - letterSpacing: 1, - }) - .setOrigin(0, 0) - .setScrollFactor(0) // UI — pinned to the screen - .setDepth(30), - ); - y += 18; + return this.add + .text(0, 0, `TETHER LV ${t.level} · RANGE ${fmtRange(t.radius)} PX${label}`, { + fontFamily: fam, + fontSize: '12px', + color: neon, + letterSpacing: 1, + }) + .setOrigin(0, 0) + .setScrollFactor(0) // UI — pinned to the screen + .setDepth(30); + }); + + // The deck's MENU button (last slot by default) is the anchor. + const bar = this.actionBar; + const menu = + bar && Array.isArray(bar.slots) && bar.slots.length > 0 + ? bar.slots.find((s) => s.id === 'menu') ?? bar.slots[bar.slots.length - 1] + : null; + if (menu) { + // Right edge on the DECK panel's, closest line's bottom a little + // above the button's top (a few px of padding over the bar). + const right = bar.rect.x + bar.rect.w; + let y = menu.slot.y - bar.style.bh / 2 - 20; + for (let i = made.length - 1; i >= 0; i--) { + const t = made[i]; + y -= t.height; + t.setPosition(right - t.width, y); + y -= 2; // the line above + } + } else { + // No deck (disabled): the old left-column spot under the dossier. + let y = this.hudEndY; + for (const t of made) { + t.setPosition(16, y); + y += 18; + } } + this.tetherHudTexts = made; } update(_time, delta) { @@ -441,7 +677,7 @@ export class GameScene extends Phaser.Scene { // fire. (Same quirk as MenuScene.update; verified Sept 2026.) this.time.update(_time, delta); this.tweens.update(); - this.updateHudDecode(_time); // the dossier types itself in (first frames only) + this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle this.ship.update(_time, delta); // The clusters are ALIVE: each rock tumbles, the loose group drifts, // the dust orbits. (The keep-out constraint runs in onPostUpdate, @@ -631,9 +867,25 @@ export class GameScene extends Phaser.Scene { this.hideHint(); } + /** + * Play one of the configured sound effects (data/sfx.json). + * Silently no-ops when SFX are disabled, the sound manager isn't + * available (headless/test), or the asset never loaded — the game + * never blocks or warns on sound. + */ + playSfx(name) { + if (!config.get('sfx.enabled', true)) return; + const snd = this.sound; + if (!snd || typeof snd.play !== 'function') return; + const key = `sfx_${name}`; + if (this.cache && typeof this.cache.hasAudio === 'function' && !this.cache.hasAudio(key)) return; + snd.play(key, { volume: config.get('sfx.volume', 0.55) }); + } + /** The "new object" moment: a rim ping at the world + a HUD toast. */ celebrateDiscovery(o) { const neon = themeColor('neon', 0x00e5ff); + this.playSfx('discovery'); // the signal that something new is here // Expanding ring at the world's rim (world space). const ring = this.add.circle(o.x, o.y, o.radius, 0, 0).setStrokeStyle(2, neon, 0.9).setDepth(6); diff --git a/js/utils/Decode.js b/js/utils/Decode.js index 1098bef..5aa1f9e 100644 --- a/js/utils/Decode.js +++ b/js/utils/Decode.js @@ -8,7 +8,10 @@ * * Over `dur` ms the target string reveals left-to-right while the * unrevealed slots hold random glyphs from the decode alphabet — the - * console pulling a signal out of static. + * console pulling a signal out of static. With `reverse = true` the + * same reveal plays BACKWARDS (value → shrinking stable prefix → static + * → '') — the console dropping the signal, used to deconstruct the + * dossier when it folds away. * * Pure (no Phaser): build one per line with a start time, poll * `display(time)` from the scene's `update()`, and drop it once @@ -31,11 +34,14 @@ export class ScrambleDecode { * @param {string} value the final text * @param {number} t0 absolute start time (scene time, in ms) * @param {number} [dur] reveal window in ms + * @param {boolean} [reverse] false = construct ('' → static → value); + * true = deconstruct (value → static → ''), the same reveal in reverse */ - constructor(value, t0, dur = DECODE_DURATION) { + constructor(value, t0, dur = DECODE_DURATION, reverse = false) { this.value = String(value ?? ''); this.t0 = t0; this.dur = dur > 0 ? dur : 1; + this.reverse = !!reverse; } started(time) { @@ -47,15 +53,21 @@ export class ScrambleDecode { } /** - * The display string at `time`: the revealed prefix of the target with - * the rest scrambled. '' before the start, the exact target once - * finished (the reveal lands at ~87% of the window, then holds). + * The display string at `time`: a stable prefix of the target with the + * remaining slots scrambled. Forward: the prefix grows. Reverse: it + * shrinks. Before the start: forward is '', reverse holds the value + * (the line is already showing it). After the finish: forward holds + * the value, reverse is '' (the reveal lands at ~87% of the window, + * then holds). */ display(time) { - if (!this.started(time)) return ''; - if (this.finished(time)) return this.value; + if (!this.started(time)) return this.reverse ? this.value : ''; + if (this.finished(time)) return this.reverse ? '' : this.value; const u = (time - this.t0) / this.dur; - const reveal = Math.floor(Math.max(0, Math.min(1, u * 1.15)) * this.value.length); + const progress = this.reverse + ? Math.max(0, Math.min(1, (1 - u) * 1.15)) + : Math.max(0, Math.min(1, u * 1.15)); + const reveal = Math.floor(progress * this.value.length); let out = ''; for (let i = 0; i < this.value.length; i++) { out += i < reveal ? this.value[i] : DECODE_CHARS[(Math.random() * DECODE_CHARS.length) | 0];