diff --git a/README.md b/README.md index 27e4dda..2e0b2f1 100644 --- a/README.md +++ b/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 diff --git a/assets/images/spacestations.png b/assets/images/spacestations.png new file mode 100644 index 0000000..7cc6981 Binary files /dev/null and b/assets/images/spacestations.png differ diff --git a/assets/images/spacestations.psd b/assets/images/spacestations.psd new file mode 100644 index 0000000..1374a2f Binary files /dev/null and b/assets/images/spacestations.psd differ diff --git a/data/map.json b/data/map.json index 2ac300e..2a16bf6 100644 --- a/data/map.json +++ b/data/map.json @@ -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, diff --git a/dev/galaxy-labels.test.mjs b/dev/galaxy-labels.test.mjs new file mode 100644 index 0000000..bba3f3a --- /dev/null +++ b/dev/galaxy-labels.test.mjs @@ -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); diff --git a/dev/galaxy-map.test.mjs b/dev/galaxy-map.test.mjs new file mode 100644 index 0000000..edd10eb --- /dev/null +++ b/dev/galaxy-map.test.mjs @@ -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 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${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); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 361addb..97ffbcb 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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.` 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} [o.visited] system ids the run has ENTERED + * @param {Iterable} [o.used] lane keys (edgeKey) the run has + * TRAVELED + * @param {Iterable} [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, edges:Array, + * 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.: `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..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; +} diff --git a/js/save/SaveData.js b/js/save/SaveData.js index 548f4cd..efcae0e 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -18,6 +18,13 @@ * // Set, replayed on load — * // gates + tethers re-form * // in GameScene.create) + * visitedSystems: ["", …], // the systems the run + * // ENTERED (the GALAXY + * // tab's charted region) + * usedGates: ["", …], // 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); } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 14ccc3b..6447ab8 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 /** diff --git a/js/ui/GalaxyView.js b/js/ui/GalaxyView.js new file mode 100644 index 0000000..adf2e35 --- /dev/null +++ b/js/ui/GalaxyView.js @@ -0,0 +1,1105 @@ +/** + * GalaxyView — the live GALAXY tab of the MAP console (a MapWindow + * sub-view, data/map.json → galaxy). + * + * The whole galaxy on the chart plate (js/galaxy/GalaxyChart.js feeds it + * via GameScene.galaxySnapshot): + * • a GLOWING STAR per system — colored by archetype (data/systems.json) + * and pulsing on that archetype's heartbeat (data/map.json → + * galaxy.pulse — the "per-star-type animation"): binary stars beat + * fast with a twin core, red dwarfs breathe slow, nebulae shimmer. + * • THIN LANES between systems where the jump gates connect (the + * JumpNetwork — a spanning tree, so the web reads as a maze): + * - unexplored: a faint thread + * - FRONTIER (one end visited): a brighter "next step" + * - TRAVELED (the run jumped it): the bright lane — a glow pass + + * a flow packet drifting along it (the route the player flew) + * • the CHARTED REGION — the convex hull of the visited systems, + * inflated: a soft fill + subtle outline (discovered-area shading). + * • HOME + SHIP markers (ring + tag) on the plate. + * • ambient dust + the galaxy's core glow (depth, structure). + * + * Interaction (the MapWindow 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 system → "YOU ARE HERE" + * - live lane → CONFIRM JUMP (fires `onJump` — the scene jumps + * through that system's activated gate) + * - dormant lane → the GATE DORMANT readout (chart + research) + * - no direct lane → NO DIRECT LINK (follow the maze) + * Reveal on tab switch / window open: a ripple out of the home system + * (stars bloom by distance, lanes trace in, the region fades last). + * + * FACTIONS (planned — not yet implemented): the snapshot carries + * per-system `faction: null` already; when factions ship, the reserved + * seams are the star color layer (a faction ring/tint over the + * archetype color) + a territory-region pass (the same hull/fill + * recipe as the charted region, per faction color + relation alpha). + * See docs/PROJECT_NOTES.md → "Factions (planned)". + * + * Pure view: it owns no game state — everything arrives through the + * `getSnapshot` callback (polled by MapWindow while open). + */ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { toColor, toCss } from '../utils/Color.js'; +import { fontStack, themeColor } from '../utils/Theme.js'; +import { setInteractiveEnabled } from '../utils/Input.js'; +import { canvasTexture } from '../utils/Textures.js'; +import { Rng } from '../utils/Rng.js'; +import { chartBounds, fitToRect } from '../galaxy/SystemChart.js'; +import { paddedHullPolygon, starPulse, starTypeColor, clipLineToRect, clipPolygonToRect } from '../galaxy/GalaxyChart.js'; + +const TAU = Math.PI * 2; +const BODY = fontStack('body'); +const clamp01 = (v) => Math.min(1, Math.max(0, v)); +const easeIO = (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2); + +/** Theme palette (data/theme.json), resolved once at build time. */ +const C = { + ink: themeColor('ink', 0xeaf6ff), + dim: themeColor('dim', 0x7d92c4), + faint: themeColor('faint', 0x3d4c74), + neon: themeColor('neon', 0x00e5ff), + amber: themeColor('amber', 0xffc94d), + panel: themeColor('panel', 0x0a1120), +}; + +// ── tiny color helpers (hex in, css out) ──────────────────────────────────── +function hx(c) { + if (typeof c === 'number') c = (c >>> 0).toString(16).padStart(6, '0'); + c = String(c).trim().replace(/^#/, ''); + if (c.length === 3) c = c.split('').map((ch) => ch + ch).join(''); + const n = parseInt(c.slice(0, 6), 16) || 0; + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} +const rgba = (c, a) => { + const [r, g, b] = hx(c); + return `rgba(${r},${g},${b},${a})`; +}; +const lighten = (c, t) => { + const a = hx(c); + return `#${a.map((v, i) => Math.round(v + (255 - v) * t).toString(16).padStart(2, '0')).join('')}`; +}; + +/** A soft radial glow disc (white heart melting into `color`). */ +function glowDraw(color) { + return (ctx, w, h) => { + const r = w / 2; + const g = ctx.createRadialGradient(r, r, 0, r, r, r); + g.addColorStop(0, 'rgba(255,255,255,0.85)'); + g.addColorStop(0.22, rgba(color, 0.7)); + g.addColorStop(0.5, rgba(color, 0.2)); + g.addColorStop(1, rgba(color, 0)); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); + }; +} + +/** A star core: white center, spectral hue, dark rim. */ +function coreDraw(color) { + return (ctx, w, h) => { + const r = w / 2; + const g = ctx.createRadialGradient(r - r * 0.3, r - r * 0.3, r * 0.1, r, r, r); + g.addColorStop(0, '#ffffff'); + g.addColorStop(0.55, rgba(lighten(color, 0.3), 0.98)); + g.addColorStop(1, rgba(color, 0.9)); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(r, r, r - 0.5, 0, TAU); + ctx.fill(); + }; +} + +/** A very soft nebulous blob (dust / the galaxy's core glow). */ +function blobDraw(color, coreAlpha) { + return (ctx, w, h) => { + const r = w / 2; + const g = ctx.createRadialGradient(r, r, 0, r, r, r); + g.addColorStop(0, rgba(color, coreAlpha)); + g.addColorStop(0.6, rgba(color, coreAlpha * 0.35)); + g.addColorStop(1, rgba(color, 0)); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); + }; +} + +export class GalaxyView { + /** + * @param {import('./MapWindow.js').MapWindow} win the owning window + * (its scene, its container — the view's objects paint with it —, + * its geo: the plate is geo.mapX/mapY/mapW/mapH, and its shared + * ConfirmOverlay for the star dialogs) + * @param {object} opts + * @param {() => object|null} opts.getSnapshot — the live galaxy + * snapshot (GameScene.galaxySnapshot) + * @param {(systemId: string) => void} [opts.onJump] — a star was + * CONFIRMED for a jump (the scene jumps through the gate) + */ + constructor(win, opts = {}) { + this.win = win; + this.scene = win.scene; + this.getSnapshot = opts.getSnapshot ?? null; + this.onJump = opts.onJump ?? null; + const g = win.geo; + this.px = g.mapX; + this.py = g.mapY; + this.pw = g.mapW; + this.ph = g.mapH; + // the plate is the window onto the galaxy — its content is clipped to + // this rect (lanes/hull/rings would otherwise run past the padded + // frame once the view zooms; the vendored v4 build has no mask API, + // so the drawing passes clip their own geometry, GalaxyChart.js). + this._plateRect = { x: this.px, y: this.py, w: this.pw, h: this.ph }; + + this._cfg = { + stars: config.get('map.galaxy.stars', {}), + edges: config.get('map.galaxy.edges', {}), + hull: config.get('map.galaxy.hull', {}), + flow: config.get('map.galaxy.flow', {}), + dust: config.get('map.galaxy.dust', {}), + labels: config.get('map.galaxy.labels', {}), + zoom: config.get('map.galaxy.zoom', {}), + reveal: config.get('map.galaxy.reveal', {}), + dialog: config.get('map.galaxy.dialog', {}), + }; + + this._snap = null; + this._stars = []; + this._starById = new Map(); + this._dust = []; + this._centerGlow = null; + this._bounds = null; + this._fit = null; + this._padWorld = 0; + this._view = { z: 1, cx: 0, cy: 0 }; + this._hover = null; + this._drag = null; + this._lastTap = 0; + this._revealT0 = -1e9; + this._visible = false; + this._destroyed = false; + + this._build(); + this.setVisible(false); + } + + // ------------------------------------------------------------ build + _build() { + const s = this.scene.add; + const win = this.win; + + this._buildTextures(); + + // layers (plate-local objects at scene coords; the plate sits at + // px/py) — all BELOW the window chrome (plateFrame depth 2, tags 3) + this.hullG = s.graphics().setScrollFactor(0).setDepth(1.05); + this.edgeG = s.graphics().setScrollFactor(0).setDepth(1.1); + this.flowG = s.graphics().setScrollFactor(0).setDepth(1.45); + this.markerG = s.graphics().setScrollFactor(0).setDepth(1.6); + this.hoverG = s.graphics().setScrollFactor(0).setDepth(1.7); + for (const o of [this.hullG, this.edgeG, this.flowG, this.markerG, this.hoverG]) { + o.setVisible(false); + win.add(o); + } + + // HOME / SHIP tags (above their stars — the name labels sit below) + const tagStyle = (color) => ({ + fontFamily: BODY, + fontSize: '8px', + color: toCss(color), + fontStyle: 'bold', + letterSpacing: 2, + align: 'center', + }); + this.homeTag = s + .text(0, 0, 'HOME', tagStyle(C.amber)) + .setOrigin(0.5, 1) + .setScrollFactor(0) + .setDepth(1.6) + .setVisible(false); + this.shipTag = s + .text(0, 0, 'SHIP', tagStyle(C.neon)) + .setOrigin(0.5, 1) + .setScrollFactor(0) + .setDepth(1.6) + .setVisible(false); + win.add(this.homeTag); + win.add(this.shipTag); + + // tooltip (name + kind + link state) — MapWindow's ttCont idiom + this.ttPlate = s.graphics().setScrollFactor(0); + this.ttName = s + .text(0, 0, '', { + fontFamily: BODY, + fontSize: '12px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 1.5, + }) + .setScrollFactor(0); + this.ttSub = s + .text(0, 0, '', { + fontFamily: BODY, + fontSize: '9px', + color: toCss(C.dim), + letterSpacing: 1.5, + }) + .setScrollFactor(0); + this.ttHint = s + .text(0, 0, '', { + fontFamily: BODY, + fontSize: '8px', + color: toCss(C.amber), + letterSpacing: 2, + }) + .setScrollFactor(0); + this.ttCont = new Phaser.GameObjects.Container(this.scene, 0, 0); + this.ttCont.add([this.ttPlate, this.ttName, this.ttSub, this.ttHint]); + this.ttCont.setVisible(false).setDepth(1.8); + win.add(this.ttCont); + + // the plate's INPUT surface (a transparent image — the MapWindow + // mapImg idiom: its hit area IS the plate, so pointer/wheel events + // only fire over the plate). v4's topOnly input: while this catches, + // mapImg's input is disabled (MapWindow._switchMode) — and vice + // versa — so the two modes never double-fire. + this.plateImg = s + .image(this.px + this.pw / 2, this.py + this.ph / 2, this._blankKey()) + .setScrollFactor(0) + .setDepth(0.9); + this.plateImg.setDisplaySize(this.pw, this.ph); + const f = this.plateImg.frame; + this.plateImg.setInteractive({ + useHandCursor: false, + hitArea: new Phaser.Geom.Rectangle(0, 0, f.width, f.height), + hitAreaCallback: (area, x, y) => area.contains(x, y), + }); + win.add(this.plateImg); + this._bindInput(); + } + + _blankKey() { + const key = '__map_blank'; + if (!this.scene.textures.exists(key)) { + const c = document.createElement('canvas'); + c.width = 2; + c.height = 2; + this.scene.textures.addCanvas(key, c); + } + return key; + } + + _buildTextures() { + // one glow + one core per archetype (6), plus dust + core glow. + // Canvas-drawn (data-driven colors — data/systems.json → types). + for (const t of ['main', 'redDwarf', 'binary', 'habitable', 'nebula', 'void']) { + const color = starTypeColor(t); + canvasTexture(this.scene, `gmv_glow_${t}`, 96, 96, glowDraw(color)); + canvasTexture(this.scene, `gmv_core_${t}`, 24, 24, coreDraw(color)); + } + canvasTexture(this.scene, 'gmv_dust0', 256, 256, blobDraw('#1d3a63', 0.5)); + canvasTexture(this.scene, 'gmv_dust1', 256, 256, blobDraw('#33205c', 0.45)); + canvasTexture(this.scene, 'gmv_center', 256, 256, blobDraw('#2c4d80', 0.55)); + } + + // ------------------------------------------------------------ data + /** + * Apply a galaxy snapshot. First call builds the stars (the roster is + * static within a run); later calls refresh the per-system flags + * (visits, current, gate counts) and redraw the region + lanes. + */ + applySnapshot(snap) { + if (!snap || !Array.isArray(snap.systems) || !snap.systems.length) return; + const first = this._snap == null; + this._snap = snap; + if (first) { + // frame + fit (world → plate), like the system chart + const glowW = Math.max(10, Number(this._cfg.stars.glow ?? 30)); + const pad = Number(config.get('map.galaxy.padding', 90)) || 90; + this._bounds = chartBounds( + snap.systems.map((s) => ({ x: s.x, y: s.y, radius: glowW / 2 })), + pad + ); + this._fit = fitToRect(this._bounds, this.pw, this.ph); + this._view = { z: 1, cx: this._bounds.cx, cy: this._bounds.cy }; + // charted-region inflation, in WORLD units (a fraction of the plate + // width at 1× zoom) — stable across zoom levels + this._padWorld = (this.pw * Number(this._cfg.hull.padding ?? 0.05)) / this._fit.scale; + this._buildStars(); + this._buildDust(); + this._relayout(); + // per-lane flow stagger (seeded — stable between opens) + for (const e of snap.edges) { + const rng = new Rng(Rng.derive(snap.seed || 'orbit', 'gmvflow', e.key)); + e.stagger = rng.range(0, 1); + } + this._revealT0 = -1e9; + } else { + for (const st of this._stars) { + const rec = snap.systems.find((s) => s.id === st.id); + if (!rec) continue; + st.name = rec.name ?? st.name; + st.visited = rec.visited; + st.isHome = rec.isHome; + st.isCurrent = rec.isCurrent; + st.gates = rec.gates; + } + this._redrawHull(); + this._redrawEdges(); + this._placeTags(); + this._drawMarkers(this.scene.time.now); + } + } + + _buildStars() { + const s = this.scene.add; + const win = this.win; + const glowW = Math.max(10, Number(this._cfg.stars.glow ?? 30)); + const coreW = Math.max(2, Number(this._cfg.stars.core ?? 6)); + for (const rec of this._snap.systems) { + const rng = new Rng(Rng.derive(this._snap.seed || 'orbit', 'gmv', rec.id)); + const glow = s.image(0, 0, `gmv_glow_${rec.type}`).setScrollFactor(0).setDepth(1.3); + const core = s.image(0, 0, `gmv_core_${rec.type}`).setScrollFactor(0).setDepth(1.35); + let core2 = null; + let core2Angle = 0; + if (rec.type === 'binary') { + // a twin — the binary's second star, a seeded bearing away + core2 = s.image(0, 0, `gmv_core_${rec.type}`).setScrollFactor(0).setDepth(1.35); + core2Angle = rng.range(0, TAU); + } + const label = s + .text(0, 0, String(rec.name ?? rec.id).toUpperCase(), { + fontFamily: BODY, + fontSize: '9px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 1.5, + align: 'center', + }) + .setOrigin(0.5, 0) + .setScrollFactor(0) + .setDepth(1.5) + .setVisible(false) + .setAlpha(0.9); + glow.setVisible(false); + core.setVisible(false); + if (core2) core2.setVisible(false); + win.add(glow); + win.add(core); + if (core2) win.add(core2); + win.add(label); + this._stars.push({ + id: rec.id, + name: rec.name ?? rec.id, + type: rec.type, + glow, + core, + core2, + core2Angle, + label, + wx: rec.x, + wy: rec.y, + phase: rng.range(0, TAU), + visited: rec.visited, + isHome: rec.isHome, + isCurrent: rec.isCurrent, + gates: rec.gates, + glowW, + coreW, + revealDelay: 0, + lx: 0, + ly: 0, + }); + this._starById.set(rec.id, this._stars[this._stars.length - 1]); + } + } + + _buildDust() { + const dc = this._cfg.dust; + const b = this._bounds; + // the galaxy's core glow — the disc's heart (structure, not + // decoration). Its POSITION follows the galaxy center (world space), + // but its SIZE is plate-stable — a fixed ambient light, so it never + // smears across the plate when the view zooms. + const cg = this.scene.add + .image(b.cx, b.cy, 'gmv_center') + .setScrollFactor(0) + .setDepth(1.04) + .setBlendMode(Phaser.BlendModes.ADD) + .setAlpha(Number(dc.centerAlpha ?? 0.13)) + .setVisible(false); + cg.setDisplaySize(Math.min(this.pw, this.ph) * 1.05, Math.min(this.pw, this.ph) * 1.05); + this.win.add(cg); + this._centerGlow = cg; + if (dc.enabled === false) return; + const n = Math.max(0, Math.min(12, Math.round(dc.blobs ?? 5))); + const rng = new Rng(Rng.derive(this._snap.seed || 'orbit', 'gmv', 'dust')); + // dust is PLATE space — the camera feed's ambient texture (like the + // scanlines), stable under pan/zoom (world-space blobs would smear + // and bleed past the frame when the view magnifies). + for (let i = 0; i < n; i++) { + const img = this.scene.add + .image(0, 0, i % 2 ? 'gmv_dust1' : 'gmv_dust0') + .setScrollFactor(0) + .setDepth(1.05) + .setBlendMode(Phaser.BlendModes.ADD) + .setAlpha(0) + .setVisible(false); + const base = rng.range(dc.alphaMin ?? 0.03, dc.alphaMax ?? 0.08); + const fx = rng.range(0.08, 0.92); + const fy = rng.range(0.12, 0.88); + const s = Math.min(this.pw, this.ph) * rng.range(0.3, 0.6); + img.setPosition(this.px + fx * this.pw, this.py + fy * this.ph); + img.setDisplaySize(s, s * rng.range(0.55, 0.95)); + this.win.add(img); + this._dust.push({ img, base }); + } + } + + // ------------------------------------------------------------ layout + _k() { + return this._fit.scale * this._view.z; + } + + _relayout() { + if (!this._fit || !this._bounds) return; + const k = this._k(); + const toX = (wx) => this.px + (wx - this._view.cx) * k + this.pw / 2; + const toY = (wy) => this.py + (wy - this._view.cy) * k + this.ph / 2; + for (const st of this._stars) { + st.lx = toX(st.wx); + st.ly = toY(st.wy); + st.glow.setPosition(st.lx, st.ly); + st.core.setPosition(st.lx, st.ly); + if (st.core2) { + const off = Math.max(2, st.coreW * k) * 0.95; + st.core2.setPosition(st.lx + Math.cos(st.core2Angle) * off, st.ly + Math.sin(st.core2Angle) * off); + } + st.label.setPosition(st.lx, st.ly + st.glowW * k * 0.5 + 10); + } + this._applyCull(); + if (this._centerGlow) { + // the core glow follows the galaxy center (world space); it is + // culled when it has panned out of the plate + this._centerGlow.setPosition(toX(this._bounds.cx), toY(this._bounds.cy)); + const margin = Math.min(this.pw, this.ph) * 0.6; + this._centerGlow.setVisible( + this._centerGlow.x >= this.px - margin && + this._centerGlow.x <= this.px + this.pw + margin && + this._centerGlow.y >= this.py - margin && + this._centerGlow.y <= this.py + this.ph + margin + ); + } + this._redrawHull(); + this._redrawEdges(); + this._placeTags(); + } + + /** Stars whose centers have left the plate window are culled (their + glow/core are textures — the only clean clip for them). */ + _applyCull() { + for (const st of this._stars) { + const inPlate = + st.lx >= this.px && + st.lx <= this.px + this.pw && + st.ly >= this.py && + st.ly <= this.py + this.ph; + st.culled = !inPlate; + st.glow.setVisible(inPlate); + st.core.setVisible(inPlate); + if (st.core2) st.core2.setVisible(inPlate); + st.label.setVisible(this._labelShown(st)); + } + } + + _redrawHull() { + const g = this.hullG; + g.clear(); + if (!this._snap) return; + const hc = this._cfg.hull; + if (hc.enabled === false) return; + const visited = this._snap.systems.filter((s) => s.visited); + if (!visited.length) return; + const k = this._k(); + const poly = paddedHullPolygon( + visited.map((s) => ({ x: s.x, y: s.y })), + this._padWorld + ); + if (!poly.length) return; + const raw = poly.map((p) => ({ + x: this.px + (p.x - this._view.cx) * k + this.pw / 2, + y: this.py + (p.y - this._view.cy) * k + this.ph / 2, + })); + // the region is clipped to the plate (zoomed hulls would otherwise + // run past the frame) + const pts = clipPolygonToRect(raw, this._plateRect); + if (pts.length < 3) return; + const color = toColor(hc.color ?? C.neon); + g.fillStyle(color, hc.fillAlpha ?? 0.055); + g.fillPoints(pts, true); + g.lineStyle(3.5, color, (hc.outlineAlpha ?? 0.28) * 0.35); // soft outer pass + g.strokePoints(pts, true); + g.lineStyle(1, color, hc.outlineAlpha ?? 0.28); + g.strokePoints(pts, true); + } + + _redrawEdges() { + const g = this.edgeG; + g.clear(); + if (!this._snap) return; + const ec = this._cfg.edges; + const k = this._k(); + for (const e of this._snap.edges) { + const a = this._starById.get(e.a); + const b = this._starById.get(e.b); + if (!a || !b) continue; + // the lane is clipped to the plate (the chart stays in its window) + const seg = clipLineToRect(a.lx, a.ly, b.lx, b.ly, this._plateRect); + if (!seg) continue; + const [ax, ay, bx, by] = seg; + const st = e.used ? ec.used : e.frontier ? ec.frontier : ec.unexplored; + const color = toColor(st.color); + const alpha = st.alpha ?? 0.5; + const width = Math.max(0.6, (st.width ?? 1) * (0.75 + 0.25 * k)); + if (e.used) { + // the TRAVELED lane — three passes: halo, body, bright core + g.lineStyle(width + 5, color, alpha * 0.14); + g.lineBetween(ax, ay, bx, by); + g.lineStyle(width + 1.5, color, alpha * 0.42); + g.lineBetween(ax, ay, bx, by); + } + g.lineStyle(width, color, alpha); + g.lineBetween(ax, ay, bx, by); + g.lineStyle(Math.min(1.3, width), color, Math.min(1, alpha + 0.28)); + g.lineBetween(ax, ay, bx, by); + } + } + + _placeTags() { + if (!this._snap || !this._fit) return; + const k = this._k(); + const home = this._snap.homeSystemId ? this._starById.get(this._snap.homeSystemId) : null; + const cur = this._snap.currentSystemId ? this._starById.get(this._snap.currentSystemId) : null; + const both = !!(home && cur && home.id === cur.id); + if (home) { + this.homeTag.setText(both ? (this._cfg.dialog.homeTagBoth ?? 'HOME · SHIP') : (this._cfg.dialog.homeTag ?? 'HOME')); + this.homeTag.setPosition(home.lx, Phaser.Math.Clamp(home.ly - home.glowW * k * 0.5 - 8, this.py + 12, this.py + this.ph - 3)); + this.homeTag.setVisible(true); + } else { + this.homeTag.setVisible(false); + } + if (cur && !both) { + this.shipTag.setText(this._cfg.dialog.shipTag ?? 'SHIP'); + this.shipTag.setPosition(cur.lx, Phaser.Math.Clamp(cur.ly - cur.glowW * k * 0.5 - 8, this.py + 12, this.py + this.ph - 3)); + this.shipTag.setVisible(true); + } else { + this.shipTag.setVisible(false); + } + } + + // ------------------------------------------------------------ per-frame + /** + * The plate's living layer — per-star-type pulsing, the flow packets on + * traveled lanes, the ship marker's pulse, the reveal ripple. Driven by + * MapWindow.update while the console is open in GALAXY mode. + */ + update(time) { + if (this._destroyed || !this._visible || !this._snap || !this._fit) return; + const k = this._k(); + const tR = time - this._revealT0; + const edgeF = this._easeReveal(tR, 240, 640); + const hullF = this._easeReveal(tR, 560, 700); + this.edgeG.setAlpha(edgeF); + this.hullG.setAlpha(hullF); + const dustA = this._easeReveal(tR, 80, 900); + this._centerGlow?.setAlpha((Number(this._cfg.dust?.centerAlpha ?? 0.13) || 0.13) * dustA); + for (const d of this._dust) d.img.setAlpha(d.base * dustA); + + for (const st of this._stars) { + const p = starPulse(st.type, time, st.phase); + const rf = this._easeReveal(tR, st.revealDelay, 480); + const mul = st.visited ? (this._cfg.stars.visitedBoost ?? 1.3) : (this._cfg.stars.unknownMul ?? 0.78); + const size = Math.max(2, st.glowW * (0.7 + 0.55 * p) * mul * (0.5 + 0.5 * rf) * k); + st.glow.setDisplaySize(size, size); + const aBase = (this._cfg.stars.glowAlpha ?? 0.6) * (st.visited ? 1 : (this._cfg.stars.unknownAlpha ?? 0.55)); + st.glow.setAlpha(aBase * (0.6 + 0.4 * p) * rf); + const cSize = Math.max(1.6, st.coreW * (0.85 + 0.3 * p) * k); + st.core.setDisplaySize(cSize, cSize); + st.core.setAlpha((st.visited ? 1 : 0.7) * rf); + if (st.core2) { + st.core2.setDisplaySize(cSize * 0.68, cSize * 0.68); + st.core2.setAlpha((st.visited ? 0.95 : 0.6) * rf); + } + const show = this._labelShown(st); + st.label.setVisible(show); + if (show) st.label.setAlpha(0.92 * rf); + } + + this._drawFlow(time); + this._drawMarkers(time, k); + } + + _labelVisible(st) { + const lab = this._cfg.labels; + if (lab.enabled === false) return false; + const z = this._view.z; + if (st.isCurrent || st.isHome) return true; + if (st.visited) return z >= (lab.visitedZoom ?? 2.5); + return z >= (lab.anyZoom ?? 5); + } + + /** A star's name shows only when the view is visible, the star is in the + plate, and it has EARNED its label at the current zoom. One rule, used + by BOTH the per-frame update and the cull / setVisible passes — so the + 500 ms snapshot poll (which re-runs setVisible(true)) can never flash + sub-threshold labels on for a frame (they used to blink on/off). */ + _labelShown(st) { + return this._visible && !st.culled && this._labelVisible(st); + } + + /** Flow packets drifting ALONG the traveled lanes (the routes flown). */ + _drawFlow(time) { + const g = this.flowG; + g.clear(); + const fc = this._cfg.flow; + if (fc.enabled === false) return; + const used = this._snap.edges.filter((e) => e.used); + if (!used.length) return; + const k = this._k(); + const cycle = Math.max(400, Number(fc.cycleMs ?? 2600)); + const size = Math.max(1.4, (fc.size ?? 2.4) * Math.max(0.7, k * 0.85)); + const color = toColor(fc.color ?? '#c8f2ff'); + for (const e of used) { + const a = this._starById.get(e.a); + const b = this._starById.get(e.b); + if (!a || !b) continue; + const inR = (x, y) => + x >= this.px && x <= this.px + this.pw && y >= this.py && y <= this.py + this.ph; + const st = (time / cycle + (e.stagger ?? 0)) % 1; + const px = a.lx + (b.lx - a.lx) * st; + const py = a.ly + (b.ly - a.ly) * st; + if (inR(px, py)) { + g.fillStyle(color, 0.95); + g.fillCircle(px, py, size); + } + const st2 = (st + 0.955) % 1; // a short comet tail + const tx = a.lx + (b.lx - a.lx) * st2; + const ty = a.ly + (b.ly - a.ly) * st2; + if (inR(tx, ty)) { + g.fillStyle(color, 0.3); + g.fillCircle(tx, ty, size * 0.65); + } + } + } + + /** A ring as a polygon (so the plate clip can cut it at the frame). */ + _ringPts(cx, cy, r, n = 40) { + const pts = []; + for (let i = 0; i < n; i++) { + const a = (i / n) * TAU; + pts.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r }); + } + return pts; + } + + _strokeClippedRing(g, cx, cy, r, width, color, alpha) { + const pts = clipPolygonToRect(this._ringPts(cx, cy, r), this._plateRect); + if (pts.length < 3) return; + g.lineStyle(width, color, alpha); + g.strokePoints(pts, true); + } + + /** HOME ring + the SHIP marker's expanding pulse (clipped to the plate). */ + _drawMarkers(time, k) { + const g = this.markerG; + g.clear(); + const home = this._snap.homeSystemId ? this._starById.get(this._snap.homeSystemId) : null; + if (home) { + const r = Math.max(9, 20 * k); + this._strokeClippedRing(g, home.lx, home.ly, r, 1, toColor(C.amber), 0.85); + this._strokeClippedRing(g, home.lx, home.ly, r + 4, 1, toColor(C.amber), 0.22); + } + const cur = this._snap.currentSystemId ? this._starById.get(this._snap.currentSystemId) : null; + if (cur) { + const u = (time % 1600) / 1600; + this._strokeClippedRing(g, cur.lx, cur.ly, (11 + 30 * u) * Math.max(0.6, k * 0.9), 1.6, toColor(C.neon), 0.85 * (1 - u)); + this._strokeClippedRing(g, cur.lx, cur.ly, 9 * Math.max(0.7, k * 0.9), 1, toColor(C.neon), 0.55); + } + } + + /** Reveal ripple out of the home system (the galaxy resolves). */ + reveal(now) { + const rv = this._cfg.reveal; + if (rv.enabled === false) { + this._revealT0 = -1e9; + return; + } + this._revealT0 = now; + const home = this._snap?.systems.find((s) => s.isHome); + const base = home ? { x: home.x, y: home.y } : { x: this._view.cx, y: this._view.cy }; + const speed = Number(rv.speedPerWorld ?? 0.7); // ms of delay per world px + const maxDelay = Number(rv.maxDelayMs ?? 850); + for (const st of this._stars) { + const d = Math.hypot(st.wx - base.x, st.wy - base.y); + st.revealDelay = Math.min(maxDelay, d * speed); + } + } + + _easeReveal(tR, delay, dur = 480) { + return easeIO(clamp01((tR - delay) / Math.max(1, dur))); + } + + // ------------------------------------------------------------ input + _bindInput() { + const img = this.plateImg; + img.on('pointerdown', (p) => { + if (this._dialogUp()) return; + this._startDrag(p); + }); + img.on('pointermove', (p) => { + if (this._dialogUp()) return; + if (this._drag) this._onDragMove(p); + else this._setHover(p); + }); + img.on('pointerup', () => this._finishDrag(true)); + img.on('pointerout', () => { + this._setHover(null); + if (this._drag) this._finishDrag(false); + }); + img.on('wheel', (p, _dx, dy) => { + if (this._dialogUp()) return; + const zc = this._cfg.zoom; + if (zc.enabled === false) return; + const dyPx = dy * ((p.event?.deltaMode ?? 0) === 1 ? 32 : 1); + if (!dyPx) return; + const pt = this._platePoint(p); + this._zoomAt(pt.x, pt.y, Math.exp(-dyPx * (zc.sensitivity ?? 0.0022))); + }); + } + + _dialogUp() { + const d = this.win.dialog; + return !!(d && d.isOpen); + } + + _platePoint(p) { + return { x: p.x - this.px, y: p.y - this.py }; + } + + _starAt(pt) { + if (!this._fit) return null; + const k = this._k(); + const rHit = Math.max(10, 13 * k) + 9; + let best = null; + let bestD = Infinity; + for (const st of this._stars) { + const d = Math.hypot(pt.x - (st.lx - this.px), pt.y - (st.ly - this.py)); + if (d <= rHit && d < bestD) { + bestD = d; + best = st; + } + } + return best; + } + + _zoomAt(px, py, factor) { + const fit = this._fit; + const b = this._bounds; + if (!fit || !b) return; + const zc = this._cfg.zoom; + const z0 = this._view.z; + const z1 = Phaser.Math.Clamp(z0 * factor, zc.zMin ?? 1, zc.zMax ?? 10); + if (z1 === z0) return; + const k0 = fit.scale * z0; + const wx = (px - this.pw / 2) / k0 + this._view.cx; + const wy = (py - this.ph / 2) / k0 + this._view.cy; + const k1 = fit.scale * z1; + const vw = b.w / z1; + const vh = b.h / z1; + this._view = { + z: z1, + cx: Phaser.Math.Clamp(wx - (px - this.pw / 2) / k1, b.cx - (b.w - vw) / 2, b.cx + (b.w - vw) / 2), + cy: Phaser.Math.Clamp(wy - (py - this.ph / 2) / k1, b.cy - (b.h - vh) / 2, b.cy + (b.h - vh) / 2), + }; + this._relayout(); + } + + _panBy(dpx, dpy) { + const fit = this._fit; + const b = this._bounds; + if (!fit || !b || this._view.z <= 1) return; + const z = this._view.z; + const vw = b.w / z; + const vh = b.h / z; + this._view = { + z, + cx: Phaser.Math.Clamp(this._view.cx - dpx / (fit.scale * z), b.cx - (b.w - vw) / 2, b.cx + (b.w - vw) / 2), + cy: Phaser.Math.Clamp(this._view.cy - dpy / (fit.scale * z), b.cy - (b.h - vh) / 2, b.cy + (b.h - vh) / 2), + }; + this._relayout(); + } + + _resetView() { + if (!this._bounds) return; + this._view = { z: 1, cx: this._bounds.cx, cy: this._bounds.cy }; + this._setHover(null); + this._relayout(); + } + + _startDrag(p) { + if (this._destroyed) return; + const pt = this._platePoint(p); + this._drag = { x: pt.x, y: pt.y, moved: false, over: this._starAt(pt) }; + } + + _onDragMove(p) { + const d = this._drag; + if (!d) return; + const pt = this._platePoint(p); + const dx = pt.x - d.x; + const dy = pt.y - d.y; + if (!d.moved) { + if (Math.hypot(dx, dy) < 5) return; // still a click, not a drag + d.moved = true; + this._setHover(null); + } + d.x = pt.x; + d.y = pt.y; + this._panBy(dx, dy); + } + + _finishDrag(releaseInside) { + const d = this._drag; + this._drag = null; + if (!d || this._destroyed) return; + if (d.moved) { + this._lastTap = 0; + return; + } + if (!releaseInside) return; + const now = this.scene.time.now; + if (d.over) { + this.win.sfx('ui_click'); + this._lastTap = 0; // a selection is not a zoom-reset tap + this._askStar(d.over); + return; + } + if (this._view.z > 1 && now - this._lastTap < 320) { + this._lastTap = 0; + this.win.sfx('ui_window'); + this._resetView(); + return; + } + this._lastTap = now; + } + + _setHover(p) { + if (!this.win.isOpen || p === null || this._dialogUp()) { + if (this._hover) { + this._hover = null; + this._paintHover(); + } + if (p === null) this.scene.input.setDefaultCursor('auto'); + return; + } + const st = this._starAt(this._platePoint(p)); + if (st && (!this._hover || this._hover.id !== st.id)) { + this.win.sfx('ui_hover'); + this.scene.input.setDefaultCursor('pointer'); + } else if (!st && this._hover) { + this.scene.input.setDefaultCursor('auto'); + } + this._hover = st; + this._paintHover(); + } + + _paintHover() { + const g = this.hoverG; + g.clear(); + if (!this._hover) { + this.ttCont.setVisible(false); + return; + } + const st = this._hover; + const k = this._k(); + const r = Math.max(12, st.glowW * k * 0.55); + this._strokeClippedRing(g, st.lx, st.ly, r, 1.5, toColor(C.neon), 0.9); + this._strokeClippedRing(g, st.lx, st.ly, r + 5, 4, toColor(C.neon), 0.16); + + // tooltip — name + type/gates + the link state to the current system + const curId = this._snap.currentSystemId; + const edge = this._edgeTo(st.id); + const typeLabel = + config.get(`systems.types.${st.type}.label`, null) ?? String(st.type).replace(/^./, (c) => c.toUpperCase()); + this.ttName.setText( + `${String(st.name ?? st.id).toUpperCase()}${st.isHome ? ' — HOME' : ''}${st.isCurrent ? ' — SHIP' : ''}` + ); + this.ttSub.setText( + `${typeLabel.toUpperCase()} · ${st.gates} JUMP GATE(S) · ${st.visited ? 'CHARTED' : 'UNCHARTED'}` + ); + let hint; + let hintColor = C.amber; + if (st.id === curId) { + hint = 'YOU ARE HERE'; + } else if (edge && edge.live) { + hint = 'TAP TO JUMP'; + hintColor = C.neon; + } else if (edge) { + hint = 'GATE DORMANT — CHART + RESEARCH'; + } else { + const curName = curId ? this._snap.systems.find((s) => s.id === curId)?.name : null; + hint = curName ? `NO DIRECT LINK FROM ${curName.toUpperCase()}` : 'NO DIRECT LINK'; + } + this.ttHint.setText(hint); + this.ttHint.setColor(toCss(hintColor)); + + const ttW = Math.max(this.ttName.width, this.ttSub.width, this.ttHint.width) + 26; + const ttH = 46; + let tx = st.lx; + let ty = st.ly - r - 12; + tx = Phaser.Math.Clamp(tx, this.px + ttW / 2 + 6, this.px + this.pw - ttW / 2 - 6); + ty = Math.max(ty, this.py + ttH + 8); + if (ty - ttH < this.py + 4) ty = st.ly + r + 12 + ttH; + this.ttPlate.clear(); + this.ttPlate.fillStyle(toColor(C.panel), 0.95); + this.ttPlate.fillRoundedRect(tx - ttW / 2, ty - ttH, ttW, ttH, 4); + this.ttPlate.lineStyle(1, toColor(C.neon), 0.75); + this.ttPlate.strokeRoundedRect(tx - ttW / 2, ty - ttH, ttW, ttH, 4); + this.ttName.setPosition(tx, ty - ttH + 9); + this.ttName.setOrigin(0.5, 0); + this.ttSub.setPosition(tx, ty - ttH + 25); + this.ttSub.setOrigin(0.5, 0); + this.ttHint.setPosition(tx, ty - ttH + 36); + this.ttHint.setOrigin(0.5, 0); + this.ttCont.setVisible(true); + } + + _edgeTo(id) { + return this._snap?.edges.find((e) => e.a === id || e.b === id) ?? null; + } + + // ------------------------------------------------------------ star dialog + /** + * The clicked star speaks (the window's shared ConfirmOverlay, over the + * plate — the MapWindow autopilot idiom): a LIVE lane confirms the + * JUMP (onJump → the scene jumps through the gate, the console closes); + * a dormant lane / no lane is a readout with a single CLOSE. + */ + _askStar(st) { + const dlg = this.win.dialog; + const d = this._cfg.dialog; + const name = st.visited || st.isHome || st.isCurrent ? st.name : 'UNKNOWN SYSTEM'; + const cur = this._snap.currentSystemId ? this._snap.systems.find((s) => s.id === this._snap.currentSystemId) : null; + const fill = (s) => + String(s) + .replace(/\{name\}/g, name) + .replace(/\{current\}/g, cur ? String(cur.name).toUpperCase() : 'THIS SYSTEM') + .replace(/\{gates\}/g, String(st.gates)); + // the config's body values are LINE ARRAYS (data/map.json → galaxy.dialog); + // normalize (a bare string works too) and fill the placeholders + const lines = (v, fb) => { + const raw = Array.isArray(v) ? v : (v ?? fb); + return (Array.isArray(raw) ? raw : [raw]).map((l) => fill(l)); + }; + const now = this.scene.time.now; + + if (st.id === this._snap.currentSystemId) { + dlg.show({ + title: fill(d.hereTitle ?? 'YOU ARE HERE'), + body: lines(d.hereBody, 'This is the system you are in.'), + accent: C.amber, + confirmLabel: d.closeLabel ?? 'CLOSE', + onConfirm: null, + time: now, + }); + return; + } + const edge = this._edgeTo(st.id); + if (edge && edge.live) { + dlg.show({ + title: fill(d.jumpTitle ?? 'JUMP TO {name}'), + body: lines(d.jumpBody, ['Via the activated gate out of {current}.', 'DESTINATION — {name} · {gates} JUMP GATE(S)']), + accent: C.neon, + confirmLabel: d.jumpLabel ?? 'JUMP', + onConfirm: () => { + this._hover = null; + this._paintHover(); + this.onJump?.(st.id); + this.win.close(); + }, + time: now, + }); + return; + } + if (edge) { + dlg.show({ + title: fill(d.dormantTitle ?? 'GATE DORMANT'), + body: [...lines(d.dormantBody, 'No active link from {current}.'), fill(d.dormantHint ?? 'Chart this system and research its jump gates to wake the lane.')], + accent: C.amber, + confirmLabel: d.closeLabel ?? 'CLOSE', + onConfirm: null, + time: now, + }); + return; + } + dlg.show({ + title: fill(d.noLinkTitle ?? 'NO DIRECT LINK'), + body: [...lines(d.noLinkBody, 'No jump gate connects {current} and {name}.'), fill(d.noLinkHint ?? 'The network is a maze — follow the lanes one jump at a time.')], + accent: C.amber, + confirmLabel: d.closeLabel ?? 'CLOSE', + onConfirm: null, + time: now, + }); + } + + // ------------------------------------------------------------ state + setVisible(v) { + this._visible = v; + const objs = [ + this.hullG, + this.edgeG, + this.flowG, + this.markerG, + this.hoverG, + this.ttCont, + this.homeTag, + this.shipTag, + this.plateImg, + this._centerGlow, + ...this._dust.map((d) => d.img), + ...this._stars.flatMap((st) => [st.glow, st.core, st.core2, st.label].filter(Boolean)), + ]; + for (const o of objs) o?.setVisible?.(v); + this._applyCull(); // the plate clip still applies (culled stars stay off) + setInteractiveEnabled(this.plateImg, v); + if (!v) this._hover = null; + } + + clearHover() { + this._hover = null; + this._paintHover(); + this.scene.input.setDefaultCursor('auto'); + } + + /** A press that outlives the window is no one's click. */ + cancelInteraction() { + this._drag = null; + this.clearHover(); + } + + destroy() { + this._destroyed = true; + if (this.plateImg) { + try { + this.plateImg.removeAllListeners(); + } catch { + /* already gone */ + } + setInteractiveEnabled(this.plateImg, false); + } + this._stars = []; + this._starById.clear(); + this._dust = []; + } +} diff --git a/js/ui/MapWindow.js b/js/ui/MapWindow.js index 54c0c5d..03b9791 100644 --- a/js/ui/MapWindow.js +++ b/js/ui/MapWindow.js @@ -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);