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:
parent
fbd0e56f1f
commit
2d1265d592
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -13,6 +13,7 @@
|
|||
"naming.json",
|
||||
"research.json",
|
||||
"builds.json",
|
||||
"actionbar.json"
|
||||
"actionbar.json",
|
||||
"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"
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in New Issue