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:
parent
22c2ccfcd2
commit
efe17f3d2e
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.
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ✓');
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue