diff --git a/data/signalCompass.json b/data/signalCompass.json
new file mode 100644
index 0000000..d869f43
--- /dev/null
+++ b/data/signalCompass.json
@@ -0,0 +1,42 @@
+{
+ "_comment": "SIGNAL COMPASS — the secondary compass (js/ui/SignalCompass.js). A faint radius ring (radius px around the ship) that appears after a SCAN: every UNDISCOVERED object in the tether region shows a soft 'radio signal' hugging the ring at its bearing angle (no arrows). Full strength for lifetime.fullMs, then a linear fade over lifetime.fadeMs; a signal drops the instant its object is discovered; re-scanning re-emits the whole set with a fresh clock. The ring + signals are drawn in world space centered on the ship (they track the ship), at the shared compass color by default (objects may carry their own — asteroid clusters' light gray). Everything is procedural (no assets). `enabled: false` hides the whole feature.",
+ "enabled": true,
+ "radius": 128,
+ "lifetime": {
+ "fullMs": 20000,
+ "fadeMs": 5000
+ },
+ "colors": {
+ "signal": "#00e5ff",
+ "glow": "#0090ff",
+ "ring": "#3d4c74"
+ },
+ "signal": {
+ "_comment": "One radio-signal marker on the ring: a soft arc hugging the radius line (arcHalfDeg = half the arc's angle), a diffuse glow under it, a small core dot at its center, and a few faint concentric arcs just OUTSIDE the ring (the 'radio wave' emanating). pulse = the slow beacon breath (hz = cycles/sec, amp = alpha swing, kept small so it reads as a signal, not a strobe).",
+ "arcHalfDeg": 13,
+ "coreWidth": 3,
+ "coreAlpha": 0.7,
+ "glowWidth": 14,
+ "glowAlpha": 0.16,
+ "dotRadius": 3.2,
+ "dotAlpha": 0.95,
+ "ripples": {
+ "count": 3,
+ "spacingPx": 9,
+ "width": 1.5,
+ "alpha": 0.3,
+ "arcHalfDeg": 8
+ },
+ "pulse": {
+ "hz": 1.1,
+ "amp": 0.16
+ }
+ },
+ "ring": {
+ "_comment": "The secondary-compass radius line itself — a very faint full circle. Its alpha scales with the strongest live signal (so the whole instrument fades out with them).",
+ "width": 1,
+ "alpha": 0.18,
+ "glowWidth": 6,
+ "glowAlpha": 0.05
+ }
+}
diff --git a/dev/signal-compass-flow.html b/dev/signal-compass-flow.html
new file mode 100644
index 0000000..8d6fb35
--- /dev/null
+++ b/dev/signal-compass-flow.html
@@ -0,0 +1,29 @@
+
+
+
+
+ Orbit — dev signal-compass flow test
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/signal-compass-flow.mjs b/dev/signal-compass-flow.mjs
new file mode 100644
index 0000000..d238847
--- /dev/null
+++ b/dev/signal-compass-flow.mjs
@@ -0,0 +1,250 @@
+/**
+ * Signal-compass flow (headless browser — NOT a Node test).
+ *
+ * Boots the REAL GameScene (seeded galaxy), presses the command deck's SCAN
+ * button through the real dispatch path, and then exercises the SECONDARY
+ * COMPASS (js/ui/SignalCompass.js) that a completed scan lights up:
+ *
+ * after the sweep completes — the compass emission exists and the ring is
+ * drawing one soft "radio signal" per UNDISCOVERED in-region object at its
+ * bearing, at full strength (the home world, already discovered, is NOT
+ * among them)
+ * fade — age the emission into the fade window ⇒ the signals dim
+ * proportionally (21 s in ⇒ ≈ 80%)
+ * expiry — age it past fullMs+fadeMs ⇒ the signals are GONE and the
+ * emission is cleared
+ * discovery — re-scan, then discover one of the signalled objects ⇒ that
+ * signal drops while the others stay
+ * no console errors were captured
+ *
+ * The compass is a stateless renderer, so we spy on `refresh` to inspect the
+ * (id, position, alpha, color) set the scene computes each frame. The
+ * full→fade envelope itself is covered by the pure Node test
+ * (dev/signal-compass.test.mjs).
+ *
+ * python3 -m http.server 8080
+ * node dev/cdp-firefox.mjs http://localhost:8080/dev/signal-compass-flow.html \
+ * 'window.__SIGNAL_PUMP__(); return window.__SIGNAL__;'
+ */
+import Phaser from '../js/vendor/phaser.js';
+import { config } from '../js/config/Config.js';
+import { ConfigLoader } from '../js/config/ConfigLoader.js';
+import { createGameConfig } from '../js/config/GameConfig.js';
+import { GameScene } from '../js/scenes/GameScene.js';
+
+const data = await ConfigLoader.load();
+config.init(data);
+
+globalThis.__ORBIT_DEV_SEED = 'SCAN';
+const gameConfig = createGameConfig();
+gameConfig.scene = [GameScene];
+if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
+
+const game = new Phaser.Game(gameConfig);
+window.game = game;
+
+// ----------------------------------------------------------------------
+// Results + checks
+// ----------------------------------------------------------------------
+const results = [];
+const check = (label, cond) => {
+ const pass = !!cond;
+ results.push({ label, pass });
+ console.log(`${pass ? '\u2714' : '\u2718 FAIL'} ${label}`);
+};
+
+// ----------------------------------------------------------------------
+// Manual frame pump (the only clock this box has)
+// ----------------------------------------------------------------------
+const pumpErrors = [];
+const pump = (maxSteps = 20, cpuBudgetMs = 700) => {
+ const g = window.game;
+ if (!g || !g.loop) return 0;
+ const t0 = performance.now();
+ let n = 0;
+ while (n < maxSteps) {
+ if (performance.now() - t0 > cpuBudgetMs) break;
+ try {
+ if (g.input && typeof g.input.update === 'function') g.input.update();
+ g.loop.step(performance.now());
+ } catch (e) {
+ pumpErrors.push(`pump: ${String((e && e.message) || e)}`);
+ break;
+ }
+ n++;
+ }
+ return n;
+};
+
+let finished = false;
+const finish = (pass) => {
+ if (finished) return;
+ finished = true;
+ const all = [...results];
+ if (pumpErrors.length) for (const e of pumpErrors) all.push({ label: e, pass: false });
+ const errors = (window.__CAPTURED_ERRORS__ || []).concat(pumpErrors);
+ if (errors.length === 0) all.push({ label: 'no console errors were captured', pass: true });
+ const ok = all.every((r) => r.pass);
+ window.__SIGNAL__ = { pass: ok, results: all, errors };
+ console.log(ok ? 'SIGNAL PASS' : 'SIGNAL FAIL');
+};
+
+const fail = (label) => { check(label, false); finish(false); };
+
+let stage = 0;
+let stageSince = -1;
+let stageSinceMs = 0;
+const wait = (pred, inProgress, cpuBudgetMs = 1200, maxTotalMs = 60000) => {
+ if (stageSince !== stage) { stageSince = stage; stageSinceMs = performance.now(); }
+ const t0 = performance.now();
+ for (;;) {
+ if (pred()) return true;
+ if (!inProgress()) return false;
+ if (performance.now() - stageSinceMs > maxTotalMs) return false;
+ if (performance.now() - t0 > cpuBudgetMs) return null;
+ pump(8, 120);
+ }
+};
+
+const scene = () => game.scene.getScene('GameScene');
+
+// Spy on the compass renderer: record each refresh's signal set.
+let lastSignals = null;
+const installSpy = (s) => {
+ const comp = s.signalCompass;
+ const orig = comp.refresh.bind(comp);
+ comp.refresh = (signals, time, sx, sy) => {
+ lastSignals = Array.isArray(signals) ? signals.map((x) => ({ ...x })) : signals;
+ return orig(signals, time, sx, sy);
+ };
+};
+
+const sysId = () => scene().systemRecord.id;
+const inRegion = (s) => s.discoverableObjects().filter((o) => s.tetherField.contains(o.x, o.y));
+const undiscovered = (s) => inRegion(s).filter((o) => !s.discovery.isDiscovered(s.systemRecord.id, o.id));
+const ids = (list) => (list || []).map((o) => o.id).sort().join(',');
+
+const stepMachine = () => {
+ const s = scene();
+ switch (stage) {
+ // ---- 0) scene booted ------------------------------------------------
+ case 0: {
+ const r = wait(() => s && s.ship && s.scanPulse && s.tetherField && s.signalCompass,
+ () => true, 1200, 30000);
+ if (r === null) return;
+ if (!r) fail('the scene booted (ship + scan + tether + signal compass)');
+ check('the scene booted (ship + scan + tether + signal compass)', true);
+ installSpy(s);
+ // Before any scan, the ring is idle (no signals).
+ check('before a scan, the compass has no signals',
+ Array.isArray(lastSignals) ? lastSignals.length === 0 : true);
+ stage = 1;
+ return;
+ }
+
+ // ---- 1) press SCAN and run the sweep --------------------------------
+ case 1: {
+ s.deckAction('scan');
+ if (!s.scanPulse.busy) fail('the SCAN button arms the sweep');
+ check('the SCAN button arms the sweep', true);
+ const r = wait(() => !s.scanPulse.busy, () => s.scanPulse.started, 1500, 40000);
+ if (r === null) return;
+ if (!r) fail('the sweep ran to completion');
+ check('the sweep ran to completion', true);
+ // Let a couple more frames settle the compass draw.
+ pump(6, 120);
+ stage = 2;
+ return;
+ }
+
+ // ---- 2) after the scan: full-strength signals, discovered excluded ---
+ case 2: {
+ const reveal = s.scanReveal;
+ check('a completed scan created a compass emission', !!reveal && Array.isArray(reveal.ids));
+ const expected = undiscovered(s);
+ check('signals = the undiscovered in-region objects (home world excluded)',
+ ids(lastSignals) === ids(expected) && expected.length >= 1);
+ const sigs = lastSignals || [];
+ check('every signal is at full strength just after the scan',
+ sigs.length >= 1 && sigs.every((x) => x.alpha > 0.999));
+ check('the home world (discovered) has NO signal',
+ !sigs.some((x) => x.id === 'home'));
+ // Each signal sits at its object's bearing (direction from the ship).
+ const objById = new Map(inRegion(s).map((o) => [o.id, o]));
+ const bearingOk = sigs.every((x) => {
+ const o = objById.get(x.id);
+ if (!o) return false;
+ const want = Math.atan2(o.y - s.ship.y, o.x - s.ship.x);
+ const got = Math.atan2(x.y - s.ship.y, x.x - s.ship.x);
+ return Math.abs(want - got) < 1e-9; // same position ⇒ same bearing
+ });
+ check('each signal tracks its object live position', bearingOk);
+ stage = 3;
+ return;
+ }
+
+ // ---- 3) fade — age the emission into the fade window ----------------
+ case 3: {
+ const full = s.signalFullMs, fade = s.signalFadeMs;
+ s.scanReveal.bornAt = s.time.now - (full + Math.round(fade * 0.2)); // 20% into the fade
+ pump(4, 120);
+ const a = (lastSignals || [])[0]?.alpha ?? 0;
+ check('aged into the fade ⇒ signals dim (≈0.8 at 20% in)',
+ Math.abs(a - 0.8) < 0.02);
+ stage = 4;
+ return;
+ }
+
+ // ---- 4) expiry — age it fully out -----------------------------------
+ case 4: {
+ s.scanReveal.bornAt = s.time.now - (s.signalFullMs + s.signalFadeMs + 5); // just past
+ pump(4, 120);
+ check('expired ⇒ the signals are gone', (lastSignals || []).length === 0);
+ check('expired ⇒ the emission is cleared', s.scanReveal === null);
+ stage = 5;
+ return;
+ }
+
+ // ---- 5) discovery drops a signal ------------------------------------
+ case 5: {
+ s.deckAction('scan'); // fresh emission
+ if (!s.scanPulse.busy) fail('a fresh scan re-arms the sweep');
+ const r = wait(() => !s.scanPulse.busy, () => s.scanPulse.started, 1500, 40000);
+ if (r === null) return;
+ pump(4, 120);
+ const before = (lastSignals || []).map((x) => x.id).sort().join(',');
+ if (!before) fail('the fresh scan lit up signals again');
+ check('a fresh scan lit up the signals again', true);
+ // Discover the first signalled object (force it into the discovery set).
+ const victim = (lastSignals || [])[0].id;
+ let set = s.discovery.bySystem.get(sysId());
+ if (!set) { set = new Set(); s.discovery.bySystem.set(sysId(), set); }
+ set.add(victim);
+ pump(4, 120);
+ const after = lastSignals || [];
+ check('discovering a signalled object drops ITS signal',
+ !after.some((x) => x.id === victim));
+ check('the other signals remain',
+ after.map((x) => x.id).sort().join(',') ===
+ before.split(',').filter((id) => id !== victim).sort().join(','));
+ stage = 6;
+ return;
+ }
+
+ // ---- 6) done ----------------------------------------------------------
+ case 6: {
+ finish(results.every((x) => x.pass));
+ return;
+ }
+ }
+};
+
+window.__SIGNAL_PUMP__ = () => {
+ try {
+ stepMachine();
+ } catch (e) {
+ (window.__CAPTURED_ERRORS__ || (window.__CAPTURED_ERRORS__ = [])).push(`stage: ${String((e && e.stack) || e)}`);
+ check(`THREW: ${String((e && e.message) || e)}`, false);
+ finish(false);
+ }
+};
diff --git a/dev/signal-compass.test.mjs b/dev/signal-compass.test.mjs
new file mode 100644
index 0000000..8858bea
--- /dev/null
+++ b/dev/signal-compass.test.mjs
@@ -0,0 +1,147 @@
+/**
+ * Signal-compass test (dev tool, run with Node — no browser needed):
+ *
+ * node dev/signal-compass.test.mjs
+ *
+ * Asserts:
+ * - the SIGNAL ENVELOPE (js/ui/SignalCompass.js → signalAlpha): full
+ * strength for the first `fullMs`, a LINEAR fade to 0 over `fadeMs`,
+ * then gone — including the edges (negative age, the full/fade
+ * boundary, fully expired) and the `fadeMs = 0` step case;
+ * - the BEARING (bearingAngle): a signal sits at the object's direction
+ * from the ship (screen/world y-down), for the four cardinal cases;
+ * - the CONFIG (data/signalCompass.json): sane radius + lifetime, valid
+ * hex colors, positive geometry, a boolean enable gate — and that it is
+ * registered in the manifest (so it actually loads).
+ */
+
+process.env.NODE_ENV = 'dev';
+
+import { pathToFileURL } from 'node:url';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+// --- Load the real config (data/*.json) into the config singleton --------
+const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
+const fs = await import('node:fs');
+const dataDir = join(__dirname, '../data');
+const configData = {};
+for (const f of fs.readdirSync(dataDir)) {
+ if (!f.endsWith('.json') || f === 'manifest.json') continue;
+ configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
+}
+config.init(configData);
+
+// Minimal Phaser stub — enough to import the UI module below.
+globalThis.window = {
+ Phaser: {
+ GameObjects: {
+ Graphics: class {},
+ },
+ BlendModes: { ADD: 'ADD' },
+ Display: { Color: { ValueToColor: () => ({ color: 0x00e5ff }) } },
+ },
+};
+
+const { signalAlpha, bearingAngle, SignalCompass } = await import(
+ pathToFileURL(join(__dirname, '../js/ui/SignalCompass.js')).href
+);
+
+let pass = 0;
+function check(name, cond) {
+ if (!cond) {
+ console.error(`✗ ${name}`);
+ process.exit(1);
+ }
+ pass++;
+ console.log(`✓ ${name}`);
+}
+
+const TAU = Math.PI * 2;
+const FULL = 20000;
+const FADE = 5000;
+
+// ----------------------------------------------------------------------
+// 1. Signal envelope — full → linear fade → gone
+// ----------------------------------------------------------------------
+check('signalAlpha: before full ⇒ 1', signalAlpha(0, FULL, FADE) === 1 && signalAlpha(19999, FULL, FADE) === 1);
+check('signalAlpha: exactly at full ⇒ 1', signalAlpha(FULL, FULL, FADE) === 1);
+check('signalAlpha: mid-fade ⇒ linear (½ way ⇒ ½)', Math.abs(signalAlpha(FULL + FADE / 2, FULL, FADE) - 0.5) < 1e-9);
+check('signalAlpha: a known fade point (¼ into the fade ⇒ ¾)', Math.abs(signalAlpha(FULL + FADE * 0.25, FULL, FADE) - 0.75) < 1e-9);
+check('signalAlpha: fully expired ⇒ 0', signalAlpha(FULL + FADE, FULL, FADE) === 0);
+check('signalAlpha: past expiry stays 0', signalAlpha(FULL + FADE + 5000, FULL, FADE) === 0);
+check('signalAlpha: negative age clamps to full', signalAlpha(-500, FULL, FADE) === 1);
+check('signalAlpha: monotonic non-increasing across the life', (() => {
+ let prev = 2;
+ for (let t = -100; t <= FULL + FADE + 100; t += 500) {
+ const a = signalAlpha(t, FULL, FADE);
+ if (a > prev + 1e-9) return false;
+ prev = a;
+ }
+ return true;
+})());
+check('signalAlpha: fadeMs=0 is a hard cutoff', signalAlpha(999, 1000, 0) === 1 && signalAlpha(1000, 1000, 0) === 0 && signalAlpha(1999, 1000, 0) === 0);
+check('signalAlpha: stays in [0,1]', (() => {
+ for (let t = -1000; t <= 40000; t += 700) {
+ const a = signalAlpha(t, FULL, FADE);
+ if (a < 0 || a > 1) return false;
+ }
+ return true;
+})());
+
+// ----------------------------------------------------------------------
+// 2. Bearing — the signal sits at the object's direction from the ship
+// ----------------------------------------------------------------------
+const EPS = 1e-9;
+check('bearingAngle: object to the +x ⇒ 0', Math.abs(bearingAngle(0, 0, 100, 0)) < EPS);
+check('bearingAngle: object below (+y, y-down) ⇒ +π/2', Math.abs(bearingAngle(0, 0, 0, 100) - Math.PI / 2) < EPS);
+check('bearingAngle: object above (−y) ⇒ −π/2', Math.abs(bearingAngle(0, 0, 0, -100) + Math.PI / 2) < EPS);
+check('bearingAngle: object to the −x ⇒ ±π', Math.abs(Math.abs(bearingAngle(0, 0, -100, 0)) - Math.PI) < EPS);
+check('bearingAngle: diagonal (1,1) ⇒ π/4', Math.abs(bearingAngle(0, 0, 100, 100) - Math.PI / 4) < EPS);
+check('bearingAngle: relative to a non-origin ship', (() => {
+ // ship at (10,10), object at (110,10) → due +x from the ship ⇒ 0
+ return Math.abs(bearingAngle(10, 10, 110, 10)) < EPS;
+})());
+check('bearingAngle: both sides of the −x axis land on ±π (same line)', (() => {
+ const a = bearingAngle(0, 0, -100, 0.001); // just below the −x axis (y-down)
+ const b = bearingAngle(0, 0, -100, -0.001); // just above it
+ const nearPi = (v) => Math.abs(Math.abs(v) - Math.PI) < 0.02;
+ return nearPi(a) && nearPi(b);
+})());
+
+// ----------------------------------------------------------------------
+// 3. Config — sane, valid, and actually registered
+// ----------------------------------------------------------------------
+const sc = config.section('signalCompass', {});
+check('config: enabled is a boolean', typeof sc.enabled === 'boolean');
+check('config: radius is a sensible px count', Number.isFinite(sc.radius) && sc.radius >= 40 && sc.radius <= 480);
+check('config: lifetime.fullMs > 0', Number.isFinite(sc.lifetime?.fullMs) && sc.lifetime.fullMs > 0);
+check('config: lifetime.fadeMs >= 0', Number.isFinite(sc.lifetime?.fadeMs) && sc.lifetime.fadeMs >= 0);
+const hex = /^#[0-9a-fA-F]{6}$/;
+check('config: colors are valid hex', ['signal', 'glow', 'ring'].every((k) => hex.test(sc.colors?.[k] ?? '')));
+const sig = sc.signal ?? {};
+check('config: signal arc + dot are positive', (sig.arcHalfDeg ?? 0) > 0 && (sig.coreWidth ?? 0) > 0 && (sig.dotRadius ?? 0) > 0);
+check('config: signal alphas in [0,1]', [sig.coreAlpha, sig.glowAlpha, sig.dotAlpha].every((a) => a >= 0 && a <= 1));
+const rp = sig.ripples ?? {};
+check('config: ripples sane', (rp.count ?? 0) >= 0 && (rp.spacingPx ?? 0) > 0 && rp.alpha >= 0 && rp.alpha <= 1);
+const pu = sig.pulse ?? {};
+check('config: pulse gentle (amp < 0.5 so it reads as a signal, not a strobe)', pu.amp >= 0 && pu.amp < 0.5 && pu.hz > 0 && pu.hz < 8);
+const rg = sc.ring ?? {};
+check('config: ring alphas in [0,1]', rg.alpha >= 0 && rg.alpha <= 1 && rg.glowAlpha >= 0 && rg.glowAlpha <= 1);
+check('config: the 20 s full + 5 s fade defaults match the brief', sc.lifetime.fullMs === 20000 && sc.lifetime.fadeMs === 5000);
+
+// Registered in the manifest (so it loads) + the class exposes its API.
+const manifest = JSON.parse(fs.readFileSync(join(dataDir, 'manifest.json'), 'utf8'));
+check('manifest: signalCompass.json is listed', Array.isArray(manifest.files) && manifest.files.includes('signalCompass.json'));
+check('api: SignalCompass has refresh + destroy', typeof SignalCompass === 'function' &&
+ typeof SignalCompass.prototype.refresh === 'function' && typeof SignalCompass.prototype.destroy === 'function');
+
+// A constructed instance picks up the config (radius from data/signalCompass.json).
+const fakeScene = { add: { graphics: () => ({ setDepth: () => ({ setBlendMode: () => ({ clear() {}, destroy() {} }) }) }) } };
+const inst = new SignalCompass(fakeScene);
+check('instance: radius came from config (128)', inst.radius === 128);
+check('instance: enabled gate read', inst.enabled === true);
+
+console.log(`\nsignal-compass: ${pass} checks passed`);
diff --git a/js/ui/SignalCompass.js b/js/ui/SignalCompass.js
new file mode 100644
index 0000000..69e5fed
--- /dev/null
+++ b/js/ui/SignalCompass.js
@@ -0,0 +1,202 @@
+import Phaser from '../vendor/phaser.js';
+import { config } from '../config/Config.js';
+import { toColor } from '../utils/Color.js';
+
+const TAU = Math.PI * 2;
+const DEG = Math.PI / 180;
+
+/**
+ * SIGNAL COMPASS — the secondary compass (config: data/signalCompass.json).
+ *
+ * A faint radius ring (radius px) drawn AROUND THE SHIP. After a SCAN, every
+ * UNDISCOVERED object in the tether region shows a soft "radio signal" hugging
+ * that ring, at the object's bearing (its direction from the ship) — no
+ * arrows, just a diffuse signal: a soft arc on the line + a core dot + a few
+ * faint concentric "radio wave" arcs just outside it, breathing slowly.
+ *
+ * The signals are a BEARING indicator: they are recomputed from the ship's
+ * LIVE position every frame, so as the player flies the signals rotate to
+ * keep pointing at their objects. Each is full strength for lifetime.fullMs,
+ * then fades linearly over lifetime.fadeMs; a signal drops the instant its
+ * object is discovered; a new scan re-emits the whole set with a fresh clock.
+ *
+ * The SCENE drives this (it owns the game rules — which objects are revealed,
+ * their live positions, discovery, and the age/alpha clock); this class is a
+ * stateless renderer: `refresh(signals, time, shipX, shipY)` clears + redraws.
+ * It is drawn in WORLD space centered on the ship, so it tracks the ship and
+ * stays on it while the camera follows (depths 14/15 — above the scan pulse
+ * 12/13, below the HUD dossier 30 / compass arrows 40).
+ *
+ * Pure + exported for Node testing (dev/signal-compass.test.mjs):
+ * signalAlpha(ageMs, fullMs, fadeMs) — the full→fade→gone envelope
+ * bearingAngle(sx, sy, ox, oy) — the ship→object direction (y-down)
+ */
+
+/**
+ * The signal strength envelope: 1.0 for the first `fullMs`, then a linear
+ * fade to 0 over the next `fadeMs`, then gone (0). Pure (no Phaser).
+ *
+ * @returns {number} alpha in [0, 1]
+ */
+export function signalAlpha(ageMs, fullMs, fadeMs) {
+ if (!Number.isFinite(ageMs) || ageMs < 0) ageMs = 0;
+ fullMs = Math.max(0, fullMs);
+ if (fadeMs <= 0) return ageMs < fullMs ? 1 : 0;
+ if (ageMs < fullMs) return 1;
+ const t = (ageMs - fullMs) / fadeMs;
+ if (t >= 1) return 0;
+ return 1 - t;
+}
+
+/** Direction from (sx, sy) to (ox, oy), radians, screen/world y-down. */
+export function bearingAngle(sx, sy, ox, oy) {
+ return Math.atan2(oy - sy, ox - sx);
+}
+
+export class SignalCompass {
+ constructor(scene) {
+ this.scene = scene;
+ const c = config.section('signalCompass', {});
+ this.enabled = c.enabled !== false;
+ this.radius = Math.max(24, c.radius ?? 128);
+
+ const col = c.colors ?? {};
+ this.cSignal = toColor(col.signal, 0x00e5ff);
+ this.cGlow = toColor(col.glow, 0x0090ff);
+ this.cRing = toColor(col.ring, 0x3d4c74);
+
+ const s = c.signal ?? {};
+ this.arcHalf = (s.arcHalfDeg ?? 13) * DEG;
+ this.coreWidth = s.coreWidth ?? 3;
+ this.coreAlpha = s.coreAlpha ?? 0.7;
+ this.glowWidth = s.glowWidth ?? 14;
+ this.glowAlpha = s.glowAlpha ?? 0.16;
+ this.dotRadius = s.dotRadius ?? 3.2;
+ this.dotAlpha = s.dotAlpha ?? 0.95;
+
+ const rp = s.ripples ?? {};
+ this.rippleCount = rp.count ?? 3;
+ this.rippleSpacing = rp.spacingPx ?? 9;
+ this.rippleWidth = rp.width ?? 1.5;
+ this.rippleAlpha = rp.alpha ?? 0.3;
+ this.rippleHalf = (rp.arcHalfDeg ?? 8) * DEG;
+
+ const pu = s.pulse ?? {};
+ this.pulseHz = pu.hz ?? 1.1;
+ this.pulseAmp = pu.amp ?? 0.16;
+
+ const rg = c.ring ?? {};
+ this.ringWidth = rg.width ?? 1;
+ this.ringAlpha = rg.alpha ?? 0.18;
+ this.ringGlowWidth = rg.glowWidth ?? 6;
+ this.ringGlowAlpha = rg.glowAlpha ?? 0.05;
+
+ // Two persistent layers, cleared + redrawn each frame (the ScanPulse
+ // idiom): a soft ADDITIVE glow UNDER, crisp lines OVER.
+ this.depth = 14;
+ this.gUnder = scene.add.graphics().setDepth(this.depth).setBlendMode(Phaser.BlendModes.ADD);
+ this.gOver = scene.add.graphics().setDepth(this.depth + 1);
+ }
+
+ /**
+ * Draw the ring + one soft radio-signal per entry. Call once per frame.
+ *
+ * @param {Array<{id: string, x: number, y: number, alpha: number, color?: (number|string)}>} signals
+ * live (world) positions + a pre-computed alpha (the scene's clock);
+ * `color` optionally overrides the shared signal color (clusters).
+ * @param {number} time scene clock (ms) — drives the beacon pulse
+ * @param {number} shipX ship world x (the ring's center + bearing origin)
+ * @param {number} shipY ship world y
+ */
+ refresh(signals, time, shipX, shipY) {
+ const U = this.gUnder, O = this.gOver;
+ U.clear(); O.clear();
+ if (!this.enabled || !signals || signals.length === 0) return;
+
+ const R = this.radius;
+ // The baseline radius line: present while any signal lives, scaled with
+ // the strongest one so the whole instrument fades out together.
+ let aMax = 0;
+ for (const s of signals) aMax = Math.max(aMax, s.alpha);
+ if (aMax > 0) {
+ U.lineStyle(this.ringGlowWidth, this.cRing, this.ringGlowAlpha * aMax);
+ U.strokeCircle(shipX, shipY, R);
+ O.lineStyle(this.ringWidth, this.cRing, this.ringAlpha * aMax);
+ O.strokeCircle(shipX, shipY, R);
+ }
+
+ for (const s of signals) {
+ if (s.alpha <= 0) continue;
+ const ang = bearingAngle(shipX, shipY, s.x, s.y);
+ this.drawSignal(U, O, shipX, shipY, R, ang, s.alpha, s.color, time, phaseOf(s.id));
+ }
+ }
+
+ /**
+ * One radio signal at bearing `ang` on ring radius `R`, strength `a`
+ * (0..1). A diffuse glow arc + a crisp core arc HUG the radius line; a
+ * core dot sits at its center; faint concentric arcs just outside read as
+ * the radio wave emanating. A slow per-signal pulse (phased by id so a
+ * fan of signals doesn't breathe in unison) gives it life.
+ */
+ drawSignal(U, O, cx, cy, R, ang, a, color, time, phase) {
+ const pulse = 1 + this.pulseAmp * Math.sin(time * 0.001 * TAU * this.pulseHz + phase);
+ const A = a * pulse;
+ const sig = color !== undefined ? toColor(color) : this.cSignal;
+ const half = this.arcHalf;
+
+ // Diffuse halo under (additive) + the crisp core, both on the radius line.
+ U.lineStyle(this.glowWidth, this.cGlow, this.glowAlpha * A);
+ this.arc(U, cx, cy, R, ang - half, ang + half);
+ O.lineStyle(this.coreWidth, sig, this.coreAlpha * A);
+ this.arc(O, cx, cy, R, ang - half, ang + half);
+
+ // The "radio wave": a few faint concentric arcs just OUTSIDE the line,
+ // each a little narrower + dimmer the further out (a fading emission).
+ for (let i = 1; i <= this.rippleCount; i++) {
+ const rr = R + i * this.rippleSpacing;
+ const rh = this.rippleHalf * (1 - i * 0.12);
+ const ra = this.rippleAlpha * A * (1 - i / (this.rippleCount + 1));
+ if (ra <= 0.004) continue;
+ U.lineStyle(this.rippleWidth, this.cGlow, ra);
+ this.arc(U, cx, cy, rr, ang - rh, ang + rh);
+ }
+
+ // The transmitter point: a small soft dot at the signal's center on the line.
+ const dx = cx + Math.cos(ang) * R;
+ const dy = cy + Math.sin(ang) * R;
+ U.fillStyle(this.cGlow, 0.4 * A);
+ U.fillCircle(dx, dy, this.dotRadius + 3);
+ O.fillStyle(sig, this.dotAlpha * A);
+ O.fillCircle(dx, dy, this.dotRadius);
+ }
+
+ /** An arc, or a full circle when the window spans the whole thing. */
+ arc(g, x, y, rad, a0, a1) {
+ if (a1 - a0 >= TAU - 1e-6) {
+ g.strokeCircle(x, y, rad);
+ return;
+ }
+ g.beginPath();
+ g.arc(x, y, rad, a0, a1);
+ g.strokePath();
+ }
+
+ destroy() {
+ this.gUnder.clear();
+ this.gOver.clear();
+ this.gUnder.destroy();
+ this.gOver.destroy();
+ }
+}
+
+/**
+ * A stable pseudo-phase in [0, 2π) per signal id — staggers the beacon pulse
+ * so a cluster of signals breathes out of step (same trick as the compass).
+ */
+function phaseOf(id) {
+ let h = 0;
+ const s = String(id ?? '');
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 997;
+ return (h / 997) * TAU;
+}