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