orbit/js/ui/MapWindow.js

2081 lines
71 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 (live) / GALAXY (standby, not built yet —
* clicking it fires `onLocked` and the scene toasts it)
* • 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 fires `onSelect(objectId)` — the scene
* plots the course (autopilot). 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, …).
*/
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';
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)
* @returns {{tf:object, bounds:object, hits:Array<object>}} — the world→plate
* transform + the hit list for pointer queries
*/
function paintChart(ctx, w, h, dpr, snap) {
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 bounds = chartBounds(boundsObjs, pad);
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 };
}
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 {() => {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 clicked on the chart (the scene plots the course)
* @param {() => void} [opts.onLocked] — the (standby) GALAXY tab was hit
*/
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.getShip = opts.getShip ?? null;
this.onSelect = opts.onSelect ?? null;
this.onLocked = opts.onLocked ?? 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;
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', () => {
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);
}
_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: 'galaxy', label: 'Galaxy', accent: '#ffc94d', standby: true, standbyTag: 'OFFLINE' });
}
let x = rightX;
const avail = rightW;
// two equal sockets across the column — the deck's tab row
const tabW = Math.min(220, (avail - 12) / 2);
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, 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 = x + 14 + (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 (entry.standby) {
// GALAXY — built as standby for now: shake it + the scene toasts
this.sfx('ui_click');
this._tabShakes.push({ tab: entry, t0: this.scene.time.now, dur: 340 });
this.onLocked?.();
return;
}
// CURRENT SYSTEM is already the live view — confirm, nothing to switch
this.sfx('ui_click');
}
_paintTabs() {
const { bodyY, tabH } = this.geo;
for (const t of this.tabs) {
t.g.clear();
const active = t.active;
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;
}
_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);
const hitRect = new Phaser.Geom.Rectangle(mapX, mapY, mapW, mapH);
this.mapImg.setInteractive({
useHandCursor: false,
hitArea: hitRect,
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
this.mapImg.on('pointermove', (p) => this._setHover(p));
this.mapImg.on('pointerout', () => this._setHover(null));
this.mapImg.on('pointerdown', (p) => {
const pt = this._platePoint(p);
const o = this._objectAt(pt);
if (o) {
this.sfx('ui_click');
this._flash = { x: o.x, y: o.y, r: o.r, t0: this.scene.time.now };
this.onSelect?.(o.id);
}
});
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);
// 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', {});
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.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.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 (static)
const legY = statsY + statsH - 16;
this.legendG = s.graphics().setScrollFactor(0).setDepth(2);
this.add(this.legendG);
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;
this._legendGlyph(item.glyph, lx, legY + 4);
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);
lx += 14 + tw + spacing + 12;
}
probe.setVisible(false);
}
/** 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;
this.statusTxt.setText(total > 0 ? `OBJECTS ${total} · CHARTED ${found}` : 'NO OBJECTS LOGGED');
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;
}
}
/** 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._hits = [];
this._hover = null;
this.redraw();
this._paintStats();
this._paintStatusStrip();
this._paintHover();
this.shipMark?.setVisible(true);
}
/** 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._tf = out.tf;
this._bounds = out.bounds;
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);
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);
}
close() {
if (this.openState === 'closed' || this.openState === 'closing') return;
this.openState = 'closing';
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 = 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');
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.isOpen) return;
// 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
if (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 (a discovered object was picked)
if (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 chart — repaint on any real change (discovery, tether…)
if (time >= this._pollNext) {
this._pollNext = time + 500;
const snap = 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…)
}
}
}
}
// ------------------------------------------------------------ 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.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;
// highlight ring + soft glow
g.lineStyle(1.5, C.neon, 0.9);
g.strokeCircle(o.x, o.y, o.r + 5);
g.lineStyle(4, C.neon, 0.18);
g.strokeCircle(o.x, o.y, o.r + 9);
// course line from the ship (if we have a live position)
const sh = this.getShip?.();
if (sh && this._tf) {
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, o.x, o.y));
g.fillStyle(C.amber, 0.7);
g.fillCircle(o.x, o.y, 2);
}
// tooltip above the object (clamped inside the plate)
this.ttName.setText(String(o.label ?? o.id).toUpperCase());
const ttW = Math.max(this.ttName.width, this.ttHint.width) + 24;
let tx = o.x + this.geo.mapX;
let ty = o.y + this.geo.mapY - 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;
if (this._texKey) {
try {
this.scene.textures.remove(this._texKey);
} catch {
/* already gone */
}
}
super.destroy();
}
}