Add SYSTEM tab and zoom-bloom star art to the map console

- New "System" socket in MapWindow: a charted star's chart drawn by the
  same painter as Current System (no ship/tethers), armed until the player
  picks that star on the Galaxy tab, then wakes with its map.
- Galaxy tab click flow changes: a visited star opens its chart in the
  System tab (onStarOpen) instead of confirming a jump; uncharted stars
  are readouts only and a lit lane to an uncharted star pops nothing.
- Zoom-bloom star art (GalaxyChart.starArtSpec + GalaxyView blit):
  diffraction spikes, per-type crowns (granulation, prominences, binary
  companion, habitable rings, nebula disc, void horizon) and a surface
  wobble fade in as you zoom; all angles/phases seeded per system.
- Star hit-testing now scales with the dot size and includes a crowded
  cluster dead band so lanes/empty space between stars no longer trigger
  hover.
- New systemChartSnapshot() in SystemChart.js plus tests covering shape,
  coverage, discovery flags, stats and determinism.
- Set Destination dialog (System tab) acknowledges the pick via
  onSetDestination; destination model is still being defined.
- Added spacestation image/video assets for future use.
This commit is contained in:
Brian Fertig 2026-09-07 18:56:48 -06:00
parent 22c2ccfcd2
commit efe17f3d2e
14 changed files with 1226 additions and 169 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,5 +1,5 @@
{
"_comment": "MAP — the deck's MAP button (to the right of SHIP): the full-screen cartography console, js/ui/MapWindow.js (depth 80, the ResearchWindow idiom). LEFT = the cartography feed (assets/videos/map.mp4, a muted 2:3 loop, the same recipe as the research archive feed). RIGHT = tabs on top (CURRENT SYSTEM — 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.",
"_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 live system chart, SYSTEM — a CHARTED star's chart, picked from the GALAXY tab; armed until then, 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,12 +12,19 @@
]
},
"tabs": {
"_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.",
"_comment": "The sockets above the chart (top right), equal widths. `currentSystem` is the live system chart (default). `system` is a CHARTED star's chart (the same painter, no ship/tether) — it wakes from standby the first time the player picks that star on the GALAXY tab; until then it's the armed killswitch. `galaxy` is the whole-galaxy chart (js/ui/GalaxyView.js). `standby: true` on ANY socket 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",
"accent": "#00e5ff"
},
"system": {
"id": "system",
"label": "System",
"accent": "#a8ffc4",
"standby": true,
"standbyTag": "NO TARGET"
},
"galaxy": {
"id": "galaxy",
"label": "Galaxy",
@ -27,6 +34,7 @@
}
},
"galaxyLockedToast": "GALAXY MAP OFFLINE — SECTOR DATA NOT ACQUIRED",
"systemLockedToast": "NO SYSTEM TARGET — PICK A CHARTED STAR ON THE GALAXY MAP",
"bounds": {
"_comment": "The chart's frame: the furthest X/Y extents of EVERY object in the system (planets, stations, gates, asteroid clusters — discovered or not) plus this much padding, px, on each edge (the design rule: ~1024).",
"padding": 1024
@ -113,17 +121,46 @@
]
},
"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).",
"_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 — STARS ONLY, and the zone hugs each star's drawn dot (glow radius + a few px of mouse pad), so the lanes between the stars never trigger it; click a CHARTED star = its chart in the SYSTEM tab; uncharted stars are readouts only. 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).",
"_comment": "Per-system star rendering. minPx = the core dot at 1× zoom (a tad bigger than a pixel — the fully-zoomed-out dot); zoomGrow = px gained per zoom step (the star grows as you approach). glow/core = legacy world-unit bases (kept for the glow texture's swing). 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). art = the ZOOM-BLOOM design: the star is a plain dot until flareAt, then diffraction spikes fade in; at crownAt its type's signature appears (main = granulation rim + corona ticks, redDwarf = breathing corona + prominence arcs, binary = an orbiting companion on a faint ellipse, habitable = the life-zone rings + orbiting world(s), nebula = a tilted accretion disc + drifting speckles, void = a dark horizon + shimmering photon ring + lensing ticks); at surfaceAt the core gains a surface wobble + a glint. All sizes are × the dot; all angles/phases are seeded per system (deterministic); everything animates on scene time. spikes = 0 for the void (no sparkle around a horizon).",
"glow": 30,
"core": 6,
"glowAlpha": 0.6,
"visitedBoost": 1.3,
"unknownMul": 0.78,
"unknownAlpha": 0.55
"unknownAlpha": 0.55,
"minPx": 3.2,
"zoomGrow": 1.25,
"art": {
"_comment": "Zoom-bloom layers (thresholds are zoom levels, map.galaxy.zoom.zMin..zMax). fade = the zoom span each layer takes to fade in. Per-type: spikes (diffraction rays), crown (the signature — see the parent comment), sizeMul (dot scale vs the base dot), plus crown parameters: companion.* = the binary's second star (orbit radius × dot, its core size × dot, ellipse tilt, orbit period), rings/tilt = the nebula disc (ring radii × dot, ellipse squash), ring/planet*/planet*PeriodMs = the habitable zone (outer orbit × dot, world sizes × dot, orbit periods), voidColor = the dark disc the void's horizon sits over.",
"flareAt": 1.6,
"crownAt": 2.8,
"surfaceAt": 4.5,
"fade": 0.45,
"main": { "spikes": 4, "crown": "granulation", "sizeMul": 1 },
"redDwarf": { "spikes": 4, "crown": "prominences", "sizeMul": 0.92, "spikeAlpha": 0.8 },
"binary": {
"spikes": 4,
"crown": "companion",
"sizeMul": 1.05,
"companion": { "dist": 2.7, "size": 0.5, "tilt": 0.55, "periodMs": 14000 }
},
"habitable": {
"spikes": 4,
"crown": "lifeRing",
"sizeMul": 1.05,
"ring": 2.2,
"planetSize": 0.16,
"planetPeriodMs": 21000,
"planet2Size": 0.11,
"planet2PeriodMs": 33000
},
"nebula": { "spikes": 6, "crown": "disk", "sizeMul": 1.12, "tilt": 0.4, "rings": [2.1, 2.9, 3.7] },
"void": { "spikes": 0, "crown": "horizon", "sizeMul": 0.95, "voidColor": "#040810" }
}
},
"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.",
@ -183,12 +220,9 @@
"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).",
"_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 CHARTED (visited) star wakes the SYSTEM tab with its chart (onStarOpen — the old JUMP confirm is gone; the star's chart IS the next step). Uncharted stars are readouts only (both buttons dismiss): GATE DORMANT; or NO DIRECT LINK. A lit lane to an uncharted star pops nothing — the jump stays a decision made at the gate (the LANE READY readout is gone).",
"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.",
@ -245,5 +279,10 @@
"height": 168,
"title": "ENGAGE AUTOPILOT",
"label": "ENGAGE"
},
"systemConfirm": {
"_comment": "The SYSTEM tab's SET DESTINATION dialog (same shared ConfirmOverlay — the clicked object on a CHARTED star's chart; the ship isn't in that system, so no distance row / autopilot). The destination model is still being defined — CONFIRM acknowledges the pick through onSetDestination (systemId + objectId), CANCEL / scrim / ESC leave the map as it was.",
"title": "SET DESTINATION",
"label": "SET DESTINATION"
}
}

165
dev/star-art.test.mjs Normal file
View File

