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; +}