1347 lines
50 KiB
JavaScript
1347 lines
50 KiB
JavaScript
/**
|
||
* 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, fitPadded } 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', {}),
|
||
destination: config.get('map.galaxy.destination', {}),
|
||
};
|
||
|
||
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 — but a TRUE
|
||
// rectangle fit with a PLATE-PIXEL margin on all four sides
|
||
// (map.galaxy.platePadding): the field is 2:1 to match the plate, so
|
||
// it fills the plate at 1× instead of letterboxing, and the old
|
||
// 90-WORLD-unit padding (negligible on a 32k field — the stars sat
|
||
// on the plate edges) is a 26-plate-px margin. The inflated bounds
|
||
// drive the pan/zoom clamp, so the stars keep that margin even when
|
||
// panned to the edge.
|
||
const glowW = Math.max(10, Number(this._cfg.stars.glow ?? 30));
|
||
const platePad = Number(config.get('map.galaxy.platePadding', 26)) || 26;
|
||
const b0 = chartBounds(
|
||
snap.systems.map((s) => ({ x: s.x, y: s.y, radius: glowW / 2 })),
|
||
0
|
||
);
|
||
const fit = fitPadded(b0, this.pw, this.ph, platePad);
|
||
this._bounds = fit.bounds;
|
||
this._fit = { scale: fit.scale, cx: fit.bounds.cx, cy: fit.bounds.cy };
|
||
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).
|
||
|
||
CRITICAL: this runs from setVisible() *after* the bulk hide/show, so it
|
||
must respect the view's overall visibility — otherwise re-showing the
|
||
in-plate stars here resurrects them and the whole galaxy layer bleeds
|
||
through the system chart (the "galaxy stars behind it" bug). When the
|
||
view is off, every star stays off, regardless of plate culling. */
|
||
_applyCull() {
|
||
const show = this._visible;
|
||
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(show && inPlate);
|
||
st.core.setVisible(show && 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;
|
||
// ROUTE lanes (the active SET DESTINATION path) read first — the
|
||
// "where am I going" line in orange (data/map.json → galaxy.edges.route,
|
||
// color = data/gates.json → route.compassColor) — above used/frontier.
|
||
const st = e.route ? ec.route : 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.route || e.used) {
|
||
// the ROUTE / 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);
|
||
}
|
||
// The DESTINATION star — the route's final stop. A solid orange ring
|
||
// (data/map.json → galaxy.destination, color = data/gates.json →
|
||
// route.compassColor) so the "where am I going" target is unmistakable.
|
||
// Shown only while a destination is set (snapshot.destinationId); the
|
||
// star may still be undiscovered (the target is the thing to find).
|
||
const destId = this._snap.destinationId;
|
||
if (destId) {
|
||
const d = this._starById.get(destId);
|
||
if (d) {
|
||
const dc = this._cfg.destination ?? {};
|
||
const color = toColor(dc.color ?? '#ff8c1a');
|
||
const base = Math.max(12, dot * (dc.radiusMul ?? 2.1));
|
||
const w = Math.max(1.5, Number(dc.width) || 2);
|
||
const a = Number(dc.alpha) ?? 0.9;
|
||
// a gentle breathing pulse so it reads as "active target"
|
||
const breathe = 0.5 + 0.5 * Math.sin(time / 700);
|
||
this._strokeClippedRing(g, d.lx, d.ly, base + breathe * 4, w, color, a * (0.7 + 0.3 * breathe));
|
||
this._strokeClippedRing(g, d.lx, d.ly, base + 5 + breathe * 4, 1, color, a * 0.35);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 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 = [];
|
||
}
|
||
}
|