Add SFX, dossier toggle/auto-fold, and tether HUD repositioning

- Add sound effects (construct, deconstruct, discovery) with config in data/sfx.json; play on dossier arrival/fold and new-object discovery; silently no-op when disabled or asset missing
- Rework the system dossier into a full lifecycle: open-by-default on arrival, one-shot auto-fold at 10 s, click-to-toggle (name + caret), reverse deconstruction order, state caret that swings with the open/closed state
- Add ScrambleDecode `reverse` mode so lines can deconstruct (value → static → '') for the fold animation
- Reposition the tether readout to bottom-right above the deck's MENU button, right-aligned and bottom-anchored; fall back to left column when the deck is disabled
- Expand decode and system-hud tests to cover reverse decode, the full dossier lifecycle (toggle, auto-fold cancellation, mid-animation guard), tether HUD layout, and SFX wiring
This commit is contained in:
Brian Fertig 2026-09-04 10:25:37 -06:00
parent fbd0e56f1f
commit 2d1265d592
10 changed files with 826 additions and 163 deletions

BIN
assets/fx/discovery.mp3 Normal file

Binary file not shown.

BIN
assets/fx/system-scan.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -13,6 +13,7 @@
"naming.json",
"research.json",
"builds.json",
"actionbar.json"
"actionbar.json",
"sfx.json"
]
}

8
data/sfx.json Normal file
View File

@ -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"
}

View File

@ -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 menus 620 ms', new ScrambleDecode('x', 0).dur === 620);

View File

@ -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 downright;
* - 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;
}
setText(s) { this.text = s; return this; }
setOrigin() { return this; }
setScrollFactor() { return this; }
setDepth(d) { this.depth = d; return this; }
get width() {
return this.text.length * 9; // deterministic fake metrics
}
const scene = {
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;
}
}
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; } },
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 targets 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 targets 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`);

View File

@ -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 downright
*
* a click on the name (or the caret)
* the caret swings rightdown, 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,57 +447,192 @@ 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 downright;
* - 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);
}
const tl = this.hudTimeline;
if (tl) {
let done = true;
for (const ln of hd.lines) {
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;
else done = false;
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.hudDecode = null;
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';
}
}
}
// 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 downright 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}`, {
return this.add
.text(0, 0, `TETHER LV ${t.level} · RANGE ${fmtRange(t.radius)} PX${label}`, {
fontFamily: fam,
fontSize: '12px',
color: neon,
@ -429,11 +640,36 @@ export class GameScene extends Phaser.Scene {
})
.setOrigin(0, 0)
.setScrollFactor(0) // UI — pinned to the screen
.setDepth(30),
);
.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) {
// Phaser v4 (Giedi): the engine does not step the scene's TimeClock or
@ -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);

View File

@ -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];