Extract shared decode scramble and apply it to the system dossier HUD
- New `js/utils/Decode.js` with a pure `ScrambleDecode` class plus `DECODE_CHARS`, `DECODE_DURATION`, and a per-line `decodeDur()` helper, so one effect serves both scenes. - MenuScene now delegates its seed scramble to the shared utility instead of inlining the reveal math and alphabet. - GameScene's system dossier types itself in on arrival: each line starts empty and decodes left-to-right, staggered top-to-bottom, with the timeline anchored on the first update frame; state self-clears once all lines settle so `update()` pays nothing afterward. - Add Node dev tests for the shared effect contract (`dev/decode.test.mjs`) and for the real GameScene dossier decode against actual galaxy/report data with a stubbed scene (`dev/system-hud.test.mjs`). - Document both new test commands in the README and update the planets PSD asset.
This commit is contained in:
parent
b325a19c82
commit
fbd0e56f1f
|
|
@ -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/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/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/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),
|
`dev/test-game.html` boots straight into the GameScene (no menu click),
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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');
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -6,6 +6,7 @@ import { Rng } from '../utils/Rng.js';
|
||||||
import { Galaxy } from '../galaxy/Galaxy.js';
|
import { Galaxy } from '../galaxy/Galaxy.js';
|
||||||
import { formatSystemReport } from '../galaxy/SystemReport.js';
|
import { formatSystemReport } from '../galaxy/SystemReport.js';
|
||||||
import { Discovery } from '../galaxy/Discovery.js';
|
import { Discovery } from '../galaxy/Discovery.js';
|
||||||
|
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
|
||||||
import { Ship } from '../entities/Ship.js';
|
import { Ship } from '../entities/Ship.js';
|
||||||
import { Planet } from '../entities/Planet.js';
|
import { Planet } from '../entities/Planet.js';
|
||||||
import { AsteroidCluster } from '../entities/AsteroidCluster.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).
|
* in open space (or "charted · unclaimed" when nobody's settled here).
|
||||||
* Formatted by the pure SystemReport helper; this method only renders.
|
* 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 +
|
* The system's CONTENTS were ensured in create() (ensureGalaxy +
|
||||||
* ensureContent) — the first touch of the lazy level-2 generation.
|
* ensureContent) — the first touch of the lazy level-2 generation.
|
||||||
* (Roster/positions were fixed at the menu's New Game click; this is
|
* (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 famHeader = HEADER_FONT();
|
||||||
const current = this.systemRecord;
|
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;
|
let y = 14;
|
||||||
const line = (text, style) => {
|
const line = (value, style) => {
|
||||||
this.add
|
const t = this.add
|
||||||
.text(16, y, text, style)
|
.text(16, y, '', style)
|
||||||
.setOrigin(0, 0)
|
.setOrigin(0, 0)
|
||||||
.setScrollFactor(0) // UI: pinned to the screen, not the world
|
.setScrollFactor(0) // UI: pinned to the screen, not the world
|
||||||
.setDepth(30);
|
.setDepth(30);
|
||||||
|
lines.push({ text: t, value, delay, dur: decodeDur(value.length) });
|
||||||
|
delay += STAGGER;
|
||||||
y += 20 + (style.fontSize === '17px' ? 6 : 0);
|
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(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' });
|
||||||
line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' });
|
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.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
|
* Under the dossier: one line per tether — level, range, anchor. Rebuilt
|
||||||
* whenever the field changes (TetherField.onChange), so a future
|
* 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.)
|
// fire. (Same quirk as MenuScene.update; verified Sept 2026.)
|
||||||
this.time.update(_time, delta);
|
this.time.update(_time, delta);
|
||||||
this.tweens.update();
|
this.tweens.update();
|
||||||
|
this.updateHudDecode(_time); // the dossier types itself in (first frames only)
|
||||||
this.ship.update(_time, delta);
|
this.ship.update(_time, delta);
|
||||||
// The clusters are ALIVE: each rock tumbles, the loose group drifts,
|
// The clusters are ALIVE: each rock tumbles, the loose group drifts,
|
||||||
// the dust orbits. (The keep-out constraint runs in onPostUpdate,
|
// the dust orbits. (The keep-out constraint runs in onPostUpdate,
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,13 @@ import { CyberOverlay } from '../visuals/CyberOverlay.js';
|
||||||
import { CyberShape } from '../ui/CyberShape.js';
|
import { CyberShape } from '../ui/CyberShape.js';
|
||||||
import { Rng } from '../utils/Rng.js';
|
import { Rng } from '../utils/Rng.js';
|
||||||
import { NameGenerator } from '../utils/NameGenerator.js';
|
import { NameGenerator } from '../utils/NameGenerator.js';
|
||||||
|
import { ScrambleDecode, DECODE_DURATION } from '../utils/Decode.js';
|
||||||
import { Galaxy } from '../galaxy/Galaxy.js';
|
import { Galaxy } from '../galaxy/Galaxy.js';
|
||||||
|
|
||||||
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||||
const SEED_STORAGE_KEY = 'orbit.galaxySeed';
|
const SEED_STORAGE_KEY = 'orbit.galaxySeed';
|
||||||
const SEED_MAX = 32;
|
const SEED_MAX = 32;
|
||||||
const SEED_CHAR = /^[A-Za-z0-9._-]$/;
|
const SEED_CHAR = /^[A-Za-z0-9._-]$/;
|
||||||
const DECODE_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789#%+*<>?';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main menu — the cyberpunk face of the game.
|
* 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) {
|
startDecode(value) {
|
||||||
this.decode = { value, t0: this.time.now, dur: 620 };
|
this.decode = new ScrambleDecode(value, this.time.now, DECODE_DURATION);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDecode(time) {
|
updateDecode(time) {
|
||||||
if (!this.decode || !this.seedText || this.dead) return;
|
if (!this.decode || !this.seedText || this.dead) return;
|
||||||
const { value, t0, dur } = this.decode;
|
if (this.decode.finished(time)) {
|
||||||
const u = (time - t0) / dur;
|
|
||||||
if (u >= 1) {
|
|
||||||
this.decode = null;
|
this.decode = null;
|
||||||
this.drawSeed();
|
this.drawSeed();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reveal = Math.floor(Math.max(0, Math.min(1, u * 1.15)) * value.length);
|
this.seedText.setText(this.decode.display(time));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue