Add signal compass UI for scan-revealed undiscovered objects

- New js/ui/SignalCompass.js renders a faint ring around the ship with soft radio-signal markers at each undiscovered in-region object's bearing, with full→linear-fade envelope and per-id pulse phase
- Signals track live ship position each frame, drop on discovery, and re-emit on rescan; scene drives state, renderer is stateless
- data/signalCompass.json configures radius, lifetime (20s full + 5s fade), colors, signal/ripple/pulse/ring geometry with enabled gate
- dev/signal-compass.test.mjs unit-tests the pure signalAlpha envelope, bearingAngle math, and config sanity via Node
- dev/signal-compass-flow.{html,mjs} headless browser flow test boots real GameScene, presses SCAN, and verifies emission, fade, expiry, and discovery-drop behavior
This commit is contained in:
Brian Fertig 2026-09-05 00:29:49 -06:00
parent d0bd840dbc
commit cf999a14fa
5 changed files with 670 additions and 0 deletions

42
data/signalCompass.json Normal file
View File

@ -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
}
}

View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev signal-compass flow test</title>
<!-- This page lives in /dev, but the game's relative asset paths are rooted
at the project root — resolve them against it. -->
<base href="../" />
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script>
window.__CAPTURED_ERRORS__ = [];
window.__SCAN_LOGS__ = [];
const __oe = console.error.bind(console);
const __oi = console.info.bind(console);
console.error = (...a) => { window.__CAPTURED_ERRORS__.push(a.map(String).join(' ').slice(0, 600)); __oe(...a); };
console.info = (...a) => { window.__SCAN_LOGS__.push(a.map(String).join(' ').slice(0, 300)); __oi(...a); };
window.addEventListener('error', (e) => window.__CAPTURED_ERRORS__.push('window: ' + e.message + ' @ ' + (e.filename||'') + ':' + (e.lineno||'')));
window.addEventListener('unhandledrejection', (e) => window.__CAPTURED_ERRORS__.push('rejection: ' + String(e.reason && e.reason.stack || e.reason)));
</script>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/signal-compass-flow.mjs"></script>
</body>
</html>

250
dev/signal-compass-flow.mjs Normal file
View File

@ -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
* fullfade 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);
}
};

147
dev/signal-compass.test.mjs Normal file
View File

@ -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`);

202
js/ui/SignalCompass.js Normal file
View File

@ -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 fullfadegone envelope
* bearingAngle(sx, sy, ox, oy) the shipobject 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;
}