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