orbit/js/ui/MapWindow.js

2673 lines
96 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.

/**
* MapWindow — the deck's MAP console (data/map.json): a full-screen
* cartography overlay on the same bones as ResearchWindow — the left
* video panel (assets/videos/map.mp4, muted loop), the decoded title,
* the close ✕, the glitch bursts, depth 80, the `isOpen`/`update(time)`
* contract.
*
* Right column, top to bottom:
* • tabs — CURRENT SYSTEM (the live system chart) / SYSTEM (a CHARTED
* star's chart — the same painter; armed until the player picks that
* star on the GALAXY tab) / GALAXY (the whole-galaxy chart,
* js/ui/GalaxyView.js): glowing, pulsing stars per system (colored
* by archetype), the jump-lane web (traveled lanes bright with flow
* packets, frontier lanes mid, unexplored faint), the charted region
* (convex hull of visited systems, inflated), HOME + SHIP markers.
* Hover = the star's readout + its link state to the current system;
* click a CHARTED star = its chart in the SYSTEM tab (onStarOpen);
* uncharted stars are readouts only. Clicking a `standby` socket
* fires `onLocked` (toast).
* • the CHART — the current system drawn on an offscreen canvas and
* shown as a Phaser image:
* - frame = the furthest objects (planets, stations, jump gates and
* rock fields — discovered or not) AND the player's tether reach,
* plus `bounds.padding` (data/map.json, ~1024 px) on every edge
* - adaptive grid, starfield, the central body, then the discovered
* planets / stations / gates / rock clusters with labels
* - tether zones: a soft glow + the UNION boundary
* (Tether.visibleArcs) as a cyber glitch dotted line
* - FOG: everything outside the tether union gets a soft dim layer
* (fog of war) — the read on "where the tether doesn't reach yet",
* i.e. where to explore / expand next
* • SYSTEM plate — the system name, SYSTEM DISCOVERY % (NAV points:
* planets + stations + gates) and SYSTEM RESOURCES % (asteroid
* fields), each as a segmented bar (one segment per object, lit =
* found — the % from js/galaxy/SystemChart.js)
*
* Live layer (Phaser, over the canvas): the ship marker (pulsing,
* follows the ship every frame via `getShip()`), a scan sweep band, the
* hover highlight + tooltip, a click flash, the glitch bursts.
*
* Clicking a discovered object opens the ENGAGE AUTOPILOT confirm (the
* save pop-up's ConfirmOverlay, over the plate) — CONFIRM fires
* `onSelect(objectId)` (the scene plots the course) AND closes the
* console; CANCEL / scrim / ESC keep the map as it was. The window is a
* pure view: all system data arrives through the `getChart()` snapshot
* callback (GameScene); it is re-polled while open, and the canvas
* repaints on any change (discovery, tether radius, …).
*
* WHEEL ZOOM + DRAG PAN over the plate (data/map.json → zoom): the
* chart is RE-PAINTED at the new scale (the painter's grid / labels
* adapt, so it stays crisp — no blurry texture stretch), each system
* remembers its last view, and a double-tap on empty plate resets to
* 1×. While zoomed, a drag pans the view about the cursor. (Phaser 4
* delivers wheel + pointer events to interactive objects — the plate's
* image carries the listeners, so they only fire over the plate; Phaser
* itself stops the page scroll on the canvas by default.)
*/
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 { ScrambleDecode } from '../utils/Decode.js';
import { playSfxOn } from '../utils/Sfx.js';
import { Rng } from '../utils/Rng.js';
import { Tether } from '../tether/Tether.js';
import { chartBounds, fitToRect } from '../galaxy/SystemChart.js';
import { CyberShape } from './CyberShape.js';
import { ConfirmOverlay } from './ConfirmOverlay.js';
import { GalaxyView } from './GalaxyView.js';
import { setInteractiveEnabled } from '../utils/Input.js';
const TAU = Math.PI * 2;
const HEADER = fontStack('header');
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);
const rand = (a, b) => a + (b - a) * Math.random();
/** 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),
neon2: themeColor('neon2', 0xff2d6f),
amber: themeColor('amber', 0xffc94d),
panel: themeColor('panel', 0x0a1120),
bg: themeColor('bg', 0x04060d),
};
/**
* Draw a cut-corner plate with its top-left at (x, y) into an existing
* Graphics (same recipe as ResearchWindow).
*/
function panel(g, x, y, w, h, o = {}) {
const cx = x + w / 2;
const cy = y + h / 2;
const pts = CyberShape.points(w, h, o.notch ?? Math.min(14, h * 0.28)).map(
(p) => ({ x: p.x + cx, y: p.y + cy })
);
if (o.fill !== undefined) {
g.fillStyle(o.fill, o.fillAlpha ?? 1);
g.fillPoints(pts, true);
}
if (o.stroke !== undefined) {
g.lineStyle(o.lineWidth ?? 1.5, o.stroke, o.strokeAlpha ?? 1);
g.strokePoints(pts, true);
}
if (o.glow !== undefined) {
g.lineStyle((o.lineWidth ?? 1.5) + 4, o.glow, o.glowAlpha ?? 0.22);
g.strokePoints(pts, true);
}
}
/** Corner brackets framing a rect (x, y = top-left). */
function brackets(g, x, y, w, h, o = {}) {
const color = o.color ?? C.neon;
const alpha = o.alpha ?? 0.7;
const len = o.length ?? 14;
g.lineStyle(o.lineWidth ?? 2, color, alpha);
const x2 = x + w;
const y2 = y + h;
g.lineBetween(x, y, x + len, y);
g.lineBetween(x, y, x, y + len);
g.lineBetween(x2, y, x2 - len, y);
g.lineBetween(x2, y, x2, y + len);
g.lineBetween(x, y2, x + len, y2);
g.lineBetween(x, y2, x, y2 - len);
g.lineBetween(x2, y2, x2 - len, y2);
g.lineBetween(x2, y2, x2, y2 - len);
}
// ── canvas color helpers (hex/int in, css strings 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];
}
function rgba(c, a) {
const [r, g, b] = hx(c);
return `rgba(${r},${g},${b},${a})`;
}
function mixc(c, c2, t) {
const a = hx(c);
const b = hx(c2);
return `#${a.map((v, i) => Math.round(v + (b[i] - v) * t).toString(16).padStart(2, '0')).join('')}`;
}
const lighten = (c, t) => mixc(c, '#ffffff', t);
const darken = (c, t) => mixc(c, '#04060d', t);
// ── chart painters (pure canvas 2D — the "magic" half of the map) ─────────
/** Deterministic starfield for the plate (seeded, stable between redraws). */
function drawStars(ctx, w, h, seed) {
const rng = new Rng(seed);
const n = Math.round((w * h) / 8500);
for (let i = 0; i < n; i++) {
const x = rng.range(0, w);
const y = rng.range(0, h);
const r = rng.range(0.4, 1.2);
ctx.fillStyle = `rgba(159,216,255,${rng.range(0.1, 0.5).toFixed(3)})`;
ctx.fillRect(x, y, r, r);
}
// a few bright ones with glints
for (let i = 0; i < 6; i++) {
const x = rng.range(0, w);
const y = rng.range(0, h);
const a = rng.range(0.3, 0.65);
const L = rng.range(4, 9);
ctx.strokeStyle = `rgba(159,216,255,${(a * 0.55).toFixed(3)})`;
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(x - L, y);
ctx.lineTo(x + L, y);
ctx.moveTo(x, y - L);
ctx.lineTo(x, y + L);
ctx.stroke();
ctx.fillStyle = `rgba(230,245,255,${a.toFixed(3)})`;
ctx.fillRect(x - 1, y - 1, 2, 2);
}
}
/** World-space grid step (px) that lands on-screen in [40, 120] px. */
function gridStepFor(scale) {
let step = 512;
while (step * scale < 40) step *= 2;
while (step * scale > 120 && step > 512) step /= 2;
return step;
}
/** The adaptive navigation grid + origin cross (data/map.json → chart.grid). */
function drawGrid(ctx, w, h, bounds, tf) {
const gc = config.get('map.chart.grid', {});
const alpha = gc.alpha ?? 0.26;
const step = gridStepFor(tf.scale);
const color = toCss(C.faint);
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(color, alpha);
const k0 = Math.ceil(bounds.minX / step);
const k1 = Math.floor(bounds.maxX / step);
for (let k = k0; k <= k1; k++) {
const x = tf.toX(k * step);
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
const j0 = Math.ceil(bounds.minY / step);
const j1 = Math.floor(bounds.maxY / step);
for (let j = j0; j <= j1; j++) {
const y = tf.toY(j * step);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
// origin cross — where the central body anchors the system
const ox = tf.toX(0);
const oy = tf.toY(0);
ctx.strokeStyle = rgba(color, Math.min(0.8, alpha * 2.4));
ctx.lineWidth = 1;
const L = 9;
ctx.beginPath();
ctx.moveTo(ox - L, oy);
ctx.lineTo(ox + L, oy);
ctx.moveTo(ox, oy - L);
ctx.lineTo(ox, oy + L);
ctx.stroke();
ctx.beginPath();
ctx.arc(ox, oy, 3, 0, TAU);
ctx.stroke();
}
/** The central body: the home world (planet) or the system's star. */
function drawCentral(ctx, x, y, rpx, central) {
if (central?.isHome) {
// the Terran home world — a greenish blue planet
drawPlanetDisc(ctx, x, y, rpx, '#6fc2a8');
return;
}
const color = central?.color ?? '#ffe9b0';
// outer glow
let g = ctx.createRadialGradient(x, y, 0, x, y, rpx * 3.4);
g.addColorStop(0, 'rgba(255,255,255,0.95)');
g.addColorStop(0.3, rgba(color, 0.5));
g.addColorStop(1, rgba(color, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, rpx * 3.4, 0, TAU);
ctx.fill();
// disc: white core melting into the spectral hue
g = ctx.createRadialGradient(x - rpx * 0.25, y - rpx * 0.25, rpx * 0.1, x, y, rpx);
g.addColorStop(0, '#ffffff');
g.addColorStop(0.55, lighten(color, 0.25));
g.addColorStop(1, color);
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, rpx, 0, TAU);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.lineWidth = 1;
ctx.stroke();
// cross flares
const fl = rpx * 1.9;
ctx.strokeStyle = rgba(color, 0.5);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x - fl, y);
ctx.lineTo(x + fl, y);
ctx.moveTo(x, y - fl);
ctx.lineTo(x, y + fl);
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.lineWidth = 0.6;
ctx.beginPath();
ctx.moveTo(x - fl * 0.55, y);
ctx.lineTo(x + fl * 0.55, y);
ctx.moveTo(x, y - fl * 0.55);
ctx.lineTo(x, y + fl * 0.55);
ctx.stroke();
}
/** A planet: radial disc (light from the top-left) + halo + rim. */
function drawPlanetDisc(ctx, x, y, rpx, tint) {
let g = ctx.createRadialGradient(x, y, rpx * 0.4, x, y, rpx * 1.7);
g.addColorStop(0, rgba(tint, 0.2));
g.addColorStop(1, rgba(tint, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, rpx * 1.7, 0, TAU);
ctx.fill();
g = ctx.createRadialGradient(x - rpx * 0.45, y - rpx * 0.45, rpx * 0.15, x, y, rpx);
g.addColorStop(0, lighten(tint, 0.5));
g.addColorStop(0.65, tint);
g.addColorStop(1, darken(tint, 0.62));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, rpx, 0, TAU);
ctx.fill();
ctx.strokeStyle = 'rgba(4,8,16,0.8)';
ctx.lineWidth = 1;
ctx.stroke();
// lit rim
ctx.strokeStyle = rgba(lighten(tint, 0.35), 0.55);
ctx.lineWidth = 0.7;
ctx.beginPath();
ctx.arc(x, y, Math.max(0.5, rpx - 1), -2.5, -0.6);
ctx.stroke();
}
/** A space station: hub + ring + spokes + solar wings. */
function drawStation(ctx, x, y, rpx) {
const neon = toCss(C.neon);
const amber = toCss(C.amber);
// wings
ctx.strokeStyle = rgba(amber, 0.85);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x - rpx * 1.7, y);
ctx.lineTo(x - rpx * 0.55, y);
ctx.moveTo(x + rpx * 0.55, y);
ctx.lineTo(x + rpx * 1.7, y);
ctx.stroke();
ctx.strokeStyle = rgba(amber, 0.35);
ctx.lineWidth = 0.7;
ctx.beginPath();
ctx.moveTo(x - rpx * 1.7, y - 2.5);
ctx.lineTo(x - rpx * 1.7, y + 2.5);
ctx.moveTo(x + rpx * 1.7, y - 2.5);
ctx.lineTo(x + rpx * 1.7, y + 2.5);
ctx.stroke();
// ring + spokes
ctx.strokeStyle = rgba(neon, 0.8);
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.arc(x, y, rpx, 0, TAU);
ctx.stroke();
ctx.lineWidth = 0.8;
for (let i = 0; i < 3; i++) {
const a = (i / 3) * TAU + 0.5;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(a) * rpx, y + Math.sin(a) * rpx);
ctx.stroke();
}
// hub
ctx.fillStyle = '#eaf6ff';
ctx.beginPath();
ctx.arc(x, y, Math.max(1.4, rpx * 0.3), 0, TAU);
ctx.fill();
// beacon
ctx.fillStyle = rgba(neon, 0.9);
ctx.beginPath();
ctx.arc(x, y, Math.max(0.8, rpx * 0.14), 0, TAU);
ctx.fill();
}
/** A jump gate: double ring + pylons + a direction tick to the destination. */
function drawGate(ctx, x, y, rpx, rot, active) {
const neon = toCss(C.neon);
const a = active ? 0.95 : 0.5;
ctx.strokeStyle = rgba(neon, a);
ctx.lineWidth = 1.3;
ctx.beginPath();
ctx.arc(x, y, rpx, 0, TAU);
ctx.stroke();
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.arc(x, y, rpx * 0.62, 0, TAU);
ctx.stroke();
// pylons (4 ticks at 45°)
for (let i = 0; i < 4; i++) {
const ang = (i / 4) * TAU + Math.PI / 4;
ctx.beginPath();
ctx.moveTo(x + Math.cos(ang) * rpx, y + Math.sin(ang) * rpx);
ctx.lineTo(x + Math.cos(ang) * rpx * 1.35, y + Math.sin(ang) * rpx * 1.35);
ctx.stroke();
}
// direction tick — the bearing to the destination star
ctx.strokeStyle = rgba(neon, active ? 0.8 : 0.35);
ctx.lineWidth = active ? 1.4 : 0.9;
ctx.beginPath();
ctx.moveTo(x + Math.cos(rot) * rpx * 0.5, y + Math.sin(rot) * rpx * 0.5);
ctx.lineTo(x + Math.cos(rot) * rpx * 2.1, y + Math.sin(rot) * rpx * 2.1);
ctx.stroke();
if (active) {
const g = ctx.createRadialGradient(x, y, 0, x, y, rpx * 1.4);
g.addColorStop(0, rgba(neon, 0.5));
g.addColorStop(1, rgba(neon, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, rpx * 1.4, 0, TAU);
ctx.fill();
}
}
/** A rock field: scattered rocks + a faint dashed bound circle. */
function drawCluster(ctx, x, y, rpx, rocks, scale) {
const c0 = '#a99c8c';
const k = scale > 0 ? scale : 0.02; // world→screen for the local rock offsets
ctx.setLineDash([3, 4]);
ctx.strokeStyle = 'rgba(125,146,196,0.4)';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.arc(x, y, rpx, 0, TAU);
ctx.stroke();
ctx.setLineDash([]);
for (const m of rocks) {
const rx = x + (m.dx ?? 0) * k;
const ry = y + (m.dy ?? 0) * k;
const rr = Math.min(4, Math.max(0.6, (m.r ?? 1) * k * 2));
ctx.fillStyle = rgba(mixc(c0, '#c8bca9', (m.seed ?? 0.5) % 1), 0.92);
ctx.beginPath();
ctx.arc(rx, ry, rr, 0, TAU);
ctx.fill();
}
}
/** Soft glow fills under each tether zone (drawn BEFORE the fog, so it
* only survives where the tether reaches — the explored side). */
function drawTetherZones(ctx, tethers, tf) {
const gc = config.get('map.chart.glow', {});
const span = gc.span ?? 0.92;
const alpha = gc.alpha ?? 0.14;
const color = toCss(C.neon);
for (const t of tethers) {
if (typeof t?.radius !== 'number') continue;
const rpx = Math.max(3, t.radius * tf.scale);
const x = tf.toX(t.x);
const y = tf.toY(t.y);
const g = ctx.createRadialGradient(x, y, 0, x, y, rpx);
g.addColorStop(0, rgba(color, alpha));
g.addColorStop(span, rgba(color, alpha * 0.3));
g.addColorStop(1, rgba(color, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, rpx, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(color, 0.04);
ctx.beginPath();
ctx.arc(x, y, rpx, 0, TAU);
ctx.fill();
}
}
/**
* FOG OF WAR — the gray-out of "where the tether doesn't reach yet":
* a full-plate dim layer with each tether circle ERASED (destination-out)
* through a feathered edge. Painted over everything world-space; the
* tether boundary arcs are then drawn over the fog so the line stays sharp.
*/
function drawFog(ctx, w, h, dpr, tethers, tf) {
const colors = config.get('map.chart.colors', {});
const alpha = colors.fogAlpha ?? 0.62;
const color = colors.fog ?? '#04070f';
const fcvs = document.createElement('canvas');
fcvs.width = Math.max(1, Math.round(w * dpr));
fcvs.height = Math.max(1, Math.round(h * dpr));
const f = fcvs.getContext('2d');
f.scale(dpr, dpr);
f.fillStyle = rgba(color, alpha);
f.fillRect(0, 0, w, h);
f.globalCompositeOperation = 'destination-out';
for (const t of tethers) {
if (typeof t?.radius !== 'number') continue;
const rpx = Math.max(2, t.radius * tf.scale) * 1.04; // feathered rim
const x = tf.toX(t.x);
const y = tf.toY(t.y);
const g = f.createRadialGradient(x, y, 0, x, y, rpx);
g.addColorStop(0, 'rgba(0,0,0,1)');
g.addColorStop(0.9, 'rgba(0,0,0,1)');
g.addColorStop(1, 'rgba(0,0,0,0)');
f.fillStyle = g;
f.beginPath();
f.arc(x, y, rpx, 0, TAU);
f.fill();
}
const saved = ctx.getTransform();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(fcvs, 0, 0);
ctx.setTransform(saved);
}
/**
* The tether UNION boundary — the visible arcs only (Tether.visibleArcs),
* drawn as a cyber glitch dotted line (data/map.json → tetherLine), over
* the fog. The dashes are laid out in screen px so their rhythm holds
* regardless of scale.
*/
function drawTetherArcs(ctx, tethers, tf) {
const lc = config.get('map.chart.tetherLine', {});
const dash = lc.dash ?? 7;
const gap = lc.gap ?? 5;
const width = lc.width ?? 2;
const coreWidth = lc.coreWidth ?? 1;
const core = lc.core ?? '#dff6ff';
const main = toCss(C.neon);
for (const t of tethers) {
if (typeof t?.radius !== 'number') continue;
const rpx = t.radius * tf.scale;
if (rpx < 2) continue;
const arcs = Tether.visibleArcs(t, tethers);
const x = tf.toX(t.x);
const y = tf.toY(t.y);
for (const arc of arcs) {
const L = Math.max(0.001, (arc.a1 - arc.a0) * rpx);
const passes = [
{ w: width + 4, color: main, a: 0.08 },
{ w: width, color: main, a: 0.5 },
{ w: coreWidth, color: core, a: 0.9 },
];
for (const p of passes) {
ctx.lineWidth = p.w;
ctx.strokeStyle = rgba(p.color, p.a);
let s = 0;
while (s < L) {
const seg = Math.min(dash, L - s);
ctx.beginPath();
ctx.arc(x, y, rpx, arc.a0 + s / rpx, arc.a0 + (s + seg) / rpx);
ctx.stroke();
s += dash + gap;
}
}
// tangent-point nodes (where arcs begin/end against another tether)
ctx.fillStyle = rgba(core, 0.85);
for (const aEnd of [arc.a0, arc.a1]) {
ctx.beginPath();
ctx.arc(x + Math.cos(aEnd) * rpx, y + Math.sin(aEnd) * rpx, 1.6, 0, TAU);
ctx.fill();
}
}
}
}
/** Name + kind label under an object (dark backing for legibility). */
function drawLabel(ctx, x, y, name, sub) {
const lc = config.get('map.chart.labels', {});
if (lc.enabled === false) return;
const size = lc.size ?? 10;
ctx.textAlign = 'center';
ctx.font = `700 ${size}px ${HEADER}`;
const w1 = ctx.measureText(String(name).toUpperCase()).width;
let w2 = 0;
if (sub) {
ctx.font = `500 ${size - 2}px ${BODY}`;
w2 = ctx.measureText(String(sub).toUpperCase()).width;
}
const bw = Math.max(w1, w2);
const bh = sub ? size + 12 : size + 7;
ctx.fillStyle = 'rgba(3,7,14,0.72)';
ctx.fillRect(x - bw / 2 - 5, y, bw + 10, bh);
ctx.fillStyle = rgba(toCss(C.ink), 0.95);
ctx.font = `700 ${size}px ${HEADER}`;
ctx.fillText(String(name).toUpperCase(), x, y + size);
if (sub) {
ctx.fillStyle = rgba(toCss(C.dim), 0.9);
ctx.font = `500 ${size - 2}px ${BODY}`;
ctx.fillText(String(sub).toUpperCase(), x, y + size + 7);
}
ctx.textAlign = 'left';
}
/** Plate chrome: border, edge ruler ticks, scale bar (world px). */
function niceStep(scale) {
const cands = [256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072];
for (const c of cands) if (c * scale >= 60) return c;
return cands[cands.length - 1];
}
function drawChrome(ctx, w, h, bounds, tf) {
const faint = rgba(toCss(C.faint), 0.9);
ctx.strokeStyle = rgba(toCss(C.faint), 0.55);
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, w - 1, h - 1);
// ruler ticks along the bottom + left, every grid step (minor) / 4th (major)
const step = gridStepFor(tf.scale);
ctx.strokeStyle = 'rgba(125,146,196,0.35)';
let i = 0;
for (let k = Math.ceil(bounds.minX / step); k * step <= bounds.maxX; k++, i++) {
const x = tf.toX(k * step);
if (x < 4 || x > w - 4) continue;
const L = i % 4 === 0 ? 9 : 5;
ctx.lineWidth = i % 4 === 0 ? 1 : 0.7;
ctx.beginPath();
ctx.moveTo(x, h - 1);
ctx.lineTo(x, h - 1 - L);
ctx.stroke();
}
i = 0;
for (let k = Math.ceil(bounds.minY / step); k * step <= bounds.maxY; k++, i++) {
const y = tf.toY(k * step);
if (y < 4 || y > h - 4) continue;
const L = i % 4 === 0 ? 9 : 5;
ctx.lineWidth = i % 4 === 0 ? 1 : 0.7;
ctx.beginPath();
ctx.moveTo(1, y);
ctx.lineTo(1 + L, y);
ctx.stroke();
}
// scale bar, bottom-right
const D = niceStep(tf.scale);
const len = D * tf.scale;
const bx = w - len - 16;
const by = h - 18;
ctx.strokeStyle = faint;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(bx, by);
ctx.lineTo(bx + len, by);
ctx.moveTo(bx, by - 4);
ctx.lineTo(bx, by + 4);
ctx.moveTo(bx + len, by - 4);
ctx.lineTo(bx + len, by + 4);
ctx.stroke();
ctx.fillStyle = rgba(toCss(C.dim), 0.9);
ctx.font = `500 9px ${BODY}`;
ctx.textAlign = 'center';
ctx.fillText(`${D} PX`, bx + len / 2, by - 6);
ctx.textAlign = 'left';
}
/** Edge vignette (data/map.json → chart.vignette). */
function drawVignette(ctx, w, h) {
const alpha = config.get('map.chart.vignette', 0.5);
const g = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.42, w / 2, h / 2, Math.hypot(w, h) / 2);
g.addColorStop(0, 'rgba(2,4,10,0)');
g.addColorStop(1, `rgba(2,4,10,${alpha})`);
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
}
/**
* PAINT the whole chart plate (CSS-px coords; the canvas is dpr-scaled by
* the caller). Pure given the snapshot — every pixel deterministic.
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} w,h — plate size in CSS px
* @param {number} dpr — device pixel ratio (for the fog layer's sharpness)
* @param {object} snap — the GameScene chart snapshot (see mapChartSnapshot)
* @param {{z:number,cx:number,cy:number}} [view] — optional zoom view
* (z == 1 paints the whole system; z > 1 paints a z÷1 box of the
* full bounds centred on (cx, cy), clamped inside them)
* @returns {{tf:object, bounds:object, hits:Array<object>, fullBounds:object}}
* — the world→plate transform + the hit list for pointer queries
* (+ the full un-zoomed frame, for clamping pans)
*/
function paintChart(ctx, w, h, dpr, snap, view) {
const pad = config.get('map.bounds.padding', 1024);
// the frame: EVERY object (found or not) + the central body + the
// player's tether reach (a zone is part of the system's extent), padded
const boundsObjs = [...(snap.objects ?? []), ...(snap.tethers ?? [])];
if (snap.central) boundsObjs.push({ x: 0, y: 0, radius: snap.central.radius ?? 200 });
const fullBounds = chartBounds(boundsObjs, pad);
let bounds = fullBounds;
if (view && view.z > 1) {
// ZOOMED view: shrink the frame box by 1/z about (cx, cy) and clamp
// the centre so the visible box stays inside the full frame. The
// grid/labels below are scale-adaptive, so the repaint stays crisp.
const vw = fullBounds.w / view.z;
const vh = fullBounds.h / view.z;
bounds = {
...fullBounds,
cx: Phaser.Math.Clamp(view.cx, fullBounds.cx - (fullBounds.w - vw) / 2, fullBounds.cx + (fullBounds.w - vw) / 2),
cy: Phaser.Math.Clamp(view.cy, fullBounds.cy - (fullBounds.h - vh) / 2, fullBounds.cy + (fullBounds.h - vh) / 2),
w: vw,
h: vh,
};
}
const tf = fitToRect(bounds, w, h);
const pmin = config.get('map.chart.planetMin', 5);
const pmax = config.get('map.chart.planetMax', 13);
const smin = config.get('map.chart.starMin', 14);
const smax = config.get('map.chart.starMax', 34);
// 1 — base: deep-space gradient + center glow + starfield
const bg = ctx.createLinearGradient(0, 0, 0, h);
bg.addColorStop(0, 'rgba(7,12,24,1)');
bg.addColorStop(1, 'rgba(3,5,11,1)');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
const g0 = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, Math.max(w, h) * 0.62);
g0.addColorStop(0, 'rgba(24,42,82,0.3)');
g0.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g0;
ctx.fillRect(0, 0, w, h);
drawStars(ctx, w, h, `map:${snap.systemId ?? 'x'}`);
// 2 — grid + 3 — tether zone glows
drawGrid(ctx, w, h, bounds, tf);
drawTetherZones(ctx, snap.tethers ?? [], tf);
// 4 — the objects (DISCOVERED ONLY — the undiscovered stay hidden)
const hits = [];
const labelJobs = [];
for (const o of snap.objects ?? []) {
if (!o?.discovered) continue;
const x = tf.toX(o.x);
const y = tf.toY(o.y);
const rpx = Math.max(3, (o.radius ?? 60) * tf.scale);
switch (o.kind) {
case 'planet':
drawPlanetDisc(ctx, x, y, Math.max(pmin, Math.min(pmax, rpx)), o.tint ?? '#9fb6d8');
break;
case 'station':
drawStation(ctx, x, y, Math.max(6, Math.min(14, rpx)));
break;
case 'gate':
drawGate(ctx, x, y, Math.max(7, Math.min(15, rpx)), o.rotation ?? 0, o.active !== false);
break;
case 'cluster':
drawCluster(ctx, x, y, Math.max(10, rpx), o.rocks ?? [], tf.scale);
break;
}
labelJobs.push({ x, y, r: Math.max(6, rpx), name: o.name ?? o.id, sub: o.typeLabel });
hits.push({
id: o.id,
x,
y,
r: Math.max(6, rpx),
hitR: Math.min(Math.max(10, rpx), 30),
label: o.name ?? o.id,
});
}
// 5 — the central body + its label
if (snap.central) {
const cx = tf.toX(0);
const cy = tf.toY(0);
const cpx = Math.max(smin, Math.min(smax, (snap.central.radius ?? 200) * tf.scale));
drawCentral(ctx, cx, cy, cpx, snap.central);
labelJobs.push({ x: cx, y: cy, r: cpx, name: snap.central.name, sub: snap.central.isHome ? 'HOME WORLD' : snap.central.typeLabel });
}
// 6 — labels (before the fog, so out-of-range ones dim with it)
for (const j of labelJobs) {
let ly = j.y + j.r + 7;
if (ly + 30 > h) ly = j.y - j.r - 7 - 26; // flip above when off the bottom edge
drawLabel(ctx, j.x, ly, j.name, j.sub);
}
// 7 — FOG: dim everything the tether union doesn't cover
drawFog(ctx, w, h, dpr, snap.tethers ?? [], tf);
// 8 — the tether union boundary (over the fog, sharp)
drawTetherArcs(ctx, snap.tethers ?? [], tf);
// 9 — chrome + vignette
drawChrome(ctx, w, h, bounds, tf);
drawVignette(ctx, w, h);
return { tf, bounds, hits, fullBounds };
}
export class MapWindow extends Phaser.GameObjects.Container {
/** The left-panel clip (assets/videos/map.mp4, loaded by the scene). */
static VIDEO_KEY = 'map_feed';
/**
* @param {Phaser.Scene} scene
* @param {object} [opts]
* @param {() => object|null} [opts.getChart] — the live chart snapshot
* (GameScene.mapChartSnapshot; polled while open, repaint on change)
* @param {(systemId: string) => object|null} [opts.getSystemChart]
* — a CHARTED star's snapshot (GameScene.systemChartSnapshotFor;
* the SYSTEM tab's life, polled while open)
* @param {() => {x:number, y:number, heading:number}|null} [opts.getShip]
* — the ship's live world position + heading (every frame)
* @param {(objectId: string) => void} [opts.onSelect] — a discovered
* object was CONFIRMED on the chart (dialog ENGAGE) — the scene
* plots the course (autopilot) and the console closes
* @param {(systemId: string, objectId: string) => void}
* [opts.onSetDestination] — the SYSTEM tab's SET DESTINATION CONFIRM
* (the destination model is still being defined — the scene
* acknowledges the pick for now)
* @param {(tabId: string) => void} [opts.onLocked] — a STANDBY tab was
* hit (the scene toasts)
* @param {() => object|null} [opts.getGalaxy] — the live galaxy
* snapshot (GameScene.galaxySnapshot, js/galaxy/GalaxyChart.js);
* the GALAXY tab is live when provided + data/map.json un-stands-it-by
*/
constructor(scene, opts = {}) {
super(scene, 0, 0);
this.scene.add.existing(this); // v4: new'd containers are not on the display list
this.setDepth(80); // above the action bar (50) + its sub-bars (60)
this.setScrollFactor(0);
this.getChart = opts.getChart ?? null;
this.getSystemChart = opts.getSystemChart ?? null;
this.getShip = opts.getShip ?? null;
this.onSelect = opts.onSelect ?? null;
this.onSetDestination = opts.onSetDestination ?? null;
this.onLocked = opts.onLocked ?? null;
this.getGalaxy = opts.getGalaxy ?? null;
this._mode = 'currentSystem'; // the active tab: 'currentSystem' | 'system' | 'galaxy'
this._systemId = null; // the CHARTED star the SYSTEM tab shows (picked on the GALAXY tab)
this._galaxy = null; // the GALAXY tab's view (built lazily on switch)
this._galaxySnap = null;
this._galaxyFp = null;
this.openState = 'closed'; // 'open' | 'opening' | 'closing' | 'closed'
this.reveal = [];
this.decodes = [];
this._tabShakes = [];
this._glitch = { until: 0, next: 0, dur: 260 };
this._pollNext = 0;
this._fp = null;
this._snap = null;
this._tf = null;
this._bounds = null;
this._hits = [];
this._hover = null;
this._flash = null;
// wheel zoom — current view = {z, cx, cy}: zoom factor + the world
// point at the plate centre (ignored when z == 1). _views keeps
// each system's last view so it is still there when we return.
this._view = { z: 1, cx: 0, cy: 0 };
this._views = new Map();
this._rafPending = false; // repaint queued by a wheel zoom
this._lastTap = 0; // double-tap reset clock (v4 has no dblclick)
this._drag = null; // press/drag state (click on release, pan on move)
this._full = null; // full un-zoomed frame bounds (pan clamping)
this._texN = 0;
this._texKey = null;
this._fontsRepaintDone = false; // the webfont repaint runs at most ONCE
this._destroyed = false;
this._build();
this.setVisible(true);
this.setAlpha(0); // hidden until open() — the scene is built once, shown on demand
}
sfx(name) {
playSfxOn(this.scene, name);
}
hasVideo(key) {
const c = this.scene.cache?.video;
return !!(c && typeof c.has === 'function' && c.has(key));
}
/** Decode text into `txt` starting at `t0` (driven by update()). */
decodeTo(txt, str, t0, dur = 520) {
this.decodes = this.decodes.filter((d) => d.txt !== txt);
if (txt.text === str) return;
txt.setText('');
this.decodes.push({ txt, dec: new ScrambleDecode(str, t0, dur) });
}
// ------------------------------------------------------------ geometry
_measure() {
const W = this.scene.scale.width;
const H = this.scene.scale.height;
const m = 10;
const pad = 14;
const rect = { x: m, y: m, w: W - 2 * m, h: H - 2 * m };
const titleH = 46;
const bodyY = rect.y + titleH + pad;
const bodyH = rect.h - titleH - 2 * pad;
// left: the cartography feed — 2:3 (data/map.json → video.aspect),
// header above, status strip below
const a = config.get('map.video.aspect', [2, 3]);
const ar = a[0] > 0 && a[1] > 0 ? a[0] / a[1] : 2 / 3;
const videoH = Math.max(120, bodyH - 22 - 36 - 24 - 8);
const videoW = Math.max(90, videoH * ar);
const leftX = rect.x + pad;
const leftW = videoW + 30;
// right: tabs / chart plate / system plate
const rightX = leftX + leftW + pad;
const rightW = rect.x + rect.w - pad - rightX;
const tabH = 38;
const tabGap = 12;
const statsH = 150;
const statsGap = 12;
const mapTop = bodyY + tabH + tabGap;
const statsY = bodyY + bodyH - statsH;
const mapX = rightX;
const mapY = mapTop;
const mapW = rightW;
const mapH = Math.max(160, statsY - statsGap - mapY);
return {
W, H, rect, titleH, pad,
bodyY, bodyH,
videoH, videoW, leftX, leftW,
rightX, rightW, tabH, tabGap,
mapX, mapY, mapW, mapH, statsX: rightX, statsY, statsW: rightW, statsH,
};
}
// ------------------------------------------------------------ build
_build() {
const s = this.scene.add;
const G = (d = 0) => {
const g = s.graphics().setScrollFactor(0).setDepth(d);
this.add(g);
return g;
};
const T = (x, y, str, style, d = 0) => {
const t = s.text(x, y, str, style).setScrollFactor(0).setDepth(d);
this.add(t);
return t;
};
this.geo = this._measure();
const { rect, titleH } = this.geo;
// ── window body ───────────────────────────────────────────────
const bg = G(0);
bg.clear();
panel(bg, rect.x, rect.y, rect.w, rect.h, {
notch: 20,
fill: C.bg,
fillAlpha: 0.985,
stroke: 0x0e5f86,
strokeAlpha: 0.95,
});
bg.fillStyle(C.neon, 0.16);
bg.fillRect(rect.x + 20, rect.y + rect.h - 1.5, rect.w - 40, 1.5);
this.bgG = bg;
// ── title bar ─────────────────────────────────────────────────
this.titleTxt = T(rect.x + 18, rect.y + 13, '', {
fontFamily: HEADER,
fontSize: '17px',
color: toCss(C.ink),
fontStyle: 'bold',
letterSpacing: 7,
}, 1);
this.titleCursor = G(1);
this.titleMeta = T(rect.x + rect.w - 54, rect.y + 15, config.get('map.meta', 'NAV DATA // LIVE FEED'), {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.faint),
letterSpacing: 2.5,
}, 1);
this.titleMeta.setOrigin(1, 0);
const railY = rect.y + titleH - 1;
this.titleRailG = G(1);
this.titleRailG.clear();
this.titleRailG.lineStyle(1, 0x14324f, 0.8);
this.titleRailG.lineBetween(rect.x + 16, railY, rect.x + rect.w - 16, railY);
this.titleRailG.fillStyle(C.neon, 0.9);
this.titleRailG.fillRect(rect.x + 16, railY - 1, 90, 2);
// close button (top right)
const cw = 30;
const cx = rect.x + rect.w - 18 - cw / 2;
const cy = rect.y + titleH / 2 - 2;
this.closeBtn = { x: cx, y: cy, w: cw, h: cw, hover: false };
this.closeG = G(2);
this.closeTxt = T(cx, cy + 1, '✕', {
fontFamily: BODY,
fontSize: '15px',
color: toCss(C.neon2),
align: 'center',
}, 2);
this.closeTxt.setOrigin(0.5, 0.5); // Text's default origin is (0,0)
// v4: setInteractive takes a config object — a bare shape leaves
// input.hitAreaCallback unset and pointWithinHitArea throws.
const closeRect = new Phaser.Geom.Rectangle(cx - cw / 2, cy - cw / 2, cw, cw);
this.closeG.setInteractive({
useHandCursor: true,
hitArea: closeRect,
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
this.closeG.on('pointerover', () => {
this.closeBtn.hover = true;
this._paintClose();
});
this.closeG.on('pointerout', () => {
this.closeBtn.hover = false;
this._paintClose();
});
this.closeG.on('pointerdown', () => {
if (this.dialog && this.dialog.isOpen) return; // the scrim's cancel owns the click
this.sfx('ui_click');
this.close();
});
this._paintClose();
// glitch layers (top of the window)
this.glitchG = G(10);
this.titleGhostA = this._ghost();
this.titleGhostB = this._ghost();
// ── left: the cartography feed (video) ────────────────────────
this.videoPanel = this._buildVideoPanel();
this.add(this.videoPanel);
this.videoPanel.depth = 1;
// ── right: tabs + chart plate + system plate ──────────────────
this._buildTabs();
this._buildPlate();
this._buildStats();
// first paint (GameScene already has its system — data is live now)
const snap = this._snapOf();
if (snap) this._applySnap(snap);
// autopilot CONFIRM (the save pop-up's dialog, js/ui/ConfirmOverlay.js)
// — shown over the plate before a click engages autopilot. A child of
// this window: it paints above the chrome (depth 30 > the glitch
// layers' 10), fades with the close tween, and dies with destroy().
this.dialog = new ConfirmOverlay(
this.scene,
this.geo.mapX + this.geo.mapW / 2, // over the plate
this.geo.mapY + this.geo.mapH / 2,
config.get('map.confirm.width', 440),
config.get('map.confirm.height', 168),
{ areaW: this.scene.scale.width, areaH: this.scene.scale.height }, // scrim = the whole window
);
this.dialog.setDepth(30);
this.add(this.dialog);
}
_ghost() {
const t = this.scene.add
.text(0, 0, '', {
fontFamily: HEADER,
fontSize: '17px',
color: toCss(C.neon),
fontStyle: 'bold',
letterSpacing: 7,
})
.setScrollFactor(0)
.setAlpha(0)
.setBlendMode(Phaser.BlendModes.ADD);
t.setDepth(2);
this.add(t);
return t;
}
_paintClose() {
const b = this.closeBtn;
this.closeG.clear();
panel(this.closeG, b.x - b.w / 2, b.y - b.h / 2, b.w, b.h, {
notch: 6,
fill: b.hover ? 0x3a0f1e : C.panel,
fillAlpha: b.hover ? 0.9 : 0.5,
stroke: C.neon2,
strokeAlpha: b.hover ? 1 : 0.6,
});
this.closeTxt.setColor(b.hover ? toCss('#ff8fae') : toCss(C.neon2));
this.closeTxt.setAlpha(b.hover ? 1 : 0.85);
}
// ── left panel: the cartography feed ────────────────────────────────────
_buildVideoPanel() {
const { leftX, bodyY, leftW, videoW, videoH } = this.geo;
const cont = new Phaser.GameObjects.Container(this.scene, leftX, bodyY);
const s = this.scene.add;
// header: ● CARTOGRAPHY FEED // LIVE
this.recDot = s.circle(10, 12, 4, C.amber, 0.9).setScrollFactor(0);
cont.add(this.recDot);
const head = s
.text(20, 6, 'CARTOGRAPHY FEED // LIVE', {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.faint),
letterSpacing: 2.5,
})
.setScrollFactor(0);
cont.add(head);
// frame + the clip
const vx = (leftW - videoW) / 2;
const vy = 26;
const frameG = s.graphics().setScrollFactor(0);
frameG.clear();
brackets(frameG, vx - 6, vy - 6, videoW + 12, videoH + 12, { length: 12, color: C.neon, alpha: 0.7 });
frameG.lineStyle(1, 0x14324f, 0.55);
frameG.lineBetween(vx - 6, vy + videoH / 2, vx - 2, vy + videoH / 2);
frameG.lineBetween(vx + videoW + 2, vy + videoH / 2, vx + videoW + 6, vy + videoH / 2);
cont.add(frameG);
this.videoCx = vx + videoW / 2;
this.videoCy = vy + videoH / 2;
this.videoRect = { x: vx, y: vy, w: videoW, h: videoH };
this.video = null;
if (this.hasVideo(MapWindow.VIDEO_KEY)) {
const v = s.video(0, 0, MapWindow.VIDEO_KEY);
v.setOrigin(0.5);
v.setScrollFactor(0);
// The feed is ambience: muted + looping, as the console requires.
v.setVolume(0);
v.setLoop(true);
// v4's setVolume() only sets el.volume — the browser's autoplay
// policy looks at el.muted. Mute it properly.
if (v.video) v.video.muted = true;
// v4 quirk: the bookkeeping size is a placeholder until the first
// presented frame — fit now, refit on 'created' (true w/h).
const fit = (vv, iw = 0, ih = 0) => {
const el = vv.video;
const vw = iw || (el && (el.videoWidth || el.width)) || (vv.frame && vv.frame.realWidth) || 544;
const vh = ih || (el && (el.videoHeight || el.height)) || (vv.frame && vv.frame.realHeight) || 800;
const sc = Math.min(videoW / vw, videoH / vh);
vv.setPosition(this.videoCx, this.videoCy);
vv.setScale(sc);
};
fit(v);
// Keep the clip hidden until ready ("created" carries the true dims)
// so the frame never flashes a black box.
const ready = (vv, w, h) => {
if (vv !== v) return;
fit(vv, w, h);
vv.setVisible(true);
};
v.on('created', ready);
if (v.video && v.video.readyState >= 1) ready(v, 0, 0);
cont.add(v);
this.video = v;
} else {
// NO SIGNAL plate (asset missing) — the console still works.
const ph = s.graphics().setScrollFactor(0);
ph.clear();
panel(ph, vx, vy, videoW, videoH, { notch: 6, fill: 0x050a12, fillAlpha: 0.9, stroke: 0x1b3a5a, strokeAlpha: 0.5 });
for (let i = 0; i < 40; i++) {
ph.fillStyle(C.neon, rand(0.02, 0.08));
ph.fillRect(vx + rand(0, videoW - 4), vy + rand(0, videoH), rand(8, 60), 1);
}
cont.add(ph);
const ns = s.text(this.videoCx, this.videoCy - 8, 'NO SIGNAL', {
fontFamily: HEADER,
fontSize: '14px',
color: toCss(C.ink),
fontStyle: 'bold',
letterSpacing: 5,
align: 'center',
}).setScrollFactor(0);
const nsub = s.text(this.videoCx, this.videoCy + 12, 'CARTOGRAPHY FEED OFFLINE', {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.faint),
letterSpacing: 3,
align: 'center',
}).setScrollFactor(0);
cont.add(ns);
cont.add(nsub);
}
// scanlines over the feed
const scanKey = 'map_scanlines';
if (!this.scene.textures.exists(scanKey)) {
const c = document.createElement('canvas');
c.width = 4;
c.height = 8;
const ctx = c.getContext('2d');
ctx.fillStyle = 'rgba(2,6,12,0.5)';
ctx.fillRect(0, 0, 4, 3);
ctx.fillStyle = 'rgba(120,220,255,0.05)';
ctx.fillRect(0, 4, 4, 1);
this.scene.textures.addCanvas(scanKey, c);
}
this.scan = s.tileSprite(this.videoCx, this.videoCy, videoW, videoH, scanKey).setScrollFactor(0).setAlpha(0.5);
cont.add(this.scan);
// sweep band (crawls down the feed on a loop)
const swKey = 'map_sweep';
if (!this.scene.textures.exists(swKey)) {
const c = document.createElement('canvas');
c.width = 8;
c.height = 64;
const ctx = c.getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 0, 64);
grad.addColorStop(0, 'rgba(127,223,255,0)');
grad.addColorStop(0.5, 'rgba(127,223,255,0.5)');
grad.addColorStop(1, 'rgba(127,223,255,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 8, 64);
this.scene.textures.addCanvas(swKey, c);
}
this.sweepBand = s
.image(this.videoCx, this.videoRect.y - 40, swKey)
.setDisplaySize(videoW, 48)
.setScrollFactor(0)
.setAlpha(0)
.setBlendMode(Phaser.BlendModes.ADD);
cont.add(this.sweepBand);
this.sweepCfg = config.get('map.sweep', {});
// bottom status strip — live system counts
const sy = vy + videoH + 14;
const strip = s.graphics().setScrollFactor(0);
strip.clear();
strip.lineStyle(1, 0x14324f, 0.8);
strip.lineBetween(2, sy, leftW - 2, sy);
strip.fillStyle(C.neon, 0.7);
strip.fillRect(2, sy - 1, 26, 2);
cont.add(strip);
this.statusTxt = s
.text(2, sy + 10, 'NAV FEED STANDBY', {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.faint),
letterSpacing: 2,
})
.setScrollFactor(0);
cont.add(this.statusTxt);
this.statusBar = s.graphics().setScrollFactor(0);
this.statusBarY = sy + 32;
this.statusBarW = Math.max(40, leftW - 60);
cont.add(this.statusBar);
return cont;
}
// ── right: the map/space tabs ────────────────────────────────────────────
_buildTabs() {
const { rightX, bodyY, tabH, rightW } = this.geo;
this.tabs = [];
// data/map.json → tabs is an OBJECT MAP (id → tab) — accept an array too.
const raw = config.get('map.tabs', null);
const tabs = Array.isArray(raw)
? raw
: raw && typeof raw === 'object'
? Object.values(raw).filter((t) => t && typeof t === 'object' && t.id)
: [];
if (!tabs.length) {
tabs.push({ id: 'currentSystem', label: 'Current System', accent: '#00e5ff' });
tabs.push({ id: 'system', label: 'System', accent: '#a8ffc4', standby: true, standbyTag: 'NO TARGET' });
tabs.push({ id: 'galaxy', label: 'Galaxy', accent: '#ffc94d', standby: true, standbyTag: 'OFFLINE' });
}
let x = rightX;
const avail = rightW;
// equal sockets across the column — the deck's tab row
const tabW = Math.min(220, (avail - 12 * (tabs.length - 1)) / tabs.length);
for (const tab of tabs) {
const label = String(tab.label ?? tab.id).toUpperCase();
const txt = this.scene.add
.text(0, 0, label, {
fontFamily: BODY,
fontSize: '12px',
color: toCss(C.ink),
fontStyle: 'bold',
letterSpacing: 2,
})
.setScrollFactor(0);
const w = tabW;
const y = bodyY + tabH / 2;
const g = this.scene.add.graphics().setScrollFactor(0);
const entry = { id: tab.id, x, y, w, h: tabH, txt, g, accent: toColor(tab.accent ?? C.neon, C.neon), hover: false, standby: tab.standby === true, tag: tab.standbyTag ?? tab.tag ?? null, active: tab.active === true, baseX: x + 14, baseTxtX: 0 };
const tabRect = new Phaser.Geom.Rectangle(x, y - tabH / 2, w, tabH);
g.setInteractive({
useHandCursor: true,
hitArea: tabRect,
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
g.on('pointerover', () => {
entry.hover = true;
this.sfx('ui_hover');
this._paintTabs();
});
g.on('pointerout', () => {
entry.hover = false;
this._paintTabs();
});
g.on('pointerdown', () => this._tabHit(entry));
const labelX = entry.baseX + (entry.standby && entry.tag ? 26 : 0);
txt.setPosition(labelX, y - 4);
entry.baseTxtX = labelX;
g.setDepth(2);
txt.setDepth(3);
this.add(g);
this.add(txt);
if (entry.standby && entry.tag) {
const tag = this.scene.add
.text(0, 0, entry.tag, {
fontFamily: BODY,
fontSize: '8px',
color: toCss(C.amber),
fontStyle: 'bold',
letterSpacing: 1.5,
})
.setScrollFactor(0)
.setAlpha(0.75)
.setDepth(3);
tag.setPosition(x + 14, y + 10);
this.add(tag);
entry.tagTxt = tag;
}
this.tabs.push(entry);
x += w + 12;
}
this._paintTabs();
}
_tabHit(entry) {
if (this.dialog && this.dialog.isOpen) return; // the dialog is the topmost thing
if (entry.id === this._mode) return;
this.sfx('ui_click');
if (entry.standby) {
// the killswitch is still armed: shake it + the scene toasts
this._tabShakes.push({ tab: entry, t0: this.scene.time.now, dur: 340 });
this.onLocked?.(entry.id);
return;
}
this._switchMode(entry.id);
}
/** Arm / release a tab's killswitch — the SYSTEM socket wakes when the
player picks a charted star on the GALAXY tab. */
_setTabStandby(entry, on) {
if (!entry || entry.standby === on) return;
entry.standby = on;
entry.tagTxt?.setVisible(!!(on && entry.tag));
const labelX = entry.baseX + (on && entry.tag ? 26 : 0);
entry.txt.setPosition(labelX, entry.y - 4);
entry.baseTxtX = labelX;
this._paintTabs();
}
/**
* The tab switch — the plate has three lives:
* CURRENT SYSTEM — the live system chart (hover, ENGAGE, zoom, pan)
* SYSTEM — a CHARTED star's chart (the same painter; the ship
* marker + course line are hidden — the ship isn't
* in that system)
* GALAXY — the whole-galaxy chart (GalaxyView: stars, lanes,
* the charted region)
* The plate's input surface moves between mapImg and the galaxy plate —
* one at a time (Phaser 4's topOnly input: both catching would
* double-fire). Each life is driven from the same 500 ms poll in
* update(), against its own snapshot.
*/
_switchMode(id) {
const inGalaxy = id === 'galaxy';
this._mode = id;
// move the plate's input + surface (the system chart ↔ the galaxy)
setInteractiveEnabled(this.mapImg, !inGalaxy);
this.mapImg.setVisible(!inGalaxy);
this._galaxy?.setVisible(inGalaxy);
// the ship only lives in the CURRENT system
this.shipMark?.setVisible(id === 'currentSystem');
if (inGalaxy) {
// clear the system hover + repaint the galaxy's own chrome
this._hover = null;
this.hoverG.clear();
this.ttCont.setVisible(false);
this._galaxyFp = null; // force the galaxy stats paint
this._pollNext = 0; // poll the galaxy life immediately
} else {
// back to a system chart (CURRENT SYSTEM or the picked SYSTEM) —
// restore its stats chrome + force a fresh poll (redraw + stats)
this.legendTexts?.forEach((t) => t.setVisible(true));
this.navLabel.setText(this._navLabel0);
this.resLabel.setText(this._resLabel0);
this.navMetaTxt?.setVisible(true);
this.resMetaTxt?.setVisible(true);
this._paintLegend();
this._fp = null;
this._pollNext = 0; // the tab's snapshot right now
}
this._paintTabs();
}
/** Build the galaxy life lazily (first tab switch). */
_ensureGalaxy() {
if (this._galaxy) return this._galaxy;
this.sfx('ui_window');
this._galaxy = new GalaxyView(this, {
getSnapshot: () => {
try {
return this.getGalaxy ? this.getGalaxy() : null;
} catch {
return null;
}
},
onStarOpen: (id) => this._openSystem(id),
});
this._galaxy.applySnapshot(this._galaxySnap ?? null);
this._galaxy.reveal(this.scene.time.now); // first entry: the ripple runs
return this._galaxy;
}
/** A CHARTED star was picked on the GALAXY tab — wake the SYSTEM socket
(it starts armed) and show that star's chart in the system painter.
The star's chart is the next step (the old JUMP confirm is gone). */
_openSystem(systemId) {
if (!systemId) return;
this._systemId = systemId;
const entry = this.tabs.find((t) => t.id === 'system');
if (entry?.standby) {
this.sfx('ui_window'); // the socket wakes
this._setTabStandby(entry, false);
}
if (this._mode !== 'system') {
this._switchMode('system');
} else {
this._fp = null;
this._pollNext = 0;
}
}
/** The GALAXY tab's stats plate (the SYSTEM plate's objects, repurposed). */
_paintStatsGalaxy(snap) {
const { statsX, statsY, statsW, statsH } = this.geo;
this.statsG.clear();
panel(this.statsG, statsX, statsY, statsW, statsH, {
notch: 10,
fill: C.panel,
fillAlpha: 0.6,
stroke: C.faint,
strokeAlpha: 0.6,
});
const st = snap.stats ?? {};
this.sysNameTxt.setText(String(config.get('map.galaxy.name', null) ?? 'GALAXY'));
this.sysTag.setText(`${st.visited ?? 0} / ${st.systems ?? 0} CHARTED`);
this.navLabel.setText('JUMP LANES');
this.navValue.setText(st.lanes > 0 ? `${st.lanesUsed ?? 0} / ${st.lanes} TRAVELED` : '—');
const frontier = (snap.edges ?? []).filter((e) => e.frontier).length;
this.resLabel.setText('FRONTIER');
this.resValue.setText(st.lanes > 0 ? `${frontier} OPEN LINK(S)` : '—');
this._drawBar(this.statsG, this.navBarY, st.lanes ?? 0, st.lanesUsed ?? 0, toColor(C.neon));
this._drawBar(this.statsG, this.resBarY, st.lanes ?? 0, frontier, toColor(C.amber));
}
/** The GALAXY tab's legend — the three lane states + the region swatch. */
_paintLegendGalaxy() {
const { statsX, statsY, statsH } = this.geo;
const legY = statsY + statsH - 16;
const g = this.legendG;
g.clear();
let x = statsX + 14;
const drawRow = (o) => {
if (!o.color) return;
const c = toColor(o.color);
const w = o.width ?? 1;
const a = o.alpha ?? 0.5;
g.lineStyle(w + 4, c, a * 0.15);
g.lineBetween(x, legY + 4, x + 12, legY + 4);
g.lineStyle(Math.max(0.8, w), c, a);
g.lineBetween(x, legY + 4, x + 12, legY + 4);
x += 40;
};
drawRow(config.get('map.galaxy.edges.used', {}));
drawRow(config.get('map.galaxy.edges.frontier', {}));
drawRow(config.get('map.galaxy.edges.unexplored', {}));
const hull = config.get('map.galaxy.hull', {});
if (hull.color) {
const c = toColor(hull.color);
g.fillStyle(c, Math.min(1, (hull.fillAlpha ?? 0.055) * 4));
g.fillRoundedRect(x, legY - 2, 12, 12, 2);
g.lineStyle(1, c, Math.min(1, (hull.outlineAlpha ?? 0.28) * 2.2));
g.strokeRoundedRect(x, legY - 2, 12, 12, 2);
}
}
/** The GALAXY tab's status strip (under the feed). */
_paintStatusStripGalaxy(snap) {
const st = snap.stats ?? {};
const total = st.lanes ?? 0;
const used = st.lanesUsed ?? 0;
this.statusTxt.setText(total > 0 ? `${st.systems ?? 0} SYSTEMS · LANES ${total} · TRAVELED ${used}` : 'GALAXY FEED STANDBY');
this.statusTxt.setColor(total > 0 ? toCss(C.ink) : toCss(C.faint));
const g = this.statusBar;
g.clear();
g.fillStyle(0x0a1424, 0.9);
g.fillRect(2, this.statusBarY, this.statusBarW, 3);
if (total > 0) {
g.fillStyle(C.neon, 1);
g.fillRect(2, this.statusBarY, this.statusBarW * (used / total), 3);
}
}
_paintTabs() {
const { bodyY, tabH } = this.geo;
for (const t of this.tabs) {
t.g.clear();
const active = t.id === this._mode;
const edge = t.standby ? 0x22405f : active ? t.accent : t.hover ? C.ink : 0x22405f;
panel(t.g, t.x, bodyY + 1, t.w, tabH - 2, {
notch: 8,
fill: t.standby ? 0x0a1120 : active ? t.accent : C.panel,
fillAlpha: t.standby ? 0.3 : active ? 0.16 : t.hover ? 0.85 : 0.4,
stroke: edge,
strokeAlpha: t.standby ? 0.45 : active ? 1 : t.hover ? 0.9 : 0.5,
});
if (active) {
t.g.fillStyle(t.accent, 1);
t.g.fillRect(t.x + 8, bodyY + tabH - 1, t.w - 16, 2);
}
const dx = t.x + 14;
const dy = bodyY + tabH / 2 - 1;
t.g.fillStyle(t.standby ? 0x3d4c74 : active ? t.accent : 0x3d4c74, t.standby ? 0.5 : active ? 1 : 0.5);
t.g.fillTriangle(dx, dy - 4.5, dx + 4.5, dy, dx, dy + 4.5, dx - 4.5, dy);
t.txt.setColor(t.standby ? toCss(C.dim) : active ? toCss(t.accent) : toCss(C.ink));
t.txt.setAlpha(t.standby ? 0.62 : active ? 1 : t.hover ? 0.9 : 0.62);
}
}
// ── right: the chart plate (canvas → Phaser image) + live overlays ─────
_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;
}
/** mapImg's hit area, in v4 frame space (frame top-left at (0,0)).
* The frame is the chart canvas (plate size × dpr), so this covers
* exactly the displayed plate. */
_refreshPlateHitArea() {
if (!this.mapImg?.input) return;
const f = this.mapImg.frame;
this.mapImg.input.hitArea = new Phaser.Geom.Rectangle(0, 0, f.width, f.height);
}
_buildPlate() {
const { mapX, mapY, mapW, mapH } = this.geo;
const s = this.scene.add;
const cx = mapX + mapW / 2;
const cy = mapY + mapH / 2;
// the chart itself — an offscreen canvas shown as a Phaser image.
// Redrawn (new texture, old one dropped) whenever the snapshot changes.
this.mapImg = s.image(cx, cy, this._blankKey()).setScrollFactor(0).setDepth(1);
this.mapImg.setDisplaySize(mapW, mapH);
// v4 input gotcha: the hit test maps the pointer into the image's
// UNSCALED frame space (frame top-left at (0,0)) before calling the
// hitAreaCallback — world-plate coordinates (as in v3) land a full
// display-origin off, so only the top-left quarter of the plate is
// clickable. Size the rect from the CURRENT frame and re-assert it
// on every texture swap (redraw does this).
const hitRect = new Phaser.Geom.Rectangle(0, 0, 2, 2); // matches the 2×2 blank key
this.mapImg.setInteractive({
useHandCursor: false,
hitArea: hitRect,
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
this._refreshPlateHitArea();
this.mapImg.on('pointerdown', (p) => this._startDrag(p));
this.mapImg.on('pointermove', (p) => {
if (this._drag) this._onDragMove(p);
else this._setHover(p);
});
// the CLICK (select / double-tap reset) fires on release, unless the
// press turned into a drag (pan) — so drag and tap coexist
this.mapImg.on('pointerup', () => this._finishDrag(true));
this.mapImg.on('pointerout', () => {
this._setHover(null);
if (this._drag) this._finishDrag(false); // left the plate → cancel click
});
// wheel zoom — Phaser 4's input plugin emits a `wheel` event on
// interactive objects under the pointer; mapImg's hit area IS the
// plate, so this only fires over the plate (Phaser also stops the
// page scroll on the canvas by default)
this.mapImg.on('wheel', (p, _dx, dy) => this._onWheelZoom(p, dy));
this.add(this.mapImg);
// frame chrome over the plate edge (brackets + border)
this.plateFrame = s.graphics().setScrollFactor(0).setDepth(2);
brackets(this.plateFrame, mapX - 5, mapY - 5, mapW + 10, mapH + 10, { length: 16, color: C.neon, alpha: 0.75 });
this.plateFrame.lineStyle(1, 0x14324f, 0.8);
this.plateFrame.strokeRect(mapX, mapY, mapW, mapH);
this.add(this.plateFrame);
// LIVE NAV FEED tag (top-left of the plate) — the blinking dot is live
this.feedTag = s
.text(mapX + 10, mapY + 8, 'LIVE NAV FEED', {
fontFamily: BODY,
fontSize: '9px',
color: toCss(C.faint),
letterSpacing: 2,
})
.setScrollFactor(0)
.setDepth(3);
this.add(this.feedTag);
this.feedDot = s.circle(mapX + 74, mapY + 12, 2.5, C.amber, 0.9).setScrollFactor(0).setDepth(3);
this.add(this.feedDot);
// wheel-zoom + pan hint (top-right of the plate, opposite the feed tag)
if (config.get('map.zoom.enabled', true) !== false) {
this.zoomTag = s
.text(mapX + mapW - 10, mapY + 8, 'SCROLL TO ZOOM · DRAG TO PAN · DOUBLE-TAP 1×', {
fontFamily: BODY,
fontSize: '9px',
color: toCss(C.faint),
letterSpacing: 2,
})
.setOrigin(1, 0)
.setScrollFactor(0)
.setDepth(3);
this.add(this.zoomTag);
}
// scan sweep band crawling across the plate (left → right, loop)
const swKey = 'map_plate_sweep';
if (!this.scene.textures.exists(swKey)) {
const c = document.createElement('canvas');
c.width = 64;
c.height = 8;
const ctx = c.getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 64, 0);
grad.addColorStop(0, 'rgba(127,223,255,0)');
grad.addColorStop(0.5, 'rgba(127,223,255,0.4)');
grad.addColorStop(1, 'rgba(127,223,255,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 64, 8);
this.scene.textures.addCanvas(swKey, c);
}
this.plateSweep = s
.image(mapX - 60, cy, swKey)
.setDisplaySize(70, mapH)
.setScrollFactor(0)
.setAlpha(0)
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(3);
this.add(this.plateSweep);
// the SHIP marker — a live Phaser overlay (position updated every frame)
this.shipMark = new Phaser.GameObjects.Container(this.scene, 0, 0);
this.shipDart = new Phaser.GameObjects.Container(this.scene, 0, 0);
const dart = s.graphics().setScrollFactor(0);
dart.clear();
dart.fillStyle(toColor(C.ink), 1);
dart.fillTriangle(6, 0, -4, 3.5, -4, -3.5);
this.shipDart.add(dart);
this.shipRing = s.circle(0, 0, 10, 0, 0).setStrokeStyle(1.5, C.neon, 0.9).setScrollFactor(0);
this.shipLbl = s
.text(0, 13, 'SHIP', {
fontFamily: BODY,
fontSize: '9px',
color: toCss(C.ink),
fontStyle: 'bold',
letterSpacing: 2,
align: 'center',
})
.setOrigin(0.5, 0)
.setScrollFactor(0);
this.shipMark.add([this.shipDart, this.shipRing, this.shipLbl]);
this.shipMark.setVisible(false).setDepth(4);
this.add(this.shipMark);
// hover highlight ring + click flash + course line (shared graphics)
this.hoverG = s.graphics().setScrollFactor(0).setDepth(4);
this.add(this.hoverG);
// tooltip (name + kind + "tap to plot course") — ALL children of
// ttCont, which is inside the window container. (They used to be
// bare scene objects at depth 5: painted BEHIND the map window
// (depth 80) and never hidden on close — a ghost "tap to plot
// course" button left on the world after the map closed.) Inside
// the container they paint with the window, fade with its close
// tween, and hide with ttCont.
this.ttPlate = s.graphics().setScrollFactor(0);
this.ttName = s
.text(0, 0, '', {
fontFamily: HEADER,
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, 'TAP TO PLOT COURSE', {
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(5);
this.add(this.ttCont);
// glitch slices over the plate (top of the stack)
this.mapGlitchG = s.graphics().setScrollFactor(0).setDepth(9);
this.add(this.mapGlitchG);
}
// ── right: the SYSTEM plate (name + discovery/resources readout) ─────────
_buildStats() {
const { statsX, statsY, statsW, statsH } = this.geo;
const s = this.scene.add;
const pad = 14;
this.statsG = s.graphics().setScrollFactor(0).setDepth(1);
this.add(this.statsG);
// system name (decoded on open) + home tag
this.sysNameTxt = s
.text(statsX + pad, statsY + 10, '', {
fontFamily: HEADER,
fontSize: '15px',
color: toCss(C.ink),
fontStyle: 'bold',
letterSpacing: 3,
})
.setScrollFactor(0)
.setDepth(2);
this.add(this.sysNameTxt);
this.sysTag = s
.text(statsX + statsW - pad, statsY + 14, '', {
fontFamily: BODY,
fontSize: '9px',
color: toCss(C.amber),
fontStyle: 'bold',
letterSpacing: 2,
align: 'right',
})
.setOrigin(1, 0)
.setScrollFactor(0)
.setDepth(2);
this.add(this.sysTag);
const rowY0 = statsY + 40;
const rowH = 34;
// SYSTEM DISCOVERY — label + value + segmented bar
const labels = config.get('map.stats', {});
// the original label strings — the GALAXY tab rewords these two rows
// (JUMP LANES / FRONTIER) and the switch back restores them
this._navLabel0 = labels.discoveryLabel ?? 'SYSTEM DISCOVERY';
this._resLabel0 = labels.resourcesLabel ?? 'SYSTEM RESOURCES';
this.navLabel = s
.text(statsX + pad, rowY0, labels.discoveryLabel ?? 'SYSTEM DISCOVERY', {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.dim),
letterSpacing: 2,
})
.setScrollFactor(0)
.setDepth(2);
this.add(this.navLabel);
if (labels.discoveryMeta) {
const meta = s
.text(statsX + pad, rowY0 + 1, labels.discoveryMeta, {
fontFamily: BODY,
fontSize: '8px',
color: toCss(C.faint),
letterSpacing: 1.5,
})
.setScrollFactor(0)
.setDepth(2);
meta.x = this.navLabel.x + this.navLabel.width + 12;
this.add(meta);
this.navMetaTxt = meta; // hidden while the GALAXY tab owns the rows
}
this.navValue = s
.text(statsX + statsW - pad, rowY0, '—', {
fontFamily: BODY,
fontSize: '11px',
color: toCss(C.ink),
fontStyle: 'bold',
letterSpacing: 1,
align: 'right',
})
.setOrigin(1, 0)
.setScrollFactor(0)
.setDepth(2);
this.add(this.navValue);
this.navBarY = rowY0 + 16;
this.barW = statsW - pad * 2;
this.resLabel = s
.text(statsX + pad, rowY0 + rowH, labels.resourcesLabel ?? 'SYSTEM RESOURCES', {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.dim),
letterSpacing: 2,
})
.setScrollFactor(0)
.setDepth(2);
this.add(this.resLabel);
if (labels.resourcesMeta) {
const meta = s
.text(statsX + pad, rowY0 + rowH + 1, labels.resourcesMeta, {
fontFamily: BODY,
fontSize: '8px',
color: toCss(C.faint),
letterSpacing: 1.5,
})
.setScrollFactor(0)
.setDepth(2);
meta.x = this.resLabel.x + this.resLabel.width + 12;
this.add(meta);
this.resMetaTxt = meta; // hidden while the GALAXY tab owns the rows
}
this.resValue = s
.text(statsX + statsW - pad, rowY0 + rowH, '—', {
fontFamily: BODY,
fontSize: '11px',
color: toCss(C.ink),
fontStyle: 'bold',
letterSpacing: 1,
align: 'right',
})
.setOrigin(1, 0)
.setScrollFactor(0)
.setDepth(2);
this.add(this.resValue);
this.resBarY = rowY0 + rowH + 16;
// legend — the plate's glyph language (the system tab's set; the
// GALAXY tab draws its own lane legend over the same slot)
const legY = statsY + statsH - 16;
this.legendG = s.graphics().setScrollFactor(0).setDepth(2);
this.add(this.legendG);
this.legendTexts = [];
const legendRaw = config.get('map.legend.items', null) ?? config.get('map.legend', null);
const legend = Array.isArray(legendRaw) ? legendRaw : [];
const probe = s.text(0, 0, '', { fontFamily: BODY, fontSize: '9px', color: toCss(C.faint), letterSpacing: 1.5 });
this.add(probe);
let lx = statsX + pad;
const spacing = 14;
for (const item of legend) {
probe.setText(String(item.label ?? '').toUpperCase());
const tw = probe.width;
const t = s
.text(lx + 14, legY, String(item.label ?? '').toUpperCase(), {
fontFamily: BODY,
fontSize: '9px',
color: toCss(C.faint),
letterSpacing: 1.5,
})
.setScrollFactor(0)
.setDepth(2);
this.add(t);
this.legendTexts.push(t);
this._legendLayout = this._legendLayout ?? [];
this._legendLayout.push({ x: lx, glyph: item.glyph, label: String(item.label ?? '').toUpperCase() });
lx += 14 + tw + spacing + 12;
}
probe.setVisible(false);
this._paintLegend();
}
/** Re-draw the SYSTEM tab's legend glyphs (the glyph key under the
* stats). Runs at build, and again when the tab switches BACK from
* GALAXY (whose lane legend overwrote the slot). */
_paintLegend() {
const { statsY, statsH } = this.geo;
const legY = statsY + statsH - 16;
this.legendG.clear();
for (const e of this._legendLayout ?? []) {
this._legendGlyph(e.glyph, e.x, legY + 4);
}
}
/** One legend glyph, centred on (x, y). */
_legendGlyph(kind, x, y) {
const g = this.legendG;
const neon = toColor(C.neon);
const dim = toColor(C.dim);
const amber = toColor(C.amber);
switch (kind) {
case 'planet':
g.fillStyle(0x9fb6d8, 0.95);
g.fillCircle(x, y, 3.5);
g.lineStyle(0.8, 0x48608c, 0.9);
g.strokeCircle(x, y, 3.5);
break;
case 'station':
g.lineStyle(1, neon, 0.9);
g.strokeCircle(x, y, 3.5);
g.lineBetween(x - 6, y, x + 6, y);
g.fillStyle(0xeaf6ff, 1);
g.fillCircle(x, y, 1.3);
break;
case 'gate':
g.lineStyle(1, neon, 0.9);
g.strokeCircle(x, y, 4);
g.strokeCircle(x, y, 2.4);
break;
case 'cluster':
case 'rock':
g.fillStyle(0xa99c8c, 0.95);
g.fillCircle(x - 2.5, y + 1, 1.6);
g.fillCircle(x + 1.5, y - 1.5, 1.3);
g.fillCircle(x + 2.5, y + 2, 1);
break;
case 'tether':
g.lineStyle(1.2, neon, 0.9);
g.strokeCircle(x, y, 4);
g.lineStyle(1, amber, 0.8);
g.strokeCircle(x, y, 5.5, 0.4, 2.4);
break;
case 'ship':
g.fillStyle(0xeaf6ff, 1);
g.fillTriangle(x + 4, y, x - 3, y + 3, x - 3, y - 3);
break;
default:
g.fillStyle(dim, 0.7);
g.fillCircle(x, y, 2);
}
}
/** The two segmented readout bars (one segment per object, lit = found). */
_drawBar(g, y, total, found, accent) {
const { statsX, statsW } = this.geo;
const pad = 14;
const w = this.barW;
g.fillStyle(0x0a1424, 0.9);
g.fillRect(statsX + pad, y, w, 5);
if (total <= 0) return;
const gap = 2;
const seg = (w - gap * (total - 1)) / total;
for (let i = 0; i < total; i++) {
const sx = statsX + pad + i * (seg + gap);
if (i < found) {
g.fillStyle(accent, 0.95);
g.fillRect(sx, y, seg, 5);
g.fillStyle(0xffffff, 0.2);
g.fillRect(sx, y, seg, 1.5);
}
}
}
_paintStats() {
const snap = this._snap;
const { statsX, statsY, statsW, statsH } = this.geo;
this.statsG.clear();
panel(this.statsG, statsX, statsY, statsW, statsH, {
notch: 10,
fill: C.panel,
fillAlpha: 0.6,
stroke: C.faint,
strokeAlpha: 0.6,
});
if (!snap) return;
// name + tag
this.sysNameTxt.setText(String(snap.systemName ?? '').toUpperCase());
this.sysTag.setText(snap.central?.isHome ? (config.get('map.stats.homeTag', 'HOME SYSTEM')) : String(snap.central?.typeLabel ?? '').toUpperCase());
// readouts
const nav = snap.stats?.nav ?? { total: 0, found: 0, pct: 0 };
const res = snap.stats?.res ?? { total: 0, found: 0, pct: 0 };
this.navValue.setText(nav.total > 0 ? `${nav.found} / ${nav.total} · ${Math.round(nav.pct * 100)}%` : '—');
this.resValue.setText(res.total > 0 ? `${res.found} / ${res.total} · ${Math.round(res.pct * 100)}%` : '—');
const accent = toColor(C.neon);
this._drawBar(this.statsG, this.navBarY, nav.total, nav.found, accent);
this._drawBar(this.statsG, this.resBarY, res.total, res.found, toColor(C.amber));
}
_paintStatusStrip() {
const snap = this._snap;
if (!snap) {
this.statusTxt.setText('NAV FEED STANDBY');
this.statusBar.clear();
return;
}
const nav = snap.stats?.nav ?? { total: 0, found: 0, pct: 0 };
const res = snap.stats?.res ?? { total: 0, found: 0, pct: 0 };
const total = nav.total + res.total;
const found = nav.found + res.found;
let txt = total > 0 ? `OBJECTS ${total} · CHARTED ${found}` : 'NO OBJECTS LOGGED';
const z = this._view?.z ?? 1;
if (z > 1.01) txt += ` · MAG ${z.toFixed(1)}×`;
this.statusTxt.setText(txt);
this.statusTxt.setColor(total > 0 ? toCss(C.ink) : toCss(C.faint));
const g = this.statusBar;
g.clear();
g.fillStyle(0x0a1424, 0.9);
g.fillRect(2, this.statusBarY, this.statusBarW, 3);
if (total > 0) {
g.fillStyle(C.neon, 1);
g.fillRect(2, this.statusBarY, this.statusBarW * (found / total), 3);
}
}
// ------------------------------------------------------------ data flow
/** Pull the live chart snapshot from the scene (null when unavailable). */
_snapOf() {
try {
return this.getChart ? this.getChart() : null;
} catch {
return null;
}
}
/** Pull the SYSTEM tab's snapshot (the charted star picked on the
GALAXY tab) — the same painter, a different system. */
_systemSnap() {
try {
return this.getSystemChart ? this.getSystemChart(this._systemId) : null;
} catch {
return null;
}
}
/** Fingerprint — repaint only when something visible actually changed. */
_fpOf(snap) {
const objs = (snap.objects ?? []).map((o) => `${o.id}:${o.discovered ? 1 : 0}`).join('|');
const teth = (snap.tethers ?? []).map((t) => `${t.id}:${Math.round(t.radius)}`).join('|');
return [snap.systemId, objs, teth, snap.central?.name, snap.central?.isHome].join('#');
}
_applySnap(snap) {
this._snap = snap;
this._tf = null;
this._bounds = null;
this._full = null;
this._hits = [];
this._hover = null;
// restore this system's remembered view (fresh = the whole system)
this._view = (snap.systemId != null && this._views.get(snap.systemId)) || { z: 1, cx: 0, cy: 0 };
this.redraw();
this._paintStats();
this._paintStatusStrip();
this._paintHover();
this.shipMark?.setVisible(this._mode === 'currentSystem');
}
/** Re-paint the chart canvas (new texture; the old one is dropped). */
redraw() {
const snap = this._snap;
if (!snap) return;
const { mapW, mapH } = this.geo;
const dpr = Math.min(2, window.devicePixelRatio || 1);
const canvas = document.createElement('canvas');
canvas.width = Math.max(8, Math.round(mapW * dpr));
canvas.height = Math.max(8, Math.round(mapH * dpr));
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const out = paintChart(ctx, mapW, mapH, dpr, snap, this._view);
this._tf = out.tf;
this._bounds = out.bounds;
this._full = out.fullBounds;
this._hits = out.hits;
const prev = this._texKey;
const key = `map_chart_${this._texN++}`;
if (this.scene.textures.exists(key)) this.scene.textures.remove(key);
this.scene.textures.addCanvas(key, canvas);
this.mapImg.setTexture(key);
// v4: setTexture keeps the image's existing scale (v3 reset it to 1) —
// the image was born on the 2×2 blank key, so the display size must
// be re-asserted on every texture swap or it renders ~415× oversized.
this.mapImg.setDisplaySize(mapW, mapH);
// …and the hit area is sized from the frame, so it must follow too
// (a stale 2×2 rect makes the plate almost entirely unclickable).
this._refreshPlateHitArea();
if (prev && prev !== key) this.scene.textures.remove(prev);
this._texKey = key;
// The canvas paints with the fallback face until the webfonts land —
// repaint ONCE, when they're in. A one-shot flag: re-scheduling from
// inside redraw() would loop forever (each repaint re-arms itself).
if (!this._fontsRepaintDone && document.fonts?.ready) {
document.fonts.ready
.then(() => {
if (this._destroyed || this._fontsRepaintDone) return;
this._fontsRepaintDone = true;
if (this._texKey === key && this.isOpen) this.redraw();
})
.catch(() => {});
}
}
// ------------------------------------------------------------ open/close
open() {
if (this.openState === 'open' || this.openState === 'opening') return;
this.openState = 'opening';
this.setAlpha(0);
this.video?.play?.();
this.sfx('ui_window');
const now = this.scene.time.now;
this._startReveal(now);
if (this._mode === 'galaxy' && this._galaxy) {
// re-opening the console in GALAXY mode: the ripple runs again
this._galaxy.reveal(now);
}
}
close() {
if (this.openState === 'closed' || this.openState === 'closing') return;
if (this.dialog && this.dialog.isOpen) this.dialog.cancel(); // the dialog goes first
this.openState = 'closing';
this._drag = null; // a press that outlives the window is no one's click
this._galaxy?.cancelInteraction(); // …same for the galaxy plate's press
this.video?.pause?.();
this.sfx('ui_close');
this._setHover(null);
this.scene.tweens.add({
targets: this,
alpha: 0,
duration: 150,
ease: 'Power2',
onComplete: () => {
this.openState = 'closed';
this.glitchG.clear();
this.mapGlitchG.clear();
this.titleGhostA.setAlpha(0);
this.titleGhostB.setAlpha(0);
},
});
}
// 'closing' counts as open: the close button flips openState in the SAME
// input pass, before the scene's pointerdown guard runs — while the
// window is still on screen (fading out) it keeps owning the click.
get isOpen() {
return this.openState === 'open' || this.openState === 'opening' || this.openState === 'closing';
}
// ------------------------------------------------------------ boot reveal
_startReveal(now) {
const list = [];
const push = (o, d, dur, mode, baseY) => {
list.push({ o, d, dur, mode, baseY: baseY ?? o.y, t0: now });
};
const name = (this._mode === 'galaxy'
? String(config.get('map.galaxy.name', null) ?? 'GALAXY')
: String(this._snap?.systemName ?? 'SYSTEM')).toUpperCase();
this.decodeTo(this.titleTxt, config.get('map.title', 'STELLAR CARTOGRAPHY'), now, 560);
this.decodeTo(this.sysNameTxt, name, now + 180, Math.min(900, 420 + name.length * 26));
push(this, 0, 240, 'fade');
push(this.titleMeta, 140, 300, 'fade');
push(this.closeTxt, 200, 200, 'fade');
push(this.closeG, 200, 200, 'fade');
push(this.videoPanel, 260, 380, 'fade');
this.tabs.forEach((t, i) => {
push(t.g, 300 + i * 70, 260, 'fade');
push(t.txt, 300 + i * 70, 260, 'fade');
});
push(this.mapImg, 420, 420, 'fade');
push(this.plateFrame, 500, 300, 'fade');
push(this.feedTag, 560, 260, 'fade');
push(this.feedDot, 560, 260, 'fade');
if (this.zoomTag) push(this.zoomTag, 560, 260, 'fade');
push(this.statsG, 620, 300, 'fade');
this.sysNameTxt.y = this.geo.statsY + 10;
push(this.sysNameTxt, 620, 300, 'fade');
this.sysTag.y = this.geo.statsY + 14;
push(this.sysTag, 660, 260, 'fade');
push(this.navLabel, 680, 240, 'fade');
push(this.navValue, 680, 240, 'fade');
push(this.resLabel, 720, 240, 'fade');
push(this.resValue, 720, 240, 'fade');
push(this.shipMark, 820, 300, 'fade');
this.reveal = list;
const g = config.get('map.glitch', {});
const [ia, ib] = g.intervalMs ?? g.interval ?? [2600, 6400];
this._glitch.next = now + rand(ia, ib);
this.openState = 'open';
}
// ------------------------------------------------------------ glitch bursts
_glitchBurst() {
const g = config.get('map.glitch', {});
if (g.enabled === false) return;
const [da, db] = g.durationMs ?? g.duration ?? [110, 260];
const now = this.scene.time.now;
this._glitch.until = now + rand(da, db);
const [sa, sb] = g.slices ?? [2, 6];
const maxOff = g.maxOffset ?? 12;
const { mapX, mapY, mapW, mapH } = this.geo;
const gg = this.mapGlitchG;
gg.clear();
const n = rand(sa, sb);
for (let i = 0; i < n; i++) {
const y = mapY + rand(0, Math.max(1, mapH - 6));
const hgt = rand(2, 7);
const off = rand(-maxOff, maxOff);
const color = Math.random() < 0.5 ? C.neon : C.neon2;
gg.fillStyle(color, rand(0.06, 0.2));
gg.fillRect(mapX + off, y, mapW, hgt);
}
// title RGB split
const t = this.titleTxt;
if (t.text) {
const off = rand(2, 5);
this.titleGhostA.setText(t.text);
this.titleGhostB.setText(t.text);
this.titleGhostA.setPosition(t.x - off, t.y);
this.titleGhostB.setPosition(t.x + off, t.y);
this.titleGhostA.setAlpha(0.55);
this.titleGhostB.setAlpha(0.55);
}
}
// ------------------------------------------------------------ per-frame
update(time) {
if (this.dialog) this.dialog.update(time); // the autopilot confirm, if up
if (!this.isOpen) return;
// the GALAXY tab's living plate — the per-star pulses, the flow
// packets, the ship's ring, the reveal (runs while the console is
// open in galaxy mode)
if (this._mode === 'galaxy') this._galaxy?.update(time);
// reveal timeline
if (this.reveal.length) {
let done = true;
for (const r of this.reveal) {
const u = clamp01((time - r.t0 - r.d) / Math.max(1, r.dur));
const e = easeIO(u);
if (u < 1) done = false;
if (r.mode === 'fade') r.o.setAlpha(e);
else if (r.mode === 'rise') {
r.o.setAlpha(e);
r.o.y = r.baseY + (1 - e) * 14;
} else if (r.mode === 'pop') {
r.o.setAlpha(e);
r.o.setScale(0.92 + 0.08 * e);
}
}
if (done) this.reveal = [];
}
// decodes
if (this.decodes.length) {
const rest = [];
for (const d of this.decodes) {
if (d.dec.started(time)) {
d.txt.setText(d.dec.display(time));
if (!d.dec.finished(time)) rest.push(d);
} else rest.push(d);
}
this.decodes = rest;
}
// title cursor (blinks after the decode)
const tw = this.titleTxt.width + 4;
this.titleCursor.clear();
if (time % 900 < 480) {
this.titleCursor.fillStyle(C.neon, 0.9);
this.titleCursor.fillRect(this.titleTxt.x + tw, this.titleTxt.y + 4, 8, this.titleTxt.height - 6);
}
// glitch bursts
if (time >= this._glitch.next) {
this._glitchBurst();
const g = config.get('map.glitch', {});
const [ia, ib] = g.intervalMs ?? g.interval ?? [2600, 6400];
this._glitch.next = time + rand(ia, ib);
}
const burstOn = time < this._glitch.until;
if (!burstOn && (this.titleGhostA.alpha > 0 || this.titleGhostB.alpha > 0 || this.mapGlitchG.visible)) {
this.titleGhostA.setAlpha(0);
this.titleGhostB.setAlpha(0);
this.mapGlitchG.clear();
}
// tab shakes (the locked GALAXY socket)
if (this._tabShakes.length) {
const rest = [];
for (const sh of this._tabShakes) {
const u = clamp01((time - sh.t0) / sh.dur);
if (u < 1) {
const off = Math.sin(u * Math.PI * 7) * 4 * (1 - u);
sh.tab.txt.setPosition(sh.tab.baseTxtX + off, sh.tab.y - 4);
rest.push(sh);
} else {
sh.tab.txt.setPosition(sh.tab.baseTxtX, sh.tab.y - 4);
}
}
this._tabShakes = rest;
}
// live feed touches: REC dots
const blink = 0.55 + 0.45 * Math.sin(time * 0.006);
this.recDot.setAlpha(blink);
this.feedDot.setAlpha(blink);
// plate sweep band (left → right, looping)
const sw = this.sweepCfg;
if (sw?.enabled !== false && this.plateSweep) {
const dur = sw.durationMs ?? sw.duration ?? 7000;
const bandW = 70;
const span = this.geo.mapW + bandW * 2;
const u = (time % dur) / dur;
this.plateSweep.setPosition(this.geo.mapX - bandW + u * span, this.geo.mapY + this.geo.mapH / 2);
this.plateSweep.setAlpha(Math.sin(u * Math.PI) * (sw.alpha ?? 0.16));
}
// video sweep (crawls down the feed)
if (this.sweepBand && this.videoRect) {
const dur = this.sweepCfg?.durationMs ?? this.sweepCfg?.duration ?? 7000;
const u = (time % dur) / dur;
const yTop = this.videoRect.y - 40;
const yBot = this.videoRect.y + this.videoRect.h + 40;
this.sweepBand.setPosition(this.videoCx, yTop + u * (yBot - yTop));
this.sweepBand.setAlpha(Math.sin(u * Math.PI) * 0.5);
}
// the ship marker — follows the ship every frame (system tab only —
// the galaxy tab carries its own SHIP marker on the plate)
if (this._mode === 'currentSystem' && this._tf && this.shipMark) {
const sh = this.getShip ? this.getShip() : null;
if (sh && Number.isFinite(sh.x) && Number.isFinite(sh.y)) {
const px = this.geo.mapX + this._tf.toX(sh.x);
const py = this.geo.mapY + this._tf.toY(sh.y);
this.shipMark.setPosition(px, py);
this.shipDart.rotation = sh.heading ?? 0;
const u = (time % 1400) / 1400;
this.shipRing.setScale(1 + 0.9 * u).setAlpha(0.85 * (1 - u));
this.shipMark.setVisible(true);
} else {
this.shipMark.setVisible(false);
}
}
// click flash (an object was picked — the system chart tabs)
if ((this._mode === 'currentSystem' || this._mode === 'system') && this._flash) {
const u = clamp01((time - this._flash.t0) / 420);
if (u >= 1) {
this._flash = null;
this.hoverG.clear();
this._paintHover();
} else {
this.hoverG.clear();
const f = this._flash;
const r = f.r + 18 * u;
this.hoverG.lineStyle(2, C.neon, (1 - u) * 0.9);
this.hoverG.strokeCircle(f.x, f.y, r);
this._paintHover();
}
}
// poll the scene's data — repaint on any real change (discovery,
// tether… on the system tab; the run's charted state on the galaxy)
if (time >= this._pollNext) {
this._pollNext = time + 500;
if (this._mode === 'galaxy') {
let snap = null;
try {
snap = this.getGalaxy ? this.getGalaxy() : null;
} catch {
snap = null;
}
if (snap) {
this._galaxySnap = snap;
const gv = this._ensureGalaxy();
gv.setVisible(true);
gv.applySnapshot(snap);
const fp = JSON.stringify([snap.stats, snap.currentSystemId, snap.edges.length]);
if (fp !== this._galaxyFp) {
this._galaxyFp = fp;
this._paintStatsGalaxy(snap);
this._paintLegendGalaxy();
this._paintStatusStripGalaxy(snap);
this.legendTexts?.forEach((t) => t.setVisible(false));
this.navMetaTxt?.setVisible(false);
this.resMetaTxt?.setVisible(false);
}
}
return;
}
const snap = this._mode === 'system' ? this._systemSnap() : this._snapOf();
if (snap) {
const fp = this._fpOf(snap);
if (fp !== this._fp) {
this._fp = fp;
this._applySnap(snap);
} else {
this._snap = snap; // keep live data fresh (ship, names…)
}
}
}
}
// ------------------------------------------------------------ wheel zoom
/** Wheel zoom (object-level `wheel` event from Phaser 4's input
* plugin — mapImg's hit area is the plate, so this only fires over
* the plate). `p` is the pointer (game coords), `dy` the raw deltaY. */
_onWheelZoom(p, dy) {
if (this._destroyed || !this.isOpen || !this._tf || (this.dialog && this.dialog.isOpen)) return;
const z = config.get('map.zoom', {});
if (z.enabled === false) return;
const dyPx = dy * ((p.event?.deltaMode ?? 0) === 1 ? 32 : 1); // line-mode → px
if (!dyPx) return;
const { mapX, mapY } = this.geo;
this._zoomAt(p.x - mapX, p.y - mapY, Math.exp(-dyPx * (z.sensitivity ?? 0.0022)), z.zMin ?? 1, z.zMax ?? 8);
}
/** Zoom about a plate point (px,py in plate-local px) by `factor`. */
_zoomAt(px, py, factor, zMin = 1, zMax = 8) {
const tf = this._tf;
if (!tf || !this._snap) return;
const z0 = this._view.z;
const z1 = Phaser.Math.Clamp(z0 * factor, zMin, zMax);
if (z1 === z0) return;
// the world point under the cursor, in the CURRENT view
const wx = (px - tf.ox) / tf.scale;
const wy = (py - tf.oy) / tf.scale;
// solve the new centre so that point stays under the cursor when the
// plate re-fits at scale (tf.scale / z0) * z1
const s1 = (tf.scale / z0) * z1;
this._view = {
z: z1,
cx: wx - (px - this.geo.mapW / 2) / s1,
cy: wy - (py - this.geo.mapH / 2) / s1,
};
this._saveView();
this._queueRedraw();
}
_resetZoom() {
if (this._view.z <= 1) return;
this._view = { z: 1, cx: 0, cy: 0 };
this._saveView();
this._hover = null;
this._queueRedraw();
}
/** Remember this system's view so it is still there when we return. */
_saveView() {
const id = this._snap?.systemId;
if (id != null) this._views.set(id, { ...this._view });
}
/** One repaint per frame, no matter how fast the wheel spins. */
_queueRedraw() {
if (this._rafPending) return;
this._rafPending = true;
requestAnimationFrame(() => {
this._rafPending = false;
if (this._destroyed) return;
this._hover = null; // every hit position moved under the new view
this.redraw();
this._paintHover();
this._paintStatusStrip();
});
}
// ------------------------------------------------------------ drag pan
/** A press on the plate. Nothing happens yet — the click (select /
* double-tap reset) fires on release, unless the pointer moves past
* the slop, in which case the drag pans the zoomed view instead. */
_startDrag(p) {
if (this._destroyed || !this.isOpen || (this.dialog && this.dialog.isOpen)) return;
this._drag = {
x: p.x,
y: p.y,
moved: false,
over: this._objectAt(this._platePoint(p)),
};
}
_onDragMove(p) {
const d = this._drag;
if (!d) return;
const dx = p.x - d.x;
const dy = p.y - d.y;
if (!d.moved) {
if (Math.hypot(dx, dy) < 5) return; // still a click, not a drag
d.moved = true;
this._hover = null; // the drag owns the plate from here on
this._paintHover();
if (this._view.z <= 1) return; // nothing to pan at 1×
}
d.x = p.x;
d.y = p.y;
this._panBy(dx, dy);
}
/** Release after a press. If it dragged, the pan was applied as it
* went and the click is cancelled. Otherwise it's a genuine click:
* select the object under the press, or (empty plate) a double-tap
* zoom reset. Releasing off the plate cancels the click. */
_finishDrag(releaseInside) {
const d = this._drag;
this._drag = null;
if (!d || this._destroyed) return;
if (d.moved) {
this._lastTap = 0; // a drag is not a reset tap
this._saveView();
return;
}
if (!releaseInside) return;
const now = this.scene.time.now;
const o = d.over;
if (o) {
this.sfx('ui_click');
this._lastTap = 0; // a selection is not a zoom-reset tap
this._askObject(o); // confirm first — the tab's own ask (autopilot / destination)
return;
}
// double-tap on EMPTY plate → reset zoom (v4 has no dblclick event)
if (this._view.z > 1 && now - this._lastTap < 320) {
this._lastTap = 0;
this.sfx('ui_window');
this._resetZoom();
return;
}
this._lastTap = now;
}
/** Pan the zoomed view by plate pixels: the point under the cursor
* stays under the cursor, the centre stays inside the full frame. */
_panBy(dpx, dpy) {
const tf = this._tf;
const full = this._full;
if (!tf || !full || this._view.z <= 1) return;
const z = this._view.z;
const vw = full.w / z;
const vh = full.h / z;
this._view = {
z,
cx: Phaser.Math.Clamp(this._view.cx - dpx / tf.scale, full.cx - (full.w - vw) / 2, full.cx + (full.w - vw) / 2),
cy: Phaser.Math.Clamp(this._view.cy - dpy / tf.scale, full.cy - (full.h - vh) / 2, full.cy + (full.h - vh) / 2),
};
this._saveView();
this._queueRedraw();
}
/**
* The clicked-object's ask (the save pop-up's ConfirmOverlay, over the
* plate) — mode-aware:
* CURRENT SYSTEM — ENGAGE AUTOPILOT is a commitment; CONFIRM fires
* onSelect (the scene plots the course) and closes the console.
* SYSTEM — SET DESTINATION on a charted star's object (the ship
* isn't in that system, so no distance row); CONFIRM fires
* onSetDestination (the destination model is still being defined).
* CANCEL, the scrim, or ESC leave the map exactly as it was. While the
* dialog is up the plate is inert (the guards in _startDrag /
* _onWheelZoom / _setHover — the scrim covers the whole window, so it
* also takes the clicks meant for the chrome, and its own pointerdown
* IS the cancel).
*/
_askObject(o) {
this._hover = null; // the dialog speaks for the pick
this._paintHover();
const label = String(o.label ?? o.id).toUpperCase();
if (this._mode === 'system') {
const sysName = String(this._snap?.systemName ?? '').toUpperCase();
this.dialog.show({
title: config.get('map.systemConfirm.title', 'SET DESTINATION'),
body: [`DESTINATION — ${label}`, sysName ? `SYSTEM — ${sysName}` : ''].filter(Boolean),
accent: C.neon,
confirmLabel: config.get('map.systemConfirm.label', 'SET DESTINATION'),
onConfirm: () => {
// the pick ripples once while the window fades out
this._flash = { x: o.x + this.geo.mapX, y: o.y + this.geo.mapY, r: o.r, t0: this.scene.time.now };
this.onSetDestination?.(this._snap?.systemId ?? this._systemId, o.id);
this.close();
},
time: this.scene.time.now,
});
return;
}
const body = [`DESTINATION — ${label}`];
const sh = this.getShip?.();
if (sh) body[1] = `DISTANCE ${Math.round(Math.hypot(o.x - sh.x, o.y - sh.y)).toLocaleString('en-US')}`;
this.dialog.show({
title: config.get('map.confirm.title', 'ENGAGE AUTOPILOT'),
body,
accent: C.amber,
confirmLabel: config.get('map.confirm.label', 'ENGAGE'),
onConfirm: () => {
// the pick ripples once while the window fades out
this._flash = { x: o.x + this.geo.mapX, y: o.y + this.geo.mapY, r: o.r, t0: this.scene.time.now };
this.onSelect?.(o.id);
this.close();
},
time: this.scene.time.now,
});
}
// ------------------------------------------------------------ pointer
_platePoint(p) {
return { x: p.x - this.geo.mapX, y: p.y - this.geo.mapY };
}
_objectAt(pt) {
if (!pt) return null;
let best = null;
let bestD = Infinity;
for (const o of this._hits) {
const d = Math.hypot(pt.x - o.x, pt.y - o.y);
if (d <= Math.max(o.hitR, 12) + 9 && d < bestD) {
bestD = d;
best = o;
}
}
return best;
}
_setHover(p) {
if (this._mode === 'galaxy') return; // the galaxy plate owns itself
if (this.dialog && this.dialog.isOpen) {
// the confirm owns the plate — no hover churn under the scrim
if (this._hover) { this._hover = null; this._paintHover(); }
return;
}
if (!this.isOpen || p === null) {
this._hover = null;
this._paintHover();
return;
}
const o = this._objectAt(this._platePoint(p));
if (o && (!this._hover || this._hover.id !== o.id)) {
this.sfx('ui_hover');
this._hover = o;
this.scene.input.setDefaultCursor('pointer');
} else if (!o && this._hover) {
this.scene.input.setDefaultCursor('auto');
}
if (!o) this._hover = null;
this._paintHover();
}
_paintHover() {
const g = this.hoverG;
g.clear();
const tt = this.ttCont;
if (!this._hover) {
tt.setVisible(false);
return;
}
const o = this._hover;
// hits are PLATE-LOCAL (plate top-left = 0,0); hoverG draws in world
// coords, and the plate sits at geo.mapX/mapY — so the destination
// end of everything below is offset to world first. (The ship end
// already was: a line from a world point to a plate-local point is
// why the course line used to miss its destination.)
const ox = o.x + this.geo.mapX;
const oy = o.y + this.geo.mapY;
// highlight ring + soft glow
g.lineStyle(1.5, C.neon, 0.9);
g.strokeCircle(ox, oy, o.r + 5);
g.lineStyle(4, C.neon, 0.18);
g.strokeCircle(ox, oy, o.r + 9);
// course line from the ship — only where the ship IS (the SYSTEM tab
// shows a star the ship isn't in)
const sh = this.getShip?.();
if (sh && this._tf && this._mode !== 'system') {
const sx = this.geo.mapX + this._tf.toX(sh.x);
const sy = this.geo.mapY + this._tf.toY(sh.y);
g.lineStyle(1, C.amber, 0.5);
g.strokeLineShape(new Phaser.Geom.Line(sx, sy, ox, oy));
g.fillStyle(C.amber, 0.7);
g.fillCircle(ox, oy, 2);
}
// tooltip above the object (clamped inside the plate)
this.ttName.setText(String(o.label ?? o.id).toUpperCase());
this.ttHint.setText(this._mode === 'system' ? 'TAP TO SET DESTINATION' : 'TAP TO PLOT COURSE');
const ttW = Math.max(this.ttName.width, this.ttHint.width) + 24;
let tx = ox;
let ty = oy - o.r - 16;
tx = Phaser.Math.Clamp(tx, this.geo.mapX + ttW / 2 + 6, this.geo.mapX + this.geo.mapW - ttW / 2 - 6);
ty = Math.max(ty, this.geo.mapY + 46);
this.ttPlate.clear();
panel(this.ttPlate, tx - ttW / 2, ty - 40, ttW, 40, {
notch: 6,
fill: 0x050a12,
fillAlpha: 0.94,
stroke: C.neon,
strokeAlpha: 0.8,
});
this.ttName.setPosition(tx, ty - 32);
this.ttName.setOrigin(0.5, 0);
this.ttHint.setPosition(tx, ty - 16);
this.ttHint.setOrigin(0.5, 0);
this.ttCont.setVisible(true);
}
destroy() {
this._destroyed = true;
this._galaxy?.destroy();
this._galaxy = null;
if (this._texKey) {
try {
this.scene.textures.remove(this._texKey);
} catch {
/* already gone */
}
}
super.destroy();
}
}