Add upper-left surface HUD showing planet name, type, and tether level

- GameScene now passes the world's type label and tether level into the SurfaceScene launch payload; new tetherLevelFor() resolves a world's anchored tether (by label or anchor position) for that line.
- SurfaceScene builds/decodes a two-line HUD (Ethnocentric name in the menu title shade with black stroke, Centauri line below) while the surface clip loops, driven by the shared scramble decode; it is torn down on take-off and shutdown.
- landing.json gains a hud config block for margins, fonts, spacing, strokes, and colors.
- Added dev-only harnesses (surface-hud.html/.mjs test page and surface-hud-shot.html/.mjs screenshot probe) to verify the HUD styling, decode settle, payload, and teardown in headless Firefox.
This commit is contained in:
Brian Fertig 2026-09-04 23:23:41 -06:00
parent 1cbbf502df
commit cd4d751edd
7 changed files with 386 additions and 5 deletions

View File

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

17
dev/surface-hud-shot.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev surface HUD shot</title>
<base href="../" />
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/surface-hud-shot.mjs"></script>
</body>
</html>

83
dev/surface-hud-shot.mjs Normal file
View File

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

20
dev/surface-hud.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev surface HUD test</title>
<!-- This page lives in /dev, but the game's relative asset paths are rooted
at the project root — resolve them against it. ("../" keeps this
working even if the project is served from a subdirectory.) -->
<base href="../" />
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/surface-hud.mjs"></script>
</body>
</html>

114
dev/surface-hud.mjs Normal file
View File

@ -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,
}));

View File

@ -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.

View File

@ -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();