@ -0,0 +1,165 @@
/**
* dev/star-art.test.mjs — the GALAXY tab's zoom-bloom star art (pure
* geometry in js/galaxy/GalaxyChart.js). Bare Node, no Phaser:
*
* node dev/star-art.test.mjs
*/
import { strict as assert } from 'node:assert';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href;
// --- Load the real config (data/*.json) into the config singleton --------
const { config } = await import(file('config/Config.js'));
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const {
mixHex,
starDotPx,
layerFade,
starArtSpec,
} = await import(file('galaxy/GalaxyChart.js'));
const ART = config.get('map.galaxy.stars.art', {});
const TYPES = ['main', 'redDwarf', 'binary', 'habitable', 'nebula', 'void'];
/* — dot size — */
{
const d1 = starDotPx(1, ART);
const d2 = starDotPx(2, ART);
const d10 = starDotPx(10, ART);
assert.ok(d1 >= 3, `dot at 1× should be a real dot (got ${d1})`);
assert.ok(d2 > d1, 'dot grows with zoom');
assert.ok(d10 > d2, 'dot keeps growing to max zoom');
assert.equal(starDotPx(0.5, ART), starDotPx(1, ART), 'below 1× the dot holds its floor');
console.log(' ok dot size: floor + zoom growth');
}
/* — layer fade — */
{
assert.equal(layerFade(1.0, 1.6, 0.45), 0, 'below the threshold: 0');
assert.ok(layerFade(1.61, 1.6, 0.45) > 0, 'just past the threshold: rising');
assert.equal(layerFade(3.0, 1.6, 0.45), 1, 'past the fade span: 1');
const a = layerFade(1.7, 1.6, 0.45);
const b = layerFade(1.8, 1.6, 0.45);
assert.ok(a < b, 'fade is monotonic');
console.log(' ok layer fade: 0 → 1 across the fade span');
}
/* — mixHex — */
{
assert.equal(mixHex('#000000', '#ffffff', 0), '#000000');
assert.equal(mixHex('#000000', '#ffffff', 1), '#ffffff');
const mid = mixHex('#000000', '#ffffff', 0.5);
assert.match(mid, /^#[0-9a-f]{6}$/);
assert.ok(mid !== '#000000' && mid !== '#ffffff', 'mid mix is between the ends');
console.log(' ok mixHex: endpoints + midpoint');
}
/* — below the flare threshold: a plain dot, no art — */
{
for (const t of TYPES) {
const spec = starArtSpec(t, { z: 1, time: 0, dot: 4, pulse: 0.5, cfg: ART });
assert.equal(spec.prims.length, 0, `${t}: no art below flareAt`);
}
console.log(' ok all types: plain dot below flareAt');
}
/* — every type blooms: spikes (all but the void) + a crown — */
{
const atCrown = { z: 4, time: 0, dot: 8, pulse: 0.5, cfg: ART };
for (const t of TYPES) {
const spec = starArtSpec(t, atCrown);
assert.ok(spec.prims.length > 0, `${t}: crown present at z=4`);
const tc = ART[t] ?? {};
const hasSpikes = (tc.spikes ?? 4) > 0;
assert.equal(spec.prims.some((p) => p.k === 'tri'), hasSpikes, `${t}: spike presence follows config`);
if (t === 'void') assert.ok(spec.prims.some((p) => p.k === 'darkdot'), 'void: dark horizon present');
if (t === 'binary') assert.ok(spec.prims.some((p) => p.k === 'ellipse'), 'binary: orbit ellipse present');
if (t === 'nebula') assert.equal(spec.prims.filter((p) => p.k === 'ellipse').length, 3, 'nebula: 3 disc layers');
if (t === 'habitable') assert.ok(spec.prims.filter((p) => p.k === 'dot' && p.x !== 0 || (p.k === 'dot' && p.y !== 0)).length >= 1, 'habitable: orbiting world(s) present');
}
console.log(' ok crowns: spikes + the per-type signature');
}
/* — binary: the companion rides its ellipse (and moves over time) — */
{
const base = { z: 5, dot: 8, pulse: 0.5, cfg: ART };
const s1 = starArtSpec('binary', { ...base, time: 0, seed: { compPhase: 0.3 } });
const s2 = starArtSpec('binary', { ...base, time: 7000, seed: { compPhase: 0.3 } });
const comp = (spec) => spec.prims.filter((p) => p.k === 'dot' && p.a > 0.9);
const c1 = comp(s1);
const c2 = comp(s2);
assert.ok(c1.length >= 1 && c2.length >= 1, 'companion dot present at both times');
const [a] = c1;
const [b] = c2;
assert.ok(
Math.hypot(a.x - b.x, a.y - b.y) > 1,
'the companion moves over time',
);
// on the ellipse: (x')²/rx² + (y')²/ry² ≈ 1 after undoing the rotation
const tc = ART.binary.companion;
const dot = 8;
const rx = tc.dist * dot;
const ry = rx * tc.tilt;
const onEllipse = (pt) => {
// rotation defaults: seed.orbitRot missing → 0.5 in the fallback
const rot = 0.5;
const cs = Math.cos(rot);
const sn = Math.sin(rot);
const lx = pt.x * cs + pt.y * sn;
const ly = -pt.x * sn + pt.y * cs;
const v = (lx * lx) / (rx * rx) + (ly * ly) / (ry * ry);
return Math.abs(v - 1) < 0.01;
};
assert.ok(onEllipse(a), 'companion sits on the orbit ellipse');
assert.ok(onEllipse(b), 'companion stays on the orbit ellipse over time');
console.log(' ok binary: companion orbits on its ellipse');
}
/* — habitable: the world(s) sit on their orbit radii — */
{
const spec = starArtSpec('habitable', { z: 5, time: 1000, dot: 8, pulse: 0.5, cfg: ART });
const dots = spec.prims.filter((p) => p.k === 'dot' && p.a > 0.9 && p.r >= 1.3 && p.r < 8);
assert.ok(dots.length >= 1, 'orbiting world present');
for (const d of dots) {
const rr = Math.hypot(d.x, d.y);
assert.ok(Math.abs(rr - 8 * 2.2) < 0.01 || Math.abs(rr - 8 * 1.7) < 0.01, `world on an orbit ring (r=${rr.toFixed(2)})`);
}
console.log(' ok habitable: worlds ride their zone rings');
}
/* — determinism: same input → same spec — */
{
const o = { z: 5, time: 4200, dot: 9, pulse: 0.6, cfg: ART, seed: { spikeAngle: 1.1, compPhase: 2.2 } };
const a = JSON.stringify(starArtSpec('binary', o));
const b = JSON.stringify(starArtSpec('binary', { ...o }));
assert.equal(a, b, 'specs are deterministic');
console.log(' ok deterministic specs');
}
/* — every primitive is finite + sane — */
{
for (const t of TYPES) {
const spec = starArtSpec(t, { z: 8, time: 12345, dot: 12, pulse: 0.7, cfg: ART });
for (const p of spec.prims) {
for (const v of Object.values(p)) {
if (typeof v === 'number') assert.ok(Number.isFinite(v), `${t}: finite ${JSON.stringify(p)}`);
}
if (p.a !== undefined) assert.ok(p.a >= 0 && p.a <= 1.01, `${t}: alpha in range`);
}
}
console.log(' ok all primitives finite, alphas in range');
}
console.log('star-art: all tests passed ✓');

View File

@ -17,7 +17,7 @@
* - resourceStats: the SYSTEM RESOURCES share over the asteroid fields
* (content.asteroids) — found/total + pct; missing list → 0/0.
*/
import { chartBounds, fitToRect, navDiscoveryStats, resourceStats } from '../js/galaxy/SystemChart.js';
import { chartBounds, fitToRect, navDiscoveryStats, resourceStats, systemChartSnapshot } from '../js/galaxy/SystemChart.js';
let passed = 0;
let failed = 0;
@ -143,6 +143,128 @@ console.log('resourceStats');
ok(st.total === 1 && st.found === 0, 'null discovery state → nothing found');
}
// ── systemChartSnapshot (the SYSTEM tab) ──────────────────────────────────
console.log('systemChartSnapshot');
{
// Real config + a real generated galaxy (the snapshot's true inputs).
const fs = await import('node:fs');
const { pathToFileURL, fileURLToPath } = await import('node:url');
const { dirname, join } = await import('node:path');
const here = dirname(fileURLToPath(import.meta.url));
const { config } = await import(pathToFileURL(join(here, '../js/config/Config.js')).href);
const configData = {};
for (const f of fs.readdirSync(join(here, '../data'))) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(here, '../data', f), 'utf8'));
}
config.init(configData);
const { Galaxy } = await import(pathToFileURL(join(here, '../js/galaxy/Galaxy.js')).href);
const g = Galaxy.create('system-tab-test', { systemCount: 40 });
let checked = 0;
let shapeOk = true;
let coverageOk = true;
let laidOk = true;
let discoverOk = true;
let statsOk = true;
for (const rec of g.records) {
if (rec.id === g.homeSystemId) continue; // the home chart is a different path
const content = g.contentOf(rec.id);
// Discover exactly the first planet + first cluster (if the system has them).
const found = new Set();
const firstPlanet = (content.planets ?? [])[0]?.name;
const firstCluster = (content.asteroids ?? [])[0]?.id;
if (firstPlanet) found.add(firstPlanet);
if (firstCluster) found.add(firstCluster);
const isDisc = (id) => found.has(id);
const discovery = { isDiscovered: (sysId, id) => (sysId === rec.id ? isDisc(id) : false) };
const snap = systemChartSnapshot(rec.id, content, { discovery, isDiscovered: isDisc });
checked++;
// the shape contract the SYSTEM tab relies on
if (
!snap ||
snap.systemId !== rec.id ||
snap.systemName !== (content.name ?? rec.id) ||
snap.isHome !== false ||
snap.ship !== null ||
!Array.isArray(snap.tethers) ||
snap.tethers.length !== 0 ||
!snap.central ||
snap.central.isHome !== false ||
typeof snap.central.radius !== 'number' ||
typeof snap.central.typeLabel !== 'string' ||
!snap.stats ||
typeof snap.stats.nav.total !== 'number' ||
typeof snap.stats.res.total !== 'number'
)
shapeOk = false;
// coverage: EVERY laid-out object appears exactly once, by its
// discovery identity (planets by name; stations/gates/clusters by id)
const expected = new Set();
for (const p of content.planets ?? []) if (typeof p.x === 'number') expected.add(p.name);
for (const s of content.settlements ?? [])
if (s.anchor?.type === 'space' && typeof s.x === 'number') expected.add(s.id);
for (const j of content.jumps ?? []) if (typeof j.x === 'number') expected.add(j.id);
for (const c of content.asteroids ?? []) if (typeof c.x === 'number') expected.add(c.id);
const ids = snap.objects.map((o) => o.id);
if (new Set(ids).size !== ids.length) coverageOk = false; // duplicates
for (const id of expected) if (!ids.includes(id)) coverageOk = false;
if (ids.length !== expected.size) coverageOk = false;
// every object is laid out (x/y/radius) and label-ready (name/kind)
for (const o of snap.objects) {
if (typeof o.x !== 'number' || typeof o.y !== 'number' || !(o.radius > 0) || typeof o.name !== 'string' || typeof o.kind !== 'string') laidOk = false;
}
// discovered flags track the Discovery footprint exactly
for (const o of snap.objects) if (o.discovered !== isDisc(o.id)) discoverOk = false;
// the readout = the nav/resource shares over the same footprint
let navTotal = 0;
let navFound = 0;
for (const p of content.planets ?? []) {
navTotal++;
if (isDisc(p.name)) navFound++;
}
for (const s of content.settlements ?? []) {
if (s.anchor?.type !== 'space') continue;
navTotal++;
if (isDisc(s.id)) navFound++;
}
for (const j of content.jumps ?? []) {
navTotal++;
if (isDisc(j.id)) navFound++;
}
const resTotal = (content.asteroids ?? []).length;
const resFound = (content.asteroids ?? []).filter((c) => isDisc(c.id)).length;
if (snap.stats.nav.total !== navTotal || snap.stats.nav.found !== navFound || snap.stats.res.total !== resTotal || snap.stats.res.found !== resFound)
statsOk = false;
}
ok(checked >= 10, `checked ${checked} remote systems`);
ok(shapeOk, 'shape: remote snapshot (ship null, tethers [], isHome false, central non-home, stats present)');
ok(coverageOk, 'objects = every laid-out planet/station/gate/cluster, by discovery id, no duplicates');
ok(laidOk, 'every object has x/y/radius>0/name/kind');
ok(discoverOk, 'discovered flags track the Discovery footprint');
ok(statsOk, 'stats = the nav/resource shares over the same footprint');
// determinism: same seed ⇒ identical snapshot (the chart is the data)
const remote = g.records.find((r) => r.id !== g.homeSystemId);
const a = systemChartSnapshot(remote.id, g.contentOf(remote.id), { isDiscovered: () => true });
const g2 = Galaxy.create('system-tab-test', { systemCount: 40 });
const b = systemChartSnapshot(remote.id, g2.contentOf(remote.id), { isDiscovered: () => true });
ok(JSON.stringify(a) === JSON.stringify(b), 'deterministic per seed (fresh galaxy, same content)');
// no discovery state at all → everything undiscovered, no crash
const c = systemChartSnapshot(remote.id, g.contentOf(remote.id), {});
ok(c.objects.every((o) => o.discovered === false) && c.stats.nav.found === 0 && c.stats.res.found === 0, 'no discovery state → nothing discovered');
// missing inputs
ok(systemChartSnapshot(null, { planets: [] }) === null, 'null systemId → null');
ok(systemChartSnapshot('sys', null) === null, 'null content → null');
}
// ── result ─────────────────────────────────────────────────────────────────
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);

