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:
parent
a846562782
commit
22c2ccfcd2
25
README.md
25
README.md
|
|
@ -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.
|
|
@ -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.75–1.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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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 = n−1 lanes (dedup’d)',
|
||||
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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (i−1)'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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue