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:
parent
d0bd840dbc
commit
cf999a14fa
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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`);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue