diff --git a/assets/images/originals/spacestation-01.png b/assets/images/originals/spacestation-01.png new file mode 100644 index 0000000..818cf3c Binary files /dev/null and b/assets/images/originals/spacestation-01.png differ diff --git a/assets/images/originals/spacestation-02.png b/assets/images/originals/spacestation-02.png new file mode 100644 index 0000000..59d184d Binary files /dev/null and b/assets/images/originals/spacestation-02.png differ diff --git a/assets/images/originals/spacestation-03.png b/assets/images/originals/spacestation-03.png new file mode 100644 index 0000000..ecf52ff Binary files /dev/null and b/assets/images/originals/spacestation-03.png differ diff --git a/assets/videos/ss-land-01.mp4 b/assets/videos/ss-land-01.mp4 new file mode 100644 index 0000000..58dcb19 Binary files /dev/null and b/assets/videos/ss-land-01.mp4 differ diff --git a/assets/videos/ss-land-02.mp4 b/assets/videos/ss-land-02.mp4 new file mode 100644 index 0000000..659e077 Binary files /dev/null and b/assets/videos/ss-land-02.mp4 differ diff --git a/assets/videos/ss-takeoff-01.mp4 b/assets/videos/ss-takeoff-01.mp4 new file mode 100644 index 0000000..308b2f2 Binary files /dev/null and b/assets/videos/ss-takeoff-01.mp4 differ diff --git a/data/map.json b/data/map.json index 2a16bf6..44aab3b 100644 --- a/data/map.json +++ b/data/map.json @@ -1,5 +1,5 @@ { - "_comment": "MAP — the deck's MAP button (to the right of SHIP): the full-screen cartography console, js/ui/MapWindow.js (depth 80, the ResearchWindow idiom). LEFT = the cartography feed (assets/videos/map.mp4, a muted 2:3 loop, the same recipe as the research archive feed). RIGHT = tabs on top (CURRENT SYSTEM — 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" } } \ No newline at end of file diff --git a/dev/star-art.test.mjs b/dev/star-art.test.mjs new file mode 100644 index 0000000..ffa2aaa --- /dev/null +++ b/dev/star-art.test.mjs @@ -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 ✓'); diff --git a/dev/system-chart.test.mjs b/dev/system-chart.test.mjs index 22a451f..fca094f 100644 --- a/dev/system-chart.test.mjs +++ b/dev/system-chart.test.mjs @@ -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); diff --git a/js/galaxy/GalaxyChart.js b/js/galaxy/GalaxyChart.js index a0dfb10..4297141 100644 --- a/js/galaxy/GalaxyChart.js +++ b/js/galaxy/GalaxyChart.js @@ -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), + }; +} diff --git a/js/galaxy/SystemChart.js b/js/galaxy/SystemChart.js index 854d7b8..e8e50f8 100644 --- a/js/galaxy/SystemChart.js +++ b/js/galaxy/SystemChart.js @@ -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), + }, + }; +} diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 6447ab8..ac71b69 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 /** diff --git a/js/ui/GalaxyView.js b/js/ui/GalaxyView.js index adf2e35..8157152 100644 --- a/js/ui/GalaxyView.js +++ b/js/ui/GalaxyView.js @@ -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() { diff --git a/js/ui/MapWindow.js b/js/ui/MapWindow.js index 03b9791..68c3e94 100644 --- a/js/ui/MapWindow.js +++ b/js/ui/MapWindow.js @@ -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;