Add GALAXY tab to the MAP console with whole-galaxy chart, jump lanes, a

- New `GalaxyView` renders glowing stars per archetype (pulsing heartbeat),
  the jump-lane web (traveled/frontier/unexplored), the charted region's
  convex hull, HOME/SHIP markers, hover readouts, and CONFIRM JUMP on a
  live lane (reuses the existing gate transport).
- `GalaxyChart.js` provides the pure model + geometry: edgeKey, snapshot
  builder, convex hull / padded polygon, star pulse/color, and plate
  clipping (Liang-Barsky + Sutherland-Hodgman) — all Node-testable.
- Run's footprint (`visitedSystems`, `usedGates`) is captured/restored/
  reset in SaveData; legacy saves default to "current system charted,
  nothing traveled".
- MapWindow gains tab switching between the system chart and the galaxy
  view, with shared stats/legend/status chrome per mode.
- Two dev tests cover the galaxy model, save round-trip, hull geometry,
  plate clipping, and a regression for the star-label blink bug.
This commit is contained in:
Brian Fertig 2026-09-07 15:59:47 -06:00
parent a846562782
commit 22c2ccfcd2
12 changed files with 2641 additions and 26 deletions

View File

@ -143,6 +143,30 @@ node dev/server.mjs 8080
stays open to watch the progress); completion fires the build's
`effects` and a toast. Build records + the in-progress build persist in
the save bank — and resume with their remaining time on load
- **The Map console** — the **MAP** deck button opens a full-screen
cartography window (`data/map.json`, `js/ui/MapWindow.js`), the
ResearchWindow's bones: the left pane loops the muted 2:3
`assets/videos/map.mp4` feed (scanlines, sweep band, REC pulse, glitch
bursts). Two tabs on the chart plate:
- **CURRENT SYSTEM** — the whole solar system on a padded frame
(discovered objects, the tether range as a glitch-dotted union
boundary, and a fog-of-war dim over what the tether doesn't reach
yet), the system's discovery + resources readout, hover tooltips,
and click-to-ENGAGE AUTOPILOT (the same autopilot as a world click);
wheel zoom / drag pan (the chart repaints crisp at the new scale)
- **GALAXY** — the whole galaxy: a **glowing star per system**, each
colored by its archetype and pulsing on that archetype's heartbeat
(binary stars beat fast with a twin core), the **jump-lane web** as
thin lanes — traveled lanes glow brighter with a flow packet drifting
the route, frontier lanes (one end charted) brighter still, unexplored
lanes faint threads — plus the **charted region** (the hull of the
visited systems, soft fill + subtle outline), HOME/SHIP markers, and
a reveal ripple out of home. Hover a star for its readout + its link
state to your current system; **click a star on a live lane → CONFIRM
JUMP** (the same gate transport as the world's gates). The run's
footprint — visited systems + traveled lanes — is part of the save
(old saves load, defaulting to “your system is charted, nothing
traveled”). All of it tuned in `data/map.json → galaxy`
- Camera gently trails the ship; the **parallax starfield** streams past
while it flies and the view slowly recenters (≈1.5 s) once the ship
comes to rest
@ -160,6 +184,7 @@ orbit/
│ ├── theme.json # shared UI theme: fonts, palette, CRT/glitch tuning
│ ├── ship.json # the ship: art, feel (thrust, drag, maxSpeed…), base stats (hull, shields, cargo, minerals)
│ ├── planets.json # home world + system layout + solid-disc rules
│ ├── map.json # the MAP console: tabs, the system chart, the GALAXY tab (stars/pulse/lanes/region), stats, zoom
│ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact
│ ├── galaxy.json # galaxy scale & shape (count, radius, sparse disk…)
│ ├── systems.json # system archetypes: theme, attributes, distribution

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

View File

@ -1,5 +1,5 @@
{
"_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.",
"_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 — the system chart, and GALAXY — the whole-galaxy chart: glowing, pulsing stars per archetype, the jump-lane web with traveled lanes bright + flow packets, the charted region's outline — js/ui/GalaxyView.js), and the readout on the bottom (per tab: the system's discovery/resources, or the galaxy's charted stats). 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",
@ -12,7 +12,7 @@
]
},
"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.",
"_comment": "The two sockets above the chart (top right). `currentSystem` is the system chart (default). `galaxy` is the whole-galaxy chart (js/ui/GalaxyView.js). `standby: true` on EITHER is the killswitch — the tab renders dimmed with its standbyTag and a press fires the scene's onLocked (a console toast) instead of switching.",
"currentSystem": {
"id": "currentSystem",
"label": "Current System",
@ -22,7 +22,7 @@
"id": "galaxy",
"label": "Galaxy",
"accent": "#ffc94d",
"standby": true,
"standby": false,
"standbyTag": "OFFLINE"
}
},
@ -112,6 +112,95 @@
}
]
},
"galaxy": {
"_comment": "THE GALAXY TAB (js/ui/GalaxyView.js) — the whole-galaxy chart on the plate: one GLOWING STAR per system (colored by its archetype, data/systems.json → types, pulsing on that archetype's heartbeat in `pulse`), THIN LANES where the jump gates connect (the JumpNetwork spanning tree — a maze), the CHARTED REGION (the convex hull of the visited systems, inflated — the discovered-area shading), HOME + SHIP markers, ambient dust + the core glow. TRAVELED lanes (the run jumped them — saved with the run) glow brighter + carry a flow packet; FRONTIER lanes (one end visited) are the 'next step'; unexplored lanes are faint threads. Hover = the star's readout + its link state to the current system; click a star on a LIVE lane = CONFIRM JUMP (the scene jumps through that system's activated gate). FACTIONS (planned, not yet implemented): the snapshot carries per-system `faction: null` — the reserved seams are a faction color layer over the stars + a per-faction territory region (the same hull/fill recipe as `hull`, per faction color + relation alpha).",
"name": "GALAXY",
"padding": 90,
"stars": {
"_comment": "Per-system star rendering. glow/core = the plate-px size at 1× zoom (scaled with zoom). visitedBoost = how much brighter a charted star's glow reads; unknownMul/unknownAlpha = the dimmer read on uncharted stars (the galaxy is bigger than the run).",
"glow": 30,
"core": 6,
"glowAlpha": 0.6,
"visitedBoost": 1.3,
"unknownMul": 0.78,
"unknownAlpha": 0.55
},
"pulse": {
"_comment": "The per-archetype heartbeat (the 'per-star-type animation' — the plate's aliveness): `speed` in Hz (cycles/s of the sin), `amp` = the swing depth 0..1 (1 = full 0..1 swing, 0 = steady 0.5). Deterministic for (type, time, phase) — js/galaxy/GalaxyChart.js starPulse; each star's `phase` is seeded so they don't beat in unison. binary = fast double-beat; nebula = a wide shimmer; void = a slow dim breath.",
"main": { "speed": 0.5, "amp": 0.5 },
"redDwarf": { "speed": 0.3, "amp": 0.45 },
"binary": { "speed": 1.1, "amp": 0.6 },
"habitable": { "speed": 0.4, "amp": 0.55 },
"nebula": { "speed": 0.8, "amp": 0.8 },
"void": { "speed": 0.2, "amp": 0.3 }
},
"edges": {
"_comment": "The jump-lane web — three reads of the same thin line. used = a lane the run TRAVELED (bright glow pass + the flow packet); frontier = exactly one end charted (the next step); unexplored = the faint maze. width = plate-px at 1× (0.751.25× with zoom).",
"used": { "color": "#8df3ff", "width": 1.8, "alpha": 0.95 },
"frontier": { "color": "#3fb9d8", "width": 1.1, "alpha": 0.62 },
"unexplored": { "color": "#2a4f70", "width": 0.8, "alpha": 0.3 }
},
"hull": {
"_comment": "The CHARTED REGION — the convex hull of the visited systems, inflated by `padding` (a fraction of the plate width at 1× zoom — stable under zoom). A soft fill + a subtle double outline (the discovered-area shading). FACTIONS (planned): per-faction territories reuse this exact pass, colored per faction.",
"enabled": true,
"color": "#00e5ff",
"padding": 0.055,
"fillAlpha": 0.05,
"outlineAlpha": 0.26
},
"flow": {
"_comment": "Flow packets drifting along the TRAVELED lanes — the routes the player flew (the lane's history). size = plate-px; cycleMs = one traversal (a per-lane seeded stagger desyncs them).",
"enabled": true,
"color": "#d8f7ff",
"size": 2.6,
"cycleMs": 2600
},
"dust": {
"_comment": "Ambient structure under the stars — a few soft nebulous blobs + the galaxy's core glow (the disc's heart). alphaMin/alphaMax = the seeded per-blob alpha band.",
"enabled": true,
"blobs": 5,
"alphaMin": 0.03,
"alphaMax": 0.08,
"centerAlpha": 0.13
},
"labels": {
"_comment": "Star name labels (under the star). The HOME/SHIP stars always label; the rest earn their label with zoom: visited at `visitedZoom`×, uncharted at `anyZoom`× (the uncharted galaxy stays quiet until you look in — labels appear only at a fairly close zoom, so the plate stays uncluttered).",
"enabled": true,
"visitedZoom": 2.5,
"anyZoom": 5
},
"zoom": {
"_comment": "Wheel zoom about the cursor + drag pan (the map.zoom idiom, galaxy scope) + double-tap 1× reset.",
"enabled": true,
"zMin": 1,
"zMax": 10,
"sensitivity": 0.0022
},
"reveal": {
"_comment": "Tab switch / window open: a ripple OUT OF THE HOME system — stars bloom by distance (speedPerWorld = ms of delay per world-px, clamped to maxDelayMs), lanes trace in, the region fades in last.",
"enabled": true,
"speedPerWorld": 0.7,
"maxDelayMs": 850
},
"dialog": {
"_comment": "The clicked star's readouts (the window's shared ConfirmOverlay — the MapWindow autopilot idiom). {name} = the star, {current} = the current system, {gates} = its gate count. A LIVE lane confirms the JUMP (CONFIRM fires the jump + closes the console); dormant / no-link are readouts (both buttons dismiss).",
"hereTitle": "YOU ARE HERE",
"hereBody": ["{name} — the system your ship is in."],
"jumpTitle": "JUMP TO {name}",
"jumpBody": ["Via the activated gate out of {current}.", "DESTINATION — {name} · {gates} JUMP GATE(S)"],
"jumpLabel": "JUMP",
"dormantTitle": "GATE DORMANT",
"dormantBody": ["The lane to {name} exists, but the gate out of {current} is dark."],
"dormantHint": "Chart {name} and research its jump gates to wake the lane.",
"noLinkTitle": "NO DIRECT LINK",
"noLinkBody": ["No jump gate connects {current} and {name}."],
"noLinkHint": "The network is a maze — follow the lanes one jump at a time.",
"closeLabel": "CLOSE",
"homeTag": "HOME",
"homeTagBoth": "HOME · SHIP",
"shipTag": "SHIP"
}
},
"sweep": {
"_comment": "The scan band crawling across the chart (the CRT pass of light).",
"enabled": true,

239
dev/galaxy-labels.test.mjs Normal file
View File

@ -0,0 +1,239 @@
/**
* GalaxyView star-name label test (dev tool, run with Node no browser):
*
* node dev/galaxy-labels.test.mjs
*
* Regression for the "star names blink on and off" bug: MapWindow.update
* polls the galaxy every 500 ms (js/ui/MapWindow.js) and calls
* `gv.setVisible(true)` + `gv.applySnapshot(snap)` while open. That used to
* force EVERY in-plate name label visible for one frame (setVisible(true)
* shows all labels; _applyCull only hid OFF-plate ones), and the next
* frame's GalaxyView.update() hid the sub-threshold ones again every star
* name flashed on at ~8 Hz.
*
* Asserts:
* - at 1× zoom only HOME/SHIP carry a name; visited/unvisited are quiet;
* - the 500 ms poll (setVisible(true) + applySnapshot, end-of-frame
* state, no update() after) NEVER flashes sub-threshold labels on;
* - a hidden view stays hidden through a poll;
* - labels earn in with zoom: visited at visitedZoom×, uncharted at
* anyZoom× (data/map.json galaxy.labels).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
// ── browser stubs (canvas, Phaser) BEFORE importing the game modules ─────
const gradStub = { addColorStop() {} };
const ctx2d = {
createRadialGradient: () => gradStub,
createLinearGradient: () => gradStub,
fillRect() {},
beginPath() {},
arc() {},
fill() {},
fillStyle: null,
};
globalThis.document = {
createElement: () => ({ width: 0, height: 0, getContext: () => ctx2d }),
};
/** A chainable stand-in for a Phaser game object (records visibility). */
function makeObj(props = {}) {
const o = {
x: 0,
y: 0,
alpha: 1,
visible: true,
rotation: 0,
text: '',
width: 40,
height: 10,
frame: { width: 2, height: 2 },
...props,
};
o.setScrollFactor = () => o;
o.setDepth = () => o;
o.setAlpha = (a) => ((o.alpha = a), o);
o.setVisible = (v) => ((o.visible = !!v), o);
o.setOrigin = () => o;
o.setPosition = (x, y) => ((o.x = x), (o.y = y), o);
o.setDisplaySize = (w, h) => ((o.width = w), (o.height = h), o);
o.setText = (t) => ((o.text = String(t)), o);
o.setColor = () => o;
o.setBlendMode = () => o;
o.setInteractive = () => ((o.input = { enabled: true }), o);
o.on = () => o;
o.removeAllListeners = () => o;
// graphics API
o.clear = () => o;
o.fillStyle = () => o;
o.lineStyle = () => o;
o.fillCircle = () => o;
o.strokeCircle = () => o;
o.fillPoints = () => o;
o.strokePoints = () => o;
o.lineBetween = () => o;
o.fillRoundedRect = () => o;
o.strokeRoundedRect = () => o;
return o;
}
globalThis.window = {
Phaser: {
GameObjects: { Container: class { add() {} setVisible() { return this; } setDepth() { return this; } } },
Math: { Clamp: (v, a, b) => Math.min(b, Math.max(a, v)) },
BlendModes: { ADD: 'ADD' },
Display: {
Color: {
ValueToColor: (v) => {
let s = String(v).trim().replace(/^#/, '');
if (s.length === 3) s = s.split('').map((c) => c + c).join('');
return { color: parseInt(s.slice(0, 6), 16) || 0 };
},
},
},
Geom: {
Rectangle: class {
constructor(x = 0, y = 0, w = 0, h = 0) {
Object.assign(this, { x, y, w, h });
}
contains(px, py) {
return px >= this.x && px <= this.x + this.w && py >= this.y && py <= this.y + this.h;
}
},
},
},
};
const { config } = await import('../js/config/Config.js');
const mapJson = JSON.parse(fs.readFileSync(path.join(root, 'data/map.json'), 'utf8'));
config.init({ map: mapJson });
const scene = {
textures: { exists: () => false, addCanvas() {} },
add: {
image: (x, y, key) => makeObj({ key }),
text: (x, y, str) => makeObj({ text: String(str) }),
graphics: () => makeObj(),
},
time: { now: 1000 },
input: { setDefaultCursor() {} },
};
const win = {
scene,
geo: { mapX: 100, mapY: 100, mapW: 400, mapH: 300 },
add() {},
dialog: null,
sfx() {},
isOpen: true,
};
const { GalaxyView } = await import('../js/ui/GalaxyView.js');
// ── helpers ────────────────────────────────────────────────────────────────
let passed = 0;
let failed = 0;
function ok(cond, label) {
if (cond) {
passed++;
console.log(` ok ${label}`);
} else {
failed++;
console.error(`FAIL ${label}`);
}
}
/** A fresh copy of the snapshot (MapWindow polls for a FRESH object). */
function freshSnap() {
return {
seed: 'labeltest',
homeSystemId: 'home',
currentSystemId: 'home',
stats: { visited: 1, total: 3 },
systems: [
{ id: 'home', name: 'Home', type: 'habitable', x: 0, y: 0, visited: true, isHome: true, isCurrent: true, gates: 3 },
{ id: 'a', name: 'Alpha', type: 'main', x: 300, y: 100, visited: true, gates: 2 },
{ id: 'b', name: 'Beta', type: 'redDwarf', x: -200, y: -150, visited: false, gates: 1 },
],
edges: [
{ key: 'home-a', a: 'home', b: 'a', used: true, live: true, frontier: false },
{ key: 'home-b', a: 'home', b: 'b', used: false, live: false, frontier: false },
],
};
}
const LABELS = mapJson.galaxy.labels;
const visitedZoom = LABELS.visitedZoom;
const anyZoom = LABELS.anyZoom;
const gv = new GalaxyView(win, { getSnapshot: freshSnap });
gv.applySnapshot(freshSnap());
gv.reveal(0);
const byId = Object.fromEntries(gv._stars.map((s) => [s.id, s]));
// ── 1× zoom: only HOME/SHIP label ─────────────────────────────────────────
console.log('at 1× zoom');
gv.setVisible(true);
gv.update(2000);
ok(byId.home.label.visible === true, 'HOME star keeps its name at 1×');
ok(byId.a.label.visible === false, 'visited star stays quiet at 1×');
ok(byId.b.label.visible === false, 'unvisited star stays quiet at 1×');
// ── THE BUG: the 500 ms poll must not flash sub-threshold labels on ───────
console.log('the 500 ms snapshot poll (end-of-frame state)');
gv.setVisible(true); // MapWindow.update → gv.setVisible(true)
gv.applySnapshot(freshSnap()); // …then gv.applySnapshot(snap) — no update() after
ok(byId.a.label.visible === false, 'poll does NOT flash a visited label on at 1× (was the blink)');
ok(byId.b.label.visible === false, 'poll does NOT flash an unvisited label on at 1× (was the blink)');
ok(byId.home.label.visible === true, 'HOME name survives the poll at 1×');
// a hidden view stays hidden even through a poll
gv.setVisible(false);
gv.setVisible(true);
gv.applySnapshot(freshSnap());
ok(byId.a.label.visible === false, 'poll keeps sub-threshold labels off (view visible)');
gv.setVisible(false);
ok(byId.home.label.visible === false, 'setVisible(false) hides even the HOME name');
gv.setVisible(true);
gv.update(2500);
// ── labels earn in with zoom (on-plate + threshold, like a real cursor zoom) ──
console.log('zoom thresholds');
ok(Number.isFinite(visitedZoom) && visitedZoom >= 2, `visitedZoom is a fairly close zoom (${visitedZoom}×)`);
ok(Number.isFinite(anyZoom) && anyZoom > visitedZoom, `anyZoom > visitedZoom (${anyZoom}× > ${visitedZoom}×)`);
// the threshold rule itself (culling-agnostic)
function setZoom(z) {
gv._view = { z, cx: gv._bounds.cx, cy: gv._bounds.cy };
}
setZoom(1);
ok(gv._labelVisible(byId.a) === false, 'visited rule: off below visitedZoom');
setZoom(visitedZoom);
ok(gv._labelVisible(byId.a) === true, 'visited rule: on at visitedZoom');
ok(gv._labelVisible(byId.b) === false, 'unvisited rule: off below anyZoom');
setZoom(anyZoom);
ok(gv._labelVisible(byId.b) === true, 'unvisited rule: on at anyZoom');
// end-to-end: zoom ONTO a star (the cursor-zoom idiom) and its label appears
function viewTo(z, wx, wy) {
gv._view = { z, cx: wx, cy: wy };
gv._relayout();
gv.update(2500);
}
viewTo(1, 0, 0);
ok(byId.a.label.visible === false && byId.b.label.visible === false, 'at 1× only HOME/SHIP carry names');
viewTo(visitedZoom, 300, 100); // zoom onto the visited star
ok(byId.a.label.visible === true, `visited label shows at ${visitedZoom}× when zoomed onto it`);
viewTo(1, 300, 100);
ok(byId.a.label.visible === false, 'zoom back out → the label drops again');
viewTo(anyZoom, -200, -150); // zoom onto the unvisited star
ok(byId.b.label.visible === true, `unvisited label shows at ${anyZoom}× when zoomed onto it`);
viewTo(1, 0, 0);
ok(byId.home.label.visible === true, 'HOME stays labeled at every zoom');
console.log(failed ? `\n${failed} label check(s) FAILED` : '\nAll galaxy label checks passed ✓');
process.exit(failed ? 1 : 0);

387
dev/galaxy-map.test.mjs Normal file
View File

@ -0,0 +1,387 @@
/**
* Galaxy map test (dev tool, run with Node no browser):
*
* node dev/galaxy-map.test.mjs
*
* Covers the GALAXY tab's pure model + save seam (js/galaxy/GalaxyChart.js,
* js/save/SaveData.js):
* - edgeKey: the undirected jump-lane key (min<max, symmetric);
* - buildGalaxySnapshot: systems (the FACTIONS seam: `faction: null`),
* the dedup'd lane web (used / frontier / live), the stats;
* - convexHull / paddedHullPolygon: the charted-region geometry
* (1 pt ring, 2 capsule, 3+ the parallel polygon, inflated);
* - starPulse / starTypeColor: the per-archetype heartbeat (deterministic,
* bounded) + the archetype chart colors;
* - clipLineToRect / clipPolygonToRect: the plate clip (lanes, the hull,
* the rings stay INSIDE the chart's window at any zoom);
* - SaveData: the run's footprint (visitedSystems + usedGates) captured,
* restored, reset and the LEGACY default for saves that predate it.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href;
// --- Load the real config (data/*.json) into the config singleton --------
const { config } = await import(file('config/Config.js'));
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const { Galaxy } = await import(file('galaxy/Galaxy.js'));
const {
edgeKey,
buildGalaxySnapshot,
convexHull,
paddedHullPolygon,
starPulse,
starTypeColor,
} = await import(file('galaxy/GalaxyChart.js'));
const { SaveManager, SAVE_FORMAT } = await import(file('save/SaveManager.js'));
const { captureState, prepareLoad, resetRunState } = await import(file('save/SaveData.js'));
const { Discovery } = await import(file('galaxy/Discovery.js'));
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const SEED = 'GALAXYMAPTEST';
const galaxy = Galaxy.create(SEED, { systemCount: 9 });
const homeId = galaxy.currentSystemId;
const others = galaxy.records.filter((r) => r.id !== homeId);
const a = others[0];
const b = others[1];
// ---------------------------------------------------------------------------
// edgeKey — the undirected lane identity
// ---------------------------------------------------------------------------
{
check('edgeKey: symmetric', edgeKey('S000001', 'S000002') === edgeKey('S000002', 'S000001'));
check('edgeKey: min<max ordering', edgeKey('S000009', 'S000002') === 'S000002<S000009');
check('edgeKey: distinct lanes differ', edgeKey('A', 'B') !== edgeKey('A', 'C'));
}
// ---------------------------------------------------------------------------
// buildGalaxySnapshot — the plate's live data
// ---------------------------------------------------------------------------
{
const lane = edgeKey(homeId, a.id);
const snap = buildGalaxySnapshot({
galaxy,
visited: [homeId, a.id],
used: [lane],
live: [`${homeId}>${a.id}`],
currentSystemId: homeId,
});
check('snap: one entry per system', snap.systems.length === galaxy.records.length);
check('snap: identity carried (name/seed/home/current)',
snap.name === galaxy.name && snap.seed === SEED
&& snap.homeSystemId === homeId && snap.currentSystemId === homeId);
const recA = snap.systems.find((s) => s.id === a.id);
const recB = snap.systems.find((s) => s.id === b.id);
check('snap: visited flags', recA.visited === true && recB.visited === false);
check('snap: home/current flags', recA.isHome === false && recA.isCurrent === false);
check('snap: gate counts from the jump network',
recA.gates === (galaxy.jumpNetwork.gates.get(a.id)?.length ?? 0));
check('snap: FACTIONS seam present (faction: null, not yet implemented)',
snap.systems.every((s) => 'faction' in s && s.faction === null));
check('snap: a spanning tree = n1 lanes (dedupd)',
galaxy.records.length === 9 ? snap.edges.length === 8 : snap.edges.length === galaxy.records.length - 1);
const homeA = snap.edges.find((e) => edgeKey(e.a, e.b) === lane);
check('snap: the traveled lane is used', homeA?.used === true);
check('snap: the live lane out of the current system is live', homeA?.live === true);
const homeB = snap.edges.find((e) => edgeKey(e.a, e.b) === edgeKey(homeId, b.id));
check('snap: a lane with exactly one end visited is frontier',
homeB ? homeB.frontier === true && homeB.used === false : false);
check('snap: stats',
snap.stats.systems === 9 && snap.stats.visited === 2
&& snap.stats.lanes === snap.edges.length && snap.stats.lanesUsed === 1);
// No live set → no live lane (the jump confirm must not apply).
const snap2 = buildGalaxySnapshot({
galaxy,
visited: [homeId],
used: [],
live: new Set(),
currentSystemId: homeId,
});
check('snap: no live gates → no live edges', snap2.edges.every((e) => e.live === false));
// Current-system-only visit → no frontier (both ends unvisited or both the
// current system's own edges count: exactly one end visited = frontier).
check('snap: frontier count matches the definition',
snap2.edges.filter((e) => e.frontier).length
=== snap2.edges.filter((e) => {
const va = snap2.systems.find((s) => s.id === e.a).visited;
const vb = snap2.systems.find((s) => s.id === e.b).visited;
return va !== vb;
}).length);
}
// ---------------------------------------------------------------------------
// charted-region geometry
// ---------------------------------------------------------------------------
{
const square = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 },
{ x: 5, y: 5 }, // interior — must drop
];
const hull = convexHull(square);
check('hull: interior point dropped', hull.length === 4);
check('hull: all-collinear → the two extremes',
convexHull([{ x: 0, y: 0 }, { x: 5, y: 5 }, { x: 10, y: 10 }]).length === 2);
check('hull: 2 pts → both kept', convexHull([{ x: 0, y: 0 }, { x: 3, y: 4 }]).length === 2);
check('hull: 1 pt → itself', convexHull([{ x: 2, y: 2 }]).length === 1);
check('hull: [] → []', convexHull([]).length === 0);
const ring = paddedHullPolygon([{ x: 4, y: 4 }], 5);
check('pad(1 pt) → a ring of ~radius 5',
ring.length >= 8 && Math.abs(Math.hypot(ring[0].x - 4, ring[0].y - 4) - 5) < 0.5);
const cap = paddedHullPolygon([{ x: 0, y: 0 }, { x: 10, y: 0 }], 3);
check('pad(2 pts) → a capsule (segment + end discs)',
cap.length >= 4 && cap.every((p) => Math.abs(p.y) <= 3 + 1e-9));
const padSq = paddedHullPolygon(square, 2);
const inBounds = (p, lo, hi) => p >= lo - 1e-9 && p <= hi + 1e-9;
const area = (poly) => {
let s = 0;
for (let i = 0; i < poly.length; i++) {
const p = poly[i];
const q = poly[(i + 1) % poly.length];
s += p.x * q.y - q.x * p.y;
}
return Math.abs(s) / 2;
};
check('pad(square) → a bigger square (the parallel polygon)',
padSq.length === 4
&& padSq.every((p) => inBounds(p.x, -2, 12) && inBounds(p.y, -2, 12))
&& area(padSq) === 14 * 14 // (10 + 2·2)²
);
check('pad(square): corners are the true offsets (not a 12-vertex fan)',
padSq.length === 4);
const padTri = paddedHullPolygon(
[
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 5, y: 8 },
],
1.5
);
check('pad(triangle) → 3 parallel corners', padTri.length === 3);
check('pad(0) → the hull unchanged',
paddedHullPolygon(square, 0).length === 4 && area(paddedHullPolygon(square, 0)) === 100);
}
// ---------------------------------------------------------------------------
// the per-archetype heartbeat
// ---------------------------------------------------------------------------
{
const p1 = starPulse('binary', 1234, 0.7);
const p2 = starPulse('binary', 1234, 0.7);
check('pulse: deterministic for (type, time, phase)', p1 === p2);
check('pulse: in the 0..1 band', p1 >= 0 && p1 <= 1);
const lo = Infinity;
const hi = -Infinity;
let min = Infinity;
let max = -Infinity;
for (let t = 0; t < 200; t++) {
const v = starPulse('nebula', t * 17.3, 0.2);
min = Math.min(min, v);
max = Math.max(max, v);
}
const pc = config.get('map.galaxy.pulse.nebula', {});
const amp = Math.min(1, Math.max(0, Number(pc?.amp ?? 0.6) || 0));
check('pulse: bounded by its amp (0.5 ± 0.5·amp)',
min >= 0.5 - 0.5 * amp - 1e-9 && max <= 0.5 + 0.5 * amp + 1e-9);
void lo;
void hi;
// every configured archetype has a chart color + a pulse definition
const types = Object.keys(config.get('systems.types', {}));
check('types: the six archetypes present',
['main', 'redDwarf', 'binary', 'habitable', 'nebula', 'void'].every((t) => types.includes(t)));
check('types: each has a chart color', types.every((t) => starTypeColor(t).startsWith('#')));
check('types: each has a pulse entry (the per-type animation)',
types.every((t) => {
const pc2 = config.get(`map.galaxy.pulse.${t}`, null);
return pc2 && Number.isFinite(Number(pc2.speed)) && Number.isFinite(Number(pc2.amp));
}));
check('pulse: unknown type falls back (no crash)',
Number.isFinite(starPulse('unknownTypeXyz', 100, 0)));
check('color: unknown type falls back to the default hex',
starTypeColor('unknownTypeXyz') === '#9fb6d8');
}
// ---------------------------------------------------------------------------
// SaveData — the run's footprint (visitedSystems + usedGates)
// ---------------------------------------------------------------------------
{
const system = galaxy.byId.get(homeId);
const lane = edgeKey(homeId, a.id);
const reg = {
map: new Map(),
set(k, v) {
this.map.set(k, v);
},
get(k) {
return this.map.get(k);
},
};
const scene = {
registry: reg,
galaxy,
systemRecord: { id: homeId, name: system.name },
ship: { x: 1, y: 2, rotation: 0.3, minerals: 0 },
discovery: new Discovery(540),
tetherField: { tethers: [] },
playTimeMs: 1000,
activatedGates: new Set([`${homeId}>${a.id}`]),
visitedSystems: new Set([homeId, a.id]),
usedGates: new Set([lane]),
};
const rec = captureState(scene);
check('capture: visitedSystems carried', JSON.stringify(rec.visitedSystems.sort()) === JSON.stringify([homeId, a.id].sort()));
check('capture: usedGates carried', rec.usedGates.includes(lane));
check('capture: record still validates', SaveManager.validateRecord(rec) === null);
const reg2 = {
map: new Map(),
set(k, v) {
this.map.set(k, v);
},
get(k) {
return this.map.get(k);
},
};
prepareLoad(reg2, rec);
check('prepare: visitedSystems restored (the region)',
reg2.get('visitedSystems') instanceof Set && reg2.get('visitedSystems').has(homeId) && reg2.get('visitedSystems').has(a.id));
check('prepare: usedGates restored (the bright lanes)',
reg2.get('usedGates') instanceof Set && reg2.get('usedGates').has(lane));
// LEGACY — a record from before the galaxy tab: no footprint fields at all.
const legacy = { ...rec };
delete legacy.visitedSystems;
delete legacy.usedGates;
check('legacy: still validates (old saves load)', SaveManager.validateRecord(legacy) === null);
const reg3 = {
map: new Map(),
set(k, v) {
this.map.set(k, v);
},
get(k) {
return this.map.get(k);
},
};
prepareLoad(reg3, legacy);
check('legacy: visited defaults to the current system only',
reg3.get('visitedSystems') instanceof Set && reg3.get('visitedSystems').size === 1
&& reg3.get('visitedSystems').has(homeId));
check('legacy: usedGates default to empty', reg3.get('usedGates') instanceof Set && reg3.get('usedGates').size === 0);
// New Game — the footprint must not leak into the next run.
const reg4 = {
map: new Map(),
set(k, v) {
this.map.set(k, v);
},
get(k) {
return this.map.get(k);
},
};
reg4.set('visitedSystems', new Set(['X']));
reg4.set('usedGates', new Set(['Y']));
resetRunState(reg4);
check('reset: the footprint is cleared for a new run',
reg4.get('visitedSystems') === null && reg4.get('usedGates') === null);
}
// ── the plate clip (the chart stays inside its window) ──────────────────
{
const { clipLineToRect, clipPolygonToRect } = await import(file('galaxy/GalaxyChart.js'));
const R = { x: 10, y: 10, w: 100, h: 60 }; // plate rect: 10..110 × 10..70
// line fully inside → untouched
const li = clipLineToRect(20, 20, 90, 50, R);
check('clipLine: a fully-inside segment is unchanged',
li && Math.abs(li[0] - 20) < 1e-9 && Math.abs(li[1] - 20) < 1e-9 &&
Math.abs(li[2] - 90) < 1e-9 && Math.abs(li[3] - 50) < 1e-9);
// line fully outside → null
check('clipLine: a fully-outside segment is dropped',
clipLineToRect(120, 80, 140, 90, R) === null);
// line crossing the right edge (x = 110): from (50,20) to (150,40)
// t at x=110: (110-50)/(150-50) = 0.6 → y = 20 + 0.6*20 = 32
const lr = clipLineToRect(50, 20, 150, 40, R);
check('clipLine: a right-edge crossing ends exactly on the frame',
lr && Math.abs(lr[2] - 110) < 1e-9 && Math.abs(lr[3] - 32) < 1e-9);
// line crossing the top edge (y = 10): from (20, -20) to (20, 30)
const lt = clipLineToRect(20, -20, 20, 30, R);
check('clipLine: a top-edge crossing starts exactly on the frame',
lt && Math.abs(lt[0] - 20) < 1e-9 && Math.abs(lt[1] - 10) < 1e-9);
// a diagonal corner crossing (bottom-right): (50,50) → (150,100)
// hits x=110 at t=(110-50)/(150-50)=0.6 → y=50+0.6*50=80 (outside h)
// → then hits y=70 at t=(70-50)/(100-50)=0.4 → x=50+0.4*100=90 (inside)
// → the kept end is (90, 70)
const lc = clipLineToRect(50, 50, 150, 100, R);
check('clipLine: a corner crossing clips to the nearer boundary',
lc && Math.abs(lc[2] - 90) < 1e-9 && Math.abs(lc[3] - 70) < 1e-9);
// polygon fully inside → same winding, same point count
const sq = [{ x: 20, y: 20 }, { x: 90, y: 20 }, { x: 90, y: 50 }, { x: 20, y: 50 }];
const pi = clipPolygonToRect(sq, R);
check('clipPoly: a fully-inside polygon is unchanged',
pi.length === 4 && Math.abs(pi[0].x - 20) < 1e-9 && Math.abs(pi[2].y - 50) < 1e-9);
// polygon fully outside → empty
check('clipPoly: a fully-outside polygon is dropped',
clipPolygonToRect(
[{ x: 120, y: 80 }, { x: 140, y: 80 }, { x: 140, y: 100 }, { x: 120, y: 100 }], R,
).length === 0);
// polygon straddling the right edge: 20..150 × 20..50
// → clipped to 20..110 × 20..50 (a quad with two new edge points)
const ps = clipPolygonToRect(
[{ x: 20, y: 20 }, { x: 150, y: 20 }, { x: 150, y: 50 }, { x: 20, y: 50 }], R,
);
check('clipPoly: a straddling polygon is cut at the frame',
ps.length >= 4 &&
ps.every((p) => p.x <= 110 + 1e-9 && p.x >= 10 - 1e-9 && p.y >= 10 - 1e-9 && p.y <= 70 + 1e-9) &&
ps.some((p) => Math.abs(p.x - 110) < 1e-9));
// a hull-like ring that panned half out the TOP-LEFT corner
const ring = [];
for (let i = 0; i < 24; i++) {
const a = (i / 24) * Math.PI * 2;
ring.push({ x: 10 + Math.cos(a) * 30, y: 10 + Math.sin(a) * 30 }); // center on the corner
}
const pr = clipPolygonToRect(ring, R);
check('clipPoly: a corner-clipped ring stays in-frame (and has area)',
pr.length >= 3 && pr.every((p) => p.x >= 10 - 1e-9 && p.y >= 10 - 1e-9 && p.x <= 110 + 1e-9 && p.y <= 70 + 1e-9));
// determinism (same input → same output)
const d1 = JSON.stringify(clipPolygonToRect(sq.map((p) => ({ ...p })), R));
const d2 = JSON.stringify(clipPolygonToRect(sq.map((p) => ({ ...p })), R));
check('clipPoly: deterministic', d1 === d2);
}
// ---------------------------------------------------------------------------
console.log(failures === 0 ? '\nall galaxy-map checks passed ✓' : `\n${failures} check(s) FAILED`);
if (failures > 0) process.exit(1);

View File

@ -366,6 +366,71 @@ collide). **Barren systems** (no anchors — the `objectCount` → 0 stops)
coverage + determinism), and the layout band in
`dev/discovery.test.mjs`.
## Galaxy map — the MAP console's GALAXY tab
The deck's MAP button opens the cartography console
(`js/ui/MapWindow.js`, `data/map.json`, depth 80). It has two lives on
the plate, switched by the tabs:
- **CURRENT SYSTEM** — the system chart (canvas-painted: discovered
objects, the tether union, the fog of war), hover + ENGAGE AUTOPILOT,
wheel zoom / drag pan. Unchanged by the galaxy work.
- **GALAXY** (live — `tabs.galaxy.standby` is the killswitch; `true`
returns it to the old "OFFLINE" toast) — the whole-galaxy chart,
`js/ui/GalaxyView.js`, fed by `GameScene.galaxySnapshot()`
(`js/galaxy/GalaxyChart.js`, pure + Node-tested):
- **A glowing star per system**, colored by its archetype
(`data/systems.json → types`, 6 kinds) and **pulsing on that
archetype's heartbeat** — `galaxy.pulse.<type>` in `data/map.json`
(`speed` Hz + `amp`, the per-star-type animation; binary beats fast
with a seeded twin core, nebulae shimmer, voids breathe slow).
- **The jump-lane web** — the JumpNetwork's spanning tree as thin
lanes, three reads: TRAVELED (the run jumped it — bright glow pass +
a flow packet drifting the route), FRONTIER (exactly one end charted
— the "next step"), unexplored (faint threads). Lane state comes from
the run's footprint (below) + the SYSTEM category's activated gates
(`activatedGates`, the LIVE lanes a jump can be confirmed on).
- **The charted region** — the convex hull of the visited systems,
inflated (`galaxy.hull`): a soft fill + subtle double outline (the
discovered-area shading).
- **HOME + SHIP markers**, ambient dust blobs + the core glow, and a
reveal ripple OUT OF HOME on tab switch / window open
(stars bloom by distance — `galaxy.reveal`).
- **Interaction** (the plate idiom): wheel zoom about the cursor,
drag pan, double-tap 1×; hover = ring + tooltip (name, type, gate
count, charted state, link state to the current system); click a
star: current → "YOU ARE HERE"; LIVE lane → **CONFIRM JUMP** (the
same transport as the world's gate click — `GameScene.jumpFromMap`
`jumpThroughGate`); dormant lane / no lane → the readouts. The
window is a pure view: the galaxy tab polls `getGalaxy()` on the
same 500 ms cycle the system chart is.
**The run's footprint (save-ready):** `GameScene` keeps two
registry-backed sets (the `activatedGates` pattern) — `visitedSystems`
(the systems the run has ENTERED; the current one is marked at
`create()`) and `usedGates` (the undirected lanes the run has TRAVELED —
`GalaxyChart.edgeKey(a,b)` = `min<max`, so a lane is the same key from
either end). `jumpThroughGate()` records the lane + destination right
before `captureState()`, so the save carries the footprint; `SaveData`
captures/restores/resets both, and a save that predates the tab
defaults to "current system charted, nothing traveled" (old saves load).
**FACTIONS (planned — not yet implemented):** the snapshot already
reserves the seam — every system carries `faction: null`, and
`GalaxyView`'s header documents the two reserved rendering passes for
when they ship: a **faction color layer** over the stars (a ring/tint on
top of the archetype color) and a **territory region fill** (the exact
hull/fill recipe of the charted region, per faction color + relation
alpha). The settlements' `owner: null` seam (above) +
`Reputation.factionStanding()` are the data-side counterparts.
- **Tuning** — all of it in `data/map.json → galaxy` (stars, pulse per
archetype, edges, hull, flow, dust, labels, zoom, reveal, dialog copy).
- **Tests**`dev/galaxy-map.test.mjs` (edgeKey symmetry, the snapshot's
systems/lanes/flags + the faction seam, hull/padding geometry incl.
the parallel-polygon corners, the pulse's bounds + determinism, the
archetype colors, and the save round-trip incl. the legacy default).
## The tether — the player's range (important)
The ship starts with **one level-1 tether** anchored on the home world
@ -1027,10 +1092,22 @@ The player holds a REPUTATION (standing) on each planet and space station:
(the `add`/`setLevel`/`onChange` seams are in place)
(`data/builds.json → tether-l2 / tether-l3`, `js/build/*`,
`js/ui/BuildWindow.js`, `dev/builds.test.mjs`)
- [x] Galaxy map (the MAP console's GALAXY tab): the whole galaxy on the
chart plate — glowing stars pulsing per archetype, the jump-lane
web (traveled lanes bright + flow packets, frontier lanes, the
charted region's outline), HOME/SHIP markers, hover readouts, and
CONFIRM JUMP on a live lane (the gate transport, unchanged). The
run's footprint (visited systems + traveled lanes) is save-ready;
the FACTIONS seam is reserved (per-system `faction: null` + the
documented color-layer / territory-region passes)
(`js/ui/GalaxyView.js`, `js/galaxy/GalaxyChart.js`,
`data/map.json → galaxy`, dev/galaxy-map.test.mjs)
- [ ] Factions & pirates: claim settlements (`owner`), flags, borders,
and the player's place in a populated galaxy (the reputation layer
already resolves standing through `owner` — `Reputation.
factionStanding()` is the stub to fill)
factionStanding()` is the stub to fill; the galaxy tab's reserved
seams are the star color layer + the territory region — see the
Galaxy map section above)
- [x] Reputation (data layer): standing on each planet & space station,
20…+20 (best = +20), neutral 0 default, home world pinned at +20,
faction check as a placeholder on the `owner` seam; stable place ids

368
js/galaxy/GalaxyChart.js Normal file
View File

@ -0,0 +1,368 @@
/**
* GalaxyChart the pure model + geometry behind the MAP console's
* GALAXY tab (js/ui/GalaxyView.js renders it; GameScene feeds it).
*
* edgeKey(a, b) the undirected jump-lane key min/max pair
* joined with '<' (save-stable: the same lane
* from either end)
* buildGalaxySnapshot(o) the live GALAXY plate data the systems
* (position, type, visited), the dedup'd jump
* lanes (used / frontier / live), and the
* readout stats. Per-system `faction: null`
* is the FACTIONS seam (planned see
* docs/PROJECT_NOTES.md "Factions"): the
* plate already reserves a faction color
* layer + a territory-region pass for it.
* convexHull(points) monotone-chain hull (interior + collinear
* points dropped)
* paddedHullPolygon(p, pad) the hull inflated by `pad` world-px (the
* CHARTED REGION outline; 1 pt ring,
* 2 capsule)
* starPulse(type, timeMs) the per-star-type pulse phase 0..1
* (data/map.json galaxy.pulse)
* starTypeColor(type) the archetype's chart color
* (data/systems.json types)
*
* Pure (no Phaser) Node-testable (dev/galaxy-map.test.mjs).
*/
import { config } from '../config/Config.js';
/**
* The undirected key of a jump lane between systems a and b.
* @param {string} a
* @param {string} b
* @returns {string} `min<max` identical from either end
*/
export function edgeKey(a, b) {
return a <= b ? `${a}<${b}` : `${b}<${a}`;
}
/**
* The live galaxy-plate snapshot (see the file header).
*
* @param {object} o
* @param {import('./Galaxy.js').Galaxy} o.galaxy
* @param {Iterable<string>} [o.visited] system ids the run has ENTERED
* @param {Iterable<string>} [o.used] lane keys (edgeKey) the run has
* TRAVELED
* @param {Iterable<string>} [o.live] activated gate keys
* (`"${from}>${to}"`, GameScene.activatedGates) the lanes a JUMP
* can currently be confirmed from the current system
* @param {string|null} [o.currentSystemId]
* @returns {{name:string, seed:string, homeSystemId:string|null,
* currentSystemId:string|null, systems:Array<object>, edges:Array<object>,
* stats:{systems:number, visited:number, lanes:number, lanesUsed:number}}}
*/
export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live = null, currentSystemId = null } = {}) {
const visitedSet = new Set(visited ?? []);
const usedSet = new Set(used ?? []);
const liveSet = new Set(live ?? []);
const network = galaxy?.jumpNetwork ?? null;
const homeId = galaxy?.homeSystemId ?? null;
const systems = [];
for (const s of galaxy?.records ?? []) {
if (!s || typeof s.id !== 'string') continue;
systems.push({
id: s.id,
name: s.name,
type: s.type,
x: s.x,
y: s.y,
visited: visitedSet.has(s.id),
isHome: s.id === homeId,
isCurrent: s.id === currentSystemId,
gates: network?.gates?.get?.(s.id)?.length ?? 0,
// FACTIONS (planned — not yet implemented): the system's faction
// id + the player's relation tier (Neutral / Friendly / Aligned /
// Hostile) will land here. The galaxy plate reserves a faction
// color layer on the stars + a territory-region fill (the same
// hull pass as the charted region) for when it ships.
faction: null,
});
}
const ids = new Set(systems.map((s) => s.id));
const seen = new Set();
const edges = [];
for (const [a, dests] of network?.gates ?? []) {
if (!ids.has(a) || !Array.isArray(dests)) continue;
for (const b of dests) {
if (!ids.has(b) || a === b) continue;
const key = edgeKey(a, b);
if (seen.has(key)) continue;
seen.add(key);
const used = usedSet.has(key);
const lo = a < b ? a : b;
const hi = a < b ? b : a;
edges.push({
a: lo,
b: hi,
key,
used,
// FRONTIER — a lane leaving the charted region (exactly one end
// visited): the "next step" of the maze, drawn brighter than the
// unexplored web.
frontier: !used && visitedSet.has(lo) !== visitedSet.has(hi),
// LIVE — an activated gate on this lane out of the CURRENT
// system: the plate's CONFIRM JUMP applies to these.
live:
currentSystemId != null &&
(liveSet.has(`${currentSystemId}>${lo}`) || liveSet.has(`${currentSystemId}>${hi}`)),
});
}
}
const stats = {
systems: systems.length,
visited: systems.filter((s) => s.visited).length,
lanes: edges.length,
lanesUsed: edges.filter((e) => e.used).length,
};
return {
name: galaxy?.name ?? 'UNKNOWN GALAXY',
seed: galaxy?.seed ?? '',
homeSystemId: homeId,
currentSystemId,
systems,
edges,
stats,
};
}
// ── charted-region geometry ──────────────────────────────────────────────────
/**
* Monotone-chain convex hull.
* @param {Array<{x:number, y:number}>} points
* @returns {Array<{x:number, y:number}>} the hull vertices in order
* (fewer than 3 input points the input itself; all-collinear input
* the two extreme points)
*/
export function convexHull(points) {
const pts = (points ?? []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
if (pts.length < 3) return pts.map((p) => ({ x: p.x, y: p.y }));
const sorted = [...pts].sort((a, b) => a.x - b.x || a.y - b.y);
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
const lower = [];
for (const p of sorted) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
lower.push(p);
}
const upper = [];
for (let i = sorted.length - 1; i >= 0; i--) {
const p = sorted[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
upper.push(p);
}
return lower.slice(0, -1).concat(upper.slice(0, -1));
}
/** Shoelace signed area (positive for the hull orientation convexHull
* yields the paddedHullPolygon outward-normal formula assumes it). */
function signedArea(pts) {
let s = 0;
for (let i = 0; i < pts.length; i++) {
const a = pts[i];
const b = pts[(i + 1) % pts.length];
s += a.x * b.y - b.x * a.y;
}
return s / 2;
}
/** An n-gon ring of radius r about (cx, cy). */
function ring(cx, cy, r, n = 40) {
const out = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
out.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
}
return out;
}
/** A capsule: the segment a→b thickened by r (both end-discs). */
function capsule(a, b, r) {
if (r <= 0) return [a, b].map((p) => ({ x: p.x, y: p.y }));
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const nx = dy / len;
const ny = -dx / len; // either normal — the shape is symmetric
const n = 14;
const out = [];
for (let i = 0; i <= n; i++) {
const t = i / n;
out.push({ x: a.x + dx * t + nx * r, y: a.y + dy * t + ny * r });
}
for (let i = n; i >= 0; i--) {
const t = i / n;
out.push({ x: a.x + dx * t - nx * r, y: a.y + dy * t - ny * r });
}
return out;
}
/**
* The charted-region polygon: the convex hull of the given points
* inflated by `pad` (world px) each edge translated outward along its
* normal, corners bridged (the Minkowski-sum-with-a-disc shape, sampled).
*
* @param {Array<{x:number, y:number}>} points the region's points
* @param {number} [pad=0] the inflation, in the same units as the points
* @returns {Array<{x:number, y:number}>} a closed polygon (length 2)
*/
export function paddedHullPolygon(points, pad = 0) {
const hull = convexHull(points);
const p = Math.max(0, Number(pad) || 0);
if (hull.length === 0) return [];
if (hull.length === 1) return ring(hull[0].x, hull[0].y, p);
if (hull.length === 2) return capsule(hull[0], hull[1], p);
if (p <= 0) return hull;
// Outward normals assume the convexHull orientation — enforce it.
const poly = signedArea(hull) < 0 ? [...hull].reverse() : hull;
const n = poly.length;
// Each edge translated outward by p along its normal…
const offs = poly.map((a, i) => {
const b = poly[(i + 1) % n];
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const nx = dy / len;
const ny = -dx / len; // outward (see signedArea's orientation note)
return { p: { x: a.x + nx * p, y: a.y + ny * p }, d: { x: dx, y: dy } };
});
// …and corner i = where edge (i1)'s offset line meets edge i's —
// the true parallel polygon (the Minkowski sum's sharp corners).
const out = [];
for (let i = 0; i < n; i++) {
const L1 = offs[(i - 1 + n) % n];
const L2 = offs[i];
const det = L1.d.x * L2.d.y - L1.d.y * L2.d.x;
if (Math.abs(det) < 1e-12) {
out.push({ x: L2.p.x, y: L2.p.y });
continue;
}
const t = ((L2.p.x - L1.p.x) * L2.d.y - (L2.p.y - L1.p.y) * L2.d.x) / det;
out.push({ x: L1.p.x + t * L1.d.x, y: L1.p.y + t * L1.d.y });
}
return out;
}
// ── star animation ───────────────────────────────────────────────────────────
/**
* A star-type's pulse phase, 0..1 the per-archetype heartbeat of the
* galaxy plate (data/map.json galaxy.pulse.<type>: `speed` in Hz,
* `amp` = the swing depth 0..1 (1 = full 0..1 swing, 0 = steady 0.5)).
* Deterministic for (type, timeMs, phase) the per-star `phase` is a
* seeded offset (GalaxyView) so stars don't beat in unison.
*
* @param {string} type the system archetype (data/systems.json keys)
* @param {number} timeMs scene time (ms)
* @param {number} [phase=0] per-star phase offset (radians)
* @returns {number} 0.5 - 0.5·amp 0.5 + 0.5·amp
*/
export function starPulse(type, timeMs = 0, phase = 0) {
const pc = config.get(`map.galaxy.pulse.${type}`, {});
const speed = Math.max(0.02, Number(pc?.speed ?? 1) || 1);
const amp = Math.min(1, Math.max(0, Number(pc?.amp ?? 0.6) || 0));
return 0.5 + 0.5 * amp * Math.sin((timeMs / 1000) * speed * Math.PI * 2 + phase);
}
/**
* The archetype's chart color (data/systems.json types.<t>.theme.color)
* a CSS hex string, or `fallback` for an unknown/misconfigured type.
* @param {string} type
* @param {string} [fallback]
* @returns {string} '#rrggbb'
*/
export function starTypeColor(type, fallback = '#9fb6d8') {
const hex = config.get(`systems.types.${type}.theme.color`, null);
return typeof hex === 'string' && /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : fallback;
}
// ── plate clipping (the chart stays INSIDE its window) ────────────────────────────
//
// The galaxy is drawn in world space and magnified by the view (zoom/pan), so
// at high zoom the lanes/hull run past the plate's padded frame. The plate is
// the window onto the galaxy — its content is clipped to the plate rect.
// (The vendored v4 build has no mask API, so the drawing passes clip their
// own geometry with these two pure functions.)
/**
* Liang-Barsky: clip a line segment to an axis-aligned rect.
* @param {number} x1 @param {number} y1 @param {number} x2 @param {number} y2
* @param {{x:number, y:number, w:number, h:number}} r the plate rect
* @returns {number[]|null} [x1, y1, x2, y2] or null when fully outside
*/
export function clipLineToRect(x1, y1, x2, y2, r) {
let t0 = 0;
let t1 = 1;
const dx = x2 - x1;
const dy = y2 - y1;
const clips = [
[-dx, x1 - r.x],
[dx, r.x + r.w - x1],
[-dy, y1 - r.y],
[dy, r.y + r.h - y1],
];
for (const [p, q] of clips) {
if (p === 0) {
if (q < 0) return null; // parallel and outside
continue;
}
const t = q / p;
if (p < 0) {
if (t > t1) return null;
if (t > t0) t0 = t;
} else {
if (t < t0) return null;
if (t < t1) t1 = t;
}
}
return [x1 + t0 * dx, y1 + t0 * dy, x1 + t1 * dx, y1 + t1 * dy];
}
/**
* Sutherland-Hodgman: clip a polygon to an axis-aligned rect (four
* half-plane passes). The result keeps winding; it is empty when the
* polygon is fully outside.
* @param {Array<{x:number, y:number}>} pts
* @param {{x:number, y:number, w:number, h:number}} r the plate rect
* @returns {Array<{x:number, y:number}>} the clipped polygon (0..n pts)
*/
export function clipPolygonToRect(pts, r) {
const x1 = r.x;
const y1 = r.y;
const x2 = r.x + r.w;
const y2 = r.y + r.h;
const clipEdge = (list, inside, cross) => {
const out = [];
for (let i = 0; i < list.length; i++) {
const a = list[i];
const b = list[(i + 1) % list.length];
const ain = inside(a);
const bin = inside(b);
if (ain && bin) out.push(b);
else if (ain) out.push(cross(a, b));
else if (bin) {
out.push(cross(a, b));
out.push(b);
}
}
return out;
};
const xInt = (bnd) => (a, b) => {
const t = (bnd - a.x) / (b.x - a.x);
return { x: bnd, y: a.y + t * (b.y - a.y) };
};
const yInt = (bnd) => (a, b) => {
const t = (bnd - a.y) / (b.y - a.y);
return { x: a.x + t * (b.x - a.x), y: bnd };
};
let list = pts;
list = clipEdge(list, (p) => p.x >= x1, xInt(x1));
list = clipEdge(list, (p) => p.x <= x2, xInt(x2));
list = clipEdge(list, (p) => p.y >= y1, yInt(y1));
list = clipEdge(list, (p) => p.y <= y2, yInt(y2));
return list;
}

View File

@ -18,6 +18,13 @@
* // Set, replayed on load —
* // gates + tethers re-form
* // in GameScene.create)
* visitedSystems: ["<sysId>", ], // the systems the run
* // ENTERED (the GALAXY
* // tab's charted region)
* usedGates: ["<sysId><destId>", ], // the jump lanes the run
* // TRAVELED (GalaxyChart.
* // edgeKey — undirected;
* // the bright lanes)
* research: ResearchState.toJSON() | null,
* builds: BuildState.toJSON() | null,
* playTimeMs }
@ -109,6 +116,13 @@ export function captureState(scene, now) {
// Pre-system-category saves lack the field; the restore treats the
// absence as an empty set (old saves load).
activatedGates: Array.from(scene.activatedGates ?? []),
// THE RUN'S GALAXY FOOTPRINT (the MAP console's GALAXY tab —
// js/galaxy/GalaxyChart.js): the systems entered + the lanes traveled.
// A save predating the galaxy tab lacks both fields; the restore
// treats the absence as "just the current system, no lanes yet"
// (old saves load, the region starts small).
visitedSystems: Array.from(scene.visitedSystems ?? []),
usedGates: Array.from(scene.usedGates ?? []),
playTimeMs: Math.round(scene.playTimeMs ?? 0),
};
const err = SaveManager.validateRecord(rec);
@ -151,6 +165,21 @@ export function prepareLoad(registry, record) {
'activatedGates',
new Set(Array.isArray(record.activatedGates) ? record.activatedGates : []),
);
// The run's galaxy footprint (the GALAXY tab's charted region + bright
// lanes). Legacy saves (no fields) default to "the current system is
// charted, nothing traveled" — the region starts at one star.
registry.set(
'visitedSystems',
new Set(
Array.isArray(record.visitedSystems) && record.visitedSystems.length
? record.visitedSystems
: [record.currentSystemId].filter((id) => typeof id === 'string'),
),
);
registry.set(
'usedGates',
new Set(Array.isArray(record.usedGates) ? record.usedGates : []),
);
registry.set(PENDING_RESTORE_KEY, {
ship: record.ship,
tethers: Array.isArray(record.tethers) ? record.tethers : [],
@ -180,6 +209,8 @@ export function resetRunState(registry) {
registry.set('discovery', null);
registry.set('reputation', null);
registry.set('activatedGates', null);
registry.set('visitedSystems', null);
registry.set('usedGates', null);
registry.set(PENDING_RESTORE_KEY, null);
}

View File

@ -34,6 +34,7 @@ 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 { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
import { ResearchState } from '../research/ResearchState.js';
import { categories, loadCategory, isAvailable, buildDefs } from '../research/ResearchModel.js';
import {
@ -303,6 +304,23 @@ export class GameScene extends Phaser.Scene {
this.activatedGates = new Set();
this.registry.set('activatedGates', this.activatedGates);
}
// THE RUN'S GALAXY FOOTPRINT (the MAP console's GALAXY tab —
// js/ui/GalaxyView.js): visitedSystems = the systems the run has
// ENTERED (chart the region), usedGates = the jump lanes the run has
// TRAVELED (edgeKey(a,b), undirected — the lane glows brighter). Both
// are per-run world state — registry-backed (like activatedGates):
// New Game clears them, a load restores them (js/save/SaveData.js).
this.visitedSystems = this.registry.get('visitedSystems') ?? null;
if (!this.visitedSystems) {
this.visitedSystems = new Set();
this.registry.set('visitedSystems', this.visitedSystems);
}
this.usedGates = this.registry.get('usedGates') ?? null;
if (!this.usedGates) {
this.usedGates = new Set();
this.registry.set('usedGates', this.usedGates);
}
this.visitedSystems.add(this.systemRecord.id); // we start here — it's charted
for (const j of this.systemContent.jumps ?? []) {
if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true;
}
@ -649,16 +667,25 @@ export class GameScene extends Phaser.Scene {
// ---- 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.
// left cartography feed + TWO tabs — CURRENT SYSTEM (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 plots the course, the same autopilot
// as a world click) and GALAXY (js/ui/GalaxyView.js: the whole-galaxy
// chart — glowing, pulsing stars per archetype, the jump-lane web with
// traveled lanes bright + flow packets, the charted region's outline,
// hover readouts, and CONFIRM JUMP on a live lane).
// The window is a pure view — it polls mapChartSnapshot() /
// galaxySnapshot() while open and repaints on any change.
this.mapWindow = new MapWindow(this, {
getChart: () => this.mapChartSnapshot(),
getGalaxy: () => this.galaxySnapshot(),
getShip: () => (this.ship ? { x: this.ship.x, y: this.ship.y, heading: this.ship.rotation } : null),
onSelect: (objectId) => this.autopilotTo(objectId),
onGalaxyJump: (systemId) => {
this.mapWindow?.close(); // the confirm already fired — let the cut play
this.jumpFromMap(systemId);
},
onLocked: () =>
this.consoleToast(config.get('map.galaxyLockedToast', 'GALAXY MAP OFFLINE — SECTOR DATA NOT ACQUIRED'), {
glyph: '✕',
@ -1724,6 +1751,11 @@ export class GameScene extends Phaser.Scene {
// the current config): the destination's origin (its center is empty —
// the star is invisible flavor, so this is just open space).
const destContent = this.galaxy.ensureContent(destId);
// THE RUN'S FOOTPRINT — record the lane we just traveled + the system
// we are entering (the GALAXY tab's brighter lane + charted region;
// captured into `rec` right below, so the save carries it).
this.usedGates.add(edgeKey(from.id, destId));
this.visitedSystems.add(destId);
const arrival =
jumpArrival(destContent, from.id, {
radius: gt.radius ?? 0,
@ -2267,6 +2299,52 @@ export class GameScene extends Phaser.Scene {
console.info(`[orbit] command deck: ${id}`);
}
/**
* The GALAXY tab's JUMP (js/ui/MapWindow.js GalaxyView CONFIRM on
* a star reached by a LIVE lane out of the current system): the same
* transport as the world's gate click — find THIS system's activated
* gate bound for that star and jump through it. A dormant/unknown
* target is toasted, not thrown away.
*/
jumpFromMap(targetId) {
if (!targetId || targetId === this.systemRecord?.id) return;
const gt = (this.systemGates ?? []).find((g) => g?.gate?.to === targetId);
if (!gt) {
this.consoleToast('NO DIRECT LINK FROM THIS SYSTEM', {
glyph: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
return;
}
if (gt.active === false) {
this.consoleToast('GATE DORMANT — NO JUMP AVAILABLE', {
glyph: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
return;
}
this.jumpThroughGate(gt);
}
/**
* The GALAXY tab's live snapshot (js/ui/GalaxyView.js renders it, polled
* the same 500 ms the system chart is js/ui/MapWindow.js). Pure data
* from the shared Galaxy + the run's footprint (js/galaxy/GalaxyChart.js):
* every system (type + archetype color), the jump lanes (used /
* frontier / live), the charted region's stats. Per-system `faction`
* stays `null` the FACTIONS seam (planned, not yet implemented).
*/
galaxySnapshot() {
if (!this.galaxy || !this.systemRecord) return null;
return buildGalaxySnapshot({
galaxy: this.galaxy,
visited: this.visitedSystems,
used: this.usedGates,
live: this.activatedGates,
currentSystemId: this.systemRecord.id,
});
}
// ------------------------------------------------------------ map console
/**

1105
js/ui/GalaxyView.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,15 @@
* contract.
*
* Right column, top to bottom:
* tabs CURRENT SYSTEM (live) / GALAXY (standby, not built yet
* clicking it fires `onLocked` and the scene toasts it)
* tabs CURRENT SYSTEM (the system chart) / GALAXY (the whole-galaxy
* chart, js/ui/GalaxyView.js): glowing, pulsing stars per system
* (colored by archetype), the jump-lane web (traveled lanes bright
* with flow packets, frontier lanes mid, unexplored faint), the
* charted region (convex hull of visited systems, inflated), HOME +
* SHIP markers. Hover = the star's readout + its link state to the
* current system; click a LIVE lane neighbor = the JUMP confirm
* (onGalaxyJump the scene jumps through the gate). Clicking the
* GALAXY tab while it is still `standby` fires `onLocked` (toast).
* 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
@ -57,6 +64,8 @@ import { Tether } from '../tether/Tether.js';
import { chartBounds, fitToRect } from '../galaxy/SystemChart.js';
import { CyberShape } from './CyberShape.js';
import { ConfirmOverlay } from './ConfirmOverlay.js';
import { GalaxyView } from './GalaxyView.js';
import { setInteractiveEnabled } from '../utils/Input.js';
const TAU = Math.PI * 2;
const HEADER = fontStack('header');
@ -754,6 +763,12 @@ export class MapWindow extends Phaser.GameObjects.Container {
* object was CONFIRMED on the chart (dialog ENGAGE) the scene
* plots the course (autopilot) and the console closes
* @param {() => void} [opts.onLocked] the (standby) GALAXY tab was hit
* @param {() => object|null} [opts.getGalaxy] the live galaxy
* snapshot (GameScene.galaxySnapshot, js/galaxy/GalaxyChart.js);
* the GALAXY tab is live when provided + data/map.json un-stands-it-by
* @param {(systemId: string) => void} [opts.onGalaxyJump] a galaxy
* star was CONFIRMED for a jump (the scene jumps through that
* system's activated gate out of the current one)
*/
constructor(scene, opts = {}) {
super(scene, 0, 0);
@ -765,6 +780,12 @@ export class MapWindow extends Phaser.GameObjects.Container {
this.getShip = opts.getShip ?? null;
this.onSelect = opts.onSelect ?? null;
this.onLocked = opts.onLocked ?? null;
this.getGalaxy = opts.getGalaxy ?? null;
this.onGalaxyJump = opts.onGalaxyJump ?? null;
this._mode = 'currentSystem'; // the active tab: 'currentSystem' | 'galaxy'
this._galaxy = null; // the GALAXY tab's view (built lazily on switch)
this._galaxySnap = null;
this._galaxyFp = null;
this.openState = 'closed'; // 'open' | 'opening' | 'closing' | 'closed'
this.reveal = [];
@ -1255,22 +1276,152 @@ export class MapWindow extends Phaser.GameObjects.Container {
_tabHit(entry) {
if (this.dialog && this.dialog.isOpen) return; // the dialog is the topmost thing
if (entry.id === 'galaxy' && this._mode === 'galaxy') return;
if (entry.id === 'currentSystem' && this._mode === 'currentSystem') return;
this.sfx('ui_click');
if (entry.standby) {
// GALAXY — built as standby for now: shake it + the scene toasts
this.sfx('ui_click');
// the killswitch is still armed: shake it + the scene toasts
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');
this._switchMode(entry.id);
}
/**
* The tab switch the plate has two lives:
* CURRENT SYSTEM the system chart (hover, ENGAGE, zoom, pan)
* GALAXY the whole-galaxy chart (GalaxyView: stars, lanes,
* the charted region, JUMP on a live lane)
* The plate's input surface moves between mapImg and the galaxy plate
* one at a time (Phaser 4's topOnly input: both catching would
* double-fire). Both lives are driven from the same 500 ms poll in
* update(), each against its own snapshot.
*/
_switchMode(id) {
const inGalaxy = id === 'galaxy';
this._mode = id;
// move the plate's input + surface (the system chart ↔ the galaxy)
setInteractiveEnabled(this.mapImg, !inGalaxy);
this.mapImg.setVisible(!inGalaxy);
this._galaxy?.setVisible(inGalaxy);
this.shipMark?.setVisible(!inGalaxy);
if (inGalaxy) {
// clear the system hover + repaint the galaxy's own chrome
this._hover = null;
this.hoverG.clear();
this.ttCont.setVisible(false);
this._galaxyFp = null; // force the galaxy stats paint
this._pollNext = 0; // poll the galaxy life immediately
} else {
// back to the system chart — restore its stats chrome + force a
// fresh poll (redraw + stats + strip)
this.legendTexts?.forEach((t) => t.setVisible(true));
this.navLabel.setText(this._navLabel0);
this.resLabel.setText(this._resLabel0);
this.navMetaTxt?.setVisible(true);
this.resMetaTxt?.setVisible(true);
this._paintLegend();
this._fp = null;
}
this._paintTabs();
}
/** Build the galaxy life lazily (first tab switch). */
_ensureGalaxy() {
if (this._galaxy) return this._galaxy;
this.sfx('ui_window');
this._galaxy = new GalaxyView(this, {
getSnapshot: () => {
try {
return this.getGalaxy ? this.getGalaxy() : null;
} catch {
return null;
}
},
onJump: (id) => this.onGalaxyJump?.(id),
});
this._galaxy.applySnapshot(this._galaxySnap ?? null);
this._galaxy.reveal(this.scene.time.now); // first entry: the ripple runs
return this._galaxy;
}
/** The GALAXY tab's stats plate (the SYSTEM plate's objects, repurposed). */
_paintStatsGalaxy(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,
});
const st = snap.stats ?? {};
this.sysNameTxt.setText(String(config.get('map.galaxy.name', null) ?? 'GALAXY'));
this.sysTag.setText(`${st.visited ?? 0} / ${st.systems ?? 0} CHARTED`);
this.navLabel.setText('JUMP LANES');
this.navValue.setText(st.lanes > 0 ? `${st.lanesUsed ?? 0} / ${st.lanes} TRAVELED` : '—');
const frontier = (snap.edges ?? []).filter((e) => e.frontier).length;
this.resLabel.setText('FRONTIER');
this.resValue.setText(st.lanes > 0 ? `${frontier} OPEN LINK(S)` : '—');
this._drawBar(this.statsG, this.navBarY, st.lanes ?? 0, st.lanesUsed ?? 0, toColor(C.neon));
this._drawBar(this.statsG, this.resBarY, st.lanes ?? 0, frontier, toColor(C.amber));
}
/** The GALAXY tab's legend — the three lane states + the region swatch. */
_paintLegendGalaxy() {
const { statsX, statsY, statsH } = this.geo;
const legY = statsY + statsH - 16;
const g = this.legendG;
g.clear();
let x = statsX + 14;
const drawRow = (o) => {
if (!o.color) return;
const c = toColor(o.color);
const w = o.width ?? 1;
const a = o.alpha ?? 0.5;
g.lineStyle(w + 4, c, a * 0.15);
g.lineBetween(x, legY + 4, x + 12, legY + 4);
g.lineStyle(Math.max(0.8, w), c, a);
g.lineBetween(x, legY + 4, x + 12, legY + 4);
x += 40;
};
drawRow(config.get('map.galaxy.edges.used', {}));
drawRow(config.get('map.galaxy.edges.frontier', {}));
drawRow(config.get('map.galaxy.edges.unexplored', {}));
const hull = config.get('map.galaxy.hull', {});
if (hull.color) {
const c = toColor(hull.color);
g.fillStyle(c, Math.min(1, (hull.fillAlpha ?? 0.055) * 4));
g.fillRoundedRect(x, legY - 2, 12, 12, 2);
g.lineStyle(1, c, Math.min(1, (hull.outlineAlpha ?? 0.28) * 2.2));
g.strokeRoundedRect(x, legY - 2, 12, 12, 2);
}
}
/** The GALAXY tab's status strip (under the feed). */
_paintStatusStripGalaxy(snap) {
const st = snap.stats ?? {};
const total = st.lanes ?? 0;
const used = st.lanesUsed ?? 0;
this.statusTxt.setText(total > 0 ? `${st.systems ?? 0} SYSTEMS · LANES ${total} · TRAVELED ${used}` : 'GALAXY FEED STANDBY');
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 * (used / total), 3);
}
}
_paintTabs() {
const { bodyY, tabH } = this.geo;
for (const t of this.tabs) {
t.g.clear();
const active = t.active;
const active = t.id === (this._mode === 'galaxy' && !t.standby ? 'galaxy' : 'currentSystem');
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,
@ -1525,6 +1676,10 @@ export class MapWindow extends Phaser.GameObjects.Container {
const rowH = 34;
// SYSTEM DISCOVERY — label + value + segmented bar
const labels = config.get('map.stats', {});
// the original label strings — the GALAXY tab rewords these two rows
// (JUMP LANES / FRONTIER) and the switch back restores them
this._navLabel0 = labels.discoveryLabel ?? 'SYSTEM DISCOVERY';
this._resLabel0 = labels.resourcesLabel ?? 'SYSTEM RESOURCES';
this.navLabel = s
.text(statsX + pad, rowY0, labels.discoveryLabel ?? 'SYSTEM DISCOVERY', {
fontFamily: BODY,
@ -1547,6 +1702,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
.setDepth(2);
meta.x = this.navLabel.x + this.navLabel.width + 12;
this.add(meta);
this.navMetaTxt = meta; // hidden while the GALAXY tab owns the rows
}
this.navValue = s
.text(statsX + statsW - pad, rowY0, '—', {
@ -1586,6 +1742,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
.setDepth(2);
meta.x = this.resLabel.x + this.resLabel.width + 12;
this.add(meta);
this.resMetaTxt = meta; // hidden while the GALAXY tab owns the rows
}
this.resValue = s
.text(statsX + statsW - pad, rowY0 + rowH, '—', {
@ -1602,10 +1759,12 @@ export class MapWindow extends Phaser.GameObjects.Container {
this.add(this.resValue);
this.resBarY = rowY0 + rowH + 16;
// legend — the plate's glyph language (static)
// legend — the plate's glyph language (the system tab's set; the
// GALAXY tab draws its own lane legend over the same slot)
const legY = statsY + statsH - 16;
this.legendG = s.graphics().setScrollFactor(0).setDepth(2);
this.add(this.legendG);
this.legendTexts = [];
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 });
@ -1615,7 +1774,6 @@ export class MapWindow extends Phaser.GameObjects.Container {
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,
@ -1626,9 +1784,25 @@ export class MapWindow extends Phaser.GameObjects.Container {
.setScrollFactor(0)
.setDepth(2);
this.add(t);
this.legendTexts.push(t);
this._legendLayout = this._legendLayout ?? [];
this._legendLayout.push({ x: lx, glyph: item.glyph, label: String(item.label ?? '').toUpperCase() });
lx += 14 + tw + spacing + 12;
}
probe.setVisible(false);
this._paintLegend();
}
/** Re-draw the SYSTEM tab's legend glyphs (the glyph key under the
* stats). Runs at build, and again when the tab switches BACK from
* GALAXY (whose lane legend overwrote the slot). */
_paintLegend() {
const { statsY, statsH } = this.geo;
const legY = statsY + statsH - 16;
this.legendG.clear();
for (const e of this._legendLayout ?? []) {
this._legendGlyph(e.glyph, e.x, legY + 4);
}
}
/** One legend glyph, centred on (x, y). */
@ -1837,6 +2011,10 @@ export class MapWindow extends Phaser.GameObjects.Container {
this.sfx('ui_window');
const now = this.scene.time.now;
this._startReveal(now);
if (this._mode === 'galaxy' && this._galaxy) {
// re-opening the console in GALAXY mode: the ripple runs again
this._galaxy.reveal(now);
}
}
close() {
@ -1844,6 +2022,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
if (this.dialog && this.dialog.isOpen) this.dialog.cancel(); // the dialog goes first
this.openState = 'closing';
this._drag = null; // a press that outlives the window is no one's click
this._galaxy?.cancelInteraction(); // …same for the galaxy plate's press
this.video?.pause?.();
this.sfx('ui_close');
this._setHover(null);
@ -1875,7 +2054,9 @@ export class MapWindow extends Phaser.GameObjects.Container {
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();
const name = (this._mode === 'galaxy'
? String(config.get('map.galaxy.name', null) ?? 'GALAXY')
: 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');
@ -1948,6 +2129,11 @@ export class MapWindow extends Phaser.GameObjects.Container {
if (this.dialog) this.dialog.update(time); // the autopilot confirm, if up
if (!this.isOpen) return;
// the GALAXY tab's living plate — the per-star pulses, the flow
// packets, the ship's ring, the reveal (runs while the console is
// open in galaxy mode)
if (this._mode === 'galaxy') this._galaxy?.update(time);
// reveal timeline
if (this.reveal.length) {
let done = true;
@ -2043,8 +2229,9 @@ export class MapWindow extends Phaser.GameObjects.Container {
this.sweepBand.setAlpha(Math.sin(u * Math.PI) * 0.5);
}
// the ship marker — follows the ship every frame
if (this._tf && this.shipMark) {
// the ship marker — follows the ship every frame (system tab only —
// the galaxy tab carries its own SHIP marker on the plate)
if (this._mode === 'currentSystem' && 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);
@ -2059,8 +2246,8 @@ export class MapWindow extends Phaser.GameObjects.Container {
}
}
// click flash (a discovered object was picked)
if (this._flash) {
// click flash (a discovered object was picked — the system tab)
if (this._mode === 'currentSystem' && this._flash) {
const u = clamp01((time - this._flash.t0) / 420);
if (u >= 1) {
this._flash = null;
@ -2076,9 +2263,35 @@ export class MapWindow extends Phaser.GameObjects.Container {
}
}
// poll the scene's chart — repaint on any real change (discovery, tether…)
// poll the scene's data — repaint on any real change (discovery,
// tether… on the system tab; the run's charted state on the galaxy)
if (time >= this._pollNext) {
this._pollNext = time + 500;
if (this._mode === 'galaxy') {
let snap = null;
try {
snap = this.getGalaxy ? this.getGalaxy() : null;
} catch {
snap = null;
}
if (snap) {
this._galaxySnap = snap;
const gv = this._ensureGalaxy();
gv.setVisible(true);
gv.applySnapshot(snap);
const fp = JSON.stringify([snap.stats, snap.currentSystemId, snap.edges.length]);
if (fp !== this._galaxyFp) {
this._galaxyFp = fp;
this._paintStatsGalaxy(snap);
this._paintLegendGalaxy();
this._paintStatusStripGalaxy(snap);
this.legendTexts?.forEach((t) => t.setVisible(false));
this.navMetaTxt?.setVisible(false);
this.resMetaTxt?.setVisible(false);
}
}
return;
}
const snap = this._snapOf();
if (snap) {
const fp = this._fpOf(snap);
@ -2161,7 +2374,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
* double-tap reset) fires on release, unless the pointer moves past
* the slop, in which case the drag pans the zoomed view instead. */
_startDrag(p) {
if (this._destroyed || (this.dialog && this.dialog.isOpen)) return;
if (this._destroyed || !this.isOpen || (this.dialog && this.dialog.isOpen)) return;
this._drag = {
x: p.x,
y: p.y,
@ -2288,6 +2501,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
}
_setHover(p) {
if (this._mode !== 'currentSystem') return; // the galaxy plate owns itself
if (this.dialog && this.dialog.isOpen) {
// the confirm owns the plate — no hover churn under the scrim
if (this._hover) { this._hover = null; this._paintHover(); }
@ -2365,6 +2579,8 @@ export class MapWindow extends Phaser.GameObjects.Container {
destroy() {
this._destroyed = true;
this._galaxy?.destroy();
this._galaxy = null;
if (this._texKey) {
try {
this.scene.textures.remove(this._texKey);