orbit/js/ui/GalaxyView.js

1106 lines
39 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"): binary stars beat
* fast with a twin core, red dwarfs breathe slow, nebulae shimmer.
* • 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), 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)
* 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 } 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.onJump] — a star was
* CONFIRMED for a jump (the scene jumps through the gate)
*/
constructor(win, opts = {}) {
this.win = win;
this.scene = win.scene;
this.getSnapshot = opts.getSnapshot ?? null;
this.onJump = opts.onJump ?? 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.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]) {
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);
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,
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);
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,
name: rec.name ?? rec.id,
type: rec.type,
glow,
core,
core2,
core2Angle,
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,
});
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);
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);
}
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);
if (st.core2) st.core2.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 size = Math.max(2, st.glowW * (0.7 + 0.55 * p) * mul * (0.5 + 0.5 * rf) * k);
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);
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._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);
}
/** HOME ring + the SHIP marker's expanding pulse (clipped to the plate). */
_drawMarkers(time, k) {
const g = this.markerG;
g.clear();
const home = this._snap.homeSystemId ? this._starById.get(this._snap.homeSystemId) : null;
if (home) {
const r = Math.max(9, 20 * k);
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);
}
}
/** 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;
const k = this._k();
const rHit = Math.max(10, 13 * k) + 9;
let best = null;
let bestD = Infinity;
for (const st of this._stars) {
const d = Math.hypot(pt.x - (st.lx - this.px), pt.y - (st.ly - this.py));
if (d <= rHit && d < bestD) {
bestD = d;
best = st;
}
}
return best;
}
_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 k = this._k();
const r = Math.max(12, st.glowW * k * 0.55);
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 (edge && edge.live) {
hint = 'TAP TO JUMP';
hintColor = C.neon;
} 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 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.
*/
_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;
}
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,
});
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.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.core2, 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;
}
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 = [];
}
}