View File

@ -366,3 +366,294 @@ export function clipPolygonToRect(pts, r) {
list = clipEdge(list, (p) => p.y <= y2, yInt(y2));
return list;
}
/* ------------------------------------------------------------------ *
* STAR ART — the zoom-bloom design for each star on the GALAXY plate.
*
* A star is a ~3px dot when the plate is fully zoomed out, and it
* BLOOMS as you zoom in (data/map.json → galaxy.stars: minPx/zoomGrow
* set the dot, art.flareAt/crownAt/surfaceAt set when each layer
* fades in):
*
* flare — diffraction spikes (the "star sparkle"), per-type count
* crown — the type's signature, all seeded per system:
* main granulation rim + corona ticks
* redDwarf breathing corona + prominence arcs
* binary an orbiting companion on a faint ellipse
* habitable the life-zone rings + orbiting planet(s)
* nebula a tilted accretion disc + speckles
* void photon ring + dark horizon + lensing ticks
* surface — a slow surface wobble + a glint (the core gains texture)
*
* Pure (no Phaser) — this module returns a list of typed primitives
* relative to the star's center; GalaxyView blits them. Node-testable
* (dev/star-art.test.mjs).
* ------------------------------------------------------------------ */
const TAU_ = Math.PI * 2;
const num = (v, d = 0) => (Number.isFinite(+v) ? +v : d);
/** Mix two #rrggbb colors (t = 0..1, toward b). Returns #rrggbb. */
export function mixHex(a, b, t) {
const p = (h) => {
const n = parseInt(String(h).replace('#', ''), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const [r1, g1, b1] = p(a);
const [r2, g2, b2] = p(b);
const c = (x, y) => Math.round(x + (y - x) * Math.min(1, Math.max(0, t)));
return (
'#' +
[c(r1, r2), c(g1, g2), c(b1, b2)]
.map((v) => v.toString(16).padStart(2, '0'))
.join('')
);
}
/**
* The star's core-dot size in plate-px at zoom z.
* minPx = the fully-zoomed-out floor (a tad bigger than a pixel);
* zoomGrow = how many px it gains per zoom step.
* @returns {number}
*/
export function starDotPx(z, cfg = {}) {
const minPx = Math.max(1, num(cfg.minPx, 3));
const grow = Math.max(0, num(cfg.zoomGrow, 1.2));
return minPx + Math.max(0, num(z, 1) - 1) * grow;
}
/** 0 below `at`, 1 at/after `at + fade`, linear between (layer fade-in). */
export function layerFade(z, at, fade) {
const f = Math.max(0.01, num(fade, 0.45));
return Math.min(1, Math.max(0, (num(z, 1) - at) / f));
}
/**
* The star art spec — the primitives to draw for one star.
* @param {string} type the archetype (main | redDwarf | binary | habitable | nebula | void)
* @param {object} o
* o.z zoom (map.galaxy.zoom.zMin..zMax)
* o.time ms (scene time — drives the slow animations)
* o.dot the core-dot px at this zoom (starDotPx × type sizeMul)
* o.pulse the type's pulse phase 0..1 (starPulse)
* o.seed pre-rolled per-system values (see below; any value may
* be missing — a fresh seed is derived deterministically)
* o.cfg the `galaxy.stars.art` config block
* @returns {{prims: object[]}} primitives in paint order (earliest first)
* each prim: { k, … } — one of:
* ellipse { rx, ry, rot, w, a, c } a tilted ring (disc layers)
* ring { r, w, a, c, dash?, dashA? } a circle (dash = n segments)
* arc { r, a0, a1, w, a, c } a partial circle (radians)
* tri { len, w, ang, a, c } a diffraction spike (triangle)
* dot { x, y, r, a, c } a filled circle (companion, planet)
* tick { r0, r1, ang, w, a, c } a radial line
* wobble { r, amp, n, rot, w, a, c } a surface-noise circle (n lobes)
* darkdot { r, a, c } a flat dark disc (the void horizon)
*/
export function starArtSpec(type, o = {}) {
const z = num(o.z, 1);
const time = num(o.time, 0);
const dot = Math.max(1, num(o.dot, 3));
const p = num(o.pulse, 0.5);
const cfg = o.cfg ?? {};
const fadeSpan = num(cfg.fade, 0.45);
const tc = typeCfg(cfg, type);
const aFlare = layerFade(z, num(tc.flareAt ?? cfg.flareAt, 1.6), fadeSpan);
const aCrown = layerFade(z, num(tc.crownAt ?? cfg.crownAt, 2.8), fadeSpan);
const aSurf = layerFade(z, num(tc.surfaceAt ?? cfg.surfaceAt, 4.5), fadeSpan);
if (aFlare + aCrown + aSurf <= 0.001) return { prims: [] };
const base = starTypeColor(type);
const seed = o.seed ?? {};
// deterministic per-system fallbacks (the painter pre-rolls these in
// _buildStars; the fallbacks keep this pure function self-sufficient)
const S = {
spikeAngle: num(seed.spikeAngle, 0),
ringRot: num(seed.ringRot, 0),
tickAngles: seed.tickAngles ?? [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((i) => (i / 12) * TAU_ + 0.07),
tickLens: seed.tickLens ?? [0.3, 0.4, 0.35, 0.45, 0.3, 0.4, 0.38, 0.32, 0.42, 0.36, 0.3, 0.44],
proms: seed.proms ?? [
{ a: 0.6, span: 0.9, r: 1.35 },
{ a: 2.7, span: 0.7, r: 1.5 },
{ a: 4.4, span: 1.0, r: 1.3 },
],
orbitRot: num(seed.orbitRot, 0.5),
compPhase: num(seed.compPhase, 0),
planetPhases: seed.planetPhases ?? [0.8, 3.9],
speckles: seed.speckles ?? [0, 1, 2, 3, 4, 5, 6, 7, 8].map((i) => ({ a: i * 0.7 + 0.3, s: 0.7 + ((i * 37) % 10) / 22 })),
glintAngle: num(seed.glintAngle, 0.9),
wobblePhase: num(seed.wobblePhase, 0),
dashOffsets: seed.dashOffsets ?? [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => i / 10),
lensAngles: seed.lensAngles ?? [0.4, 1.9, 3.6, 5.2],
};
const prims = [];
const slow = time * 0.00005; // a slow, calm rotation for all spinners
/* — NEBULA: the tilted disc goes FIRST (behind the star body) — */
if (tc.crown === 'disk' && aCrown > 0) {
const tilt = num(tc.tilt, 0.4);
const rot = S.orbitRot + slow * 0.4;
const rings = tc.rings ?? [2.1, 2.9, 3.7];
const alphas = [0.16, 0.11, 0.08];
rings.forEach((rr, i) => {
const rx = num(rr, 2 + i) * dot;
prims.push({ k: 'ellipse', rx, ry: rx * tilt, rot, w: 2.6 - i * 0.5, a: alphas[i] * aCrown, c: base });
});
// drifting speckles on the middle ring (gas clumps)
const mr = num(rings[1] ?? 2.9, 2.9) * dot;
const mry = mr * tilt;
S.speckles.forEach((sp, i) => {
const ang = sp.a + slow * 1.2;
const x = Math.cos(ang) * mr;
const y = Math.sin(ang) * mry;
// rotate into the disc plane
const cs = Math.cos(rot);
const sn = Math.sin(rot);
prims.push({
k: 'dot',
x: x * cs - y * sn,
y: x * sn + y * cs,
r: 0.7 + sp.s * 0.7,
a: (0.22 + 0.1 * Math.sin(time * 0.001 + i)) * aCrown,
c: mixHex(base, '#ffffff', 0.3),
});
});
}
/* — BINARY: the faint orbit ellipse (behind both stars) — */
if (tc.crown === 'companion' && aCrown > 0) {
const dist = num(tc.companion?.dist, 2.7) * dot;
const tilt = num(tc.companion?.tilt, 0.55);
prims.push({ k: 'ellipse', rx: dist, ry: dist * tilt, rot: S.orbitRot, w: 1, a: 0.3 * aCrown, c: base });
}
/* — SPIKES: the diffraction cross (all types but the void) — */
const spikes = Math.max(0, num(tc.spikes, 4));
if (spikes > 0 && aFlare > 0) {
const n = spikes;
const len = dot * (3.0 + 0.5 * p) * (tc.spikeLenMul ?? 1);
for (let i = 0; i < n; i++) {
const ang = S.spikeAngle + (i / n) * TAU_ + (tc.spikeRot ?? 0);
prims.push({ k: 'tri', len, w: Math.max(0.8, dot * 0.22), ang, a: 0.42 * aFlare * (tc.spikeAlpha ?? 1), c: base });
}
// a hot inner cross, rotated half a step (the sparkle's second layer)
for (let i = 0; i < n; i++) {
const ang = S.spikeAngle + (i / n) * TAU_ + Math.PI / n + (tc.spikeRot ?? 0);
prims.push({ k: 'tri', len: len * 0.45, w: Math.max(0.6, dot * 0.14), ang, a: 0.5 * aFlare, c: mixHex(base, '#ffffff', 0.55) });
}
}
/* — TYPE CROWNS — */
if (aCrown > 0) {
if (tc.crown === 'granulation') {
prims.push({ k: 'ring', r: dot * 1.42, w: 1.2, a: 0.42 * aCrown, c: base });
prims.push({ k: 'ring', r: dot * 1.1, w: 1, a: 0.28 * aCrown, c: base });
// corona ticks — seeded lengths, a slow drift
S.tickAngles.forEach((ang, i) => {
const a = ang + slow;
const r0 = dot * 1.12;
const r1 = r0 + dot * (S.tickLens[i % S.tickLens.length] ?? 0.35);
prims.push({ k: 'tick', r0, r1, ang: a, w: 1, a: 0.34 * aCrown, c: base });
});
} else if (tc.crown === 'prominences') {
// a breathing double corona
const breathe = 0.75 + 0.25 * p;
prims.push({ k: 'ring', r: dot * 1.38 * breathe, w: 1.6, a: 0.34 * aCrown, c: base });
prims.push({ k: 'ring', r: dot * 1.85 * breathe, w: 1.2, a: 0.2 * aCrown, c: base });
// prominence arcs — hot filaments arcing off the limb
S.proms.forEach((pr, i) => {
const span = num(pr.span, 0.8);
const r = num(pr.r, 1.4) * dot * (1 + 0.08 * Math.sin(time * 0.0009 + i * 2));
const a0 = num(pr.a, i) + slow * 0.7;
prims.push({ k: 'arc', r, a0, a1: a0 + span, w: 1.8, a: 0.4 * aCrown, c: base });
prims.push({ k: 'arc', r, a0: a0 + span * 0.25, a1: a0 + span * 0.75, w: 1, a: 0.5 * aCrown, c: mixHex(base, '#ffd9a0', 0.6) });
});
} else if (tc.crown === 'companion') {
// the second star, riding its seeded orbit
const dist = num(tc.companion?.dist, 2.7) * dot;
const tilt = num(tc.companion?.tilt, 0.55);
const period = Math.max(1000, num(tc.companion?.periodMs, 14000));
const ang = S.compPhase + (TAU_ * time) / period;
const lx = Math.cos(ang) * dist;
const ly = Math.sin(ang) * dist * tilt;
const cs = Math.cos(S.orbitRot);
const sn = Math.sin(S.orbitRot);
const x = lx * cs - ly * sn;
const y = lx * sn + ly * cs;
const compColor = mixHex(base, '#ffffff', 0.28);
const cr = Math.max(1.1, dot * num(tc.companion?.size, 0.5) * 0.62);
// a soft halo + core + its own mini-sparkle
prims.push({ k: 'dot', x, y, r: cr * 2.6, a: 0.14 * aCrown, c: compColor });
prims.push({ k: 'dot', x, y, r: cr * 1.5, a: 0.3 * aCrown, c: compColor });
prims.push({ k: 'dot', x, y, r: cr, a: 0.95 * aCrown, c: mixHex(compColor, '#ffffff', 0.5) });
for (let i = 0; i < 4; i++) {
const sa = S.compPhase * 1.7 + (i / 4) * TAU_;
prims.push({ k: 'tri', len: cr * 3.2, w: Math.max(0.6, cr * 0.3), ang: sa, x, y, a: 0.3 * aCrown, c: compColor });
}
} else if (tc.crown === 'lifeRing') {
// the habitable band — a soft annulus + two crisp orbits
prims.push({ k: 'ring', r: dot * 1.95, w: dot * 0.7, a: 0.12 * aCrown, c: base });
const orbits = [num(tc.ring ?? 2.2, 2.2), 1.7];
prims.push({ k: 'ring', r: dot * orbits[0], w: 1, a: 0.4 * aCrown, c: base });
prims.push({ k: 'ring', r: dot * orbits[1], w: 1, a: 0.26 * aCrown, c: base });
// orbiting world(s) with a comet trail behind
const periods = [num(tc.planetPeriodMs, 21000), num(tc.planet2PeriodMs, 33000)];
[0, 1].forEach((i) => {
const rr = dot * orbits[i];
const period = Math.max(1000, periods[i]);
const ang = (S.planetPhases[i] ?? i * 2.2) + (TAU_ * time) / period;
const x = Math.cos(ang) * rr;
const y = Math.sin(ang) * rr;
prims.push({ k: 'arc', r: rr, a0: ang - 0.85, a1: ang, w: 1.2, a: 0.3 * aCrown, c: base });
prims.push({ k: 'dot', x, y, r: Math.max(1.3, dot * num(i === 0 ? tc.planetSize : tc.planet2Size, i === 0 ? 0.16 : 0.11)), a: 0.95 * aCrown, c: mixHex(base, '#ffffff', 0.35) });
});
} else if (tc.crown === 'horizon') {
// the void — a dark horizon with a photon ring that slowly shimmers
const shim = 0.8 + 0.2 * Math.sin(time * 0.0012 + S.glintAngle * 3);
const voidC = typeof tc.voidColor === 'string' ? tc.voidColor : '#040810';
prims.push({ k: 'darkdot', r: dot * 1.02, a: 0.92 * aCrown, c: voidC });
prims.push({ k: 'ring', r: dot * 1.2, w: 1.4, a: 0.8 * aCrown * shim, c: mixHex(base, '#ffffff', 0.25) });
// a faint outer cage (lensing ticks, seeded)
prims.push({ k: 'ring', r: dot * 1.9, w: 1, a: 0.22 * aCrown, c: base, dash: 10, dashA: 0.5 });
S.lensAngles.forEach((la) => {
const a = la + slow * 0.6;
prims.push({ k: 'tick', r0: dot * 2.05, r1: dot * 2.4, ang: a, w: 1, a: 0.3 * aCrown, c: base });
});
}
}
/* — SURFACE: the core gains texture at high zoom — */
if (aSurf > 0) {
prims.push({
k: 'wobble',
r: dot * 1.0,
amp: 0.07,
n: 3,
rot: S.wobblePhase + slow * 1.5,
w: 1,
a: 0.4 * aSurf,
c: mixHex(base, '#ffffff', 0.3),
});
// the glint — a fixed hot spot on the surface (seeded bearing)
const gx = Math.cos(S.glintAngle) * dot * 0.34;
const gy = Math.sin(S.glintAngle) * dot * 0.34;
prims.push({ k: 'dot', x: gx, y: gy, r: Math.max(0.9, dot * 0.16), a: 0.7 * aSurf, c: '#ffffff' });
}
return { prims };
}
/** The art config for one type (falls back to the shared defaults). */
function typeCfg(art, type) {
const t = art?.[type] ?? {};
return {
...art,
...t,
spikes: num(t.spikes, art?.spikes ?? 4),
flareAt: num(t.flareAt, art?.flareAt ?? 1.6),
crownAt: num(t.crownAt, art?.crownAt ?? 2.8),
surfaceAt: num(t.surfaceAt, art?.surfaceAt ?? 4.5),
};
}

View File

@ -18,6 +18,14 @@
* resourceStats(d, sysId, c) the SYSTEM RESOURCES share — the
* asteroid fields vs. Discovery (the only
* resource kind for now).
* systemChartSnapshot(id, c, o) the SYSTEM tab's snapshot (the
* 'system' socket of js/ui/MapWindow.js)
* — the SAME shape GameScene.
* mapChartSnapshot() returns for the
* CURRENT system, built purely from a
* REMOTE system's generated content
* (no ship, no tethers — they live in
* the current system only).
*
* Pure (no Phaser) — Node-testable (dev/system-chart.test.mjs). The NAV
* point set is SystemCategory.navPoints (the same discoverable set the
@ -25,6 +33,7 @@
* rule: planets, stations, gates.
*/
import { navPoints } from '../research/SystemCategory.js';
import { config } from '../config/Config.js';
/**
* The chart's bounding frame from a list of objects.
@ -128,3 +137,130 @@ export function resourceStats(discovery, systemId, content) {
const total = clusters.length;
return { total, found, pct: total > 0 ? found / total : 0 };
}
/**
* The SYSTEM tab's chart snapshot — a CHARTED star picked on the GALAXY
* tab, drawn by the same painter as the CURRENT SYSTEM tab (the plate
* covers EVERY object of that system; the chart shows the discovered
* ones). Same shape as GameScene.mapChartSnapshot(), minus what cannot
* exist in a remote system: the ship (it is in the CURRENT system) and
* the tether union (the player's built-in tethers are current-system
* state).
*
* Object ids are the DISCOVERY identities (navPoints + asteroid cluster
* ids), so a later jump to the system repaints an identical chart from
* the same Discovery footprint.
*
* @param {string} systemId
* @param {object} content — the system's generated content (ensureContent)
* @param {object} [o={}] — hooks
* @param {(id:string)=>boolean} [o.isDiscovered] — discovery check for
* `systemId` (pass `(id) => discovery.isDiscovered(systemId, id)`);
* absent → nothing discovered (the safe default for a REMOTE system —
* the stats share the same footing)
* @param {object} [o.discovery] — the Discovery state (for the stats)
* @returns {?{systemId:string, systemName:string, isHome:false,
* central:object, objects:object[], tethers:number[],
* ship:null, stats:{nav:object, res:object}}}
* (null when the inputs are missing)
*/
export function systemChartSnapshot(systemId, content, o = {}) {
if (!systemId || !content) return null;
const isDisc = (id) => (typeof o.isDiscovered === 'function' ? !!o.isDiscovered(id) : false);
const frameWidth = config.get('planets.frameWidth', 1024);
const planetScale = config.get('planets.scale', 1);
const objects = [];
// planets — the discovery identity is the planet's NAME (navPoints)
for (const rec of content.planets ?? []) {
if (!rec || typeof rec.x !== 'number' || typeof rec.y !== 'number') continue;
objects.push({
id: rec.name,
kind: 'planet',
x: rec.x,
y: rec.y,
radius: (frameWidth * planetScale * (rec.scale ?? 1)) / 2,
name: rec.name,
typeLabel: config.get(`planets.typeLabels.${rec.class}`, rec.class),
discovered: isDisc(rec.name),
tint: config.get(`planets.classTint.${rec.class}`, '#9fb6d8'),
});
}
// free-space stations (a settlement's id — navPoints)
for (const s of content.settlements ?? []) {
if (!s || s.anchor?.type !== 'space' || typeof s.x !== 'number' || typeof s.y !== 'number') continue;
const kind = s.kind ?? 'deepSpaceStation';
objects.push({
id: s.id,
kind: 'station',
x: s.x,
y: s.y,
radius: config.get(`stations.kinds.${kind}.size`, kind === 'waypoint' ? 46 : 108),
name: s.name,
typeLabel: kind === 'waypoint' ? 'Waypoint' : 'Station',
discovered: isDisc(s.id),
});
}
// jump gates (their id — navPoints)
for (const j of content.jumps ?? []) {
if (!j || typeof j.x !== 'number' || typeof j.y !== 'number') continue;
objects.push({
id: j.id,
kind: 'gate',
x: j.x,
y: j.y,
radius: j.size ?? config.get('gates.size', 96),
name: j.name,
typeLabel: j.toName ? `Gate → ${j.toName}` : 'Jump Gate',
discovered: isDisc(j.id),
rotation: j.rotation,
active: j.active === true,
});
}
// asteroid clusters (their id — resourceStats)
for (const c of content.asteroids ?? []) {
if (!c || typeof c.x !== 'number' || typeof c.y !== 'number') continue;
objects.push({
id: c.id,
kind: 'cluster',
x: c.x,
y: c.y,
radius: c.bound,
name: c.name,
typeLabel: 'Rock Field',
discovered: isDisc(c.id),
rocks: (c.asteroids ?? []).map((m) => ({
dx: m.x,
dy: m.y,
r: Math.max(1, m.size / 2),
seed: ((m.x * 7919 + m.y * 104729) % 1000) / 1000,
})),
});
}
// central body — the same rule as the current system's chart: the star
// is invisible dossier flavor (its class feeds the plate tag + hue)
const starClass = String(content.star?.class ?? '').toUpperCase();
const central = {
name: 'Star',
isHome: false,
visible: false,
radius: 240,
typeLabel: config.get(`planets.starTypeLabels.${starClass}`, 'Star'),
color: config.get(`planets.star.classColor.${starClass}`, '#ffe9b0'),
};
return {
systemId,
systemName: content.name ?? systemId,
isHome: false,
central,
objects,
tethers: [], // the player's tethers live in the CURRENT system only
ship: null, // the ship lives in the CURRENT system only
stats: {
nav: navDiscoveryStats(o.discovery, systemId, content),
res: resourceStats(o.discovery, systemId, content),
},
};
}

View File

@ -33,7 +33,7 @@ import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js';
import { CommsPanel } from '../ui/CommsPanel.js';
import { ResearchWindow } from '../ui/ResearchWindow.js';
import { MapWindow } from '../ui/MapWindow.js';
import { navDiscoveryStats, resourceStats } from '../galaxy/SystemChart.js';
import { navDiscoveryStats, resourceStats, systemChartSnapshot } 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';
@ -667,30 +667,38 @@ 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 + 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).
// left cartography feed + THREE 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), SYSTEM (a CHARTED star's chart — the
// same painter for another system, no ship/tether; armed until the
// player picks that star on the GALAXY tab, then clicking an object
// asks SET DESTINATION), 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 a CHARTED star's
// chart in the SYSTEM tab).
// The window is a pure view — it polls mapChartSnapshot() /
// galaxySnapshot() while open and repaints on any change.
// systemChartSnapshotFor() / galaxySnapshot() while open and repaints
// on any change.
this.mapWindow = new MapWindow(this, {
getChart: () => this.mapChartSnapshot(),
getSystemChart: (systemId) => this.systemChartSnapshotFor(systemId),
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: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
}),
onSetDestination: (systemId, objectId) => this.setDestination(systemId, objectId),
onLocked: (tabId) =>
this.consoleToast(
tabId === 'system'
? config.get('map.systemLockedToast', 'NO SYSTEM TARGET — PICK A CHARTED STAR ON THE GALAXY MAP')
: config.get('map.galaxyLockedToast', 'GALAXY MAP OFFLINE — SECTOR DATA NOT ACQUIRED'),
{
glyph: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
},
),
});
// deck progress bar — drawn over the RESEARCH button while a project runs
this._researchDeckBar = {
@ -2299,33 +2307,6 @@ 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
@ -2468,6 +2449,49 @@ export class GameScene extends Phaser.Scene {
};
}
/**
* The SYSTEM tab's chart snapshot (js/ui/MapWindow.js polls this while
* that tab is open): a CHARTED star picked on the GALAXY tab, drawn by
* the same painter as the CURRENT SYSTEM tab. The snapshot itself is
* pure — js/galaxy/SystemChart.js → systemChartSnapshot() — built
* straight from the generated content (no live entities; the ship and
* tethers exist only in the CURRENT system), so it is safe to poll
* with nothing of the system loaded. Discovered flags are the run's
* Discovery footprint.
*/
systemChartSnapshotFor(systemId) {
if (!systemId || !this.galaxy || systemId === this.systemRecord?.id) return null;
const record = this.galaxy.byId.get(systemId);
if (!record) return null;
return systemChartSnapshot(systemId, this.galaxy.contentOf(systemId), {
discovery: this.discovery,
isDiscovered: (id) => this.discovery?.isDiscovered(systemId, id),
});
}
/**
* The SYSTEM tab's SET DESTINATION CONFIRM (js/ui/MapWindow.js →
* onSetDestination): the player marked an object on a CHARTED star's
* chart as their destination. The destination model is still being
* defined — for now the pick is acknowledged (the seam is stable:
* systemId + objectId).
*/
setDestination(systemId, objectId) {
const content = this.galaxy?.contentOf?.(systemId);
const name =
(content?.planets ?? []).find((p) => p.name === objectId)?.name ??
(content?.settlements ?? []).find((s) => s.id === objectId)?.name ??
(content?.jumps ?? []).find((j) => j.id === objectId)?.name ??
(content?.asteroids ?? []).find((c) => c.id === objectId)?.name ??
objectId;
const sys = content?.name ?? systemId;
console.info(`[orbit] set destination: ${systemId} → ${objectId}`);
this.consoleToast(`DESTINATION NOTED — ${String(name).toUpperCase()} · ${String(sys).toUpperCase()}`, {
glyph: '◆',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
}
// ------------------------------------------------------------ research
/**

View File

@ -6,8 +6,18 @@
* 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.
* galaxy.pulse — the "per-star-type animation"). Each star has a
* ZOOM-BLOOM DESIGN (galaxy.stars.art + GalaxyChart.starArtSpec —
* pure, node-tested): a plain dot at 1× that, as you zoom in, first
* throws off diffraction spikes, then blooms into its type's
* signature — main: granulation rim + corona ticks; redDwarf: a
* breathing corona + prominence arcs; binary: an orbiting companion
* on a faint ellipse; habitable: the life-zone rings + orbiting
* world(s); nebula: a tilted accretion disc + drifting speckles;
* void: a dark horizon + shimmering photon ring + lensing ticks — and
* at close range the core gains a surface wobble + glint. All sizes
* are × the dot; every angle/phase is seeded per system (deterministic)
* and animated on scene time.
* • 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
@ -21,12 +31,20 @@
*
* 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)
* count, charted state, link state to the current system) — STARS ONLY,
* and only the star ITSELF: the zone is the dot's own scale (a 2px
* floor at 1× zoom, growing with it), so the lanes and the open space
* between the dots never trigger it (a 5px mouse pad around a 2px dot
* kept firing while the cursor "moved through empty space" — pointer
* logs showed unwanted hits at d=2.8/d=4.4px).
* CLICK a star:
* - current system → "YOU ARE HERE"
* - CHARTED (visited) star → its chart: the SYSTEM tab wakes + shows
* the star's map (fires `onStarOpen`)
* - uncharted, live lane → no popup (the lane is lit — the jump
* stays a decision made at the gate)
* - uncharted, dormant lane → the GATE DORMANT readout (chart + research)
* - uncharted, 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).
*
@ -48,7 +66,7 @@ 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';
import { paddedHullPolygon, starPulse, starTypeColor, clipLineToRect, clipPolygonToRect, starDotPx, starArtSpec } from '../galaxy/GalaxyChart.js';
const TAU = Math.PI * 2;
const BODY = fontStack('body');
@ -133,14 +151,14 @@ export class GalaxyView {
* @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)
* @param {(systemId: string) => void} [opts.onStarOpen] — a CHARTED star
* was picked (the MapWindow wakes the SYSTEM tab with its chart)
*/
constructor(win, opts = {}) {
this.win = win;
this.scene = win.scene;
this.getSnapshot = opts.getSnapshot ?? null;
this.onJump = opts.onJump ?? null;
this.onStarOpen = opts.onStarOpen ?? null;
const g = win.geo;
this.px = g.mapX;
this.py = g.mapY;
@ -196,9 +214,10 @@ export class GalaxyView {
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.starG = s.graphics().setScrollFactor(0).setDepth(1.42); // the zoom-bloom star art (above the core image, below the flow packets + labels)
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]) {
for (const o of [this.hullG, this.edgeG, this.flowG, this.starG, this.markerG, this.hoverG]) {
o.setVisible(false);
win.add(o);
}
@ -361,13 +380,6 @@ export class GalaxyView {
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,
@ -384,10 +396,8 @@ export class GalaxyView {
.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,
@ -395,8 +405,6 @@ export class GalaxyView {
type: rec.type,
glow,
core,
core2,
core2Angle,
label,
wx: rec.x,
wy: rec.y,
@ -410,6 +418,25 @@ export class GalaxyView {
revealDelay: 0,
lx: 0,
ly: 0,
// the zoom-bloom art (GalaxyChart.starArtSpec): the type's dot
// scale + its seeded bearings/phases (twin, prominences, disc,
// lensing ticks…) — rolled ONCE here, stable for the run.
sizeMul: Number(this._cfg.stars?.art?.[rec.type]?.sizeMul ?? 1),
artSeed: {
spikeAngle: rng.range(0, TAU),
ringRot: rng.range(0, TAU),
tickAngles: Array.from({ length: 12 }, () => rng.range(0, TAU)),
tickLens: Array.from({ length: 12 }, () => rng.range(0.25, 0.5)),
proms: Array.from({ length: 3 }, () => ({ a: rng.range(0, TAU), span: rng.range(0.5, 1.1), r: rng.range(1.2, 1.7) })),
orbitRot: rng.range(0, TAU),
compPhase: rng.range(0, TAU),
planetPhases: [rng.range(0, TAU), rng.range(0, TAU)],
speckles: Array.from({ length: 9 }, () => ({ a: rng.range(0, TAU), s: rng.range(0.4, 1.4) })),
glintAngle: rng.range(0, TAU),
wobblePhase: rng.range(0, TAU),
dashOffsets: Array.from({ length: 10 }, () => rng.next()),
lensAngles: Array.from({ length: 4 }, () => rng.range(0, TAU)),
},
});
this._starById.set(rec.id, this._stars[this._stars.length - 1]);
}
@ -472,11 +499,7 @@ export class GalaxyView {
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);
st.label.setPosition(st.lx, st.ly + this._dotOf(st) * 1.5 + 8);
}
this._applyCull();
if (this._centerGlow) {
@ -508,7 +531,6 @@ export class GalaxyView {
st.culled = !inPlate;
st.glow.setVisible(inPlate);
st.core.setVisible(inPlate);
if (st.core2) st.core2.setVisible(inPlate);
st.label.setVisible(this._labelShown(st));
}
}
@ -620,21 +642,21 @@ export class GalaxyView {
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);
const dot = this._dotOf(st);
// the core dot (minPx floor at 1×, growing with zoom) + its soft
// halo — the pulse/visited/reveal multipliers ride on both
const size = Math.max(2, dot * 2.4 * (0.7 + 0.55 * p) * mul * (0.5 + 0.5 * rf));
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);
const cSize = Math.max(1.8, dot * (0.8 + 0.2 * p));
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._drawStarArt(time, tR);
this._drawFlow(time);
this._drawMarkers(time, k);
@ -710,21 +732,173 @@ export class GalaxyView {
g.strokePoints(pts, true);
}
/** The star's core dot in plate-px at the current zoom (type-scaled). */
_dotOf(st) {
return starDotPx(this._view.z, this._cfg.stars) * (st.sizeMul ?? 1);
}
/**
* The ZOOM-BLOOM star art — each star's typed design (spikes, the type
* crown, the surface wobble), from the pure spec (GalaxyChart.starArtSpec)
* blitted to this.starG. Runs every frame while the tab is visible:
* the twins orbit, the worlds ride their rings, the discs turn.
*/
_drawStarArt(time, tR = 0) {
const g = this.starG;
g.clear();
const z = this._view.z;
const cfg = this._cfg.stars;
const art = cfg.art ?? {};
if (z < (art.flareAt ?? 1.6)) return; // plain dots at this zoom
for (const st of this._stars) {
if (st.culled) continue;
const rf = this._easeReveal(tR, st.revealDelay, 480);
if (rf <= 0.01) continue; // still in the reveal fade-in
const dot = this._dotOf(st);
const spec = starArtSpec(st.type, {
z,
time,
dot,
pulse: starPulse(st.type, time, st.phase),
seed: st.artSeed,
cfg: art,
});
if (!spec.prims.length) continue;
for (const p of spec.prims) this._blitStarPrim(g, st.lx, st.ly, p, rf);
}
}
/** One star-art primitive (GalaxyChart.starArtSpec's prim list). */
_blitStarPrim(g, cx, cy, p, aa = 1) {
const X = cx + (p.x ?? 0);
const Y = cy + (p.y ?? 0);
const col = toColor(p.c);
const rect = this._plateRect;
const inR = (r = 0) => X >= rect.x - r && X <= rect.x + rect.w + r && Y >= rect.y - r && Y <= rect.y + rect.h + r;
switch (p.k) {
case 'dot':
case 'darkdot':
if (inR(p.r)) {
g.fillStyle(col, p.a * aa);
g.fillCircle(X, Y, p.r);
}
return;
case 'tri': {
// a diffraction spike — a thin triangle from the core out
const w = (p.w ?? 1) * 0.5;
const ca = Math.cos(p.ang);
const sa = Math.sin(p.ang);
const poly = clipPolygonToRect(
[
{ x: X + ca * p.len, y: Y + sa * p.len }, // tip
{ x: X - sa * w, y: Y + ca * w }, // base corners
{ x: X + sa * w, y: Y - ca * w },
],
rect,
);
if (poly.length >= 3) {
g.fillStyle(col, p.a * aa);
g.fillPoints(poly, true);
}
return;
}
case 'ring': {
if (p.dash) {
// a dashed cage (the void's outer ring) — seeded gap rhythm
const seg = p.dash;
for (let i = 0; i < seg; i++) {
const a0 = (i / seg) * TAU;
const a1 = a0 + ((1 / seg) * TAU) * 0.55;
this._strokeStarArc(g, X, Y, p.r, a0, a1, p.w, p.a * aa, col);
}
} else {
this._strokeStarArc(g, X, Y, p.r, 0, TAU, p.w, p.a * aa, col);
}
return;
}
case 'arc':
this._strokeStarArc(g, X, Y, p.r, p.a0, p.a1, p.w, p.a * aa, col);
return;
case 'tick': {
const x1 = X + Math.cos(p.ang) * p.r0;
const y1 = Y + Math.sin(p.ang) * p.r0;
const x2 = X + Math.cos(p.ang) * p.r1;
const y2 = Y + Math.sin(p.ang) * p.r1;
const cl = clipLineToRect(x1, y1, x2, y2, rect);
if (cl) {
g.lineStyle(p.w, col, p.a * aa);
g.lineBetween(cl.x1, cl.y1, cl.x2, cl.y2);
}
return;
}
case 'ellipse': {
const n = 44;
const cs = Math.cos(p.rot);
const sn = Math.sin(p.rot);
const pts = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * TAU;
const lx = Math.cos(a) * p.rx;
const ly = Math.sin(a) * p.ry;
pts.push({ x: X + lx * cs - ly * sn, y: Y + lx * sn + ly * cs });
}
const poly = clipPolygonToRect(pts, rect);
if (poly.length >= 3) {
g.lineStyle(p.w, col, p.a * aa);
g.strokePoints(poly, true);
}
return;
}
case 'wobble': {
// the surface-noise circle — n lobes of slow rotation
const n = 40;
const pts = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * TAU;
const rr = p.r * (1 + p.amp * Math.sin(p.n * a + p.rot));
pts.push({ x: X + Math.cos(a) * rr, y: Y + Math.sin(a) * rr });
}
const poly = clipPolygonToRect(pts, rect);
if (poly.length >= 3) {
g.lineStyle(p.w, col, p.a * aa);
g.strokePoints(poly, true);
}
return;
}
}
}
/** A sampled, plate-clipped arc (ring/arc primitives share it). */
_strokeStarArc(g, X, Y, r, a0, a1, w, a, col) {
const span = a1 - a0;
const n = Math.max(6, Math.min(64, Math.ceil((Math.abs(span) / TAU) * 48) + 4));
const pts = [];
for (let i = 0; i <= n; i++) {
const ang = a0 + (span * i) / n;
pts.push({ x: X + Math.cos(ang) * r, y: Y + Math.sin(ang) * r });
}
const poly = clipPolygonToRect(pts, this._plateRect);
if (poly.length < 2) return;
g.lineStyle(w, col, a);
g.strokePoints(poly, a0 === 0 && Math.abs(span - TAU) < 0.001);
}
/** HOME ring + the SHIP marker's expanding pulse (clipped to the plate). */
_drawMarkers(time, k) {
const g = this.markerG;
g.clear();
const dot = starDotPx(this._view.z, this._cfg.stars);
const home = this._snap.homeSystemId ? this._starById.get(this._snap.homeSystemId) : null;
if (home) {
const r = Math.max(9, 20 * k);
const r = Math.max(10, dot * 2.4);
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);
this._strokeClippedRing(g, cur.lx, cur.ly, dot * (1.6 + 2.2 * u), 1.6, toColor(C.neon), 0.85 * (1 - u));
this._strokeClippedRing(g, cur.lx, cur.ly, dot * 1.35, 1, toColor(C.neon), 0.55);
}
}
@ -789,18 +963,35 @@ export class GalaxyView {
_starAt(pt) {
if (!this._fit) return null;
const k = this._k();
const rHit = Math.max(10, 13 * k) + 9;
// STARS ONLY — and only the star ITSELF: the zone is the dot's own
// scale (the dot is a ~3px point at 1× zoom, growing with zoom,
// stars.minPx/zoomGrow) + a small pad. The pad used to be a fixed
// 5px around a 2px dot — the mouse kept grazing it while "moving
// through empty space" and the tooltip blinked on/off (pointer
// logs: unwanted hits at d=2.8 / d=4.4px). Now the zone scales with
// the star: a few px at 1×, generous once the star has bloomed.
let best = null;
let bestD = Infinity;
const dotBase = starDotPx(this._view.z, this._cfg.stars);
for (const st of this._stars) {
const d = Math.hypot(pt.x - (st.lx - this.px), pt.y - (st.ly - this.py));
const rHit = dotBase * (st.sizeMul ?? 1) * 0.9 + 1.5;
if (d <= rHit && d < bestD) {
bestD = d;
best = st;
}
}
return best;
if (!best) return null;
// Crowded-cluster safety net: never cross the lane's midpoint toward
// the nearest star (a 10% dead band around each lane's middle).
let near = Infinity;
for (const st of this._stars) {
if (st === best) continue;
const dd = Math.hypot(best.lx - st.lx, best.ly - st.ly);
if (dd < near) near = dd;
}
const rHit = dotBase * (best.sizeMul ?? 1) * 0.9 + 1.5;
return bestD <= Math.min(rHit, near * 0.45) ? best : null;
}
_zoomAt(px, py, factor) {
@ -922,8 +1113,8 @@ export class GalaxyView {
return;
}
const st = this._hover;
const k = this._k();
const r = Math.max(12, st.glowW * k * 0.55);
const dot = this._dotOf(st);
const r = Math.max(10, dot * 1.9);
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);
@ -942,9 +1133,11 @@ export class GalaxyView {
let hintColor = C.amber;
if (st.id === curId) {
hint = 'YOU ARE HERE';
} else if (edge && edge.live) {
hint = 'TAP TO JUMP';
} else if (st.visited) {
hint = 'TAP TO OPEN CHART';
hintColor = C.neon;
} else if (edge && edge.live) {
hint = 'LANE LIT — JUMP FROM THE GATE';
} else if (edge) {
hint = 'GATE DORMANT — CHART + RESEARCH';
} else {
@ -982,9 +1175,12 @@ export class GalaxyView {
// ------------------------------------------------------------ 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.
* plate — the MapWindow autopilot idiom): a CHARTED star opens its
* chart in the SYSTEM tab (onStarOpen — the old JUMP confirm is gone,
* the chart IS the next step); uncharted stars are readouts with a
* single CLOSE (GATE DORMANT / NO DIRECT LINK) — a lit lane to an
* uncharted star pops nothing (the LANE READY readout is gone; the
* jump stays a decision made at the gate).
*/
_askStar(st) {
const dlg = this.win.dialog;
@ -1015,23 +1211,20 @@ export class GalaxyView {
});
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,
});
// A CHARTED star — its chart is the next step: the SYSTEM tab wakes
// (it starts armed) and shows this star's map. (The old JUMP confirm
// lived here — the chart replaces it.)
if (st.visited) {
this._hover = null;
this._paintHover();
this.onStarOpen?.(st.id);
return;
}
// Uncharted stars are readouts only — there's no chart to show yet.
// A lit lane to an uncharted star pops NOTHING (the LANE READY
// readout is gone — the jump stays a decision made at the gate).
const edge = this._edgeTo(st.id);
if (edge && edge.live) return;
if (edge) {
dlg.show({
title: fill(d.dormantTitle ?? 'GATE DORMANT'),
@ -1061,6 +1254,7 @@ export class GalaxyView {
this.edgeG,
this.flowG,
this.markerG,
this.starG,
this.hoverG,
this.ttCont,
this.homeTag,
@ -1068,12 +1262,19 @@ export class GalaxyView {
this.plateImg,
this._centerGlow,
...this._dust.map((d) => d.img),
...this._stars.flatMap((st) => [st.glow, st.core, st.core2, st.label].filter(Boolean)),
...this._stars.flatMap((st) => [st.glow, st.core, 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;
// Re-sync the hover ring + tooltip CONTENT with the hover state:
// the loop above just un-hides the layers, so without this a stale
// readout from the last hover would come back with the view (the
// "ghost" star popup that sat over the empty plate until the next
// mouse move). _paintHover clears the ring and hides the plate when
// there's no live hover, and repaints them when there is.
this._paintHover();
}
clearHover() {

View File

@ -6,15 +6,17 @@
* contract.
*
* Right column, top to bottom:
* • 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).
* • tabs — CURRENT SYSTEM (the live system chart) / SYSTEM (a CHARTED
* star's chart — the same painter; armed until the player picks that
* star on the GALAXY tab) / 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 CHARTED star = its chart in the SYSTEM tab (onStarOpen);
* uncharted stars are readouts only. Clicking a `standby` socket
* 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
@ -757,18 +759,23 @@ export class MapWindow extends Phaser.GameObjects.Container {
* @param {object} [opts]
* @param {() => object|null} [opts.getChart] — the live chart snapshot
* (GameScene.mapChartSnapshot; polled while open, repaint on change)
* @param {(systemId: string) => object|null} [opts.getSystemChart]
* — a CHARTED star's snapshot (GameScene.systemChartSnapshotFor;
* the SYSTEM tab's life, polled while open)
* @param {() => {x:number, y:number, heading:number}|null} [opts.getShip]
* — the ship's live world position + heading (every frame)
* @param {(objectId: string) => void} [opts.onSelect] — a discovered
* object was 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 {(systemId: string, objectId: string) => void}
* [opts.onSetDestination] — the SYSTEM tab's SET DESTINATION CONFIRM
* (the destination model is still being defined — the scene
* acknowledges the pick for now)
* @param {(tabId: string) => void} [opts.onLocked] — a STANDBY tab was
* hit (the scene toasts)
* @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);
@ -777,12 +784,14 @@ export class MapWindow extends Phaser.GameObjects.Container {
this.setScrollFactor(0);
this.getChart = opts.getChart ?? null;
this.getSystemChart = opts.getSystemChart ?? null;
this.getShip = opts.getShip ?? null;
this.onSelect = opts.onSelect ?? null;
this.onSetDestination = opts.onSetDestination ?? 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._mode = 'currentSystem'; // the active tab: 'currentSystem' | 'system' | 'galaxy'
this._systemId = null; // the CHARTED star the SYSTEM tab shows (picked on the GALAXY tab)
this._galaxy = null; // the GALAXY tab's view (built lazily on switch)
this._galaxySnap = null;
this._galaxyFp = null;
@ -1208,12 +1217,13 @@ export class MapWindow extends Phaser.GameObjects.Container {
: [];
if (!tabs.length) {
tabs.push({ id: 'currentSystem', label: 'Current System', accent: '#00e5ff' });
tabs.push({ id: 'system', label: 'System', accent: '#a8ffc4', standby: true, standbyTag: 'NO TARGET' });
tabs.push({ id: 'galaxy', label: 'Galaxy', accent: '#ffc94d', standby: true, standbyTag: 'OFFLINE' });
}
let x = rightX;
const avail = rightW;
// two equal sockets across the column — the deck's tab row
const tabW = Math.min(220, (avail - 12) / 2);
// equal sockets across the column — the deck's tab row
const tabW = Math.min(220, (avail - 12 * (tabs.length - 1)) / tabs.length);
for (const tab of tabs) {
const label = String(tab.label ?? tab.id).toUpperCase();
const txt = this.scene.add
@ -1228,7 +1238,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
const w = tabW;
const y = bodyY + tabH / 2;
const g = this.scene.add.graphics().setScrollFactor(0);
const entry = { id: tab.id, x, y, w, h: tabH, txt, g, accent: toColor(tab.accent ?? C.neon, C.neon), hover: false, standby: tab.standby === true, tag: tab.standbyTag ?? tab.tag ?? null, active: tab.active === true, baseTxtX: 0 };
const entry = { id: tab.id, x, y, w, h: tabH, txt, g, accent: toColor(tab.accent ?? C.neon, C.neon), hover: false, standby: tab.standby === true, tag: tab.standbyTag ?? tab.tag ?? null, active: tab.active === true, baseX: x + 14, baseTxtX: 0 };
const tabRect = new Phaser.Geom.Rectangle(x, y - tabH / 2, w, tabH);
g.setInteractive({
useHandCursor: true,
@ -1245,7 +1255,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
this._paintTabs();
});
g.on('pointerdown', () => this._tabHit(entry));
const labelX = x + 14 + (entry.standby && entry.tag ? 26 : 0);
const labelX = entry.baseX + (entry.standby && entry.tag ? 26 : 0);
txt.setPosition(labelX, y - 4);
entry.baseTxtX = labelX;
g.setDepth(2);
@ -1276,27 +1286,41 @@ 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;
if (entry.id === this._mode) return;
this.sfx('ui_click');
if (entry.standby) {
// the killswitch is still armed: shake it + the scene toasts
this._tabShakes.push({ tab: entry, t0: this.scene.time.now, dur: 340 });
this.onLocked?.();
this.onLocked?.(entry.id);
return;
}
this._switchMode(entry.id);
}
/** Arm / release a tab's killswitch — the SYSTEM socket wakes when the
player picks a charted star on the GALAXY tab. */
_setTabStandby(entry, on) {
if (!entry || entry.standby === on) return;
entry.standby = on;
entry.tagTxt?.setVisible(!!(on && entry.tag));
const labelX = entry.baseX + (on && entry.tag ? 26 : 0);
entry.txt.setPosition(labelX, entry.y - 4);
entry.baseTxtX = labelX;
this._paintTabs();
}
/**
* The tab switch — the plate has two lives:
* CURRENT SYSTEM — the system chart (hover, ENGAGE, zoom, pan)
* The tab switch — the plate has three lives:
* CURRENT SYSTEM — the live system chart (hover, ENGAGE, zoom, pan)
* SYSTEM — a CHARTED star's chart (the same painter; the ship
* marker + course line are hidden — the ship isn't
* in that system)
* GALAXY — the whole-galaxy chart (GalaxyView: stars, lanes,
* the charted region, JUMP on a live lane)
* the charted region)
* 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.
* double-fire). Each life is driven from the same 500 ms poll in
* update(), against its own snapshot.
*/
_switchMode(id) {
const inGalaxy = id === 'galaxy';
@ -1305,7 +1329,8 @@ export class MapWindow extends Phaser.GameObjects.Container {
setInteractiveEnabled(this.mapImg, !inGalaxy);
this.mapImg.setVisible(!inGalaxy);
this._galaxy?.setVisible(inGalaxy);
this.shipMark?.setVisible(!inGalaxy);
// the ship only lives in the CURRENT system
this.shipMark?.setVisible(id === 'currentSystem');
if (inGalaxy) {
// clear the system hover + repaint the galaxy's own chrome
this._hover = null;
@ -1314,8 +1339,8 @@ export class MapWindow extends Phaser.GameObjects.Container {
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)
// back to a system chart (CURRENT SYSTEM or the picked SYSTEM) —
// restore its stats chrome + force a fresh poll (redraw + stats)
this.legendTexts?.forEach((t) => t.setVisible(true));
this.navLabel.setText(this._navLabel0);
this.resLabel.setText(this._resLabel0);
@ -1323,6 +1348,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
this.resMetaTxt?.setVisible(true);
this._paintLegend();
this._fp = null;
this._pollNext = 0; // the tab's snapshot right now
}
this._paintTabs();
}
@ -1339,13 +1365,32 @@ export class MapWindow extends Phaser.GameObjects.Container {
return null;
}
},
onJump: (id) => this.onGalaxyJump?.(id),
onStarOpen: (id) => this._openSystem(id),
});
this._galaxy.applySnapshot(this._galaxySnap ?? null);
this._galaxy.reveal(this.scene.time.now); // first entry: the ripple runs
return this._galaxy;
}
/** A CHARTED star was picked on the GALAXY tab — wake the SYSTEM socket
(it starts armed) and show that star's chart in the system painter.
The star's chart is the next step (the old JUMP confirm is gone). */
_openSystem(systemId) {
if (!systemId) return;
this._systemId = systemId;
const entry = this.tabs.find((t) => t.id === 'system');
if (entry?.standby) {
this.sfx('ui_window'); // the socket wakes
this._setTabStandby(entry, false);
}
if (this._mode !== 'system') {
this._switchMode('system');
} else {
this._fp = null;
this._pollNext = 0;
}
}
/** The GALAXY tab's stats plate (the SYSTEM plate's objects, repurposed). */
_paintStatsGalaxy(snap) {
const { statsX, statsY, statsW, statsH } = this.geo;
@ -1421,7 +1466,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
const { bodyY, tabH } = this.geo;
for (const t of this.tabs) {
t.g.clear();
const active = t.id === (this._mode === 'galaxy' && !t.standby ? 'galaxy' : 'currentSystem');
const active = t.id === this._mode;
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,
@ -1935,6 +1980,16 @@ export class MapWindow extends Phaser.GameObjects.Container {
}
}
/** Pull the SYSTEM tab's snapshot (the charted star picked on the
GALAXY tab) — the same painter, a different system. */
_systemSnap() {
try {
return this.getSystemChart ? this.getSystemChart(this._systemId) : null;
} catch {
return null;
}
}
/** Fingerprint — repaint only when something visible actually changed. */
_fpOf(snap) {
const objs = (snap.objects ?? []).map((o) => `${o.id}:${o.discovered ? 1 : 0}`).join('|');
@ -1955,7 +2010,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
this._paintStats();
this._paintStatusStrip();
this._paintHover();
this.shipMark?.setVisible(true);
this.shipMark?.setVisible(this._mode === 'currentSystem');
}
/** Re-paint the chart canvas (new texture; the old one is dropped). */
@ -2246,8 +2301,8 @@ export class MapWindow extends Phaser.GameObjects.Container {
}
}
// click flash (a discovered object was picked — the system tab)
if (this._mode === 'currentSystem' && this._flash) {
// click flash (an object was picked — the system chart tabs)
if ((this._mode === 'currentSystem' || this._mode === 'system') && this._flash) {
const u = clamp01((time - this._flash.t0) / 420);
if (u >= 1) {
this._flash = null;
@ -2292,7 +2347,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
}
return;
}
const snap = this._snapOf();
const snap = this._mode === 'system' ? this._systemSnap() : this._snapOf();
if (snap) {
const fp = this._fpOf(snap);
if (fp !== this._fp) {
@ -2419,7 +2474,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
if (o) {
this.sfx('ui_click');
this._lastTap = 0; // a selection is not a zoom-reset tap
this._askAutopilot(o); // confirm first — CONFIRM plots the course + closes
this._askObject(o); // confirm first — the tab's own ask (autopilot / destination)
return;
}
// double-tap on EMPTY plate → reset zoom (v4 has no dblclick event)
@ -2451,18 +2506,40 @@ export class MapWindow extends Phaser.GameObjects.Container {
}
/**
* Autopilot is a commitment — ask before engaging (the save pop-up's
* ConfirmOverlay, over the plate). CONFIRM fires onSelect (the scene
* plots the course) and closes the console; CANCEL, the scrim, or ESC
* leave the map exactly as it was. While the dialog is up the plate is
* inert (the guards in _startDrag / _onWheelZoom / _setHover — the
* scrim covers the whole window, so it also takes the clicks meant for
* the chrome, and its own pointerdown IS the cancel).
* The clicked-object's ask (the save pop-up's ConfirmOverlay, over the
* plate) — mode-aware:
* CURRENT SYSTEM — ENGAGE AUTOPILOT is a commitment; CONFIRM fires
* onSelect (the scene plots the course) and closes the console.
* SYSTEM — SET DESTINATION on a charted star's object (the ship
* isn't in that system, so no distance row); CONFIRM fires
* onSetDestination (the destination model is still being defined).
* CANCEL, the scrim, or ESC leave the map exactly as it was. While the
* dialog is up the plate is inert (the guards in _startDrag /
* _onWheelZoom / _setHover — the scrim covers the whole window, so it
* also takes the clicks meant for the chrome, and its own pointerdown
* IS the cancel).
*/
_askAutopilot(o) {
_askObject(o) {
this._hover = null; // the dialog speaks for the pick
this._paintHover();
const label = String(o.label ?? o.id).toUpperCase();
if (this._mode === 'system') {
const sysName = String(this._snap?.systemName ?? '').toUpperCase();
this.dialog.show({
title: config.get('map.systemConfirm.title', 'SET DESTINATION'),
body: [`DESTINATION — ${label}`, sysName ? `SYSTEM — ${sysName}` : ''].filter(Boolean),
accent: C.neon,
confirmLabel: config.get('map.systemConfirm.label', 'SET DESTINATION'),
onConfirm: () => {
// the pick ripples once while the window fades out
this._flash = { x: o.x + this.geo.mapX, y: o.y + this.geo.mapY, r: o.r, t0: this.scene.time.now };
this.onSetDestination?.(this._snap?.systemId ?? this._systemId, o.id);
this.close();
},
time: this.scene.time.now,
});
return;
}
const body = [`DESTINATION — ${label}`];
const sh = this.getShip?.();
if (sh) body[1] = `DISTANCE ${Math.round(Math.hypot(o.x - sh.x, o.y - sh.y)).toLocaleString('en-US')}`;
@ -2501,7 +2578,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
}
_setHover(p) {
if (this._mode !== 'currentSystem') return; // the galaxy plate owns itself
if (this._mode === 'galaxy') 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(); }
@ -2545,9 +2622,10 @@ export class MapWindow extends Phaser.GameObjects.Container {
g.strokeCircle(ox, oy, o.r + 5);
g.lineStyle(4, C.neon, 0.18);
g.strokeCircle(ox, oy, o.r + 9);
// course line from the ship (if we have a live position)
// course line from the ship — only where the ship IS (the SYSTEM tab
// shows a star the ship isn't in)
const sh = this.getShip?.();
if (sh && this._tf) {
if (sh && this._tf && this._mode !== 'system') {
const sx = this.geo.mapX + this._tf.toX(sh.x);
const sy = this.geo.mapY + this._tf.toY(sh.y);
g.lineStyle(1, C.amber, 0.5);
@ -2557,6 +2635,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
}
// tooltip above the object (clamped inside the plate)
this.ttName.setText(String(o.label ?? o.id).toUpperCase());
this.ttHint.setText(this._mode === 'system' ? 'TAP TO SET DESTINATION' : 'TAP TO PLOT COURSE');
const ttW = Math.max(this.ttName.width, this.ttHint.width) + 24;
let tx = ox;
let ty = oy - o.r - 16;