diff --git a/data/landing.json b/data/landing.json
index 869a1fc..2a5f952 100644
--- a/data/landing.json
+++ b/data/landing.json
@@ -21,6 +21,21 @@
"enabled": true,
"videoBase": "assets/videos/",
"volume": 1,
+ "_hud_comment": "The upper-left HUD while the surface clip loops: the planet's NAME (Ethnocentric, the SAME shade the menu's game title uses — data/menu.json → colors.title — with a black stroke behind it so it holds over any clip) and, beneath it in the body face (Centauri), the planet TYPE + TETHER LEVEL (\"Terran World, Tether Level 1\"). Both decode in with the shared scramble (the name first). marginX/marginY = corner offsets (px); name/line = font sizes, letter spacing (px), stroke color/thickness, and line color (the theme's dim by default).",
+ "hud": {
+ "marginX": 24,
+ "marginY": 20,
+ "nameFontSize": 32,
+ "nameLetterSpacing": 2,
+ "nameStroke": "#000000",
+ "nameStrokeWidth": 4,
+ "lineGap": 10,
+ "lineFontSize": 16,
+ "lineLetterSpacing": 1,
+ "lineColor": "#7d92c4",
+ "lineStroke": "#000000",
+ "lineStrokeWidth": 2
+ },
"videos": [
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4" },
{ "land": "terran-land-02.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-02.mp4" },
diff --git a/dev/surface-hud-shot.html b/dev/surface-hud-shot.html
new file mode 100644
index 0000000..bc6dd78
--- /dev/null
+++ b/dev/surface-hud-shot.html
@@ -0,0 +1,17 @@
+
+
+
+
+ Orbit — dev surface HUD shot
+
+
+
+
+
+
+
+
+
diff --git a/dev/surface-hud-shot.mjs b/dev/surface-hud-shot.mjs
new file mode 100644
index 0000000..17b61eb
--- /dev/null
+++ b/dev/surface-hud-shot.mjs
@@ -0,0 +1,83 @@
+/**
+ * Visual probe for the surface HUD screenshot — PUMP-DRIVEN (like
+ * dev/landing-click.mjs): this box's headless Firefox freezes rAF/timers
+ * after first paint, so the page advances only when the runner's poll
+ * calls __HUD_TICK__(), which steps the engine loop once. It parks on
+ * the surface stage with the upper-left HUD decoded, then HOLDS
+ * (__HUD_SHOT__ = 'surface') so dev/shot-firefox.mjs can grab the frame.
+ *
+ * python3 -m http.server 8131
+ * node dev/shot-firefox.mjs http://127.0.0.1:8131/dev/surface-hud-shot.html \
+ * 'window.__HUD_TICK__(); window.__HUD_SHOT__ === "surface" ? window.__HUD_SHOT__ : null' \
+ * /tmp/surface-hud.png 60000
+ */
+import Phaser from '../js/vendor/phaser.js';
+import { config } from '../js/config/Config.js';
+import { ConfigLoader } from '../js/config/ConfigLoader.js';
+import { createGameConfig } from '../js/config/GameConfig.js';
+import { GameScene } from '../js/scenes/GameScene.js';
+import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
+
+window.__HUD_SHOT__ = 'booting';
+
+const data = await ConfigLoader.load();
+config.init(data);
+const gameConfig = createGameConfig();
+gameConfig.scene = [GameScene, SurfaceScene];
+const game = new Phaser.Game(gameConfig);
+window.game = game;
+
+const scene = (key) => { try { return game.scene?.getScene(key) ?? null; } catch { return null; } };
+
+let stage = 0;
+function tick() {
+ // v4: do NOT step the loop before boot completes — `game.scene` is
+ // created during boot, and pumping loop.step() before that wedges boot.
+ if (!game.loop || !game.scene) return;
+ try {
+ game.loop.step(performance.now());
+
+ const gs = scene('GameScene');
+ const ss = scene('SurfaceScene');
+
+ switch (stage) {
+ case 0: // boot: the flight scene is live
+ if (!(gs && gs.ship)) return;
+ gs.startLanding(gs.commsTargetFor(gs.planet)); // the real entry point
+ stage = 1;
+ return;
+
+ case 1: // the surface scene is up → force the loop stage
+ if (!(ss && ss.sys.isActive())) return;
+ ss.startSurface();
+ if (!ss.hudName) ss.buildHud(); // headless: the loop clip may never cache
+ if (ss.surfaceVideo && ss.surfaceVideo.video) {
+ ss.surfaceVideo.video.muted = true; // headless autoplay policy
+ ss.surfaceVideo.video.play().catch(() => {});
+ }
+ stage = 2;
+ return;
+
+ case 2: // the HUD's decode has settled → HOLD for the screenshot
+ if (ss.hudLines !== null) return;
+ window.__HUD_SHOT__ = 'surface';
+ window.__HUD_DIAG__ = {
+ gs: gs.sys.getStatus(),
+ ss: ss.sys.getStatus(),
+ ssPhase: ss.phase,
+ loopCached: ss.cache.video.has(`__surf_loop_${ss.planetFrame}`),
+ surfaceVideo: !!ss.surfaceVideo,
+ hudName: ss.hudName?.text,
+ hudLine: ss.hudLine?.text,
+ nameStyle: ss.hudName ? [ss.hudName.style.fontFamily, ss.hudName.style.color, ss.hudName.style.stroke, ss.hudName.style.strokeThickness] : null,
+ lineStyle: ss.hudLine ? [ss.hudLine.style.fontFamily, ss.hudLine.style.color, ss.hudLine.style.stroke] : null,
+ deckIds: (ss.actionBar?.slots ?? []).map((s) => s.id),
+ };
+ stage = 3;
+ }
+ } catch (err) {
+ window.__HUD_SHOT__ = 'FAILED: ' + String(err?.message ?? err);
+ stage = 99;
+ }
+}
+window.__HUD_TICK__ = tick;
diff --git a/dev/surface-hud.html b/dev/surface-hud.html
new file mode 100644
index 0000000..ff0b8a5
--- /dev/null
+++ b/dev/surface-hud.html
@@ -0,0 +1,20 @@
+
+
+
+
+ Orbit — dev surface HUD test
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/surface-hud.mjs b/dev/surface-hud.mjs
new file mode 100644
index 0000000..e7474d2
--- /dev/null
+++ b/dev/surface-hud.mjs
@@ -0,0 +1,114 @@
+/**
+ * Dev-only SURFACE HUD test: boots into the GameScene, lands on the home
+ * world, and checks the new upper-left HUD (SurfaceScene.buildHud):
+ *
+ * - the launch payload carries the world's type + tether level
+ * - the name text: Ethnocentric (header face), the menu title's shade
+ * (data/menu.json → colors.title), a black stroke behind it
+ * - the line below: "TYPE, TETHER LEVEL N" in the body face (Centauri)
+ * - both lines decode in (the shared scramble) and settle on the
+ * final strings
+ * - destroyHud() tears it down (take-off/shutdown path)
+ *
+ * python3 -m http.server 8124
+ * node dev/cdp-firefox.mjs http://127.0.0.1:8124/dev/surface-hud.html \
+ * 'return window.__SURFACE_HUD_TEST__;'
+ */
+import Phaser from '../js/vendor/phaser.js';
+import { config } from '../js/config/Config.js';
+import { ConfigLoader } from '../js/config/ConfigLoader.js';
+import { createGameConfig } from '../js/config/GameConfig.js';
+import { GameScene } from '../js/scenes/GameScene.js';
+import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
+
+const fail = (msg, extra = {}) => {
+ window.__SURFACE_HUD_TEST__ = { pass: false, error: msg, diag: extra };
+ console.error('[surface-hud] FAIL:', msg, extra);
+};
+window.__FLOW_STARTED__ = true;
+const step = (name) => {
+ window.__STEPS__ = (window.__STEPS__ ?? []).concat(name);
+};
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+const data = await ConfigLoader.load();
+config.init(data);
+const gameConfig = createGameConfig();
+gameConfig.scene = [GameScene, SurfaceScene];
+const game = new Phaser.Game(gameConfig);
+window.game = game;
+
+(async () => {
+ // 1 — the flight scene is live (v4 boots asynchronously).
+ let gs = null;
+ for (let i = 0; i < 300; i++) {
+ gs = game.scene?.getScene('GameScene') ?? null;
+ if (gs && gs.ship) break;
+ await sleep(100);
+ }
+ if (!gs || !gs.ship) throw new Error('GameScene never came up');
+ step('boot');
+
+ // 2 — the tether lookup the launch payload relies on.
+ const target = gs.commsTargetFor(gs.planet);
+ const level = gs.tetherLevelFor(target.name);
+ if (!(level === gs.tetherField.tethers.find((t) => t.label === target.name)?.level))
+ throw new Error('tetherLevelFor mismatch', { level, tethers: gs.tetherField.tethers });
+ if (gs.tetherLevelFor('NOT A PLANET') !== 0) throw new Error('unknown world must be 0');
+ step('tether-level-lookup');
+
+ // 3 — the real entry point: the launch payload carries type + level.
+ gs.startLanding(target);
+ await sleep(250);
+ const ss = game.scene.getScene('SurfaceScene');
+ if (!ss || !ss.sys.isActive()) throw new Error('SurfaceScene did not launch');
+ if (ss.planetType !== target.kindLabel) throw new Error('planetType', { got: ss.planetType, want: target.kindLabel });
+ if (ss.tetherLevel !== level) throw new Error('tetherLevel', { got: ss.tetherLevel, want: level });
+ step('launch-payload', { name: ss.planetName, type: ss.planetType, level: ss.tetherLevel });
+
+ // 4 — the HUD exists: auto-built with the loop clip (startSurface), or
+ // built directly when headless never cached the video.
+ ss.startSurface();
+ await sleep(50);
+ if (!ss.hudName) ss.buildHud();
+ if (!ss.hudName || !ss.hudLine || !ss.hudLines) throw new Error('HUD missing after buildHud');
+ step('hud-built');
+
+ // 5 — the decode settles on the final strings (poll a few seconds).
+ const nameFinal = String(ss.planetName).toUpperCase();
+ const lineFinal = `${ss.planetType}, Tether Level ${ss.tetherLevel}`;
+ let settled = false;
+ for (let i = 0; i < 200 && !settled; i++) {
+ await sleep(100);
+ if (ss.hudLines === null && ss.hudName.text === nameFinal && ss.hudLine.text === lineFinal) settled = true;
+ }
+ if (!settled) throw new Error('decode did not settle', {
+ name: ss.hudName?.text, line: ss.hudLine?.text, hudLines: !!ss.hudLines,
+ });
+ step('decode-settled', { name: ss.hudName.text, line: ss.hudLine.text });
+
+ // 6 — the styling contract.
+ const nStyle = ss.hudName.style;
+ const lStyle = ss.hudLine.style;
+ if (!/Ethnocentric/.test(nStyle.fontFamily)) throw new Error('name font', { got: nStyle.fontFamily });
+ if (!/Centauri/.test(lStyle.fontFamily)) throw new Error('line font', { got: lStyle.fontFamily });
+ const titleCss = '#' + (config.get('menu.colors.title', '#eaf6ff').slice(1) || 'eaf6ff');
+ if (nStyle.color.toLowerCase() !== titleCss) throw new Error('name shade', { got: nStyle.color, want: titleCss });
+ if (nStyle.stroke.toLowerCase() !== '#000000' || nStyle.strokeThickness !== 4)
+ throw new Error('name stroke', { got: [nStyle.stroke, nStyle.strokeThickness] });
+ if (lStyle.stroke.toLowerCase() !== '#000000') throw new Error('line stroke', { got: lStyle.stroke });
+ if (!(ss.hudName.x >= 16 && ss.hudName.y >= 12)) throw new Error('corner placement', { x: ss.hudName.x, y: ss.hudName.y });
+ if (!(ss.hudLine.y > ss.hudName.y)) throw new Error('line sits below the name');
+ step('styling');
+
+ // 7 — teardown (the take-off/shutdown path).
+ ss.destroyHud();
+ if (ss.hudName !== null || ss.hudLine !== null || ss.hudLines !== null) throw new Error('destroyHud left state');
+ step('teardown');
+
+ window.__SURFACE_HUD_TEST__ = { pass: true, steps: window.__STEPS__ };
+ console.info('[surface-hud] PASS:', window.__STEPS__.join(' → '));
+})().catch((err) => fail(String(err && err.message || err), {
+ gs: game.scene?.getScene('GameScene')?.sys.getStatus?.(),
+ ss: game.scene?.getScene('SurfaceScene') ? { phase: game.scene.getScene('SurfaceScene').phase } : null,
+}));
diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js
index df3b8a3..3e3b09a 100644
--- a/js/scenes/GameScene.js
+++ b/js/scenes/GameScene.js
@@ -1279,8 +1279,9 @@ export class GameScene extends Phaser.Scene {
/**
* LAND (commsAction) — the surface sequence: SurfaceScene starts ON
* TOP of this scene, handed the world's planets.png sheet frame so it
- * can pick the landing/surface videos from data/landing.json. v4
- * note: `launch` does not freeze the caller, so sleep the flight
+ * can pick the landing/surface videos from data/landing.json, plus
+ * the world's type label and tether level for its upper-left HUD.
+ * v4 note: `launch` does not freeze the caller, so sleep the flight
* world explicitly — Take Off (SurfaceScene) wakes it back, with the
* ship, tethers and discovery exactly where they were.
*/
@@ -1295,10 +1296,31 @@ export class GameScene extends Phaser.Scene {
this.scene.launch('SurfaceScene', {
frame: Number(target.frame ?? 0),
name: target.name,
+ type: target.kindLabel ?? '',
+ tetherLevel: this.tetherLevelFor(target.name),
});
this.scene.sleep();
}
+ /**
+ * The tether LEVEL anchored on this world (the surface HUD's second
+ * line — "Tether Level N"): the tether whose label is the world's
+ * name, or whose anchor sits on the world's center (the home tether
+ * is anchored at the origin with the home world's name). 0 = no
+ * tether on this world yet.
+ */
+ tetherLevelFor(name) {
+ if (!name || !this.tetherField) return 0;
+ const obj =
+ this.systemPlanets.find((p) => p.discoveryName === name) ??
+ (name === this.planet?.discoveryName ? this.planet : null);
+ if (!obj) return 0;
+ const t = this.tetherField.tethers.find(
+ (tt) => tt.label === name || (tt.x === obj.x && tt.y === obj.y),
+ );
+ return t ? t.level : 0;
+ }
+
/**
* Mining phase changes (Mining → onPhase): the scene's share of the
* sequence — the ship's STATE, the ship lock, the console calls, the sfx.
diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js
index d26a891..a17b6e1 100644
--- a/js/scenes/SurfaceScene.js
+++ b/js/scenes/SurfaceScene.js
@@ -1,5 +1,8 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
+import { toCss } from '../utils/Color.js';
+import { fontStack, themeColor } from '../utils/Theme.js';
+import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
import { ActionBar } from '../ui/ActionBar.js';
import { MenuSubBar } from '../ui/MenuSubBar.js';
import { SavePanel } from '../ui/SavePanel.js';
@@ -17,7 +20,10 @@ import { SaveManager } from '../save/SaveManager.js';
* replaced by SHOP (a seam for now) and TAKE OFF left of MENU
* (data/actionbar.json → surface.buttons). Menu keeps the GameScene's
* save sub-bar — Save/Load operate on the paused GameScene's state
- * (SavePanel `stateScene`).
+ * (SavePanel `stateScene`). While the clip loops, the planet's name
+ * decodes in at the upper-left (Ethnocentric, the menu title's shade,
+ * black-stroked) with its type + tether level beneath (Centauri) —
+ * see buildHud(); the HUD dies with the loop clip.
*
* 3. TAKE OFF — the world's one-shot take-off clip (`takeoff`) plays,
* then this scene stops — the paused GameScene resumes exactly where
@@ -39,11 +45,17 @@ export class SurfaceScene extends Phaser.Scene {
init(data = {}) {
this.planetFrame = Number(data.frame ?? 0); // planets.png frame (video key)
this.planetName = String(data.name ?? '');
+ this.planetType = String(data.type ?? ''); // e.g. "Rocky World" (planets.typeLabels)
+ this.tetherLevel = Math.max(0, Math.round(Number(data.tetherLevel ?? 0)));
this.phase = 'landing'; // 'landing' → 'surface'
this.gameScene = null; // the paused GameScene beneath us (save state lives there)
this.backdrop = null;
this.landVideo = null;
this.surfaceVideo = null;
+ this.hudName = null; // upper-left: the planet's name (decodes in)
+ this.hudLine = null; // upper-left: type + tether level (decodes in)
+ this.hudLines = null; // the HUD's decode timeline (updateHud)
+ this.hudT0 = null;
this.actionBar = null;
this.menuSubBar = null;
this.savePanel = null;
@@ -151,11 +163,106 @@ export class SurfaceScene extends Phaser.Scene {
this.surfaceVideo = s;
// A decode failure on the loop clip keeps the plate — the deck
// still works, the world just stays dark.
+ this.buildHud(); // the name + type/tether — only while the clip loops
}
this.buildDeck();
}
+ // ------------------------------------------------------------------
+ // HUD (upper-left) — the planet's name + type + tether level
+ // ------------------------------------------------------------------
+
+ /**
+ * The upper-left HUD, built with the looping surface clip and alive
+ * exactly as long as that clip runs (destroyHud on take-off/shutdown):
+ *
+ * ESHKAELURA ← the planet's name — Ethnocentric
+ * Home World, Tether Level 1 ← the body face (Centauri)
+ *
+ * The name takes the SAME shade the menu's game title uses
+ * (data/menu.json → colors.title, falling back to the theme's ink) with
+ * a black stroke behind it, so it holds over any clip regardless of
+ * what the video shows beneath it. Both lines decode in with the
+ * shared scramble (js/utils/Decode.js) — the name first, the line
+ * beneath it after (the dossier's pacing, GameScene.createSystemHud).
+ */
+ buildHud() {
+ const hud = config.section('landing.hud', {});
+ const X = hud.marginX ?? 24;
+ const Y = hud.marginY ?? 20;
+
+ const name = String(this.planetName ?? '').toUpperCase();
+ this.hudName = this.add
+ .text(X, Y, '', {
+ fontFamily: fontStack('header'), // Ethnocentric — the theme's header face
+ fontSize: `${hud.nameFontSize ?? 32}px`,
+ color: toCss(config.get('menu.colors.title') ?? themeColor('ink', 0xeaf6ff)),
+ stroke: toCss(hud.nameStroke ?? '#000000'),
+ strokeThickness: hud.nameStrokeWidth ?? 4,
+ letterSpacing: hud.nameLetterSpacing ?? 2,
+ })
+ .setOrigin(0, 0)
+ .setScrollFactor(0) // UI: pinned to the screen
+ .setDepth(60); // above the deck (50) / sub-bar — it never overlaps them
+
+ const parts = [];
+ if (this.planetType) parts.push(this.planetType);
+ parts.push(`Tether Level ${this.tetherLevel}`);
+ const line = parts.join(', ');
+ this.hudLine = this.add
+ .text(X, Y + this.hudName.height + (hud.lineGap ?? 10), '', {
+ fontFamily: fontStack('body'), // Centauri — the theme's body face
+ fontSize: `${hud.lineFontSize ?? 16}px`,
+ color: toCss(hud.lineColor ?? themeColor('dim', 0x7d92c4)),
+ stroke: toCss(hud.lineStroke ?? '#000000'),
+ strokeThickness: hud.lineStrokeWidth ?? 2,
+ letterSpacing: hud.lineLetterSpacing ?? 1,
+ })
+ .setOrigin(0, 0)
+ .setScrollFactor(0) // UI: pinned to the screen
+ .setDepth(60);
+
+ // The decode timeline — updateHud() drives it from the scene's
+ // update() (the engine loop time, same base as the dossier's).
+ this.hudLines = [
+ { text: this.hudName, value: name, delay: 140, dur: decodeDur(name.length), settled: false },
+ { text: this.hudLine, value: line, delay: 420, dur: decodeDur(line.length), settled: false },
+ ];
+ this.hudT0 = null; // anchored on the first update frame (the clock is stale in create())
+ }
+
+ /** Per-frame: the HUD's decode (driven from update() with the loop time). */
+ updateHud(time) {
+ if (!this.hudLines) return;
+ if (this.hudT0 === null) {
+ this.hudT0 = time;
+ for (const ln of this.hudLines) ln.dec = new ScrambleDecode(ln.value, time + ln.delay, ln.dur);
+ }
+ let done = true;
+ for (const ln of this.hudLines) {
+ 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.hudLines = null; // the text holds — stop the per-frame churn
+ }
+
+ /** The HUD belongs to the surface stage — the take-off clip owns the screen. */
+ destroyHud() {
+ this.hudName?.destroy();
+ this.hudLine?.destroy();
+ this.hudName = null;
+ this.hudLine = null;
+ this.hudLines = null;
+ this.hudT0 = null;
+ }
+
buildDeck() {
// The surface deck: same bar, different slots — Shop for Research,
// Take Off left of Menu (data/actionbar.json → surface.buttons).
@@ -280,9 +387,10 @@ export class SurfaceScene extends Phaser.Scene {
* past duration + margin, must not hold the run.
*/
playTakeoff() {
- // The deck + loop clip belong to the surface stage — the clip owns
- // the screen (same "no UI mid-flight" rule as the landing stage).
+ // The deck + loop clip + HUD belong to the surface stage — the clip
+ // owns the screen (same "no UI mid-flight" rule as the landing stage).
this.actionBar?.setVisible(false);
+ this.destroyHud();
if (this.surfaceVideo) {
this.destroyVideo(this.surfaceVideo); // the opaque clip hides it — free the decoder
this.surfaceVideo = null;
@@ -420,6 +528,7 @@ export class SurfaceScene extends Phaser.Scene {
this.actionBar?.update(time, delta);
this.menuSubBar?.update(time, delta);
this.savePanel?.update(time);
+ this.updateHud(time); // the upper-left name/type decode (surface stage only)
}
shutdown() {
@@ -429,6 +538,7 @@ export class SurfaceScene extends Phaser.Scene {
this.landVideo = null;
this.surfaceVideo = null;
this.takeoffVideo = null;
+ this.destroyHud();
this.actionBar?.destroy();
this.menuSubBar?.destroy();
this.savePanel?.destroy();