diff --git a/assets/videos/map.mp4 b/assets/videos/map.mp4 new file mode 100644 index 0000000..56ab95a Binary files /dev/null and b/assets/videos/map.mp4 differ diff --git a/assets/videos/rocky-surface-01.mp4 b/assets/videos/rocky-surface-01.mp4 new file mode 100644 index 0000000..2bbb7b6 Binary files /dev/null and b/assets/videos/rocky-surface-01.mp4 differ diff --git a/data/actionbar.json b/data/actionbar.json index 7dc48de..360626b 100644 --- a/data/actionbar.json +++ b/data/actionbar.json @@ -15,7 +15,7 @@ { "id": "research", "label": "Research", "accent": "#00e5ff" }, { "id": "scan", "label": "Scan", "accent": "#ffc94d" }, { "id": "ship", "label": "Ship", "accent": "#7ce8a4" }, - { "id": null, "label": null }, + { "id": "map", "label": "Map", "accent": "#c084fc" }, { "id": null, "label": null }, { "id": "menu", "label": "Menu", "accent": "#ff2d6f" } ], diff --git a/data/landing.json b/data/landing.json index 6cf297b..3027fc6 100644 --- a/data/landing.json +++ b/data/landing.json @@ -49,7 +49,7 @@ { "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "gasgiant-shop-01.mp4" }, { "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "gasgiant-shop-02.mp4" }, { "land": "gasgiant-land-03.mp4", "surface": "gasgiant-surface-03.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "gasgiant-shop-03.mp4" }, - { "land": "rocky-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }, + { "land": "rocky-land-01.mp4", "surface": "rocky-surface-01.mp4", "takeoff": "rocky-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }, { "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" }, { "land": "rocky-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" } ] diff --git a/data/manifest.json b/data/manifest.json index a4782cd..5949eb2 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -24,6 +24,7 @@ "scan.json", "signalCompass.json", "save.json", - "mineralhud.json" + "mineralhud.json", + "map.json" ] } diff --git a/data/map.json b/data/map.json new file mode 100644 index 0000000..6eb74db --- /dev/null +++ b/data/map.json @@ -0,0 +1,146 @@ +{ + "_comment": "MAP — the deck's MAP button (to the right of SHIP): the full-screen cartography console, js/ui/MapWindow.js (depth 80, the ResearchWindow idiom). LEFT = the cartography feed (assets/videos/map.mp4, a muted 2:3 loop, the same recipe as the research archive feed). RIGHT = tabs on top (CURRENT SYSTEM is live; GALAXY is a reserved socket — pressing it toasts the lock line), the SYSTEM CHART in the middle, and the SYSTEM readout on the bottom (name + discovery % + resource %). THE CHART (the magic, drawn to a canvas texture): the whole solar system — every object's extent (planets, stations, gates AND asteroid clusters, discovered or not) + `bounds.padding` px of padding on each edge — fitted to the plate. Only DISCOVERED objects are drawn (planets / stations / gates / rock fields, small representations). The TETHER RANGE is drawn as the union boundary (Tether.visibleArcs) with a soft zone glow inside, and everything OUTSIDE the tether union is grayed out (a fog-of-war dim pass with feathered rim) — so the chart reads as 'here is what you have charted, and here is the room to expand'. A live SHIP marker + hover tooltips + click-to-autopilot (GameScene.autopilotTo) sit on top. All colors/motion are steered here.", + "enabled": true, + "title": "STELLAR CARTOGRAPHY", + "meta": "NAV DATA // LIVE FEED", + "video": { + "_comment": "The cartography feed (like research.video): `file` is the clip under assets/videos/ (or a full relative path / URL). Muted, looping, 2:3 portrait (map.mp4 is 544×800). A missing file leaves the NO SIGNAL plate up — the console still works.", + "file": "map.mp4", + "aspect": [ + 2, + 3 + ] + }, + "tabs": { + "_comment": "The two sockets above the chart (top right). `currentSystem` is the live tab (default). `galaxy` is reserved — `standby: true` renders it dimmed with the standbyTag, and a press fires the scene's onLocked (a console toast) instead of switching.", + "currentSystem": { + "id": "currentSystem", + "label": "Current System", + "accent": "#00e5ff" + }, + "galaxy": { + "id": "galaxy", + "label": "Galaxy", + "accent": "#ffc94d", + "standby": true, + "standbyTag": "OFFLINE" + } + }, + "galaxyLockedToast": "GALAXY MAP OFFLINE — SECTOR DATA NOT ACQUIRED", + "bounds": { + "_comment": "The chart's frame: the furthest X/Y extents of EVERY object in the system (planets, stations, gates, asteroid clusters — discovered or not) plus this much padding, px, on each edge (the design rule: ~1024).", + "padding": 1024 + }, + "chart": { + "_comment": "The plate's rendering (the canvas texture is redrawn on open / on discovery / on tether changes). colors follow theme.json (neon cyan range, amber alerts). grid.spacing = the world-px step of the adaptive grid (snapped to 1/2/5×10^k). glow = the soft light pooled inside each tether zone (fraction of the zone radius the gradient spans, and its strength). labels = the small name tags under NAV objects.", + "colors": { + "bg": "#040914", + "bgGlow": "#0a2238", + "star": "#9fd8ff", + "grid": "#1b3a5a", + "gridAlpha": 0.28, + "ink": "#eaf6ff", + "dim": "#7d92c4", + "faint": "#3d4c74", + "tether": "#00e5ff", + "tetherGlow": "#00e5ff", + "neon2": "#ff2d6f", + "amber": "#ffc94d", + "fog": "#111c33", + "fogAlpha": 0.55 + }, + "grid": { + "spacing": 512, + "alpha": 0.28 + }, + "glow": { + "span": 0.92, + "alpha": 0.22 + }, + "tetherLine": { + "dash": 7, + "gap": 5, + "width": 2, + "coreWidth": 1, + "ghost": 2.5 + }, + "labels": { + "enabled": true, + "size": 10, + "letterSpacing": 1.2 + }, + "planetMin": 4, + "planetMax": 16, + "starMin": 7, + "starMax": 30, + "vignette": 0.34 + }, + "stats": { + "_comment": "The bottom-right SYSTEM readout. discovery = the share of the system's NAV objects (planets + space stations + jump gates — the central body is the chart's anchor, not a count) the player has found; resources = the share of asteroid fields found (the only resource kind for now). Both render as segmented bars (one segment per object) + a percent.", + "discoveryLabel": "SYSTEM DISCOVERY", + "discoveryMeta": "PLANETS · STATIONS · JUMP GATES", + "resourcesLabel": "SYSTEM RESOURCES", + "resourcesMeta": "ASTEROID FIELDS", + "noResources": "NO RESOURCE FIELDS CHARTED" + }, + "legend": { + "_comment": "The glyph key under the stats (the chart's visual language).", + "items": [ + { + "glyph": "planet", + "label": "WORLD" + }, + { + "glyph": "station", + "label": "STATION" + }, + { + "glyph": "gate", + "label": "JUMP GATE" + }, + { + "glyph": "rock", + "label": "ROCKS" + }, + { + "glyph": "tether", + "label": "TETHER RANGE" + }, + { + "glyph": "ship", + "label": "SHIP" + } + ] + }, + "sweep": { + "_comment": "The scan band crawling across the chart (the CRT pass of light).", + "enabled": true, + "everyMs": [ + 5200, + 9800 + ], + "durationMs": 1500 + }, + "glitch": { + "_comment": "Ambient glitch bursts over the window (slice bars + title RGB split, the ResearchWindow model).", + "enabled": true, + "intervalMs": [ + 5000, + 11000 + ], + "durationMs": [ + 200, + 420 + ] + }, + "hover": { + "_comment": "Plate hover: the object under the cursor gets a highlight ring + tooltip. radiusSlop = extra screen-px hit slop (tiny map objects need room).", + "enabled": true, + "radiusSlop": 9 + }, + "clickToast": { + "_comment": "Plate interaction copy.", + "coursePlotted": "COURSE PLOTTED — {name}", + "noCourse": "NO NAV LOCK — OBJECT NOT CHARTED" + } +} \ No newline at end of file diff --git a/data/menu.json b/data/menu.json index aa93725..cb4fc91 100644 --- a/data/menu.json +++ b/data/menu.json @@ -1,4 +1,6 @@ { + "_comment_revealDelay": "ms to wait before the title + buttons arrive — synced to the crescendo intro of the menu track (assets/music/mainmenu.mp3). Beat 1 (corner frame, chrome text, subtitle) is up immediately; this delay is beat 2.", + "revealDelay": 3200, "title": "ORBIT", "titleFontSize": 118, "titleLetterSpacing": 12, diff --git a/dev/map-shot.html b/dev/map-shot.html new file mode 100644 index 0000000..9f63c00 --- /dev/null +++ b/dev/map-shot.html @@ -0,0 +1,17 @@ + + + + + + Orbit — Map console (dev shot) + + + + +
+ + + diff --git a/dev/map-shot.mjs b/dev/map-shot.mjs new file mode 100644 index 0000000..d7cf86a --- /dev/null +++ b/dev/map-shot.mjs @@ -0,0 +1,116 @@ +/** + * Dev-only: boot GameScene, discover a handful of the system's objects, + * and open the MAP console (depth 80) so a headless screenshot shows the + * whole feature — left cartography feed (muted loop), the CURRENT SYSTEM / + * GALAXY(standby) tabs, the system chart (discovered objects on the padded + * frame, the tether union boundary, the fog of what the tether doesn't + * cover yet, the ship marker) and the SYSTEM readout (discovery / + * resources segmented bars + legend). + * + * node dev/server.mjs 8082 # static server + * node dev/shot-firefox.mjs \ + * "http://127.0.0.1:8082/dev/map-shot.html" \ + * "window.__MAP_SHOT ? window.__MAP_SHOT.ready : null" \ + * MAP_SHOT.png 30000 + * + * The report (top-left) lists console errors + the window's paint state. + */ +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'; + +const data = await ConfigLoader.load(); +config.init(data); + +// Capture console errors + uncaught exceptions for the report. +const errors = []; +const origErr = console.error.bind(console); +console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); }; +window.addEventListener('error', (e) => errors.push(String(e.message))); +window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`)); + +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; + +// The report lives OUTSIDE the canvas — a DOM
 the screenshot can
+// always read (headless canvases don't have to cooperate).
+const report = document.createElement('pre');
+report.id = 'report';
+report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:70%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
+document.body.appendChild(report);
+const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
+setReport('booting…');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function waitScene() {
+  for (let i = 0; i < 100; i++) {
+    const s = game.scene.getScene('GameScene');
+    if (s && s.ship && s.mapWindow) return s;
+    await sleep(100);
+  }
+  throw new Error('GameScene never booted');
+}
+
+try {
+  const s = await waitScene();
+  await sleep(800); // let the boot settle (dossier decode, deck flicker)
+
+  // Discover a HANDFUL of objects so the chart shows both sides: the
+  // discovered (bright, lit segments) and the still-hidden (fog + dark
+  // segments). Deterministic picks: the central body (home), the planets,
+  // the first station (if any) and the first rock field (if any).
+  const sysId = s.systemRecord.id;
+  let known = s.discovery.bySystem.get(sysId);
+  if (!known) {
+    known = new Set();
+    s.discovery.bySystem.set(sysId, known);
+  }
+  const push = (obj) => {
+    if (!obj || known.has(obj.id)) return;
+    // Discovery.check with the ship "on" the object = discovered, no FX.
+    s.discovery.check(sysId, obj.x, obj.y, [obj]);
+  };
+  if (s.isHomeSystem && s.planet) push({ id: 'home', x: 0, y: 0, radius: s.planet.radius });
+  for (const p of s.systemPlanets) push({ id: p.discoveryId, x: p.x, y: p.y, radius: p.radius });
+  if (s.systemStations[0]) push({ id: s.systemStations[0].discoveryId, x: s.systemStations[0].x, y: s.systemStations[0].y, radius: s.systemStations[0].bound ?? 60 });
+  if (s.asteroidClusters[0]) push({ id: s.asteroidClusters[0].discoveryId, x: s.asteroidClusters[0].x, y: s.asteroidClusters[0].y, radius: s.asteroidClusters[0].bound ?? 100 });
+
+  // Open the console (the deck MAP button does exactly this).
+  s.deckAction('map');
+  await sleep(1600); // the boot reveal + first chart paint + font repaint
+
+  // The locked GALAXY socket: a press must not crash (onLocked toast).
+  let locked = 'n/a';
+  try {
+    const gal = s.mapWindow.tabs.find((t) => t.id === 'galaxy');
+    s.mapWindow._tabHit(gal);
+    locked = 'press ok';
+  } catch (e) { locked = 'press err: ' + e.message; }
+
+  const win = s.mapWindow;
+  const snap = s.mapChartSnapshot?.() ?? null;
+  const lines = [
+    errors.length === 0 ? 'SMOKE OK — no console errors' : `ERRORS:\n${errors.slice(0, 4).join('\n')}`,
+    `window: ${win?.openState} · tabs=${win?.tabs.map((t) => t.id).join(',')}`,
+    snap
+      ? `snapshot: ${snap.objects.length} objects · ${snap.tethers.length} tethers · nav=${snap.stats.nav.found}/${snap.stats.nav.total} · res=${snap.stats.res.found}/${snap.stats.res.total}`
+      : 'snapshot: NONE',
+    `discovered now: ${s.discovery.discoveredIds(sysId).join(', ') || '—'}`,
+    `video: ${win?.video ? (win.video.video?.paused ? 'paused' : 'playing') : 'NO SIGNAL'}`,
+    `galaxy tab: ${locked}`,
+    `chart tex: ${win?._texKey ?? '—'} · tf scale=${win?._tf ? win._tf.scale.toExponential(2) : '—'}`,
+  ];
+  setReport(lines);
+  window.__MAP_SHOT = { ready: true, lines, errors };
+  console.info('map-shot: report painted');
+} catch (err) {
+  errors.push(`FATAL: ${err.message}`);
+  setReport(['FATAL: ' + err.message, ...errors.slice(0, 4)]);
+  window.__MAP_SHOT = { ready: true, fatal: String(err.message), errors };
+  console.error('map-shot: fatal', err);
+}
diff --git a/dev/menu-intro-test.html b/dev/menu-intro-test.html
new file mode 100644
index 0000000..8f73632
--- /dev/null
+++ b/dev/menu-intro-test.html
@@ -0,0 +1,31 @@
+
+
+
+  
+  Orbit — dev menu intro test
+  
+  
+  
+  
+  
+  
+
+
+  
+ + + diff --git a/dev/menu-intro-test.mjs b/dev/menu-intro-test.mjs new file mode 100644 index 0000000..b311ffb --- /dev/null +++ b/dev/menu-intro-test.mjs @@ -0,0 +1,131 @@ +/** + * Dev-only MenuScene intro driver (headless browser — NOT a Node test). + * + * Verifies the two-beat menu intro synced to the menu track's crescendo + * (assets/music/mainmenu.mp3 swells at ~2.2 s, data/menu.json revealDelay): + * + * beat 1 (t≈0): the console is up — corner frame, chrome text, and + * the "THE GALAXY AWAITS" subtitle + rule are visible, + * while the title, bloom, buttons, and seed panel + * are STILL DARK. + * beat 2 (t≈2200): the title flickers in (boot flicker armed), the + * bloom blooms, the buttons rise in priority order, + * the seed decodes, one signature glitch fires. + * + * Served by dev/menu-intro-test.html; results land in + * `window.__MENU_INTRO__` for the CDP runner (dev/cdp-firefox.mjs): + * + * python3 -m http.server 8080 + * node dev/cdp-firefox.mjs http://localhost:8080/dev/menu-intro-test.html + */ +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 { MenuScene } from '../js/scenes/MenuScene.js'; + +const data = await ConfigLoader.load(); +config.init(data); + +// A quiet run (no audio files to fetch in headless). +if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true; +const gameConfig = createGameConfig(); +gameConfig.scene = [MenuScene]; // the menu boots first + +const game = new Phaser.Game(gameConfig); +window.game = game; + +const results = []; +const check = (label, cond) => { + const pass = !!cond; + results.push({ label, pass }); + console.log(`${pass ? '✔' : '✘ FAIL'} ${label}`); +}; + +// WAIT — throttling-proof. This headless box starves setTimeout and +// stretches its compositor clock, so the only honest barrier is the +// GAME'S OWN CLOCK: poll scene time.now on rAF until it has advanced +// past the base (same pattern as dev/saves-ui-test.mjs). +const gameClock = () => { + try { + const s = window.game.scene.getScenes(true)[0]; + if (s && typeof s.time.now === 'number') return s.time.now; + } catch { /* not booted yet */ } + return null; +}; +const wait = (ms) => new Promise((resolve) => { + const base = gameClock(); + if (base === null) { // pre-boot: fall back to wall clock + const start = performance.now(); + setTimeout(() => resolve(), ms); + return; + } + const poll = () => { + const now = gameClock(); + if (now !== null && now - base >= ms) return resolve(); + requestAnimationFrame(poll); + }; + requestAnimationFrame(poll); +}); + +const near = (a, v) => Math.abs(a - v) < 0.02; + +const run = async () => { + // ---- boot: the menu must come up -------------------------------------- + const bootT0 = Date.now(); + while (!game.scene.isActive('MenuScene') || !game.scene.getScene('MenuScene').title) { + if (Date.now() - bootT0 > 90000) throw new Error('MenuScene never came up'); + await wait(200); + } + const scene = game.scene.getScene('MenuScene'); + check('the menu boots', game.scene.isActive('MenuScene')); + + // Beat flags for dev/shot-firefox.mjs (screenshot at the right moment). + window.__MENU_BEAT1__ = null; + window.__MENU_BEAT2__ = null; + + const revealAt = config.get('menu.revealDelay', 3200); + + // ---- beat 1: console up, title + buttons still dark -------------------- + // Sample at +1000 ms: past beat 1's fade (60+450) and well before the + // reveal (revealDelay, default 3200). + await wait(1000); + window.__MENU_BEAT1__ = true; + check('beat 1: the "THE GALAXY AWAITS" subtitle is up', + scene.subtitle.alpha > 0.95); + check('beat 1: the subtitle rule is up', + scene.rule.alpha > 0.95); + check('beat 1: the main title is STILL DARK', + near(scene.title.alpha, 0) && scene.title.bootT0 === null); + check('beat 1: the title bloom is STILL DARK', + near(scene.titleBloom.main.alpha, 0) && near(scene.titleBloom.fringe.alpha, 0)); + check('beat 1: the buttons are STILL DARK', + near(scene.continueBtn.alpha, 0) && near(scene.newGameBtn.alpha, 0) && near(scene.loadGameBtn.alpha, 0)); + check('beat 1: the seed panel is STILL DARK', + scene.seedIntroTargets.every((t) => near(t.alpha, 0)) && near(scene.rerollBtn.alpha, 0)); + + // ---- beat 2: the downbeat — title, bloom, buttons, seed, decode ------- + // The last beat 2 effect settles at revealAt + 540 + 620 (decode end); + // sample at revealAt + 1300 to be comfortably past everything. + await wait(revealAt + 1300 - 1000); + window.__MENU_BEAT2__ = true; + check('beat 2: the main title is up (boot flicker finished)', + near(scene.title.alpha, 1) && scene.title.bootT0 === null); + check('beat 2: the title bloom is up', + near(scene.titleBloom.main.alpha, 1) && near(scene.titleBloom.fringe.alpha, 1)); + check('beat 2: the buttons are up (Continue / New Game / Load Game)', + near(scene.continueBtn.alpha, 1) && near(scene.newGameBtn.alpha, 1) && near(scene.loadGameBtn.alpha, 1)); + check('beat 2: the seed panel is up', + scene.seedIntroTargets.every((t) => near(t.alpha, 1)) && near(scene.rerollBtn.alpha, 1)); + check('beat 2: the seed decoded to its final value', + scene.decode === null && scene.seedText.text.startsWith(scene.seedValue)); + + const failed = results.filter((r) => !r.pass).length; + console.log(failed === 0 ? `MENU INTRO OK (${results.length} checks)` : `MENU INTRO FAILED (${failed}/${results.length})`); + window.__MENU_INTRO__ = { results, failed }; +}; + +run().catch((err) => { + console.error('menu-intro-test crashed:', err); + window.__MENU_INTRO__ = { results, failed: results.length, error: String(err && err.stack || err) }; +}); diff --git a/dev/research-builds.test.mjs b/dev/research-builds.test.mjs index 8ef6fb4..1decd74 100644 --- a/dev/research-builds.test.mjs +++ b/dev/research-builds.test.mjs @@ -238,8 +238,9 @@ check('builds._template.requires is an array', Array.isArray(bt.requires)); // ---------------------------------------------------------------------- const slots = actionbar.buttons ?? []; check('actionbar: exactly six slots', slots.length === 6); -check('actionbar: slot ids in order (Research, Scan, Ship, ·, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', null, null, 'menu'])); -check('actionbar: labels (Research / Scan / Ship / · / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', null, null, 'Menu'])); +check('actionbar: slot ids in order (Research, Scan, Ship, Map, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', 'map', null, 'menu'])); +check('actionbar: labels (Research / Scan / Ship / Map / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', 'Map', null, 'Menu'])); +check('actionbar: the MAP slot sits right of the SHIP slot', (() => { const iShip = slots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && slots[iShip + 1]?.id === 'map'; })()); check('actionbar: live slots carry hex accents', slots.filter((s) => s.id).every((s) => hex.test(s.accent ?? ''))); check('actionbar: reserved slots stay null', slots.filter((s) => s.id === null).every((s) => s.label === null)); check('actionbar: CRT scanlines configured (pitch + alpha)', typeof actionbar.scanline?.pitch === 'number' && typeof actionbar.scanline?.alpha === 'number'); diff --git a/dev/system-chart.test.mjs b/dev/system-chart.test.mjs new file mode 100644 index 0000000..22a451f --- /dev/null +++ b/dev/system-chart.test.mjs @@ -0,0 +1,148 @@ +/** + * SystemChart test (dev tool, run with Node — no browser needed): + * + * node dev/system-chart.test.mjs + * + * Asserts the pure geometry + stats behind the deck's MAP console + * (js/galaxy/SystemChart.js): + * - chartBounds: the furthest X/Y extents of the object set (object + * EDGE = center ± radius), symmetric padding on every edge, the + * degenerate empty-set case (a 2×padding box about the origin); + * - fitToRect: uniform scale that fits the frame into the plate, + * centred — toX/toY land the bounds centre on the plate centre, the + * whole frame inside the plate, aspect preserved; + * - navDiscoveryStats: the SYSTEM DISCOVERY share over the system's NAV + * points (planets + space stations + gates) MINUS the central body + * ('home') — found/total + pct; 0 total → pct 0; + * - resourceStats: the SYSTEM RESOURCES share over the asteroid fields + * (content.asteroids) — found/total + pct; missing list → 0/0. + */ +import { chartBounds, fitToRect, navDiscoveryStats, resourceStats } from '../js/galaxy/SystemChart.js'; + +let passed = 0; +let failed = 0; +function ok(cond, label) { + if (cond) { + passed++; + console.log(` ok ${label}`); + } else { + failed++; + console.error(`FAIL ${label}`); + } +} +function near(a, b, eps = 1e-9) { + return Math.abs(a - b) <= eps; +} + +// ── chartBounds ──────────────────────────────────────────────────────────── +console.log('chartBounds'); +{ + const b = chartBounds( + [ + { x: 100, y: -50, radius: 30 }, + { x: -200, y: 40, radius: 10 }, + { x: 0, y: 500, radius: 200 }, + ], + 1000 + ); + ok(near(b.minX, -210 - 1000), 'minX = leftmost edge − padding'); + ok(near(b.maxX, 0 + 200 + 1000), 'maxX = rightmost edge (the 200-radius object) + padding'); + ok(near(b.minY, -50 - 30 - 1000), 'minY = top edge − padding'); + ok(near(b.maxY, 500 + 200 + 1000), 'maxY = bottom edge + padding'); + ok(near(b.w, b.maxX - b.minX) && near(b.h, b.maxY - b.minY), 'w/h consistent'); + ok(near(b.cx, (b.minX + b.maxX) / 2) && near(b.cy, (b.minY + b.maxY) / 2), 'centre = midpoint'); +} +{ + // default padding (the design rule: ~1024 px on every edge) + const b = chartBounds([{ x: 0, y: 0, radius: 500 }], 1024); + ok(near(b.minX, -1524) && near(b.maxX, 1524), '±(radius + 1024) about the origin'); +} +{ + const b = chartBounds([], 512); + ok(near(b.minX, -512) && near(b.maxX, 512) && near(b.minY, -512) && near(b.maxY, 512), 'empty set → a 2×padding box about the origin'); +} +{ + const b = chartBounds([{ x: 0, y: 0, radius: 10 }, { bogus: true }], 100); + ok(near(b.minX, -110), 'non-object entries ignored'); +} + +// ── fitToRect ────────────────────────────────────────────────────────────── +console.log('fitToRect'); +{ + const bounds = { minX: -1000, minY: -500, maxX: 3000, maxY: 1500, w: 4000, h: 2000, cx: 1000, cy: 500 }; + const tf = fitToRect(bounds, 800, 600); + // uniform scale = min(800,600)/max(4000,2000) = 600/4000 = 0.15 + ok(near(tf.scale, 0.15), 'scale = min(w,h) / max(bounds w,h)'); + ok(near(tf.toX(1000), 400), 'toX: bounds centre → plate centre x'); + ok(near(tf.toY(500), 300), 'toY: bounds centre → plate centre y'); + ok(tf.toX(-1000) >= 0 && tf.toX(3000) <= 800, 'frame x inside plate'); + ok(tf.toY(-500) >= 0 && tf.toY(1500) <= 600, 'frame y inside plate'); + // aspect preserved: a 100×100 world square maps to a 15×15 px square + const x0 = tf.toX(0); + const y0 = tf.toY(0); + ok(near(tf.toX(100) - x0, tf.toY(100) - y0, 1e-9), 'uniform scale — x and y move equally'); +} +{ + // wider-than-tall plate: the frame is limited by WIDTH + const bounds = { minX: -100, minY: -100, maxX: 100, maxY: 100, w: 200, h: 200, cx: 0, cy: 0 }; + const tf = fitToRect(bounds, 1000, 200); + ok(near(tf.scale, 1), 'width-constrained: scale = h / bounds.h'); + ok(near(tf.toX(0), 500) && near(tf.toY(0), 100), 'centred'); + ok(near(tf.toX(100) - tf.toX(-100), 200), 'frame width preserved'); +} + +// ── navDiscoveryStats ────────────────────────────────────────────────────── +console.log('navDiscoveryStats'); +{ + const content = { + planets: [{ name: 'A' }, { name: 'B' }, { name: 'C' }], + settlements: [ + { id: 's1', anchor: { type: 'space' } }, + { id: 's2', anchor: { type: 'surface' } }, // not a NAV point (planet-anchored) + ], + jumps: [{ id: 'g1' }], + }; + const foundSet = new Set(['A', 'g1']); + const discovery = { isDiscovered: (sysId, id) => foundSet.has(id) }; + const st = navDiscoveryStats(discovery, 'sys1', content); + ok(st.total === 5, 'total = 3 planets + 1 space station + 1 gate (central body excluded, surface settlement excluded)'); + ok(st.found === 2, 'found counts the discovered NAV points'); + ok(near(st.pct, 2 / 5), 'pct = found/total'); +} +{ + const st = navDiscoveryStats({ isDiscovered: () => true }, 'sys', { planets: [], settlements: [], jumps: [] }); + ok(st.total === 0 && st.found === 0 && st.pct === 0, 'no NAV points → 0/0, pct 0'); +} +{ + // the central body is discoverable ('home') but NOT counted — the + // readout rule: planets + stations + gates only. + const content = { planets: [{ name: 'A' }], settlements: [], jumps: [] }; + const st = navDiscoveryStats({ isDiscovered: () => true }, 'sys', content); + ok(st.total === 1, 'home body never enters the total'); +} +{ + const st = navDiscoveryStats(null, 'sys', { planets: [{ name: 'A' }] }); + ok(st.total === 1 && st.found === 0 && st.pct === 0, 'null discovery state → nothing found, no crash'); +} + +// ── resourceStats ────────────────────────────────────────────────────────── +console.log('resourceStats'); +{ + const content = { asteroids: [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }] }; + const foundSet = new Set(['r2']); + const st = resourceStats({ isDiscovered: (sysId, id) => foundSet.has(id) }, 'sys', content); + ok(st.total === 3, 'total = asteroid cluster count'); + ok(st.found === 1 && near(st.pct, 1 / 3), 'found/pct over the clusters'); +} +{ + const st = resourceStats({ isDiscovered: () => true }, 'sys', {}); + ok(st.total === 0 && st.pct === 0, 'no asteroid fields → 0/0'); +} +{ + const st = resourceStats(null, 'sys', { asteroids: [{ id: 'r1' }] }); + ok(st.total === 1 && st.found === 0, 'null discovery state → nothing found'); +} + +// ── result ───────────────────────────────────────────────────────────────── +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/js/galaxy/SystemChart.js b/js/galaxy/SystemChart.js new file mode 100644 index 0000000..854d7b8 --- /dev/null +++ b/js/galaxy/SystemChart.js @@ -0,0 +1,130 @@ +/** + * SystemChart — the pure geometry + stats behind the deck's MAP console + * (js/ui/MapWindow.js draws on it; GameScene feeds it). + * + * chartBounds(objects, padding) the system's chart frame: the furthest + * X/Y extents of EVERY object (planets, + * stations, jump gates AND asteroid + * clusters — discovered or not) plus + * `padding` px (data/map.json → + * bounds.padding, ~1024) on each edge. + * fitToRect(bounds, w, h) the world → plate transform that fits + * that frame into the map plate, centred. + * navDiscoveryStats(d, sysId, c) the SYSTEM DISCOVERY share — the NAV + * objects (planets + free-space stations + * + jump gates; the central body is the + * chart's anchor, not a count) vs. the + * player's Discovery state. + * resourceStats(d, sysId, c) the SYSTEM RESOURCES share — the + * asteroid fields vs. Discovery (the only + * resource kind for now). + * + * Pure (no Phaser) — Node-testable (dev/system-chart.test.mjs). The NAV + * point set is SystemCategory.navPoints (the same discoverable set the + * jumpgate chart gate uses) minus the central body, per the readout's + * rule: planets, stations, gates. + */ +import { navPoints } from '../research/SystemCategory.js'; + +/** + * The chart's bounding frame from a list of objects. + * + * @param {Array<{x:number, y:number, radius:number}>} objects — the system's + * objects (any discoverable set; `radius` = the object's extent from its + * center — planet disc radius, station/gate keepout, cluster bound) + * @param {number} [padding=1024] — px of margin added on every edge + * @returns {{minX:number, minY:number, maxX:number, maxY:number, + * w:number, h:number, cx:number, cy:number}} + * (a degenerate empty list yields a `2×padding` box about the origin) + */ +export function chartBounds(objects, padding = 1024) { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const o of objects ?? []) { + if (!o || typeof o.x !== 'number' || typeof o.y !== 'number') continue; + const r = Math.max(0, Number(o.radius) || 0); + if (o.x - r < minX) minX = o.x - r; + if (o.y - r < minY) minY = o.y - r; + if (o.x + r > maxX) maxX = o.x + r; + if (o.y + r > maxY) maxY = o.y + r; + } + if (!Number.isFinite(minX) || !Number.isFinite(maxX) || !Number.isFinite(minY) || !Number.isFinite(maxY)) { + // No objects: a zero-size box about the origin (the padding then makes + // the chart a 2×padding box about it). + minX = 0; + minY = 0; + maxX = 0; + maxY = 0; + } + minX -= padding; + minY -= padding; + maxX += padding; + maxY += padding; + return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY, cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 }; +} + +/** + * Fit a world-space rect into a plate of `w × h`, centred, uniform scale. + * + * @param {ReturnType} bounds + * @param {number} w — plate width (px) + * @param {number} h — plate height (px) + * @returns {{scale:number, ox:number, oy:number, toX:(x:number)=>number, + * toY:(y:number)=>number}} + * `toX`/`toY` map world → plate; `scale` = plate px per world px. + */ +export function fitToRect(bounds, w = 100, h = 100) { + const scale = Math.min(w, h) / Math.max(1, Math.max(bounds.w, bounds.h)); + const ox = w / 2 - bounds.cx * scale; + const oy = h / 2 - bounds.cy * scale; + return { + scale, + ox, + oy, + toX: (x) => x * scale + ox, + toY: (y) => y * scale + oy, + }; +} + +/** + * The SYSTEM DISCOVERY share (data/map.json → stats): the player's found / + * total over the system's NAV objects — planets, free-space stations and + * jump gates (SystemCategory.navPoints minus the central body 'home', + * which is the chart's anchor rather than a discoverable count — and + * minus the asteroid clusters, which are the RESOURCE side of the readout). + * + * @param {object} discovery — the Discovery state (isDiscovered) + * @param {string} systemId + * @param {object} content — the generated system content + * @returns {{total:number, found:number, pct:number}} — `pct` is 0..1 + * (0 when the system has no NAV objects, which the readout renders as —) + */ +export function navDiscoveryStats(discovery, systemId, content) { + const points = navPoints(content).filter((p) => p.kind !== 'home'); + const canQuery = typeof discovery?.isDiscovered === 'function'; + let found = 0; + for (const p of points) if (canQuery && discovery.isDiscovered(systemId, p.id)) found++; + const total = points.length; + return { total, found, pct: total > 0 ? found / total : 0 }; +} + +/** + * The SYSTEM RESOURCES share (data/map.json → stats): the player's found / + * total over the system's asteroid fields (content.asteroids — the only + * resource kind for now). + * + * @param {object} discovery — the Discovery state (isDiscovered) + * @param {string} systemId + * @param {object} content — the generated system content + * @returns {{total:number, found:number, pct:number}} + */ +export function resourceStats(discovery, systemId, content) { + const clusters = (content?.asteroids ?? []).filter((c) => c && typeof c.id === 'string'); + const canQuery = typeof discovery?.isDiscovered === 'function'; + let found = 0; + for (const c of clusters) if (canQuery && discovery.isDiscovered(systemId, c.id)) found++; + const total = clusters.length; + return { total, found, pct: total > 0 ? found / total : 0 }; +} diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 95f12eb..b1738dc 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -33,6 +33,8 @@ import { ScanPulse } from '../scan/ScanPulse.js'; import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js'; import { CommsPanel } from '../ui/CommsPanel.js'; import { ResearchWindow } from '../ui/ResearchWindow.js'; +import { MapWindow } from '../ui/MapWindow.js'; +import { navDiscoveryStats, resourceStats } from '../galaxy/SystemChart.js'; import { ResearchState } from '../research/ResearchState.js'; import { categories, loadCategory, isAvailable, buildDefs } from '../research/ResearchModel.js'; import { @@ -166,6 +168,19 @@ export class GameScene extends Phaser.Scene { } } + // The map console's cartography feed (data/map.json → video): a muted + // 2:3 loop behind the MAP window. Same contract as the research feed — + // a missing file just leaves the window's NO SIGNAL plate up. + if (config.get('map.enabled', true)) { + const mapVideo = String(config.get('map.video.file') ?? ''); + if (mapVideo) { + const url = /^(https?:)?\/\//.test(mapVideo) || mapVideo.startsWith('assets/') + ? mapVideo + : `assets/videos/${mapVideo}`; + this.load.video(MapWindow.VIDEO_KEY, url); + } + } + // The build console's feed (data/builds.json → video): a muted 2:3 // loop behind the BUILD window on a planet surface (SurfaceScene's // BuildWindow reads the shared cache). A missing file just leaves the @@ -610,6 +625,25 @@ export class GameScene extends Phaser.Scene { systemTree: this.systemTree, onResearch: (catId, id) => this.beginResearch(catId, id), }); + + // ---- MAP CONSOLE (the deck's MAP button, right of SHIP) ---- + // The cartography window (data/map.json, js/ui/MapWindow.js, depth 80): + // left cartography feed + the system chart — discovered objects on a + // padded frame, the tether union boundary, the fog of what the tether + // doesn't cover yet, the system discovery / resources readout. + // Clicking a discovered object on the chart plots the course (the same + // autopilot as a world click). The window is a pure view — it polls + // mapChartSnapshot() while open and repaints on any change. + this.mapWindow = new MapWindow(this, { + getChart: () => this.mapChartSnapshot(), + getShip: () => (this.ship ? { x: this.ship.x, y: this.ship.y, heading: this.ship.rotation } : null), + onSelect: (objectId) => this.autopilotTo(objectId), + onLocked: () => + this.consoleToast(config.get('map.galaxyLockedToast', 'GALAXY MAP OFFLINE — SECTOR DATA NOT ACQUIRED'), { + glyph: '✕', + glyphColor: toCss(themeColor('amber', 0xffc94d)), + }), + }); // deck progress bar — drawn over the RESEARCH button while a project runs this._researchDeckBar = { g: this.add.graphics().setScrollFactor(0).setDepth(51).setVisible(false), @@ -732,6 +766,8 @@ export class GameScene extends Phaser.Scene { // The research console (depth 80) is full-screen — it owns all input // (its own buttons, the close, ESC); a world click never lands behind it. if (this.researchWindow && this.researchWindow.isOpen) return; + // The map console (depth 80) — the same full-screen contract. + if (this.mapWindow && this.mapWindow.isOpen) return; // Sub-bar OPEN: a click INSIDE it is its own (the panel or one of // its buttons — the buttons fire on their own pointerdown). ANY // click OUTSIDE — world, deck, HUD, compass — folds it back down @@ -1196,6 +1232,7 @@ export class GameScene extends Phaser.Scene { for (const d of _researchDone) this._completeResearch(d.category, d.id); } this.researchWindow?.update(_time); // the console's living details (open state) + this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll this._deckResearchBar(_time); // the RESEARCH slot's progress bar // Builds: a build started on a surface keeps running in space if the // player took off mid-build (game-loop clock — see beginBuild); when @@ -2124,6 +2161,18 @@ export class GameScene extends Phaser.Scene { this.researchWindow?.open(); return; } + // The map console (full-screen, depth 80) — the deck's other console. + if (id === 'map') { + if (this.mapWindow && this.mapWindow.isOpen) { + this.mapWindow.close(); + return; + } + if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close(); + if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close(); + if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close(); + this.mapWindow?.open(); + return; + } if (id === 'menu') { this.menuAction(); return; @@ -2135,6 +2184,124 @@ export class GameScene extends Phaser.Scene { console.info(`[orbit] command deck: ${id}`); } + // ------------------------------------------------------------ map console + + /** + * The MAP console's chart snapshot (js/ui/MapWindow.js polls this while + * open; js/galaxy/SystemChart.js shapes the stats). + * + * The frame covers EVERY object of the system — discovered or not (the + * map must always show the full extent of the solar system) — plus the + * player's tether reach; the chart DRAWS only the discovered ones. + */ + mapChartSnapshot() { + const sysId = this.systemRecord?.id; + const content = this.systemContent; + if (!sysId || !content) return null; + const disc = this.discovery; + const isDisc = (id) => (disc ? disc.isDiscovered(sysId, id) : true); + + const objects = []; + for (const p of this.systemPlanets) { + objects.push({ + id: p.discoveryId, + kind: 'planet', + x: p.x, + y: p.y, + radius: p.radius, + name: p.discoveryName, + typeLabel: config.get(`planets.typeLabels.${p.name}`, p.name), + discovered: isDisc(p.discoveryId), + tint: toCss(config.get(`planets.classTint.${p.name}`, '#9fb6d8')), + }); + } + for (const st of this.systemStations) { + objects.push({ + id: st.discoveryId, + kind: 'station', + x: st.x, + y: st.y, + radius: st.bound ?? st.size, + name: st.discoveryName, + typeLabel: st.kind === 'waypoint' ? 'Waypoint' : 'Station', + discovered: isDisc(st.discoveryId), + }); + } + for (const gt of this.systemGates) { + objects.push({ + id: gt.discoveryId, + kind: 'gate', + x: gt.x, + y: gt.y, + radius: gt.bound ?? gt.size, + name: gt.discoveryName, + typeLabel: gt.toName ? `Gate → ${gt.toName}` : 'Jump Gate', + discovered: isDisc(gt.discoveryId), + rotation: gt.rotation, + active: gt.active, + }); + } + for (const c of this.asteroidClusters) { + objects.push({ + id: c.discoveryId, + kind: 'cluster', + x: c.x, + y: c.y, + radius: c.bound, + name: c.discoveryName, + typeLabel: 'Rock Field', + discovered: isDisc(c.discoveryId), + rocks: (c.members ?? []).map((m) => ({ + dx: m.lx * 1, // local offset — the plate transform is a uniform scale + dy: m.ly, + r: Math.max(1, m.radius), + seed: (m.lx * 7919 + m.ly * 104729) % 1000 / 1000, + })), + }); + } + + // the player's tether zones — the explored region (union boundary + fog) + const tethers = (this.tetherField?.tethers ?? []).map((t) => ({ + id: t.id, + x: t.x, + y: t.y, + radius: t.radius, + level: t.level, + label: t.label ?? '', + })); + + // central body: the home world in the starting system, else the star + const starClass = String(content.star?.class ?? '').toUpperCase(); + const central = this.isHomeSystem + ? { + name: this.homeWorldName ?? 'Terra', + isHome: true, + radius: this.planet?.radius ?? 200, + typeLabel: config.get('planets.homeTypeLabel', 'Home World'), + } + : { + name: this.planet?.discoveryName ?? 'Star', + isHome: false, + radius: 240, + typeLabel: config.get(`planets.starTypeLabels.${starClass}`, 'Star'), + color: toCss(config.get(`planets.star.classColor.${starClass}`, '#ffe9b0')), + }; + + return { + systemId: sysId, + systemName: this.systemRecord?.name ?? sysId, + isHome: this.isHomeSystem === true, + central, + objects, + tethers, + ship: this.ship ? { x: this.ship.x, y: this.ship.y, heading: this.ship.rotation } : null, + stats: { + nav: navDiscoveryStats(disc, sysId, content), + res: resourceStats(disc, sysId, content), + }, + }; + } + // ------------------------------------------------------------ research /** @@ -2641,6 +2808,11 @@ export class GameScene extends Phaser.Scene { this.researchWindow.close(); return; } + // The map console (depth 80) — the same contract. + if (this.mapWindow && this.mapWindow.isOpen) { + this.mapWindow.close(); + return; + } // The mining context menu is the topmost open thing after the save UI. if (this.miningPopup && this.miningPopup.isOpen) { this.miningPopup.close(); @@ -2838,6 +3010,7 @@ export class GameScene extends Phaser.Scene { this.miningPopup?.destroy(); this.commsPanel?.destroy(); this.researchWindow?.destroy(); // the console (video + UI) + this.mapWindow?.destroy(); // the map console (video + chart canvas) this.mineralHud?.destroy(); this.mining?.destroy(); this.scanPulse?.destroy(); diff --git a/js/scenes/MenuScene.js b/js/scenes/MenuScene.js index 6d25be2..bcfb8ae 100644 --- a/js/scenes/MenuScene.js +++ b/js/scenes/MenuScene.js @@ -151,9 +151,12 @@ export class MenuScene extends Phaser.Scene { { redColor: '#ff2d6f', cyanColor: '#00e5ff', baseOffset: 1.6, burstOffset: 12, ghostAlpha: 0.5 }, ); this.overlay.onGlitch((level) => this.title.setBurst(level)); - this.title.start(this.time.now); + // The title stays dark until the menu track's crescendo lands (see the + // intro sequence below) — its boot flicker is armed, not started, here. + this.title.setAlpha(0); // Title bloom: two offset radial glows (cyan core, magenta fringe). + // Parked at alpha 0 — it blooms in with the title (intro beat 2). this.titleBloom = new TitleBloom( this, cx, @@ -161,6 +164,8 @@ export class MenuScene extends Phaser.Scene { config.get('theme.colors.neon', '#00e5ff'), config.get('theme.colors.neon2', '#ff2d6f'), ); + this.titleBloom.main.setAlpha(0); + this.titleBloom.fringe.setAlpha(0); // ---- subtitle + rule this.subtitle = this.add @@ -274,29 +279,40 @@ export class MenuScene extends Phaser.Scene { letterSpacing: 2, }).setOrigin(1, 0.5); - // ---- intro sequence: title flickers in, console assembles, - // the buttons rise in priority order, the seed decodes, and - // one signature glitch fires. + // ---- intro sequence — two beats, synced to the menu track + // (assets/music/mainmenu.mp3): + // beat 1 (now): the console is already up — the corner frame, the + // chrome text, and the "THE GALAXY AWAITS" subtitle settle in + // first, while the title and buttons stay dark; + // beat 2 (revealAt): the track's crescendo lands (~3.2 s in) — the + // title flickers in, the bloom blooms, the buttons rise in + // priority order, the seed decodes, and one signature glitch fires. + const revealAt = config.get('menu.revealDelay', 3200); + this.intro(60, () => { + this.tweens.add({ targets: [this.subtitle, this.rule], alpha: 1, duration: 450, ease: 'Sine.easeOut' }); + }); + this.intro(revealAt, () => { + this.title.setAlpha(1); + this.title.start(this.time.now); // boot flicker lands ON the downbeat + this.tweens.add({ targets: [this.titleBloom.main, this.titleBloom.fringe], alpha: 1, duration: 700, ease: 'Sine.easeOut' }); + }); this.continueBtn.y += 12; this.newGameBtn.y += 12; - this.intro(180, () => { - this.tweens.add({ targets: [this.subtitle, this.rule], alpha: 1, duration: 420, ease: 'Sine.easeOut' }); - }); - this.intro(340, () => { + this.intro(revealAt, () => { this.tweens.add({ targets: this.continueBtn, alpha: 1, y: this.continueBtn.y - 12, duration: 420, ease: 'Sine.easeOut' }); }); - this.intro(460, () => { + this.intro(revealAt + 120, () => { this.tweens.add({ targets: this.newGameBtn, alpha: 1, y: this.newGameBtn.y - 12, duration: 420, ease: 'Sine.easeOut' }); }); - this.intro(580, () => { + this.intro(revealAt + 240, () => { this.tweens.add({ targets: this.loadGameBtn, alpha: 1, duration: 340, ease: 'Sine.easeOut' }); }); - this.intro(620, () => { + this.intro(revealAt + 280, () => { const targets = [...this.seedIntroTargets, this.rerollBtn]; this.tweens.add({ targets, alpha: 1, duration: 420, ease: 'Sine.easeOut' }); }); - this.intro(880, () => this.startDecode(this.seedValue)); - this.intro(1150, () => this.overlay.trigger(320, 0.95)); + this.intro(revealAt + 540, () => this.startDecode(this.seedValue)); + this.intro(revealAt + 810, () => this.overlay.trigger(320, 0.95)); // ---- input: typing goes to the seed field; a click elsewhere blurs it this.input.keyboard.on('keydown', (e) => this.onSeedKey(e)); diff --git a/js/ui/ActionBar.js b/js/ui/ActionBar.js index ca74730..78d8331 100644 --- a/js/ui/ActionBar.js +++ b/js/ui/ActionBar.js @@ -199,7 +199,7 @@ export class ActionBar extends Phaser.GameObjects.Container { { id: 'research', label: 'Research', accent: '#00e5ff' }, { id: 'scan', label: 'Scan', accent: '#ffc94d' }, { id: 'ship', label: 'Ship', accent: '#7ce8a4' }, - { id: null, label: null }, + { id: 'map', label: 'Map', accent: '#c084fc' }, { id: null, label: null }, { id: 'menu', label: 'Menu', accent: '#ff2d6f' }, ]; diff --git a/js/ui/MapWindow.js b/js/ui/MapWindow.js new file mode 100644 index 0000000..6d222b1 --- /dev/null +++ b/js/ui/MapWindow.js @@ -0,0 +1,2076 @@ +/** + * MapWindow — the deck's MAP console (data/map.json): a full-screen + * cartography overlay on the same bones as ResearchWindow — the left + * video panel (assets/videos/map.mp4, muted loop), the decoded title, + * the close ✕, the glitch bursts, depth 80, the `isOpen`/`update(time)` + * contract. + * + * Right column, top to bottom: + * • tabs — CURRENT SYSTEM (live) / GALAXY (standby, not built yet — + * clicking it fires `onLocked` and the scene toasts it) + * • the CHART — the current system drawn on an offscreen canvas and + * shown as a Phaser image: + * - frame = the furthest objects (planets, stations, jump gates and + * rock fields — discovered or not) AND the player's tether reach, + * plus `bounds.padding` (data/map.json, ~1024 px) on every edge + * - adaptive grid, starfield, the central body, then the discovered + * planets / stations / gates / rock clusters with labels + * - tether zones: a soft glow + the UNION boundary + * (Tether.visibleArcs) as a cyber glitch dotted line + * - FOG: everything outside the tether union gets a soft dim layer + * (fog of war) — the read on "where the tether doesn't reach yet", + * i.e. where to explore / expand next + * • SYSTEM plate — the system name, SYSTEM DISCOVERY % (NAV points: + * planets + stations + gates) and SYSTEM RESOURCES % (asteroid + * fields), each as a segmented bar (one segment per object, lit = + * found — the % from js/galaxy/SystemChart.js) + * + * Live layer (Phaser, over the canvas): the ship marker (pulsing, + * follows the ship every frame via `getShip()`), a scan sweep band, the + * hover highlight + tooltip, a click flash, the glitch bursts. + * + * Clicking a discovered object fires `onSelect(objectId)` — the scene + * plots the course (autopilot). The window is a pure view: all system + * data arrives through the `getChart()` snapshot callback (GameScene); + * it is re-polled while open, and the canvas repaints on any change + * (discovery, tether radius, …). + */ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { toColor, toCss } from '../utils/Color.js'; +import { fontStack, themeColor } from '../utils/Theme.js'; +import { ScrambleDecode } from '../utils/Decode.js'; +import { playSfxOn } from '../utils/Sfx.js'; +import { Rng } from '../utils/Rng.js'; +import { Tether } from '../tether/Tether.js'; +import { chartBounds, fitToRect } from '../galaxy/SystemChart.js'; +import { CyberShape } from './CyberShape.js'; + +const TAU = Math.PI * 2; +const HEADER = fontStack('header'); +const BODY = fontStack('body'); +const clamp01 = (v) => Math.min(1, Math.max(0, v)); +const easeIO = (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2); +const rand = (a, b) => a + (b - a) * Math.random(); + +/** Theme palette (data/theme.json), resolved once at build time. */ +const C = { + ink: themeColor('ink', 0xeaf6ff), + dim: themeColor('dim', 0x7d92c4), + faint: themeColor('faint', 0x3d4c74), + neon: themeColor('neon', 0x00e5ff), + neon2: themeColor('neon2', 0xff2d6f), + amber: themeColor('amber', 0xffc94d), + panel: themeColor('panel', 0x0a1120), + bg: themeColor('bg', 0x04060d), +}; + +/** + * Draw a cut-corner plate with its top-left at (x, y) into an existing + * Graphics (same recipe as ResearchWindow). + */ +function panel(g, x, y, w, h, o = {}) { + const cx = x + w / 2; + const cy = y + h / 2; + const pts = CyberShape.points(w, h, o.notch ?? Math.min(14, h * 0.28)).map( + (p) => ({ x: p.x + cx, y: p.y + cy }) + ); + if (o.fill !== undefined) { + g.fillStyle(o.fill, o.fillAlpha ?? 1); + g.fillPoints(pts, true); + } + if (o.stroke !== undefined) { + g.lineStyle(o.lineWidth ?? 1.5, o.stroke, o.strokeAlpha ?? 1); + g.strokePoints(pts, true); + } + if (o.glow !== undefined) { + g.lineStyle((o.lineWidth ?? 1.5) + 4, o.glow, o.glowAlpha ?? 0.22); + g.strokePoints(pts, true); + } +} + +/** Corner brackets framing a rect (x, y = top-left). */ +function brackets(g, x, y, w, h, o = {}) { + const color = o.color ?? C.neon; + const alpha = o.alpha ?? 0.7; + const len = o.length ?? 14; + g.lineStyle(o.lineWidth ?? 2, color, alpha); + const x2 = x + w; + const y2 = y + h; + g.lineBetween(x, y, x + len, y); + g.lineBetween(x, y, x, y + len); + g.lineBetween(x2, y, x2 - len, y); + g.lineBetween(x2, y, x2, y + len); + g.lineBetween(x, y2, x + len, y2); + g.lineBetween(x, y2, x, y2 - len); + g.lineBetween(x2, y2, x2 - len, y2); + g.lineBetween(x2, y2, x2, y2 - len); +} + +// ── canvas color helpers (hex/int in, css strings out) ──────────────────── +function hx(c) { + if (typeof c === 'number') c = (c >>> 0).toString(16).padStart(6, '0'); + c = String(c).trim().replace(/^#/, ''); + if (c.length === 3) c = c.split('').map((ch) => ch + ch).join(''); + const n = parseInt(c.slice(0, 6), 16) || 0; + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} +function rgba(c, a) { + const [r, g, b] = hx(c); + return `rgba(${r},${g},${b},${a})`; +} +function mixc(c, c2, t) { + const a = hx(c); + const b = hx(c2); + return `#${a.map((v, i) => Math.round(v + (b[i] - v) * t).toString(16).padStart(2, '0')).join('')}`; +} +const lighten = (c, t) => mixc(c, '#ffffff', t); +const darken = (c, t) => mixc(c, '#04060d', t); + +// ── chart painters (pure canvas 2D — the "magic" half of the map) ───────── +/** Deterministic starfield for the plate (seeded, stable between redraws). */ +function drawStars(ctx, w, h, seed) { + const rng = new Rng(seed); + const n = Math.round((w * h) / 8500); + for (let i = 0; i < n; i++) { + const x = rng.range(0, w); + const y = rng.range(0, h); + const r = rng.range(0.4, 1.2); + ctx.fillStyle = `rgba(159,216,255,${rng.range(0.1, 0.5).toFixed(3)})`; + ctx.fillRect(x, y, r, r); + } + // a few bright ones with glints + for (let i = 0; i < 6; i++) { + const x = rng.range(0, w); + const y = rng.range(0, h); + const a = rng.range(0.3, 0.65); + const L = rng.range(4, 9); + ctx.strokeStyle = `rgba(159,216,255,${(a * 0.55).toFixed(3)})`; + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.moveTo(x - L, y); + ctx.lineTo(x + L, y); + ctx.moveTo(x, y - L); + ctx.lineTo(x, y + L); + ctx.stroke(); + ctx.fillStyle = `rgba(230,245,255,${a.toFixed(3)})`; + ctx.fillRect(x - 1, y - 1, 2, 2); + } +} + +/** World-space grid step (px) that lands on-screen in [40, 120] px. */ +function gridStepFor(scale) { + let step = 512; + while (step * scale < 40) step *= 2; + while (step * scale > 120 && step > 512) step /= 2; + return step; +} + +/** The adaptive navigation grid + origin cross (data/map.json → chart.grid). */ +function drawGrid(ctx, w, h, bounds, tf) { + const gc = config.get('map.chart.grid', {}); + const alpha = gc.alpha ?? 0.26; + const step = gridStepFor(tf.scale); + const color = toCss(C.faint); + ctx.lineWidth = 1; + ctx.strokeStyle = rgba(color, alpha); + const k0 = Math.ceil(bounds.minX / step); + const k1 = Math.floor(bounds.maxX / step); + for (let k = k0; k <= k1; k++) { + const x = tf.toX(k * step); + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + const j0 = Math.ceil(bounds.minY / step); + const j1 = Math.floor(bounds.maxY / step); + for (let j = j0; j <= j1; j++) { + const y = tf.toY(j * step); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + // origin cross — where the central body anchors the system + const ox = tf.toX(0); + const oy = tf.toY(0); + ctx.strokeStyle = rgba(color, Math.min(0.8, alpha * 2.4)); + ctx.lineWidth = 1; + const L = 9; + ctx.beginPath(); + ctx.moveTo(ox - L, oy); + ctx.lineTo(ox + L, oy); + ctx.moveTo(ox, oy - L); + ctx.lineTo(ox, oy + L); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(ox, oy, 3, 0, TAU); + ctx.stroke(); +} + +/** The central body: the home world (planet) or the system's star. */ +function drawCentral(ctx, x, y, rpx, central) { + if (central?.isHome) { + // the Terran home world — a greenish blue planet + drawPlanetDisc(ctx, x, y, rpx, '#6fc2a8'); + return; + } + const color = central?.color ?? '#ffe9b0'; + // outer glow + let g = ctx.createRadialGradient(x, y, 0, x, y, rpx * 3.4); + g.addColorStop(0, 'rgba(255,255,255,0.95)'); + g.addColorStop(0.3, rgba(color, 0.5)); + g.addColorStop(1, rgba(color, 0)); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(x, y, rpx * 3.4, 0, TAU); + ctx.fill(); + // disc: white core melting into the spectral hue + g = ctx.createRadialGradient(x - rpx * 0.25, y - rpx * 0.25, rpx * 0.1, x, y, rpx); + g.addColorStop(0, '#ffffff'); + g.addColorStop(0.55, lighten(color, 0.25)); + g.addColorStop(1, color); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(x, y, rpx, 0, TAU); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.lineWidth = 1; + ctx.stroke(); + // cross flares + const fl = rpx * 1.9; + ctx.strokeStyle = rgba(color, 0.5); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x - fl, y); + ctx.lineTo(x + fl, y); + ctx.moveTo(x, y - fl); + ctx.lineTo(x, y + fl); + ctx.stroke(); + ctx.strokeStyle = 'rgba(255,255,255,0.6)'; + ctx.lineWidth = 0.6; + ctx.beginPath(); + ctx.moveTo(x - fl * 0.55, y); + ctx.lineTo(x + fl * 0.55, y); + ctx.moveTo(x, y - fl * 0.55); + ctx.lineTo(x, y + fl * 0.55); + ctx.stroke(); +} + +/** A planet: radial disc (light from the top-left) + halo + rim. */ +function drawPlanetDisc(ctx, x, y, rpx, tint) { + let g = ctx.createRadialGradient(x, y, rpx * 0.4, x, y, rpx * 1.7); + g.addColorStop(0, rgba(tint, 0.2)); + g.addColorStop(1, rgba(tint, 0)); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(x, y, rpx * 1.7, 0, TAU); + ctx.fill(); + g = ctx.createRadialGradient(x - rpx * 0.45, y - rpx * 0.45, rpx * 0.15, x, y, rpx); + g.addColorStop(0, lighten(tint, 0.5)); + g.addColorStop(0.65, tint); + g.addColorStop(1, darken(tint, 0.62)); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(x, y, rpx, 0, TAU); + ctx.fill(); + ctx.strokeStyle = 'rgba(4,8,16,0.8)'; + ctx.lineWidth = 1; + ctx.stroke(); + // lit rim + ctx.strokeStyle = rgba(lighten(tint, 0.35), 0.55); + ctx.lineWidth = 0.7; + ctx.beginPath(); + ctx.arc(x, y, Math.max(0.5, rpx - 1), -2.5, -0.6); + ctx.stroke(); +} + +/** A space station: hub + ring + spokes + solar wings. */ +function drawStation(ctx, x, y, rpx) { + const neon = toCss(C.neon); + const amber = toCss(C.amber); + // wings + ctx.strokeStyle = rgba(amber, 0.85); + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(x - rpx * 1.7, y); + ctx.lineTo(x - rpx * 0.55, y); + ctx.moveTo(x + rpx * 0.55, y); + ctx.lineTo(x + rpx * 1.7, y); + ctx.stroke(); + ctx.strokeStyle = rgba(amber, 0.35); + ctx.lineWidth = 0.7; + ctx.beginPath(); + ctx.moveTo(x - rpx * 1.7, y - 2.5); + ctx.lineTo(x - rpx * 1.7, y + 2.5); + ctx.moveTo(x + rpx * 1.7, y - 2.5); + ctx.lineTo(x + rpx * 1.7, y + 2.5); + ctx.stroke(); + // ring + spokes + ctx.strokeStyle = rgba(neon, 0.8); + ctx.lineWidth = 1.1; + ctx.beginPath(); + ctx.arc(x, y, rpx, 0, TAU); + ctx.stroke(); + ctx.lineWidth = 0.8; + for (let i = 0; i < 3; i++) { + const a = (i / 3) * TAU + 0.5; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + Math.cos(a) * rpx, y + Math.sin(a) * rpx); + ctx.stroke(); + } + // hub + ctx.fillStyle = '#eaf6ff'; + ctx.beginPath(); + ctx.arc(x, y, Math.max(1.4, rpx * 0.3), 0, TAU); + ctx.fill(); + // beacon + ctx.fillStyle = rgba(neon, 0.9); + ctx.beginPath(); + ctx.arc(x, y, Math.max(0.8, rpx * 0.14), 0, TAU); + ctx.fill(); +} + +/** A jump gate: double ring + pylons + a direction tick to the destination. */ +function drawGate(ctx, x, y, rpx, rot, active) { + const neon = toCss(C.neon); + const a = active ? 0.95 : 0.5; + ctx.strokeStyle = rgba(neon, a); + ctx.lineWidth = 1.3; + ctx.beginPath(); + ctx.arc(x, y, rpx, 0, TAU); + ctx.stroke(); + ctx.lineWidth = 0.9; + ctx.beginPath(); + ctx.arc(x, y, rpx * 0.62, 0, TAU); + ctx.stroke(); + // pylons (4 ticks at 45°) + for (let i = 0; i < 4; i++) { + const ang = (i / 4) * TAU + Math.PI / 4; + ctx.beginPath(); + ctx.moveTo(x + Math.cos(ang) * rpx, y + Math.sin(ang) * rpx); + ctx.lineTo(x + Math.cos(ang) * rpx * 1.35, y + Math.sin(ang) * rpx * 1.35); + ctx.stroke(); + } + // direction tick — the bearing to the destination star + ctx.strokeStyle = rgba(neon, active ? 0.8 : 0.35); + ctx.lineWidth = active ? 1.4 : 0.9; + ctx.beginPath(); + ctx.moveTo(x + Math.cos(rot) * rpx * 0.5, y + Math.sin(rot) * rpx * 0.5); + ctx.lineTo(x + Math.cos(rot) * rpx * 2.1, y + Math.sin(rot) * rpx * 2.1); + ctx.stroke(); + if (active) { + const g = ctx.createRadialGradient(x, y, 0, x, y, rpx * 1.4); + g.addColorStop(0, rgba(neon, 0.5)); + g.addColorStop(1, rgba(neon, 0)); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(x, y, rpx * 1.4, 0, TAU); + ctx.fill(); + } +} + +/** A rock field: scattered rocks + a faint dashed bound circle. */ +function drawCluster(ctx, x, y, rpx, rocks, scale) { + const c0 = '#a99c8c'; + const k = scale > 0 ? scale : 0.02; // world→screen for the local rock offsets + ctx.setLineDash([3, 4]); + ctx.strokeStyle = 'rgba(125,146,196,0.4)'; + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.arc(x, y, rpx, 0, TAU); + ctx.stroke(); + ctx.setLineDash([]); + for (const m of rocks) { + const rx = x + (m.dx ?? 0) * k; + const ry = y + (m.dy ?? 0) * k; + const rr = Math.min(4, Math.max(0.6, (m.r ?? 1) * k * 2)); + ctx.fillStyle = rgba(mixc(c0, '#c8bca9', (m.seed ?? 0.5) % 1), 0.92); + ctx.beginPath(); + ctx.arc(rx, ry, rr, 0, TAU); + ctx.fill(); + } +} + +/** Soft glow fills under each tether zone (drawn BEFORE the fog, so it + * only survives where the tether reaches — the explored side). */ +function drawTetherZones(ctx, tethers, tf) { + const gc = config.get('map.chart.glow', {}); + const span = gc.span ?? 0.92; + const alpha = gc.alpha ?? 0.14; + const color = toCss(C.neon); + for (const t of tethers) { + if (typeof t?.radius !== 'number') continue; + const rpx = Math.max(3, t.radius * tf.scale); + const x = tf.toX(t.x); + const y = tf.toY(t.y); + const g = ctx.createRadialGradient(x, y, 0, x, y, rpx); + g.addColorStop(0, rgba(color, alpha)); + g.addColorStop(span, rgba(color, alpha * 0.3)); + g.addColorStop(1, rgba(color, 0)); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(x, y, rpx, 0, TAU); + ctx.fill(); + ctx.fillStyle = rgba(color, 0.04); + ctx.beginPath(); + ctx.arc(x, y, rpx, 0, TAU); + ctx.fill(); + } +} + +/** + * FOG OF WAR — the gray-out of "where the tether doesn't reach yet": + * a full-plate dim layer with each tether circle ERASED (destination-out) + * through a feathered edge. Painted over everything world-space; the + * tether boundary arcs are then drawn over the fog so the line stays sharp. + */ +function drawFog(ctx, w, h, dpr, tethers, tf) { + const colors = config.get('map.chart.colors', {}); + const alpha = colors.fogAlpha ?? 0.62; + const color = colors.fog ?? '#04070f'; + const fcvs = document.createElement('canvas'); + fcvs.width = Math.max(1, Math.round(w * dpr)); + fcvs.height = Math.max(1, Math.round(h * dpr)); + const f = fcvs.getContext('2d'); + f.scale(dpr, dpr); + f.fillStyle = rgba(color, alpha); + f.fillRect(0, 0, w, h); + f.globalCompositeOperation = 'destination-out'; + for (const t of tethers) { + if (typeof t?.radius !== 'number') continue; + const rpx = Math.max(2, t.radius * tf.scale) * 1.04; // feathered rim + const x = tf.toX(t.x); + const y = tf.toY(t.y); + const g = f.createRadialGradient(x, y, 0, x, y, rpx); + g.addColorStop(0, 'rgba(0,0,0,1)'); + g.addColorStop(0.9, 'rgba(0,0,0,1)'); + g.addColorStop(1, 'rgba(0,0,0,0)'); + f.fillStyle = g; + f.beginPath(); + f.arc(x, y, rpx, 0, TAU); + f.fill(); + } + const saved = ctx.getTransform(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.drawImage(fcvs, 0, 0); + ctx.setTransform(saved); +} + +/** + * The tether UNION boundary — the visible arcs only (Tether.visibleArcs), + * drawn as a cyber glitch dotted line (data/map.json → tetherLine), over + * the fog. The dashes are laid out in screen px so their rhythm holds + * regardless of scale. + */ +function drawTetherArcs(ctx, tethers, tf) { + const lc = config.get('map.chart.tetherLine', {}); + const dash = lc.dash ?? 7; + const gap = lc.gap ?? 5; + const width = lc.width ?? 2; + const coreWidth = lc.coreWidth ?? 1; + const core = lc.core ?? '#dff6ff'; + const main = toCss(C.neon); + for (const t of tethers) { + if (typeof t?.radius !== 'number') continue; + const rpx = t.radius * tf.scale; + if (rpx < 2) continue; + const arcs = Tether.visibleArcs(t, tethers); + const x = tf.toX(t.x); + const y = tf.toY(t.y); + for (const arc of arcs) { + const L = Math.max(0.001, (arc.a1 - arc.a0) * rpx); + const passes = [ + { w: width + 4, color: main, a: 0.08 }, + { w: width, color: main, a: 0.5 }, + { w: coreWidth, color: core, a: 0.9 }, + ]; + for (const p of passes) { + ctx.lineWidth = p.w; + ctx.strokeStyle = rgba(p.color, p.a); + let s = 0; + while (s < L) { + const seg = Math.min(dash, L - s); + ctx.beginPath(); + ctx.arc(x, y, rpx, arc.a0 + s / rpx, arc.a0 + (s + seg) / rpx); + ctx.stroke(); + s += dash + gap; + } + } + // tangent-point nodes (where arcs begin/end against another tether) + ctx.fillStyle = rgba(core, 0.85); + for (const aEnd of [arc.a0, arc.a1]) { + ctx.beginPath(); + ctx.arc(x + Math.cos(aEnd) * rpx, y + Math.sin(aEnd) * rpx, 1.6, 0, TAU); + ctx.fill(); + } + } + } +} + +/** Name + kind label under an object (dark backing for legibility). */ +function drawLabel(ctx, x, y, name, sub) { + const lc = config.get('map.chart.labels', {}); + if (lc.enabled === false) return; + const size = lc.size ?? 10; + ctx.textAlign = 'center'; + ctx.font = `700 ${size}px ${HEADER}`; + const w1 = ctx.measureText(String(name).toUpperCase()).width; + let w2 = 0; + if (sub) { + ctx.font = `500 ${size - 2}px ${BODY}`; + w2 = ctx.measureText(String(sub).toUpperCase()).width; + } + const bw = Math.max(w1, w2); + const bh = sub ? size + 12 : size + 7; + ctx.fillStyle = 'rgba(3,7,14,0.72)'; + ctx.fillRect(x - bw / 2 - 5, y, bw + 10, bh); + ctx.fillStyle = rgba(toCss(C.ink), 0.95); + ctx.font = `700 ${size}px ${HEADER}`; + ctx.fillText(String(name).toUpperCase(), x, y + size); + if (sub) { + ctx.fillStyle = rgba(toCss(C.dim), 0.9); + ctx.font = `500 ${size - 2}px ${BODY}`; + ctx.fillText(String(sub).toUpperCase(), x, y + size + 7); + } + ctx.textAlign = 'left'; +} + +/** Plate chrome: border, edge ruler ticks, scale bar (world px). */ +function niceStep(scale) { + const cands = [256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072]; + for (const c of cands) if (c * scale >= 60) return c; + return cands[cands.length - 1]; +} +function drawChrome(ctx, w, h, bounds, tf) { + const faint = rgba(toCss(C.faint), 0.9); + ctx.strokeStyle = rgba(toCss(C.faint), 0.55); + ctx.lineWidth = 1; + ctx.strokeRect(0.5, 0.5, w - 1, h - 1); + // ruler ticks along the bottom + left, every grid step (minor) / 4th (major) + const step = gridStepFor(tf.scale); + ctx.strokeStyle = 'rgba(125,146,196,0.35)'; + let i = 0; + for (let k = Math.ceil(bounds.minX / step); k * step <= bounds.maxX; k++, i++) { + const x = tf.toX(k * step); + if (x < 4 || x > w - 4) continue; + const L = i % 4 === 0 ? 9 : 5; + ctx.lineWidth = i % 4 === 0 ? 1 : 0.7; + ctx.beginPath(); + ctx.moveTo(x, h - 1); + ctx.lineTo(x, h - 1 - L); + ctx.stroke(); + } + i = 0; + for (let k = Math.ceil(bounds.minY / step); k * step <= bounds.maxY; k++, i++) { + const y = tf.toY(k * step); + if (y < 4 || y > h - 4) continue; + const L = i % 4 === 0 ? 9 : 5; + ctx.lineWidth = i % 4 === 0 ? 1 : 0.7; + ctx.beginPath(); + ctx.moveTo(1, y); + ctx.lineTo(1 + L, y); + ctx.stroke(); + } + // scale bar, bottom-right + const D = niceStep(tf.scale); + const len = D * tf.scale; + const bx = w - len - 16; + const by = h - 18; + ctx.strokeStyle = faint; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(bx, by); + ctx.lineTo(bx + len, by); + ctx.moveTo(bx, by - 4); + ctx.lineTo(bx, by + 4); + ctx.moveTo(bx + len, by - 4); + ctx.lineTo(bx + len, by + 4); + ctx.stroke(); + ctx.fillStyle = rgba(toCss(C.dim), 0.9); + ctx.font = `500 9px ${BODY}`; + ctx.textAlign = 'center'; + ctx.fillText(`${D} PX`, bx + len / 2, by - 6); + ctx.textAlign = 'left'; +} + +/** Edge vignette (data/map.json → chart.vignette). */ +function drawVignette(ctx, w, h) { + const alpha = config.get('map.chart.vignette', 0.5); + const g = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.42, w / 2, h / 2, Math.hypot(w, h) / 2); + g.addColorStop(0, 'rgba(2,4,10,0)'); + g.addColorStop(1, `rgba(2,4,10,${alpha})`); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); +} + +/** + * PAINT the whole chart plate (CSS-px coords; the canvas is dpr-scaled by + * the caller). Pure given the snapshot — every pixel deterministic. + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} w,h — plate size in CSS px + * @param {number} dpr — device pixel ratio (for the fog layer's sharpness) + * @param {object} snap — the GameScene chart snapshot (see mapChartSnapshot) + * @returns {{tf:object, bounds:object, hits:Array}} — the world→plate + * transform + the hit list for pointer queries + */ +function paintChart(ctx, w, h, dpr, snap) { + const pad = config.get('map.bounds.padding', 1024); + // the frame: EVERY object (found or not) + the central body + the + // player's tether reach (a zone is part of the system's extent), padded + const boundsObjs = [...(snap.objects ?? []), ...(snap.tethers ?? [])]; + if (snap.central) boundsObjs.push({ x: 0, y: 0, radius: snap.central.radius ?? 200 }); + const bounds = chartBounds(boundsObjs, pad); + const tf = fitToRect(bounds, w, h); + const pmin = config.get('map.chart.planetMin', 5); + const pmax = config.get('map.chart.planetMax', 13); + const smin = config.get('map.chart.starMin', 14); + const smax = config.get('map.chart.starMax', 34); + + // 1 — base: deep-space gradient + center glow + starfield + const bg = ctx.createLinearGradient(0, 0, 0, h); + bg.addColorStop(0, 'rgba(7,12,24,1)'); + bg.addColorStop(1, 'rgba(3,5,11,1)'); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, w, h); + const g0 = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, Math.max(w, h) * 0.62); + g0.addColorStop(0, 'rgba(24,42,82,0.3)'); + g0.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g0; + ctx.fillRect(0, 0, w, h); + drawStars(ctx, w, h, `map:${snap.systemId ?? 'x'}`); + + // 2 — grid + 3 — tether zone glows + drawGrid(ctx, w, h, bounds, tf); + drawTetherZones(ctx, snap.tethers ?? [], tf); + + // 4 — the objects (DISCOVERED ONLY — the undiscovered stay hidden) + const hits = []; + const labelJobs = []; + for (const o of snap.objects ?? []) { + if (!o?.discovered) continue; + const x = tf.toX(o.x); + const y = tf.toY(o.y); + const rpx = Math.max(3, (o.radius ?? 60) * tf.scale); + switch (o.kind) { + case 'planet': + drawPlanetDisc(ctx, x, y, Math.max(pmin, Math.min(pmax, rpx)), o.tint ?? '#9fb6d8'); + break; + case 'station': + drawStation(ctx, x, y, Math.max(6, Math.min(14, rpx))); + break; + case 'gate': + drawGate(ctx, x, y, Math.max(7, Math.min(15, rpx)), o.rotation ?? 0, o.active !== false); + break; + case 'cluster': + drawCluster(ctx, x, y, Math.max(10, rpx), o.rocks ?? [], tf.scale); + break; + } + labelJobs.push({ x, y, r: Math.max(6, rpx), name: o.name ?? o.id, sub: o.typeLabel }); + hits.push({ + id: o.id, + x, + y, + r: Math.max(6, rpx), + hitR: Math.min(Math.max(10, rpx), 30), + label: o.name ?? o.id, + }); + } + // 5 — the central body + its label + if (snap.central) { + const cx = tf.toX(0); + const cy = tf.toY(0); + const cpx = Math.max(smin, Math.min(smax, (snap.central.radius ?? 200) * tf.scale)); + drawCentral(ctx, cx, cy, cpx, snap.central); + labelJobs.push({ x: cx, y: cy, r: cpx, name: snap.central.name, sub: snap.central.isHome ? 'HOME WORLD' : snap.central.typeLabel }); + } + + // 6 — labels (before the fog, so out-of-range ones dim with it) + for (const j of labelJobs) { + let ly = j.y + j.r + 7; + if (ly + 30 > h) ly = j.y - j.r - 7 - 26; // flip above when off the bottom edge + drawLabel(ctx, j.x, ly, j.name, j.sub); + } + + // 7 — FOG: dim everything the tether union doesn't cover + drawFog(ctx, w, h, dpr, snap.tethers ?? [], tf); + + // 8 — the tether union boundary (over the fog, sharp) + drawTetherArcs(ctx, snap.tethers ?? [], tf); + + // 9 — chrome + vignette + drawChrome(ctx, w, h, bounds, tf); + drawVignette(ctx, w, h); + + return { tf, bounds, hits }; +} + +export class MapWindow extends Phaser.GameObjects.Container { + /** The left-panel clip (assets/videos/map.mp4, loaded by the scene). */ + static VIDEO_KEY = 'map_feed'; + + /** + * @param {Phaser.Scene} scene + * @param {object} [opts] + * @param {() => object|null} [opts.getChart] — the live chart snapshot + * (GameScene.mapChartSnapshot; polled while open, repaint on change) + * @param {() => {x:number, y:number, heading:number}|null} [opts.getShip] + * — the ship's live world position + heading (every frame) + * @param {(objectId: string) => void} [opts.onSelect] — a discovered + * object was clicked on the chart (the scene plots the course) + * @param {() => void} [opts.onLocked] — the (standby) GALAXY tab was hit + */ + constructor(scene, opts = {}) { + super(scene, 0, 0); + this.scene.add.existing(this); // v4: new'd containers are not on the display list + this.setDepth(80); // above the action bar (50) + its sub-bars (60) + this.setScrollFactor(0); + + this.getChart = opts.getChart ?? null; + this.getShip = opts.getShip ?? null; + this.onSelect = opts.onSelect ?? null; + this.onLocked = opts.onLocked ?? null; + + this.openState = 'closed'; // 'open' | 'opening' | 'closing' | 'closed' + this.reveal = []; + this.decodes = []; + this._tabShakes = []; + this._glitch = { until: 0, next: 0, dur: 260 }; + this._pollNext = 0; + this._fp = null; + this._snap = null; + this._tf = null; + this._bounds = null; + this._hits = []; + this._hover = null; + this._flash = null; + this._texN = 0; + this._texKey = null; + this._fontsRepaintDone = false; // the webfont repaint runs at most ONCE + this._destroyed = false; + + this._build(); + this.setVisible(true); + this.setAlpha(0); // hidden until open() — the scene is built once, shown on demand + } + + sfx(name) { + playSfxOn(this.scene, name); + } + + hasVideo(key) { + const c = this.scene.cache?.video; + return !!(c && typeof c.has === 'function' && c.has(key)); + } + + /** Decode text into `txt` starting at `t0` (driven by update()). */ + decodeTo(txt, str, t0, dur = 520) { + this.decodes = this.decodes.filter((d) => d.txt !== txt); + if (txt.text === str) return; + txt.setText(''); + this.decodes.push({ txt, dec: new ScrambleDecode(str, t0, dur) }); + } + + // ------------------------------------------------------------ geometry + _measure() { + const W = this.scene.scale.width; + const H = this.scene.scale.height; + const m = 10; + const pad = 14; + const rect = { x: m, y: m, w: W - 2 * m, h: H - 2 * m }; + const titleH = 46; + const bodyY = rect.y + titleH + pad; + const bodyH = rect.h - titleH - 2 * pad; + // left: the cartography feed — 2:3 (data/map.json → video.aspect), + // header above, status strip below + const a = config.get('map.video.aspect', [2, 3]); + const ar = a[0] > 0 && a[1] > 0 ? a[0] / a[1] : 2 / 3; + const videoH = Math.max(120, bodyH - 22 - 36 - 24 - 8); + const videoW = Math.max(90, videoH * ar); + const leftX = rect.x + pad; + const leftW = videoW + 30; + // right: tabs / chart plate / system plate + const rightX = leftX + leftW + pad; + const rightW = rect.x + rect.w - pad - rightX; + const tabH = 38; + const tabGap = 12; + const statsH = 150; + const statsGap = 12; + const mapTop = bodyY + tabH + tabGap; + const statsY = bodyY + bodyH - statsH; + const mapX = rightX; + const mapY = mapTop; + const mapW = rightW; + const mapH = Math.max(160, statsY - statsGap - mapY); + return { + W, H, rect, titleH, pad, + bodyY, bodyH, + videoH, videoW, leftX, leftW, + rightX, rightW, tabH, tabGap, + mapX, mapY, mapW, mapH, statsX: rightX, statsY, statsW: rightW, statsH, + }; + } + + // ------------------------------------------------------------ build + _build() { + const s = this.scene.add; + const G = (d = 0) => { + const g = s.graphics().setScrollFactor(0).setDepth(d); + this.add(g); + return g; + }; + const T = (x, y, str, style, d = 0) => { + const t = s.text(x, y, str, style).setScrollFactor(0).setDepth(d); + this.add(t); + return t; + }; + + this.geo = this._measure(); + const { rect, titleH } = this.geo; + + // ── window body ─────────────────────────────────────────────── + const bg = G(0); + bg.clear(); + panel(bg, rect.x, rect.y, rect.w, rect.h, { + notch: 20, + fill: C.bg, + fillAlpha: 0.985, + stroke: 0x0e5f86, + strokeAlpha: 0.95, + }); + bg.fillStyle(C.neon, 0.16); + bg.fillRect(rect.x + 20, rect.y + rect.h - 1.5, rect.w - 40, 1.5); + this.bgG = bg; + + // ── title bar ───────────────────────────────────────────────── + this.titleTxt = T(rect.x + 18, rect.y + 13, '', { + fontFamily: HEADER, + fontSize: '17px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 7, + }, 1); + this.titleCursor = G(1); + this.titleMeta = T(rect.x + rect.w - 54, rect.y + 15, config.get('map.meta', 'NAV DATA // LIVE FEED'), { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.faint), + letterSpacing: 2.5, + }, 1); + this.titleMeta.setOrigin(1, 0); + const railY = rect.y + titleH - 1; + this.titleRailG = G(1); + this.titleRailG.clear(); + this.titleRailG.lineStyle(1, 0x14324f, 0.8); + this.titleRailG.lineBetween(rect.x + 16, railY, rect.x + rect.w - 16, railY); + this.titleRailG.fillStyle(C.neon, 0.9); + this.titleRailG.fillRect(rect.x + 16, railY - 1, 90, 2); + + // close button (top right) + const cw = 30; + const cx = rect.x + rect.w - 18 - cw / 2; + const cy = rect.y + titleH / 2 - 2; + this.closeBtn = { x: cx, y: cy, w: cw, h: cw, hover: false }; + this.closeG = G(2); + this.closeTxt = T(cx, cy + 1, '✕', { + fontFamily: BODY, + fontSize: '15px', + color: toCss(C.neon2), + align: 'center', + }, 2); + this.closeTxt.setOrigin(0.5, 0.5); // Text's default origin is (0,0) + // v4: setInteractive takes a config object — a bare shape leaves + // input.hitAreaCallback unset and pointWithinHitArea throws. + const closeRect = new Phaser.Geom.Rectangle(cx - cw / 2, cy - cw / 2, cw, cw); + this.closeG.setInteractive({ + useHandCursor: true, + hitArea: closeRect, + hitAreaCallback: (area, px, py) => area.contains(px, py), + }); + this.closeG.on('pointerover', () => { + this.closeBtn.hover = true; + this._paintClose(); + }); + this.closeG.on('pointerout', () => { + this.closeBtn.hover = false; + this._paintClose(); + }); + this.closeG.on('pointerdown', () => { + this.sfx('ui_click'); + this.close(); + }); + this._paintClose(); + + // glitch layers (top of the window) + this.glitchG = G(10); + this.titleGhostA = this._ghost(); + this.titleGhostB = this._ghost(); + + // ── left: the cartography feed (video) ──────────────────────── + this.videoPanel = this._buildVideoPanel(); + this.add(this.videoPanel); + this.videoPanel.depth = 1; + + // ── right: tabs + chart plate + system plate ────────────────── + this._buildTabs(); + this._buildPlate(); + this._buildStats(); + + // first paint (GameScene already has its system — data is live now) + const snap = this._snapOf(); + if (snap) this._applySnap(snap); + } + + _ghost() { + const t = this.scene.add + .text(0, 0, '', { + fontFamily: HEADER, + fontSize: '17px', + color: toCss(C.neon), + fontStyle: 'bold', + letterSpacing: 7, + }) + .setScrollFactor(0) + .setAlpha(0) + .setBlendMode(Phaser.BlendModes.ADD); + t.setDepth(2); + this.add(t); + return t; + } + + _paintClose() { + const b = this.closeBtn; + this.closeG.clear(); + panel(this.closeG, b.x - b.w / 2, b.y - b.h / 2, b.w, b.h, { + notch: 6, + fill: b.hover ? 0x3a0f1e : C.panel, + fillAlpha: b.hover ? 0.9 : 0.5, + stroke: C.neon2, + strokeAlpha: b.hover ? 1 : 0.6, + }); + this.closeTxt.setColor(b.hover ? toCss('#ff8fae') : toCss(C.neon2)); + this.closeTxt.setAlpha(b.hover ? 1 : 0.85); + } + + // ── left panel: the cartography feed ──────────────────────────────────── + _buildVideoPanel() { + const { leftX, bodyY, leftW, videoW, videoH } = this.geo; + const cont = new Phaser.GameObjects.Container(this.scene, leftX, bodyY); + const s = this.scene.add; + + // header: ● CARTOGRAPHY FEED // LIVE + this.recDot = s.circle(10, 12, 4, C.amber, 0.9).setScrollFactor(0); + cont.add(this.recDot); + const head = s + .text(20, 6, 'CARTOGRAPHY FEED // LIVE', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.faint), + letterSpacing: 2.5, + }) + .setScrollFactor(0); + cont.add(head); + + // frame + the clip + const vx = (leftW - videoW) / 2; + const vy = 26; + const frameG = s.graphics().setScrollFactor(0); + frameG.clear(); + brackets(frameG, vx - 6, vy - 6, videoW + 12, videoH + 12, { length: 12, color: C.neon, alpha: 0.7 }); + frameG.lineStyle(1, 0x14324f, 0.55); + frameG.lineBetween(vx - 6, vy + videoH / 2, vx - 2, vy + videoH / 2); + frameG.lineBetween(vx + videoW + 2, vy + videoH / 2, vx + videoW + 6, vy + videoH / 2); + cont.add(frameG); + + this.videoCx = vx + videoW / 2; + this.videoCy = vy + videoH / 2; + this.videoRect = { x: vx, y: vy, w: videoW, h: videoH }; + + this.video = null; + if (this.hasVideo(MapWindow.VIDEO_KEY)) { + const v = s.video(0, 0, MapWindow.VIDEO_KEY); + v.setOrigin(0.5); + v.setScrollFactor(0); + // The feed is ambience: muted + looping, as the console requires. + v.setVolume(0); + v.setLoop(true); + // v4's setVolume() only sets el.volume — the browser's autoplay + // policy looks at el.muted. Mute it properly. + if (v.video) v.video.muted = true; + // v4 quirk: the bookkeeping size is a placeholder until the first + // presented frame — fit now, refit on 'created' (true w/h). + const fit = (vv, iw = 0, ih = 0) => { + const el = vv.video; + const vw = iw || (el && (el.videoWidth || el.width)) || (vv.frame && vv.frame.realWidth) || 544; + const vh = ih || (el && (el.videoHeight || el.height)) || (vv.frame && vv.frame.realHeight) || 800; + const sc = Math.min(videoW / vw, videoH / vh); + vv.setPosition(this.videoCx, this.videoCy); + vv.setScale(sc); + }; + fit(v); + // Keep the clip hidden until ready ("created" carries the true dims) + // so the frame never flashes a black box. + const ready = (vv, w, h) => { + if (vv !== v) return; + fit(vv, w, h); + vv.setVisible(true); + }; + v.on('created', ready); + if (v.video && v.video.readyState >= 1) ready(v, 0, 0); + cont.add(v); + this.video = v; + } else { + // NO SIGNAL plate (asset missing) — the console still works. + const ph = s.graphics().setScrollFactor(0); + ph.clear(); + panel(ph, vx, vy, videoW, videoH, { notch: 6, fill: 0x050a12, fillAlpha: 0.9, stroke: 0x1b3a5a, strokeAlpha: 0.5 }); + for (let i = 0; i < 40; i++) { + ph.fillStyle(C.neon, rand(0.02, 0.08)); + ph.fillRect(vx + rand(0, videoW - 4), vy + rand(0, videoH), rand(8, 60), 1); + } + cont.add(ph); + const ns = s.text(this.videoCx, this.videoCy - 8, 'NO SIGNAL', { + fontFamily: HEADER, + fontSize: '14px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 5, + align: 'center', + }).setScrollFactor(0); + const nsub = s.text(this.videoCx, this.videoCy + 12, 'CARTOGRAPHY FEED OFFLINE', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.faint), + letterSpacing: 3, + align: 'center', + }).setScrollFactor(0); + cont.add(ns); + cont.add(nsub); + } + + // scanlines over the feed + const scanKey = 'map_scanlines'; + if (!this.scene.textures.exists(scanKey)) { + const c = document.createElement('canvas'); + c.width = 4; + c.height = 8; + const ctx = c.getContext('2d'); + ctx.fillStyle = 'rgba(2,6,12,0.5)'; + ctx.fillRect(0, 0, 4, 3); + ctx.fillStyle = 'rgba(120,220,255,0.05)'; + ctx.fillRect(0, 4, 4, 1); + this.scene.textures.addCanvas(scanKey, c); + } + this.scan = s.tileSprite(this.videoCx, this.videoCy, videoW, videoH, scanKey).setScrollFactor(0).setAlpha(0.5); + cont.add(this.scan); + + // sweep band (crawls down the feed on a loop) + const swKey = 'map_sweep'; + if (!this.scene.textures.exists(swKey)) { + const c = document.createElement('canvas'); + c.width = 8; + c.height = 64; + const ctx = c.getContext('2d'); + const grad = ctx.createLinearGradient(0, 0, 0, 64); + grad.addColorStop(0, 'rgba(127,223,255,0)'); + grad.addColorStop(0.5, 'rgba(127,223,255,0.5)'); + grad.addColorStop(1, 'rgba(127,223,255,0)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 8, 64); + this.scene.textures.addCanvas(swKey, c); + } + this.sweepBand = s + .image(this.videoCx, this.videoRect.y - 40, swKey) + .setDisplaySize(videoW, 48) + .setScrollFactor(0) + .setAlpha(0) + .setBlendMode(Phaser.BlendModes.ADD); + cont.add(this.sweepBand); + this.sweepCfg = config.get('map.sweep', {}); + + // bottom status strip — live system counts + const sy = vy + videoH + 14; + const strip = s.graphics().setScrollFactor(0); + strip.clear(); + strip.lineStyle(1, 0x14324f, 0.8); + strip.lineBetween(2, sy, leftW - 2, sy); + strip.fillStyle(C.neon, 0.7); + strip.fillRect(2, sy - 1, 26, 2); + cont.add(strip); + this.statusTxt = s + .text(2, sy + 10, 'NAV FEED STANDBY', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.faint), + letterSpacing: 2, + }) + .setScrollFactor(0); + cont.add(this.statusTxt); + this.statusBar = s.graphics().setScrollFactor(0); + this.statusBarY = sy + 32; + this.statusBarW = Math.max(40, leftW - 60); + cont.add(this.statusBar); + + return cont; + } + + // ── right: the map/space tabs ──────────────────────────────────────────── + _buildTabs() { + const { rightX, bodyY, tabH, rightW } = this.geo; + this.tabs = []; + // data/map.json → tabs is an OBJECT MAP (id → tab) — accept an array too. + const raw = config.get('map.tabs', null); + const tabs = Array.isArray(raw) + ? raw + : raw && typeof raw === 'object' + ? Object.values(raw).filter((t) => t && typeof t === 'object' && t.id) + : []; + if (!tabs.length) { + tabs.push({ id: 'currentSystem', label: 'Current System', accent: '#00e5ff' }); + tabs.push({ id: 'galaxy', label: 'Galaxy', accent: '#ffc94d', standby: true, standbyTag: 'OFFLINE' }); + } + let x = rightX; + const avail = rightW; + // two equal sockets across the column — the deck's tab row + const tabW = Math.min(220, (avail - 12) / 2); + for (const tab of tabs) { + const label = String(tab.label ?? tab.id).toUpperCase(); + const txt = this.scene.add + .text(0, 0, label, { + fontFamily: BODY, + fontSize: '12px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 2, + }) + .setScrollFactor(0); + const w = tabW; + const y = bodyY + tabH / 2; + const g = this.scene.add.graphics().setScrollFactor(0); + const entry = { id: tab.id, x, y, w, h: tabH, txt, g, accent: toColor(tab.accent ?? C.neon, C.neon), hover: false, standby: tab.standby === true, tag: tab.standbyTag ?? tab.tag ?? null, active: tab.active === true, baseTxtX: 0 }; + const tabRect = new Phaser.Geom.Rectangle(x, y - tabH / 2, w, tabH); + g.setInteractive({ + useHandCursor: true, + hitArea: tabRect, + hitAreaCallback: (area, px, py) => area.contains(px, py), + }); + g.on('pointerover', () => { + entry.hover = true; + this.sfx('ui_hover'); + this._paintTabs(); + }); + g.on('pointerout', () => { + entry.hover = false; + this._paintTabs(); + }); + g.on('pointerdown', () => this._tabHit(entry)); + const labelX = x + 14 + (entry.standby && entry.tag ? 26 : 0); + txt.setPosition(labelX, y - 4); + entry.baseTxtX = labelX; + g.setDepth(2); + txt.setDepth(3); + this.add(g); + this.add(txt); + if (entry.standby && entry.tag) { + const tag = this.scene.add + .text(0, 0, entry.tag, { + fontFamily: BODY, + fontSize: '8px', + color: toCss(C.amber), + fontStyle: 'bold', + letterSpacing: 1.5, + }) + .setScrollFactor(0) + .setAlpha(0.75) + .setDepth(3); + tag.setPosition(x + 14, y + 10); + this.add(tag); + entry.tagTxt = tag; + } + this.tabs.push(entry); + x += w + 12; + } + this._paintTabs(); + } + + _tabHit(entry) { + if (entry.standby) { + // GALAXY — built as standby for now: shake it + the scene toasts + this.sfx('ui_click'); + this._tabShakes.push({ tab: entry, t0: this.scene.time.now, dur: 340 }); + this.onLocked?.(); + return; + } + // CURRENT SYSTEM is already the live view — confirm, nothing to switch + this.sfx('ui_click'); + } + + _paintTabs() { + const { bodyY, tabH } = this.geo; + for (const t of this.tabs) { + t.g.clear(); + const active = t.active; + const edge = t.standby ? 0x22405f : active ? t.accent : t.hover ? C.ink : 0x22405f; + panel(t.g, t.x, bodyY + 1, t.w, tabH - 2, { + notch: 8, + fill: t.standby ? 0x0a1120 : active ? t.accent : C.panel, + fillAlpha: t.standby ? 0.3 : active ? 0.16 : t.hover ? 0.85 : 0.4, + stroke: edge, + strokeAlpha: t.standby ? 0.45 : active ? 1 : t.hover ? 0.9 : 0.5, + }); + if (active) { + t.g.fillStyle(t.accent, 1); + t.g.fillRect(t.x + 8, bodyY + tabH - 1, t.w - 16, 2); + } + const dx = t.x + 14; + const dy = bodyY + tabH / 2 - 1; + t.g.fillStyle(t.standby ? 0x3d4c74 : active ? t.accent : 0x3d4c74, t.standby ? 0.5 : active ? 1 : 0.5); + t.g.fillTriangle(dx, dy - 4.5, dx + 4.5, dy, dx, dy + 4.5, dx - 4.5, dy); + t.txt.setColor(t.standby ? toCss(C.dim) : active ? toCss(t.accent) : toCss(C.ink)); + t.txt.setAlpha(t.standby ? 0.62 : active ? 1 : t.hover ? 0.9 : 0.62); + } + } + + // ── right: the chart plate (canvas → Phaser image) + live overlays ───── + _blankKey() { + const key = '__map_blank'; + if (!this.scene.textures.exists(key)) { + const c = document.createElement('canvas'); + c.width = 2; + c.height = 2; + this.scene.textures.addCanvas(key, c); + } + return key; + } + + _buildPlate() { + const { mapX, mapY, mapW, mapH } = this.geo; + const s = this.scene.add; + const cx = mapX + mapW / 2; + const cy = mapY + mapH / 2; + + // the chart itself — an offscreen canvas shown as a Phaser image. + // Redrawn (new texture, old one dropped) whenever the snapshot changes. + this.mapImg = s.image(cx, cy, this._blankKey()).setScrollFactor(0).setDepth(1); + this.mapImg.setDisplaySize(mapW, mapH); + const hitRect = new Phaser.Geom.Rectangle(mapX, mapY, mapW, mapH); + this.mapImg.setInteractive({ + useHandCursor: false, + hitArea: hitRect, + hitAreaCallback: (area, px, py) => area.contains(px, py), + }); + this.mapImg.on('pointermove', (p) => this._setHover(p)); + this.mapImg.on('pointerout', () => this._setHover(null)); + this.mapImg.on('pointerdown', (p) => { + const pt = this._platePoint(p); + const o = this._objectAt(pt); + if (o) { + this.sfx('ui_click'); + this._flash = { x: o.x, y: o.y, r: o.r, t0: this.scene.time.now }; + this.onSelect?.(o.id); + } + }); + this.add(this.mapImg); + + // frame chrome over the plate edge (brackets + border) + this.plateFrame = s.graphics().setScrollFactor(0).setDepth(2); + brackets(this.plateFrame, mapX - 5, mapY - 5, mapW + 10, mapH + 10, { length: 16, color: C.neon, alpha: 0.75 }); + this.plateFrame.lineStyle(1, 0x14324f, 0.8); + this.plateFrame.strokeRect(mapX, mapY, mapW, mapH); + this.add(this.plateFrame); + + // LIVE NAV FEED tag (top-left of the plate) — the blinking dot is live + this.feedTag = s + .text(mapX + 10, mapY + 8, 'LIVE NAV FEED', { + fontFamily: BODY, + fontSize: '9px', + color: toCss(C.faint), + letterSpacing: 2, + }) + .setScrollFactor(0) + .setDepth(3); + this.add(this.feedTag); + this.feedDot = s.circle(mapX + 74, mapY + 12, 2.5, C.amber, 0.9).setScrollFactor(0).setDepth(3); + this.add(this.feedDot); + + // scan sweep band crawling across the plate (left → right, loop) + const swKey = 'map_plate_sweep'; + if (!this.scene.textures.exists(swKey)) { + const c = document.createElement('canvas'); + c.width = 64; + c.height = 8; + const ctx = c.getContext('2d'); + const grad = ctx.createLinearGradient(0, 0, 64, 0); + grad.addColorStop(0, 'rgba(127,223,255,0)'); + grad.addColorStop(0.5, 'rgba(127,223,255,0.4)'); + grad.addColorStop(1, 'rgba(127,223,255,0)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 64, 8); + this.scene.textures.addCanvas(swKey, c); + } + this.plateSweep = s + .image(mapX - 60, cy, swKey) + .setDisplaySize(70, mapH) + .setScrollFactor(0) + .setAlpha(0) + .setBlendMode(Phaser.BlendModes.ADD) + .setDepth(3); + this.add(this.plateSweep); + + // the SHIP marker — a live Phaser overlay (position updated every frame) + this.shipMark = new Phaser.GameObjects.Container(this.scene, 0, 0); + this.shipDart = new Phaser.GameObjects.Container(this.scene, 0, 0); + const dart = s.graphics().setScrollFactor(0); + dart.clear(); + dart.fillStyle(toColor(C.ink), 1); + dart.fillTriangle(6, 0, -4, 3.5, -4, -3.5); + this.shipDart.add(dart); + this.shipRing = s.circle(0, 0, 10, 0, 0).setStrokeStyle(1.5, C.neon, 0.9).setScrollFactor(0); + this.shipLbl = s + .text(0, 13, 'SHIP', { + fontFamily: BODY, + fontSize: '9px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 2, + align: 'center', + }) + .setOrigin(0.5, 0) + .setScrollFactor(0); + this.shipMark.add([this.shipDart, this.shipRing, this.shipLbl]); + this.shipMark.setVisible(false).setDepth(4); + this.add(this.shipMark); + + // hover highlight ring + click flash + course line (shared graphics) + this.hoverG = s.graphics().setScrollFactor(0).setDepth(4); + this.add(this.hoverG); + + // tooltip (name + kind + "tap to plot course") + this.ttPlate = s.graphics().setScrollFactor(0).setDepth(5); + this.ttName = s + .text(0, 0, '', { + fontFamily: HEADER, + fontSize: '12px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 1.5, + }) + .setScrollFactor(0) + .setDepth(5); + this.ttSub = s + .text(0, 0, '', { + fontFamily: BODY, + fontSize: '9px', + color: toCss(C.dim), + letterSpacing: 1.5, + }) + .setScrollFactor(0) + .setDepth(5); + this.ttHint = s + .text(0, 0, 'TAP TO PLOT COURSE', { + fontFamily: BODY, + fontSize: '8px', + color: toCss(C.amber), + letterSpacing: 2, + }) + .setScrollFactor(0) + .setDepth(5); + this.ttCont = new Phaser.GameObjects.Container(this.scene, 0, 0); + this.ttCont.setVisible(false).setDepth(5); + this.add(this.ttCont); + + // glitch slices over the plate (top of the stack) + this.mapGlitchG = s.graphics().setScrollFactor(0).setDepth(9); + this.add(this.mapGlitchG); + } + + // ── right: the SYSTEM plate (name + discovery/resources readout) ───────── + _buildStats() { + const { statsX, statsY, statsW, statsH } = this.geo; + const s = this.scene.add; + const pad = 14; + + this.statsG = s.graphics().setScrollFactor(0).setDepth(1); + this.add(this.statsG); + + // system name (decoded on open) + home tag + this.sysNameTxt = s + .text(statsX + pad, statsY + 10, '', { + fontFamily: HEADER, + fontSize: '15px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 3, + }) + .setScrollFactor(0) + .setDepth(2); + this.add(this.sysNameTxt); + this.sysTag = s + .text(statsX + statsW - pad, statsY + 14, '', { + fontFamily: BODY, + fontSize: '9px', + color: toCss(C.amber), + fontStyle: 'bold', + letterSpacing: 2, + align: 'right', + }) + .setOrigin(1, 0) + .setScrollFactor(0) + .setDepth(2); + this.add(this.sysTag); + + const rowY0 = statsY + 40; + const rowH = 34; + // SYSTEM DISCOVERY — label + value + segmented bar + const labels = config.get('map.stats', {}); + this.navLabel = s + .text(statsX + pad, rowY0, labels.discoveryLabel ?? 'SYSTEM DISCOVERY', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.dim), + letterSpacing: 2, + }) + .setScrollFactor(0) + .setDepth(2); + this.add(this.navLabel); + if (labels.discoveryMeta) { + const meta = s + .text(statsX + pad, rowY0 + 1, labels.discoveryMeta, { + fontFamily: BODY, + fontSize: '8px', + color: toCss(C.faint), + letterSpacing: 1.5, + }) + .setScrollFactor(0) + .setDepth(2); + meta.x = this.navLabel.x + this.navLabel.width + 12; + this.add(meta); + } + this.navValue = s + .text(statsX + statsW - pad, rowY0, '—', { + fontFamily: BODY, + fontSize: '11px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 1, + align: 'right', + }) + .setOrigin(1, 0) + .setScrollFactor(0) + .setDepth(2); + this.add(this.navValue); + this.navBarY = rowY0 + 16; + this.barW = statsW - pad * 2; + + this.resLabel = s + .text(statsX + pad, rowY0 + rowH, labels.resourcesLabel ?? 'SYSTEM RESOURCES', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.dim), + letterSpacing: 2, + }) + .setScrollFactor(0) + .setDepth(2); + this.add(this.resLabel); + if (labels.resourcesMeta) { + const meta = s + .text(statsX + pad, rowY0 + rowH + 1, labels.resourcesMeta, { + fontFamily: BODY, + fontSize: '8px', + color: toCss(C.faint), + letterSpacing: 1.5, + }) + .setScrollFactor(0) + .setDepth(2); + meta.x = this.resLabel.x + this.resLabel.width + 12; + this.add(meta); + } + this.resValue = s + .text(statsX + statsW - pad, rowY0 + rowH, '—', { + fontFamily: BODY, + fontSize: '11px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 1, + align: 'right', + }) + .setOrigin(1, 0) + .setScrollFactor(0) + .setDepth(2); + this.add(this.resValue); + this.resBarY = rowY0 + rowH + 16; + + // legend — the plate's glyph language (static) + const legY = statsY + statsH - 16; + this.legendG = s.graphics().setScrollFactor(0).setDepth(2); + this.add(this.legendG); + const legendRaw = config.get('map.legend.items', null) ?? config.get('map.legend', null); + const legend = Array.isArray(legendRaw) ? legendRaw : []; + const probe = s.text(0, 0, '', { fontFamily: BODY, fontSize: '9px', color: toCss(C.faint), letterSpacing: 1.5 }); + this.add(probe); + let lx = statsX + pad; + const spacing = 14; + for (const item of legend) { + probe.setText(String(item.label ?? '').toUpperCase()); + const tw = probe.width; + this._legendGlyph(item.glyph, lx, legY + 4); + const t = s + .text(lx + 14, legY, String(item.label ?? '').toUpperCase(), { + fontFamily: BODY, + fontSize: '9px', + color: toCss(C.faint), + letterSpacing: 1.5, + }) + .setScrollFactor(0) + .setDepth(2); + this.add(t); + lx += 14 + tw + spacing + 12; + } + probe.setVisible(false); + } + + /** One legend glyph, centred on (x, y). */ + _legendGlyph(kind, x, y) { + const g = this.legendG; + const neon = toColor(C.neon); + const dim = toColor(C.dim); + const amber = toColor(C.amber); + switch (kind) { + case 'planet': + g.fillStyle(0x9fb6d8, 0.95); + g.fillCircle(x, y, 3.5); + g.lineStyle(0.8, 0x48608c, 0.9); + g.strokeCircle(x, y, 3.5); + break; + case 'station': + g.lineStyle(1, neon, 0.9); + g.strokeCircle(x, y, 3.5); + g.lineBetween(x - 6, y, x + 6, y); + g.fillStyle(0xeaf6ff, 1); + g.fillCircle(x, y, 1.3); + break; + case 'gate': + g.lineStyle(1, neon, 0.9); + g.strokeCircle(x, y, 4); + g.strokeCircle(x, y, 2.4); + break; + case 'cluster': + case 'rock': + g.fillStyle(0xa99c8c, 0.95); + g.fillCircle(x - 2.5, y + 1, 1.6); + g.fillCircle(x + 1.5, y - 1.5, 1.3); + g.fillCircle(x + 2.5, y + 2, 1); + break; + case 'tether': + g.lineStyle(1.2, neon, 0.9); + g.strokeCircle(x, y, 4); + g.lineStyle(1, amber, 0.8); + g.strokeCircle(x, y, 5.5, 0.4, 2.4); + break; + case 'ship': + g.fillStyle(0xeaf6ff, 1); + g.fillTriangle(x + 4, y, x - 3, y + 3, x - 3, y - 3); + break; + default: + g.fillStyle(dim, 0.7); + g.fillCircle(x, y, 2); + } + } + + /** The two segmented readout bars (one segment per object, lit = found). */ + _drawBar(g, y, total, found, accent) { + const { statsX, statsW } = this.geo; + const pad = 14; + const w = this.barW; + g.fillStyle(0x0a1424, 0.9); + g.fillRect(statsX + pad, y, w, 5); + if (total <= 0) return; + const gap = 2; + const seg = (w - gap * (total - 1)) / total; + for (let i = 0; i < total; i++) { + const sx = statsX + pad + i * (seg + gap); + if (i < found) { + g.fillStyle(accent, 0.95); + g.fillRect(sx, y, seg, 5); + g.fillStyle(0xffffff, 0.2); + g.fillRect(sx, y, seg, 1.5); + } + } + } + + _paintStats() { + const snap = this._snap; + const { statsX, statsY, statsW, statsH } = this.geo; + this.statsG.clear(); + panel(this.statsG, statsX, statsY, statsW, statsH, { + notch: 10, + fill: C.panel, + fillAlpha: 0.6, + stroke: C.faint, + strokeAlpha: 0.6, + }); + if (!snap) return; + // name + tag + this.sysNameTxt.setText(String(snap.systemName ?? '').toUpperCase()); + this.sysTag.setText(snap.central?.isHome ? (config.get('map.stats.homeTag', 'HOME SYSTEM')) : String(snap.central?.typeLabel ?? '').toUpperCase()); + // readouts + const nav = snap.stats?.nav ?? { total: 0, found: 0, pct: 0 }; + const res = snap.stats?.res ?? { total: 0, found: 0, pct: 0 }; + this.navValue.setText(nav.total > 0 ? `${nav.found} / ${nav.total} · ${Math.round(nav.pct * 100)}%` : '—'); + this.resValue.setText(res.total > 0 ? `${res.found} / ${res.total} · ${Math.round(res.pct * 100)}%` : '—'); + const accent = toColor(C.neon); + this._drawBar(this.statsG, this.navBarY, nav.total, nav.found, accent); + this._drawBar(this.statsG, this.resBarY, res.total, res.found, toColor(C.amber)); + } + + _paintStatusStrip() { + const snap = this._snap; + if (!snap) { + this.statusTxt.setText('NAV FEED STANDBY'); + this.statusBar.clear(); + return; + } + const nav = snap.stats?.nav ?? { total: 0, found: 0, pct: 0 }; + const res = snap.stats?.res ?? { total: 0, found: 0, pct: 0 }; + const total = nav.total + res.total; + const found = nav.found + res.found; + this.statusTxt.setText(total > 0 ? `OBJECTS ${total} · CHARTED ${found}` : 'NO OBJECTS LOGGED'); + this.statusTxt.setColor(total > 0 ? toCss(C.ink) : toCss(C.faint)); + const g = this.statusBar; + g.clear(); + g.fillStyle(0x0a1424, 0.9); + g.fillRect(2, this.statusBarY, this.statusBarW, 3); + if (total > 0) { + g.fillStyle(C.neon, 1); + g.fillRect(2, this.statusBarY, this.statusBarW * (found / total), 3); + } + } + + // ------------------------------------------------------------ data flow + /** Pull the live chart snapshot from the scene (null when unavailable). */ + _snapOf() { + try { + return this.getChart ? this.getChart() : null; + } catch { + return null; + } + } + + /** Fingerprint — repaint only when something visible actually changed. */ + _fpOf(snap) { + const objs = (snap.objects ?? []).map((o) => `${o.id}:${o.discovered ? 1 : 0}`).join('|'); + const teth = (snap.tethers ?? []).map((t) => `${t.id}:${Math.round(t.radius)}`).join('|'); + return [snap.systemId, objs, teth, snap.central?.name, snap.central?.isHome].join('#'); + } + + _applySnap(snap) { + this._snap = snap; + this._tf = null; + this._bounds = null; + this._hits = []; + this._hover = null; + this.redraw(); + this._paintStats(); + this._paintStatusStrip(); + this._paintHover(); + this.shipMark?.setVisible(true); + } + + /** Re-paint the chart canvas (new texture; the old one is dropped). */ + redraw() { + const snap = this._snap; + if (!snap) return; + const { mapW, mapH } = this.geo; + const dpr = Math.min(2, window.devicePixelRatio || 1); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(8, Math.round(mapW * dpr)); + canvas.height = Math.max(8, Math.round(mapH * dpr)); + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + const out = paintChart(ctx, mapW, mapH, dpr, snap); + this._tf = out.tf; + this._bounds = out.bounds; + this._hits = out.hits; + const prev = this._texKey; + const key = `map_chart_${this._texN++}`; + if (this.scene.textures.exists(key)) this.scene.textures.remove(key); + this.scene.textures.addCanvas(key, canvas); + this.mapImg.setTexture(key); + // v4: setTexture keeps the image's existing scale (v3 reset it to 1) — + // the image was born on the 2×2 blank key, so the display size must + // be re-asserted on every texture swap or it renders ~415× oversized. + this.mapImg.setDisplaySize(mapW, mapH); + if (prev && prev !== key) this.scene.textures.remove(prev); + this._texKey = key; + // The canvas paints with the fallback face until the webfonts land — + // repaint ONCE, when they're in. A one-shot flag: re-scheduling from + // inside redraw() would loop forever (each repaint re-arms itself). + if (!this._fontsRepaintDone && document.fonts?.ready) { + document.fonts.ready + .then(() => { + if (this._destroyed || this._fontsRepaintDone) return; + this._fontsRepaintDone = true; + if (this._texKey === key && this.isOpen) this.redraw(); + }) + .catch(() => {}); + } + } + + // ------------------------------------------------------------ open/close + open() { + if (this.openState === 'open' || this.openState === 'opening') return; + this.openState = 'opening'; + this.setAlpha(0); + this.video?.play?.(); + this.sfx('ui_window'); + const now = this.scene.time.now; + this._startReveal(now); + } + + close() { + if (this.openState === 'closed' || this.openState === 'closing') return; + this.openState = 'closing'; + this.video?.pause?.(); + this.sfx('ui_close'); + this._setHover(null); + this.scene.tweens.add({ + targets: this, + alpha: 0, + duration: 150, + ease: 'Power2', + onComplete: () => { + this.openState = 'closed'; + this.glitchG.clear(); + this.mapGlitchG.clear(); + this.titleGhostA.setAlpha(0); + this.titleGhostB.setAlpha(0); + }, + }); + } + + // 'closing' counts as open: the close button flips openState in the SAME + // input pass, before the scene's pointerdown guard runs — while the + // window is still on screen (fading out) it keeps owning the click. + get isOpen() { + return this.openState === 'open' || this.openState === 'opening' || this.openState === 'closing'; + } + + // ------------------------------------------------------------ boot reveal + _startReveal(now) { + const list = []; + const push = (o, d, dur, mode, baseY) => { + list.push({ o, d, dur, mode, baseY: baseY ?? o.y, t0: now }); + }; + const name = String(this._snap?.systemName ?? 'SYSTEM').toUpperCase(); + this.decodeTo(this.titleTxt, config.get('map.title', 'STELLAR CARTOGRAPHY'), now, 560); + this.decodeTo(this.sysNameTxt, name, now + 180, Math.min(900, 420 + name.length * 26)); + push(this, 0, 240, 'fade'); + push(this.titleMeta, 140, 300, 'fade'); + push(this.closeTxt, 200, 200, 'fade'); + push(this.closeG, 200, 200, 'fade'); + push(this.videoPanel, 260, 380, 'fade'); + this.tabs.forEach((t, i) => { + push(t.g, 300 + i * 70, 260, 'fade'); + push(t.txt, 300 + i * 70, 260, 'fade'); + }); + push(this.mapImg, 420, 420, 'fade'); + push(this.plateFrame, 500, 300, 'fade'); + push(this.feedTag, 560, 260, 'fade'); + push(this.feedDot, 560, 260, 'fade'); + push(this.statsG, 620, 300, 'fade'); + this.sysNameTxt.y = this.geo.statsY + 10; + push(this.sysNameTxt, 620, 300, 'fade'); + this.sysTag.y = this.geo.statsY + 14; + push(this.sysTag, 660, 260, 'fade'); + push(this.navLabel, 680, 240, 'fade'); + push(this.navValue, 680, 240, 'fade'); + push(this.resLabel, 720, 240, 'fade'); + push(this.resValue, 720, 240, 'fade'); + push(this.shipMark, 820, 300, 'fade'); + this.reveal = list; + const g = config.get('map.glitch', {}); + const [ia, ib] = g.intervalMs ?? g.interval ?? [2600, 6400]; + this._glitch.next = now + rand(ia, ib); + this.openState = 'open'; + } + + // ------------------------------------------------------------ glitch bursts + _glitchBurst() { + const g = config.get('map.glitch', {}); + if (g.enabled === false) return; + const [da, db] = g.durationMs ?? g.duration ?? [110, 260]; + const now = this.scene.time.now; + this._glitch.until = now + rand(da, db); + const [sa, sb] = g.slices ?? [2, 6]; + const maxOff = g.maxOffset ?? 12; + const { mapX, mapY, mapW, mapH } = this.geo; + const gg = this.mapGlitchG; + gg.clear(); + const n = rand(sa, sb); + for (let i = 0; i < n; i++) { + const y = mapY + rand(0, Math.max(1, mapH - 6)); + const hgt = rand(2, 7); + const off = rand(-maxOff, maxOff); + const color = Math.random() < 0.5 ? C.neon : C.neon2; + gg.fillStyle(color, rand(0.06, 0.2)); + gg.fillRect(mapX + off, y, mapW, hgt); + } + // title RGB split + const t = this.titleTxt; + if (t.text) { + const off = rand(2, 5); + this.titleGhostA.setText(t.text); + this.titleGhostB.setText(t.text); + this.titleGhostA.setPosition(t.x - off, t.y); + this.titleGhostB.setPosition(t.x + off, t.y); + this.titleGhostA.setAlpha(0.55); + this.titleGhostB.setAlpha(0.55); + } + } + + // ------------------------------------------------------------ per-frame + update(time) { + if (!this.isOpen) return; + + // reveal timeline + if (this.reveal.length) { + let done = true; + for (const r of this.reveal) { + const u = clamp01((time - r.t0 - r.d) / Math.max(1, r.dur)); + const e = easeIO(u); + if (u < 1) done = false; + if (r.mode === 'fade') r.o.setAlpha(e); + else if (r.mode === 'rise') { + r.o.setAlpha(e); + r.o.y = r.baseY + (1 - e) * 14; + } else if (r.mode === 'pop') { + r.o.setAlpha(e); + r.o.setScale(0.92 + 0.08 * e); + } + } + if (done) this.reveal = []; + } + + // decodes + if (this.decodes.length) { + const rest = []; + for (const d of this.decodes) { + if (d.dec.started(time)) { + d.txt.setText(d.dec.display(time)); + if (!d.dec.finished(time)) rest.push(d); + } else rest.push(d); + } + this.decodes = rest; + } + + // title cursor (blinks after the decode) + const tw = this.titleTxt.width + 4; + this.titleCursor.clear(); + if (time % 900 < 480) { + this.titleCursor.fillStyle(C.neon, 0.9); + this.titleCursor.fillRect(this.titleTxt.x + tw, this.titleTxt.y + 4, 8, this.titleTxt.height - 6); + } + + // glitch bursts + if (time >= this._glitch.next) { + this._glitchBurst(); + const g = config.get('map.glitch', {}); + const [ia, ib] = g.intervalMs ?? g.interval ?? [2600, 6400]; + this._glitch.next = time + rand(ia, ib); + } + const burstOn = time < this._glitch.until; + if (!burstOn && (this.titleGhostA.alpha > 0 || this.titleGhostB.alpha > 0 || this.mapGlitchG.visible)) { + this.titleGhostA.setAlpha(0); + this.titleGhostB.setAlpha(0); + this.mapGlitchG.clear(); + } + + // tab shakes (the locked GALAXY socket) + if (this._tabShakes.length) { + const rest = []; + for (const sh of this._tabShakes) { + const u = clamp01((time - sh.t0) / sh.dur); + if (u < 1) { + const off = Math.sin(u * Math.PI * 7) * 4 * (1 - u); + sh.tab.txt.setPosition(sh.tab.baseTxtX + off, sh.tab.y - 4); + rest.push(sh); + } else { + sh.tab.txt.setPosition(sh.tab.baseTxtX, sh.tab.y - 4); + } + } + this._tabShakes = rest; + } + + // live feed touches: REC dots + const blink = 0.55 + 0.45 * Math.sin(time * 0.006); + this.recDot.setAlpha(blink); + this.feedDot.setAlpha(blink); + + // plate sweep band (left → right, looping) + const sw = this.sweepCfg; + if (sw?.enabled !== false && this.plateSweep) { + const dur = sw.durationMs ?? sw.duration ?? 7000; + const bandW = 70; + const span = this.geo.mapW + bandW * 2; + const u = (time % dur) / dur; + this.plateSweep.setPosition(this.geo.mapX - bandW + u * span, this.geo.mapY + this.geo.mapH / 2); + this.plateSweep.setAlpha(Math.sin(u * Math.PI) * (sw.alpha ?? 0.16)); + } + + // video sweep (crawls down the feed) + if (this.sweepBand && this.videoRect) { + const dur = this.sweepCfg?.durationMs ?? this.sweepCfg?.duration ?? 7000; + const u = (time % dur) / dur; + const yTop = this.videoRect.y - 40; + const yBot = this.videoRect.y + this.videoRect.h + 40; + this.sweepBand.setPosition(this.videoCx, yTop + u * (yBot - yTop)); + this.sweepBand.setAlpha(Math.sin(u * Math.PI) * 0.5); + } + + // the ship marker — follows the ship every frame + if (this._tf && this.shipMark) { + const sh = this.getShip ? this.getShip() : null; + if (sh && Number.isFinite(sh.x) && Number.isFinite(sh.y)) { + const px = this.geo.mapX + this._tf.toX(sh.x); + const py = this.geo.mapY + this._tf.toY(sh.y); + this.shipMark.setPosition(px, py); + this.shipDart.rotation = sh.heading ?? 0; + const u = (time % 1400) / 1400; + this.shipRing.setScale(1 + 0.9 * u).setAlpha(0.85 * (1 - u)); + this.shipMark.setVisible(true); + } else { + this.shipMark.setVisible(false); + } + } + + // click flash (a discovered object was picked) + if (this._flash) { + const u = clamp01((time - this._flash.t0) / 420); + if (u >= 1) { + this._flash = null; + this.hoverG.clear(); + this._paintHover(); + } else { + this.hoverG.clear(); + const f = this._flash; + const r = f.r + 18 * u; + this.hoverG.lineStyle(2, C.neon, (1 - u) * 0.9); + this.hoverG.strokeCircle(f.x, f.y, r); + this._paintHover(); + } + } + + // poll the scene's chart — repaint on any real change (discovery, tether…) + if (time >= this._pollNext) { + this._pollNext = time + 500; + const snap = this._snapOf(); + if (snap) { + const fp = this._fpOf(snap); + if (fp !== this._fp) { + this._fp = fp; + this._applySnap(snap); + } else { + this._snap = snap; // keep live data fresh (ship, names…) + } + } + } + } + + // ------------------------------------------------------------ pointer + _platePoint(p) { + return { x: p.x - this.geo.mapX, y: p.y - this.geo.mapY }; + } + + _objectAt(pt) { + if (!pt) return null; + let best = null; + let bestD = Infinity; + for (const o of this._hits) { + const d = Math.hypot(pt.x - o.x, pt.y - o.y); + if (d <= Math.max(o.hitR, 12) + 9 && d < bestD) { + bestD = d; + best = o; + } + } + return best; + } + + _setHover(p) { + if (!this.isOpen || p === null) { + this._hover = null; + this._paintHover(); + return; + } + const o = this._objectAt(this._platePoint(p)); + if (o && (!this._hover || this._hover.id !== o.id)) { + this.sfx('ui_hover'); + this._hover = o; + this.scene.input.setDefaultCursor('pointer'); + } else if (!o && this._hover) { + this.scene.input.setDefaultCursor('auto'); + } + if (!o) this._hover = null; + this._paintHover(); + } + + _paintHover() { + const g = this.hoverG; + g.clear(); + const tt = this.ttCont; + if (!this._hover) { + tt.setVisible(false); + return; + } + const o = this._hover; + // highlight ring + soft glow + g.lineStyle(1.5, C.neon, 0.9); + g.strokeCircle(o.x, o.y, o.r + 5); + g.lineStyle(4, C.neon, 0.18); + g.strokeCircle(o.x, o.y, o.r + 9); + // course line from the ship (if we have a live position) + const sh = this.getShip?.(); + if (sh && this._tf) { + const sx = this.geo.mapX + this._tf.toX(sh.x); + const sy = this.geo.mapY + this._tf.toY(sh.y); + g.lineStyle(1, C.amber, 0.5); + g.strokeLineShape(new Phaser.Geom.Line(sx, sy, o.x, o.y)); + g.fillStyle(C.amber, 0.7); + g.fillCircle(o.x, o.y, 2); + } + // tooltip above the object (clamped inside the plate) + this.ttName.setText(String(o.label ?? o.id).toUpperCase()); + const ttW = Math.max(this.ttName.width, this.ttHint.width) + 24; + let tx = o.x + this.geo.mapX; + let ty = o.y + this.geo.mapY - o.r - 16; + tx = Phaser.Math.Clamp(tx, this.geo.mapX + ttW / 2 + 6, this.geo.mapX + this.geo.mapW - ttW / 2 - 6); + ty = Math.max(ty, this.geo.mapY + 46); + this.ttPlate.clear(); + panel(this.ttPlate, tx - ttW / 2, ty - 40, ttW, 40, { + notch: 6, + fill: 0x050a12, + fillAlpha: 0.94, + stroke: C.neon, + strokeAlpha: 0.8, + }); + this.ttName.setPosition(tx, ty - 32); + this.ttName.setOrigin(0.5, 0); + this.ttHint.setPosition(tx, ty - 16); + this.ttHint.setOrigin(0.5, 0); + this.ttCont.setVisible(true); + } + + destroy() { + this._destroyed = true; + if (this._texKey) { + try { + this.scene.textures.remove(this._texKey); + } catch { + /* already gone */ + } + } + super.destroy(); + } +}