diff --git a/README.md b/README.md index 77ec8f9..00c8205 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,8 @@ node dev/galaxy.test.mjs # galaxy determinism, distribution, lazy vs ea node dev/discovery.test.mjs # discovery rules + compass geometry + chip hit test node dev/tether.test.mjs # tether range math: union, clamp, visible arcs (no line in overlaps) node dev/research-builds.test.mjs # data contract: research/builds/actionbar shapes + manifest +node dev/decode.test.mjs # the shared decode scramble (menu seed + system dossier) +node dev/system-hud.test.mjs # real GameScene dossier: layout + staggered decode to final report ``` `dev/test-game.html` boots straight into the GameScene (no menu click), diff --git a/assets/images/planets.psd b/assets/images/planets.psd index d305227..171b58a 100644 Binary files a/assets/images/planets.psd and b/assets/images/planets.psd differ diff --git a/dev/decode.test.mjs b/dev/decode.test.mjs new file mode 100644 index 0000000..50ff183 --- /dev/null +++ b/dev/decode.test.mjs @@ -0,0 +1,77 @@ +/** + * ScrambleDecode test (dev tool, run with Node — no browser): + * + * node dev/decode.test.mjs + * + * The shared "decode" effect (js/utils/Decode.js) — the type-out the + * menu uses on the Galaxy Seed and the game uses on the system dossier + * HUD. Checks the effect's contract: + * - '' before the start time, the exact target once finished; + * - the revealed prefix always matches the target's; + * - unrevealed slots are drawn from the decode alphabet; + * - the reveal never goes backwards over time; + * - display length is stable through the window; + * - decodeDur floors short lines, caps long ones, grows in between; + * - the default window is the menu's 620 ms. + */ +import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const { ScrambleDecode, DECODE_CHARS, DECODE_DURATION, decodeDur } = await import( + pathToFileURL(join(__dirname, '../js/utils/Decode.js')).href +); + +let failures = 0; +const check = (label, cond) => { + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); + if (!cond) failures++; +}; + +const VALUE = 'Kepler-9 · 17 planets'; +const T0 = 1000; +const dur = DECODE_DURATION; +const dec = new ScrambleDecode(VALUE, T0, dur); + +check('empty before the start time', dec.display(T0 - 1) === ''); +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. +let lenOk = true; +let prefixOk = true; +let charsOk = true; +let monotonic = true; +let prev = 0; +for (let t = T0; t <= T0 + dur; t += 16) { + const s = dec.display(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; +} +check('display length stays constant through the window', lenOk); +check('revealed prefix always matches the target', prefixOk); +check('unrevealed slots come from the decode alphabet', charsOk); +check('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)); + +// 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); + +if (failures > 0) { + console.error(`\n${failures} decode test(s) FAILED`); + process.exit(1); +} +console.log('\ndecode: all checks passed'); diff --git a/dev/system-hud.test.mjs b/dev/system-hud.test.mjs new file mode 100644 index 0000000..13d2f56 --- /dev/null +++ b/dev/system-hud.test.mjs @@ -0,0 +1,179 @@ +/** + * System dossier HUD test (dev tool, run with Node — no browser): + * + * node dev/system-hud.test.mjs + * + * Runs the REAL GameScene.createSystemHud()/updateHudDecode() + * (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). + */ +import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// --- Stub just enough of Phaser for the module-level class declarations ---- +const ClassStub = class {}; +const PhaserStub = { + Scene: ClassStub, + Physics: { Arcade: { Sprite: ClassStub } }, + GameObjects: { Sprite: ClassStub, Container: ClassStub }, + Geom: { Rectangle: class {} }, + Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } }, + Math: { + Linear: (a, b, t) => a + (b - a) * t, + FloatBetween: (a, b) => a + Math.random() * (b - a), + Angle: { Wrap: (a) => a }, + Clamp: (v, lo, hi) => Math.min(hi, Math.max(lo, v)), + }, + BlendModes: { ADD: 2 }, +}; +globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this + +// --- Load the real config (data/*.json) into the config singleton ---------- +const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); +const fs = await import('node:fs'); +const dataDir = join(__dirname, '../data'); +const configData = {}; +for (const f of fs.readdirSync(dataDir)) { + if (!f.endsWith('.json') || f === 'manifest.json') continue; + configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8')); +} +config.init(configData); + +const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href); +const { formatSystemReport } = await import(pathToFileURL(join(__dirname, '../js/galaxy/SystemReport.js')).href); +const { DECODE_CHARS } = await import(pathToFileURL(join(__dirname, '../js/utils/Decode.js')).href); +const { GameScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/GameScene.js')).href); + +let failures = 0; +const check = (label, cond) => { + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); + if (!cond) failures++; +}; + +// --- Real galaxy + real report for one system ------------------------------ +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, + 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`); + +// --- Stub scene: just what createSystemHud()/updateHudDecode() touch ------- +const texts = []; +class FakeText { + constructor(x, y, str) { + this.x = x; + this.y = y; + this.text = str; + this.depth = 0; + } + 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, +}; + +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)); +{ + // 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]; + y += 26; + ys.push(y); // subtitle + y += 20; + y += 2; + 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('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). +const T0 = 1000; +const firstNonEmpty = new Array(expected.length).fill(null); +let prefixOk = true; +let tailAlphabetOk = true; +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]; + if (firstNonEmpty[i] === null && text.text !== '') firstNonEmpty[i] = t; + if (text.text === '') return; + 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 (text.text === target) return; // mid-decode coincidence — passes the checks below + // Decoding: revealed prefix exact, tail from the decode alphabet. + 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; + }); +} + +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]); +})()); + +if (failures > 0) { + console.error(`\n${failures} system-hud test(s) FAILED`); + process.exit(1); +} +console.log('\nsystem-hud: all checks passed'); diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 5b17139..de6cd93 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -6,6 +6,7 @@ import { Rng } from '../utils/Rng.js'; import { Galaxy } from '../galaxy/Galaxy.js'; import { formatSystemReport } from '../galaxy/SystemReport.js'; import { Discovery } from '../galaxy/Discovery.js'; +import { ScrambleDecode, decodeDur } from '../utils/Decode.js'; import { Ship } from '../entities/Ship.js'; import { Planet } from '../entities/Planet.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.js'; @@ -320,6 +321,13 @@ export class GameScene extends Phaser.Scene { * in open space (or "charted · unclaimed" when nobody's settled here). * Formatted by the pure SystemReport helper; this method only renders. * + * 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). + * * The system's CONTENTS were ensured in create() (ensureGalaxy + * ensureContent) — the first touch of the lazy level-2 generation. * (Roster/positions were fixed at the menu's New Game click; this is @@ -331,13 +339,21 @@ export class GameScene extends Phaser.Scene { 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 = []; let y = 14; - const line = (text, style) => { - this.add - .text(16, y, text, style) + 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); }; @@ -355,9 +371,40 @@ 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 } + /** + * 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. + */ + 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); + } + let done = true; + for (const ln of hd.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 (done) this.hudDecode = null; + } + /** * Under the dossier: one line per tether — level, range, anchor. Rebuilt * whenever the field changes (TetherField.onChange), so a future @@ -394,6 +441,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.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, diff --git a/js/scenes/MenuScene.js b/js/scenes/MenuScene.js index 53e89fa..0f7f352 100644 --- a/js/scenes/MenuScene.js +++ b/js/scenes/MenuScene.js @@ -9,13 +9,13 @@ import { CyberOverlay } from '../visuals/CyberOverlay.js'; import { CyberShape } from '../ui/CyberShape.js'; import { Rng } from '../utils/Rng.js'; import { NameGenerator } from '../utils/NameGenerator.js'; +import { ScrambleDecode, DECODE_DURATION } from '../utils/Decode.js'; import { Galaxy } from '../galaxy/Galaxy.js'; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const SEED_STORAGE_KEY = 'orbit.galaxySeed'; const SEED_MAX = 32; const SEED_CHAR = /^[A-Za-z0-9._-]$/; -const DECODE_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789#%+*<>?'; /** * Main menu — the cyberpunk face of the game. @@ -359,28 +359,22 @@ export class MenuScene extends Phaser.Scene { } // ------------------------------------------------------------------ - // Seed "decode" scramble (boot + reroll) + // Seed "decode" scramble (boot + reroll) — the shared console effect + // (js/utils/Decode.js), the one the system dossier HUD uses too. // ------------------------------------------------------------------ startDecode(value) { - this.decode = { value, t0: this.time.now, dur: 620 }; + this.decode = new ScrambleDecode(value, this.time.now, DECODE_DURATION); } updateDecode(time) { if (!this.decode || !this.seedText || this.dead) return; - const { value, t0, dur } = this.decode; - const u = (time - t0) / dur; - if (u >= 1) { + if (this.decode.finished(time)) { this.decode = null; this.drawSeed(); return; } - const reveal = Math.floor(Math.max(0, Math.min(1, u * 1.15)) * value.length); - let out = ''; - for (let i = 0; i < value.length; i++) { - out += i < reveal ? value[i] : DECODE_CHARS[(Math.random() * DECODE_CHARS.length) | 0]; - } - this.seedText.setText(out); + this.seedText.setText(this.decode.display(time)); } // ------------------------------------------------------------------ diff --git a/js/utils/Decode.js b/js/utils/Decode.js new file mode 100644 index 0000000..1098bef --- /dev/null +++ b/js/utils/Decode.js @@ -0,0 +1,74 @@ +/** + * The "decode" scramble — the shared type-out effect of the console. + * + * The main menu uses it on the Galaxy Seed field (it "decodes" in as the + * panel assembles); the game uses it on the system dossier HUD — the + * system name and every data line below it type themselves in as the + * ship arrives. One effect, two scenes. + * + * 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. + * + * Pure (no Phaser): build one per line with a start time, poll + * `display(time)` from the scene's `update()`, and drop it once + * `finished(time)`. The time base is the engine loop time the scene's + * `update(time, delta)` receives (=== `this.time.now` after that frame). + * + * const dec = new ScrambleDecode('Kepler-9', t0, DECODE_DURATION); + * // each frame: if (dec.started(time)) text.setText(dec.display(time)); + * // done when: dec.finished(time) + */ + +/** The alphabet unrevealed slots draw from (no vowels, no 0/1 — signal, not language). */ +export const DECODE_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789#%+*<>?'; + +/** The menu's seed-field window (ms) — the canonical decode timing. */ +export const DECODE_DURATION = 620; + +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 + */ + constructor(value, t0, dur = DECODE_DURATION) { + this.value = String(value ?? ''); + this.t0 = t0; + this.dur = dur > 0 ? dur : 1; + } + + started(time) { + return time >= this.t0; + } + + finished(time) { + return (time - this.t0) / this.dur >= 1; + } + + /** + * 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). + */ + display(time) { + if (!this.started(time)) return ''; + if (this.finished(time)) return 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); + 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]; + } + return out; + } +} + +/** + * Per-line window for multi-line decodes (the system dossier): ~70 ms per + * character — the menu's feel — floored so short lines don't flicker and + * capped so long ones don't drag. + */ +export function decodeDur(length = 0) { + return Math.max(420, Math.min(880, 320 + length * 46)); +}