orbit/js/ui/GalaxyView.js

1307 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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