From cd4d751edd7026cd2e7df20ca2dc769438f095cf Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 4 Sep 2026 23:23:41 -0600 Subject: [PATCH] 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. --- data/landing.json | 15 +++++ dev/surface-hud-shot.html | 17 ++++++ dev/surface-hud-shot.mjs | 83 +++++++++++++++++++++++++++ dev/surface-hud.html | 20 +++++++ dev/surface-hud.mjs | 114 +++++++++++++++++++++++++++++++++++++ js/scenes/GameScene.js | 26 ++++++++- js/scenes/SurfaceScene.js | 116 +++++++++++++++++++++++++++++++++++++- 7 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 dev/surface-hud-shot.html create mode 100644 dev/surface-hud-shot.mjs create mode 100644 dev/surface-hud.html create mode 100644 dev/surface-hud.mjs 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();