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;