2378 lines
83 KiB
JavaScript
2378 lines
83 KiB
JavaScript
/**
|
||
* 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 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';
|
||
|
||
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 {() => {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 {() => 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;
|
||
// 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: '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 (this.dialog && this.dialog.isOpen) return; // the dialog is the topmost thing
|
||
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;
|
||
}
|
||
|
||
/** 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', {});
|
||
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;
|
||
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;
|
||
}
|
||
}
|
||
|
||
/** 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(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._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);
|
||
}
|
||
|
||
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.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');
|
||
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;
|
||
|
||
// 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…)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------ 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.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._askAutopilot(o); // confirm first — CONFIRM plots the course + closes
|
||
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();
|
||
}
|
||
|
||
/**
|
||
* Autopilot is a commitment — ask before engaging (the save pop-up's
|
||
* ConfirmOverlay, over the plate). CONFIRM fires onSelect (the scene
|
||
* plots the course) and closes the console; 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).
|
||
*/
|
||
_askAutopilot(o) {
|
||
this._hover = null; // the dialog speaks for the pick
|
||
this._paintHover();
|
||
const label = String(o.label ?? o.id).toUpperCase();
|
||
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.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 (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, 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());
|
||
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;
|
||
if (this._texKey) {
|
||
try {
|
||
this.scene.textures.remove(this._texKey);
|
||
} catch {
|
||
/* already gone */
|
||
}
|
||
}
|
||
super.destroy();
|
||
}
|
||
}
|