Compare commits

..

No commits in common. "cf999a14fa28c9e9f55180e180a6aa108d515e08" and "cd4d751edd7026cd2e7df20ca2dc769438f095cf" have entirely different histories.

21 changed files with 10 additions and 1900 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

View File

@ -13,7 +13,7 @@
}, },
"buttons": [ "buttons": [
{ "id": "research", "label": "Research", "accent": "#00e5ff" }, { "id": "research", "label": "Research", "accent": "#00e5ff" },
{ "id": "scan", "label": "Scan", "accent": "#ffc94d" }, { "id": "build", "label": "Build", "accent": "#ffc94d" },
{ "id": "ship", "label": "Ship", "accent": "#7ce8a4" }, { "id": "ship", "label": "Ship", "accent": "#7ce8a4" },
{ "id": null, "label": null }, { "id": null, "label": null },
{ "id": null, "label": null }, { "id": null, "label": null },

View File

@ -18,8 +18,6 @@
"actionbar.json", "actionbar.json",
"landing.json", "landing.json",
"sfx.json", "sfx.json",
"scan.json",
"signalCompass.json",
"save.json" "save.json"
] ]
} }

View File

@ -1,71 +0,0 @@
{
"_comment": "DEEP SCAN — the command deck's SCAN button (js/scan/ScanPulse.js): the ship emits an omnidirectional sonar pulse that expands across the TETHER REGION and is absorbed at the union boundary (it never flies into empty space). Objects inside the region ring as the front crosses them (shock rings + RGB ghost + a scale pulse); the starfield ripples as the wave passes (radial nudge + flash); the camera thumps on emission and rolls/breathes while the wave crosses the view; the barrier shivers where the wave is swallowed (TetherField.excite). All procedural — no assets; the one SFX is the 'scan' key in data/sfx.json. `enabled` gates the button's behavior (false = no-op, console note).",
"enabled": true,
"chargeMs": 240,
"durationMs": 2100,
"minRadius": 26,
"fallbackRadius": 1600,
"colors": {
"front": "#00e5ff",
"core": "#eaf6ff",
"glow": "#0090ff",
"hit": "#8ff2ff",
"ghostCyan": "#00e5ff",
"ghostMagenta": "#ff2d6f",
"absorb": "#9ff3ff"
},
"front": {
"coreWidth": 2.6,
"coreAlpha": 0.95,
"mainWidth": 8,
"mainAlpha": 0.5,
"glowWidth": 34,
"glowAlpha": 0.14
},
"tail": {
"steps": 4,
"spacing": 110,
"width": 2.2,
"alpha": 0.1
},
"echo": {
"count": 3,
"spacing": 210,
"width": 1.4,
"alpha": 0.2
},
"ticks": {
"count": 96,
"length": 13,
"alpha": 0.55,
"dropChance": 0.3
},
"ripple": {
"objectScale": 0.045,
"width": 2.4,
"glowWidth": 14,
"glowAlpha": 0.16,
"ghostOffset": 5,
"durationMs": 680,
"growth": 1.5
},
"stars": {
"amplitudePx": 16,
"sigmaPx": 160,
"flash": 0.5
},
"camera": {
"thumpMs": 150,
"thumpIntensity": 0.005,
"rollDeg": 0.55,
"zoomPulse": 0.02,
"decayMs": 850,
"hz": 1.5
},
"absorb": {
"width": 3,
"alpha": 0.8,
"glowWidth": 20,
"glowAlpha": 0.22
}
}

View File

@ -1,10 +1,9 @@
{ {
"_comment": "Sound effects. enabled = master switch (also skips the load). volume is 0..1. construct = the dossier typing in; deconstruct = the dossier deconstructing; discovery = a new object coming into view; mining = the mining arm powering up (the 'Extending Mining Arm...' reach); scan = the DEEP SCAN pulse going out (the command deck's SCAN button).", "_comment": "Sound effects. enabled = master switch (also skips the load). volume is 0..1. construct = the dossier typing in; deconstruct = the dossier deconstructing; discovery = a new object coming into view; mining = the mining arm powering up (the 'Extending Mining Arm...' reach).",
"enabled": true, "enabled": true,
"volume": 0.55, "volume": 0.55,
"construct": "assets/fx/type-construct.mp3", "construct": "assets/fx/type-construct.mp3",
"deconstruct": "assets/fx/type-deconstruct.mp3", "deconstruct": "assets/fx/type-deconstruct.mp3",
"discovery": "assets/fx/discovery.mp3", "discovery": "assets/fx/discovery.mp3",
"mining": "assets/fx/system-scan.mp3", "mining": "assets/fx/system-scan.mp3"
"scan": "assets/fx/system-scan.mp3"
} }

View File

@ -1,42 +0,0 @@
{
"_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

@ -77,8 +77,8 @@ check('builds._template.requires is an array', Array.isArray(bt.requires));
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
const slots = actionbar.buttons ?? []; const slots = actionbar.buttons ?? [];
check('actionbar: exactly six slots', slots.length === 6); check('actionbar: exactly six slots', slots.length === 6);
check('actionbar: slot ids in order (Research, Scan, Ship, ·, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', null, null, 'menu'])); check('actionbar: slot ids in order (Research, Build, Ship, ·, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'build', 'ship', null, null, 'menu']));
check('actionbar: labels (Research / Scan / Ship / · / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', null, null, 'Menu'])); check('actionbar: labels (Research / Build / Ship / · / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Build', 'Ship', null, null, 'Menu']));
const hex = /^#[0-9a-fA-F]{6}$/; const hex = /^#[0-9a-fA-F]{6}$/;
check('actionbar: live slots carry hex accents', slots.filter((s) => s.id).every((s) => hex.test(s.accent ?? ''))); check('actionbar: live slots carry hex accents', slots.filter((s) => s.id).every((s) => hex.test(s.accent ?? '')));
check('actionbar: reserved slots stay null', slots.filter((s) => s.id === null).every((s) => s.label === null)); check('actionbar: reserved slots stay null', slots.filter((s) => s.id === null).every((s) => s.label === null));

View File

@ -1,30 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev deep-scan 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. ("../" keeps this
working even if the project is served from a subdirectory.) -->
<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/scan-test.mjs"></script>
</body>
</html>

View File

@ -1,281 +0,0 @@
/**
* Deep scan 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 (deckAction('scan')), and
* walks the whole pulse:
*
* deckAction('scan') the sweep arms (busy), the TARGET SET is the
* discoverable objects inside the tether union, a SECOND press while
* busy does not re-arm it
* the charge ends the front is out: the radius GROWS MONOTONICALLY,
* the camera rolls/breathes with the wave (zoom/rotation off rest),
* the barrier is EXCITED when the front touches a tether rim
* the sweep completes every in-region object was HIT (the front
* reaches the union boundary, so dist maxR for all of them), the
* camera is RESTORED (zoom 1, rotation 0), the console says
* "SCAN COMPLETE", and the object list is logged (the result seam)
* a SECOND scan runs clean (state resets between sweeps)
* no console errors were captured
*
* The pulse's frame-stepping is driven by the runner's pump
* (window.__SCAN_PUMP__ steps one game frame + advances the stage
* machine) same headless pump as dev/mining-test.mjs. Results land in
* `window.__SCAN__`.
*
* python3 -m http.server 8080
* node dev/cdp-firefox.mjs http://localhost:8080/dev/scan-test.html \
* 'window.__SCAN_PUMP__(); return window.__SCAN__;'
*/
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);
// A deterministic galaxy (same clusters every run) + quiet audio in headless.
globalThis.__ORBIT_DEV_SEED = 'SCAN';
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene]; // GameScene boots first
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.__SCAN__ = { pass: ok, results: all, errors, dbg: window.__DBG__STATE__ || null };
console.log(ok ? 'SCAN PASS' : 'SCAN FAIL');
};
const fail = (label) => {
check(label, false);
finish(false);
};
/** Resumable wait across runner wakes (see mining-test.mjs). */
let stage = 0;
let stageSince = -1;
let stageSinceMs = 0;
const wait = (pred, inProgress, note, cpuBudgetMs = 1200, maxTotalMs = 60000) => {
if (stageSince !== stage) { stageSince = stage; stageSinceMs = performance.now(); }
const t0 = performance.now();
for (;;) {
if (pred()) {
if (note) note();
return true;
}
if (!inProgress()) return false;
if (performance.now() - stageSinceMs > maxTotalMs) return false;
if (performance.now() - t0 > cpuBudgetMs) return null;
pump(8, 120);
}
};
// ----------------------------------------------------------------------
// The stage machine
// ----------------------------------------------------------------------
let sweep1 = null; // { total, hits, excites, sawCameraWobble, radiusSamples }
let exciteSpy = 0;
let sawCompleteToast = false;
let sawEmitToast = false;
const scene = () => game.scene.getScene('GameScene');
const stepMachine = () => {
const s = scene();
switch (stage) {
// ---- 0) scene booted ------------------------------------------------
case 0: {
const r = wait(() => s && s.ship && s.scanPulse && s.tetherField,
() => true, null, 1200, 30000);
if (r === null) return;
if (!r) fail('the scene booted (ship + scan pulse + tether field)');
check('the scene booted (ship + scan pulse + tether field)', true);
// Spy on the barrier excitation (the scan → TetherField seam).
const tf = s.tetherField;
const origExcite = tf.excite.bind(tf);
tf.excite = (i) => { exciteSpy++; return origExcite(i); };
const objs = s.discoverableObjects();
const inRegion = objs.filter((o) => s.tetherField.contains(o.x, o.y));
check('the tether region holds at least one discoverable object', inRegion.length >= 1);
check('the home world is inside its tether region',
inRegion.some((o) => o.id === 'home'));
stage = 1;
return;
}
// ---- 1) press SCAN — the sweep arms ----------------------------------
case 1: {
s.deckAction('scan'); // the real dispatch path (ActionBar → deckAction)
const armed = s.scanPulse.started && s.scanPulse.busy;
if (!armed) fail('the SCAN button arms the sweep');
if (s.scanObjects === null) fail('the sweep built its target set');
check('the SCAN button arms the sweep', true);
check('the sweep built its target set (objects in the tether region)', true);
const total = s.scanObjects.length;
check('the target set matches the in-region discoverables',
total >= 1 && total === s.discoverableObjects().filter((o) => s.tetherField.contains(o.x, o.y)).length);
check('the home world is in the target set', s.scanObjects.some((o) => o.id === 'home'));
const t0front = s.scanPulse.frontT0;
s.deckAction('scan'); // a second press while busy — must be ignored
check('a second SCAN press while busy does not re-arm the sweep',
s.scanPulse.frontT0 === t0front);
sweep1 = {
total,
hits: 0,
excites: 0,
sawCameraWobble: false,
radiusSamples: [],
};
stage = 2;
return;
}
// ---- 2) the sweep is out — watch it run ------------------------------
case 2: {
const done = wait(
() => !s.scanPulse.busy,
() => {
// While the front is out, sample it (growth + camera + excite).
if (sweep1 && !sweep1.sealed) {
const r = s.scanPulse.radius;
if (r > 0) {
if (sweep1.radiusSamples.length === 0 || r > sweep1.radiusSamples[sweep1.radiusSamples.length - 1]) {
sweep1.radiusSamples.push(r);
}
const cam = s.cameras.main;
if (Math.abs(cam.rotation) > 1e-5 || Math.abs(cam.zoom - 1) > 1e-4) {
sweep1.sawCameraWobble = true;
}
sweep1.hits = s.scanObjects.filter((o) => o.hit).length;
}
}
return s.scanPulse.started; // the sweep was armed (never un-armed early)
},
null,
1500,
40000,
);
if (done === null) return;
if (!done) fail('the sweep ran to completion');
check('the sweep ran to completion', true);
// The radius must have grown (front emitted and expanded).
check('the front radius grew during the sweep',
sweep1.radiusSamples.length >= 2 && sweep1.radiusSamples[sweep1.radiusSamples.length - 1] > sweep1.radiusSamples[0]);
// The camera wobbled with the wave.
check('the camera rolled/breathed while the wave passed', sweep1.sawCameraWobble);
// Every in-region object was hit (dist ≤ maxR for all of them).
check(`every in-region object was hit (${sweep1.hits}/${sweep1.total})`,
sweep1.hits === sweep1.total && sweep1.total >= 1);
// The barrier was excited where the wave was absorbed.
sweep1.excites = exciteSpy;
check('the barrier was excited as the wave was absorbed', exciteSpy >= 1);
// The result seam fired (finishScan logged the object list).
const logged = (window.__SCAN_LOGS__ || []).some((l) => l.includes('scan complete'));
check('the scan-complete seam logged the in-region objects', logged);
// The camera was restored.
const cam = s.cameras.main;
check('the camera zoom was restored', Math.abs(cam.zoom - 1) < 0.002);
check('the camera rotation was restored', Math.abs(cam.rotation) < 1e-3);
// The console said SCAN COMPLETE.
const toast = s.consoleToastG && s.consoleToastG[1] ? s.consoleToastG[1].text : '';
check('the console reported the sweep (SCAN COMPLETE)', /SCAN COMPLETE/.test(toast));
sawCompleteToast = true;
// The sweep state reset — a second scan must be armable.
check('the sweep state reset (busy=false after completion)', !s.scanPulse.busy);
check('the target set was released', s.scanObjects === null);
stage = 3;
return;
}
// ---- 3) a second scan runs clean --------------------------------------
case 3: {
s.deckAction('scan');
if (!s.scanPulse.busy) fail('a second SCAN press arms a fresh sweep');
check('a second SCAN press arms a fresh sweep', true);
const r = wait(() => !s.scanPulse.busy, () => true, null, 1500, 40000);
if (r === null) return;
if (!r) fail('the second sweep ran to completion');
check('the second sweep ran to completion', true);
check('the second sweep hit every in-region object again',
(window.__SCAN_LOGS__ || []).filter((l) => l.includes('scan complete')).length >= 2);
const cam = s.cameras.main;
check('the camera was restored after the second sweep',
Math.abs(cam.zoom - 1) < 0.002 && Math.abs(cam.rotation) < 1e-3);
stage = 4;
return;
}
// ---- 4) done -----------------------------------------------------------
case 4: {
finish(results.every((x) => x.pass));
return;
}
}
};
window.__SCAN_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);
}
};

View File

@ -1,89 +0,0 @@
// Geometry unit tests for the DEEP SCAN pulse (js/scan/ScanGeometry.js).
//
// node dev/scan.test.mjs
//
// Verifies the circle-circle window math (arcInCircle) against brute-force
// angle sampling, plus the degenerate cases: concentric circles, full
// containment, no overlap, near-tangent, and the contact/far radii the
// sweep's timeline is built from.
import assert from 'node:assert';
import { arcInCircle, contactRadius, farRadius } from '../js/scan/ScanGeometry.js';
let passed = 0;
const check = (label, cond) => {
assert.ok(cond, 'FAIL: ' + label);
passed++;
console.log(` ok - ${label}`);
};
/** Brute force: the fraction of circle `a`'s circumference that lies inside
* circle `b` (`a` centered at the origin, `b` centered at (d, 0)). */
const fracInside = (a, b, d, N = 7200) => {
let hits = 0;
for (let i = 0; i < N; i++) {
const th = (i / N) * Math.PI * 2;
const px = a * Math.cos(th);
const py = a * Math.sin(th);
if (Math.hypot(px - d, py) <= b + 1e-9) hits++;
}
return hits / N;
};
// 1) Partial overlap — the predicted window fraction must match the
// measured fraction (the window is centered on a-center → b-center).
{
const a = 100, b = 60, d = 100;
const half = arcInCircle(a, b, d);
const measured = fracInside(a, b, d);
const predicted = (2 * half) / (2 * Math.PI);
check('partial overlap: half-angle in (0, pi)', half > 0 && half < Math.PI);
check(`window fraction ${predicted.toFixed(3)} ~ sampled ${measured.toFixed(3)}`,
Math.abs(predicted - measured) < 0.02);
}
// 2) The window's BOUNDARY points sit exactly on circle b's rim.
{
const a = 100, b = 60, d = 100;
const half = arcInCircle(a, b, d);
for (const sgn of [1, -1]) {
const px = a * Math.cos(sgn * half);
const py = a * Math.sin(sgn * half);
check(`boundary point (sign ${sgn}) lies on circle b's rim`,
Math.abs(Math.hypot(px - d, py) - b) < 1e-6);
}
}
// 3) Concentric: the whole of `a` inside / outside `b`.
check('concentric: a < b → whole circle', arcInCircle(50, 60, 0) === Math.PI);
check('concentric: a > b → none', arcInCircle(70, 60, 0) === -1);
check('concentric: a = b → whole circle', arcInCircle(60, 60, 0) === Math.PI);
// 4) Full containment (offset): d + a ≤ b → whole circle.
check('offset containment (d + a < b) → whole circle', arcInCircle(20, 60, 35) === Math.PI);
check('just contained (d + a = b) → whole circle', arcInCircle(20, 60, 40) === Math.PI);
// 5) No overlap.
check('far apart → none', arcInCircle(100, 60, 300) === -1);
check('b inside a hole (a > b, close) → none', arcInCircle(120, 60, 20) === -1);
check('zero radius → none', arcInCircle(0, 60, 10) === -1);
// 6) Near-tangent: the window shrinks to ~0 as the circles nearly miss.
{
const a = 100, b = 60;
const half = arcInCircle(a, b, a + b - 0.001);
check('near-tangent (inside) → tiny window', half > 0 && half < 0.05);
const half2 = arcInCircle(a, b, a + b + 0.001);
check('near-tangent (outside) → none', half2 === -1);
}
// 7) The contact radius — where the front first touches the rim.
check('ship outside: contact = d - R', contactRadius(60, 100) === 40);
check('ship inside: contact = R - d', contactRadius(60, 20) === 40);
check('concentric: contact = R', contactRadius(60, 0) === 60);
check('ship on the rim: contact = 0', contactRadius(60, 60) === 0);
// 8) The far radius — the last point absorbed.
check('far rim = R + d', farRadius(60, 100) === 160);
check('far rim (concentric) = R', farRadius(60, 0) === 60);
console.log(`\nscan-geometry: ${passed} checks passed`);

View File

@ -1,29 +0,0 @@
<!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>

View File

@ -1,250 +0,0 @@
/**
* 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);
}
};

View File

@ -1,147 +0,0 @@
/**
* 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`);

View File

@ -1,43 +0,0 @@
// Pure circle-circle geometry for the DEEP SCAN pulse (js/scan/ScanPulse.js).
// No Phaser — kept separately so it is unit-testable in Node
// (dev/scan.test.mjs).
//
// The wavefront is a ship-centered circle of radius r. Only the part inside
// the tether region (the UNION of tether circles) is drawn — outside the
// union the wave is absorbed, so the pulse stops at the union boundary.
// For one tether circle (center A, radius R) vs the origin circle (center
// O, radius r), both "windows" are the classic circle-circle intersection:
/**
* The arc of a circle of radius `a` (around its center) that lies inside a
* circle of radius `b`, the two centers `d` apart.
*
* Returns the half-angle (radians) of the window, centered on the
* `a`-center `b`-center direction:
* Math.PI the whole circle `a` is inside `b`
* -1 no overlap
*/
export function arcInCircle(a, b, d) {
if (a <= 0) return -1;
if (d === 0) return a <= b ? Math.PI : -1;
const c = (a * a + d * d - b * b) / (2 * a * d);
if (c <= -1) return Math.PI;
if (c >= 1) return -1;
return Math.acos(c);
}
/**
* The wavefront radius at which an origin-centered front first touches a
* tether of radius `R` whose anchor is `d` away (0 = concentric circles
* the front grows through the whole rim at once, at r = R).
*/
export function contactRadius(R, d) {
if (d === 0) return R;
return Math.abs(R - d);
}
/** The farthest point of a tether rim from the origin the point the wave
* is absorbed last. The sweep runs to the max of this over all tethers. */
export function farRadius(R, d) {
return R + d;
}

View File

@ -1,517 +0,0 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js';
import { arcInCircle, contactRadius, farRadius } from './ScanGeometry.js';
const TAU = Math.PI * 2;
const SOFT_KEY = '__scan_soft';
/**
* DEEP SCAN the SCAN button's sonar pulse (the animation; the RESULTS
* land where GameScene wires `onComplete`).
*
* ship charges (a light gathers at the hull) an omnidirectional
* wavefront expands from the ship across the TETHER REGION, clipped to
* the union of the tether circles (it is absorbed at the union boundary,
* it does not fly into empty space) objects inside the region ring as
* the front crosses them (shock rings + RGB ghost + a scale pulse,
* driven by GameScene's per-frame hit check) the starfield RIPPLES as
* the wave passes (each star nudged radially + a bright flash, Gaussian
* falloff around the front) the camera THUMPS on emission and
* ROLLS/breathes while the wave crosses the view (zoom + rotation,
* decaying) where the wave is swallowed by the barrier, a bright
* absorbing arc runs along the tether rim and the TetherField is
* "excited" (the ambient glitch bursts kick in js/tether/TetherField.js
* `excite()`).
*
* Everything is procedural (no assets) the one SFX is the `scan` key
* (data/sfx.json), played by GameScene on emission.
*
* Two Graphics layers (like the mining beam's glow pass): a soft ADDITIVE
* one under (glows) and a crisp NORMAL one over (core lines). Depths 12/13,
* just above the tether field (9-10) and the mining beam (11).
*
* Config: data/scan.json (all tuning lives there; defaults mirror it).
*/
/** Soft radial-disc texture, generated once (the charge glow). Same recipe
* as the mining beam's created BEFORE any sprite uses it (textures made
* mid-session stay valid only when created first). */
function ensureSoftTexture(scene) {
const tex = scene.textures;
if (tex.exists(SOFT_KEY)) return SOFT_KEY;
const c = document.createElement('canvas');
c.width = c.height = 64;
const g = c.getContext('2d');
const grd = g.createRadialGradient(32, 32, 1, 32, 32, 32);
grd.addColorStop(0, 'rgba(255, 255, 255, 0.9)');
grd.addColorStop(0.35, 'rgba(255, 255, 255, 0.35)');
grd.addColorStop(1, 'rgba(255, 255, 255, 0)');
g.fillStyle = grd;
g.fillRect(0, 0, 64, 64);
tex.addCanvas(SOFT_KEY, c);
return SOFT_KEY;
}
/** Deterministic per-(index, salt, quant) hash in [0, 1) the same recipe
* as TetherField's glitch flicker (stable per "tick" frame, re-rolled
* every ~90 ms of game time). */
function hash01(i, salt, quant) {
const x = Math.sin(i * 12.9898 + salt * 78.233 + quant * 37.719) * 43758.5453;
return x - Math.floor(x);
}
export class ScanPulse {
constructor(scene) {
this.scene = scene;
const c = config.section('scan', {});
const col = c.colors ?? {};
this.enabled = c.enabled !== false;
this.chargeMs = c.chargeMs ?? 240;
this.durationMs = c.durationMs ?? 2100;
this.minRadius = c.minRadius ?? 26;
this.fallbackRadius = c.fallbackRadius ?? 1600;
this.cFront = toColor(col.front, 0x00e5ff);
this.cCore = toColor(col.core, 0xeaf6ff);
this.cGlow = toColor(col.glow, 0x0090ff);
this.cHit = toColor(col.hit, 0x8ff2ff);
this.cGhostC = toColor(col.ghostCyan, 0x00e5ff);
this.cGhostM = toColor(col.ghostMagenta, 0xff2d6f);
this.cAbsorb = toColor(col.absorb, 0x9ff3ff);
const f = c.front ?? {};
this.frontCoreWidth = f.coreWidth ?? 2.6;
this.frontCoreAlpha = f.coreAlpha ?? 0.95;
this.frontMainWidth = f.mainWidth ?? 8;
this.frontMainAlpha = f.mainAlpha ?? 0.5;
this.frontGlowWidth = f.glowWidth ?? 34;
this.frontGlowAlpha = f.glowAlpha ?? 0.14;
const tl = c.tail ?? {};
this.tailSteps = tl.steps ?? 4;
this.tailSpacing = tl.spacing ?? 110;
this.tailWidth = tl.width ?? 2.2;
this.tailAlpha = tl.alpha ?? 0.1;
const ec = c.echo ?? {};
this.echoCount = ec.count ?? 3;
this.echoSpacing = ec.spacing ?? 210;
this.echoWidth = ec.width ?? 1.4;
this.echoAlpha = ec.alpha ?? 0.2;
const tk = c.ticks ?? {};
this.tickCount = tk.count ?? 96;
this.tickLength = tk.length ?? 13;
this.tickAlpha = tk.alpha ?? 0.55;
this.tickDrop = tk.dropChance ?? 0.3;
const rp = c.ripple ?? {};
this.rippleWidth = rp.width ?? 2.4;
this.rippleGlowWidth = rp.glowWidth ?? 14;
this.rippleGlowAlpha = rp.glowAlpha ?? 0.16;
this.rippleGhost = rp.ghostOffset ?? 5;
this.rippleMs = rp.durationMs ?? 680;
this.rippleGrowth = rp.growth ?? 1.5;
const st = c.stars ?? {};
this.starAmp = st.amplitudePx ?? 16;
this.starSigma = st.sigmaPx ?? 160;
this.starFlash = st.flash ?? 0.5;
const cm = c.camera ?? {};
this.thumpMs = cm.thumpMs ?? 150;
this.thumpIntensity = cm.thumpIntensity ?? 0.005;
this.camRoll = ((cm.rollDeg ?? 0.55) * Math.PI) / 180;
this.camZoom = cm.zoomPulse ?? 0.02;
this.camDecay = cm.decayMs ?? 850;
this.camHz = cm.hz ?? 1.5;
const ab = c.absorb ?? {};
this.absorbWidth = ab.width ?? 3;
this.absorbAlpha = ab.alpha ?? 0.8;
this.absorbGlowWidth = ab.glowWidth ?? 20;
this.absorbGlowAlpha = ab.glowAlpha ?? 0.22;
// Layers (soft additive glow UNDER, crisp lines OVER).
this.depth = 12;
ensureSoftTexture(scene);
this.gUnder = scene.add.graphics().setDepth(this.depth).setBlendMode(Phaser.BlendModes.ADD);
this.gOver = scene.add.graphics().setDepth(this.depth + 1);
// The charge glow, gathered at the ship.
this.emitterGlow = scene.add.image(0, 0, SOFT_KEY)
.setTint(this.cGlow)
.setDepth(this.depth + 1)
.setBlendMode(Phaser.BlendModes.ADD)
.setAlpha(0);
// State.
this.ox = 0; this.oy = 0; // emission origin (the ship)
this.tethers = []; // [{x, y, radius}] — the union boundary
this.touched = []; // per-tether "absorbed" first-contact flag
this.hooks = {}; // { onEmit, onAbsorb, onComplete }
this.ripples = []; // hit ripples { x, y, r0, t0 }
this.maxR = this.minRadius;
this.chargeT0 = 0;
this.frontT0 = 0;
this.endT = 0;
this.emitted = false;
this.done = true;
this._now = 0;
this.started = false;
this.cleared = true;
this.camActive = false;
}
/** True while a sweep is running (or its ripples are still out). */
get busy() { return this.started && !this.cleared; }
/** Current front radius (0 before emission). */
get radius() { return this.radiusAt(this._now); }
radiusAt(time) {
if (!this.started || time < this.frontT0) return 0;
const p = Phaser.Math.Clamp((time - this.frontT0) / this.durationMs, 0, 1);
const e = 1 - Math.pow(1 - p, 2.15); // fast launch, settling finish
return this.minRadius + (this.maxR - this.minRadius) * e;
}
/**
* Arm a sweep from (sx, sy) over the given tether region. `time` is
* the scene clock (ms); `hooks` = { onEmit, onAbsorb(i, tether),
* onComplete }. Returns false if already busy (one sweep at a time).
* The front runs from minRadius out to the farthest rim point of the
* union (beyond that it is absorbed nothing is drawn past the rim).
*/
begin(sx, sy, tethers, time, hooks = {}) {
if (!this.enabled || this.busy) return false;
this.ox = sx;
this.oy = sy;
this.tethers = (tethers ?? []).map((t) => ({ x: t.x, y: t.y, radius: t.radius }));
this.touched = this.tethers.map(() => false);
this.hooks = hooks;
let maxR = this.fallbackRadius;
if (this.tethers.length > 0) {
maxR = 0;
for (const t of this.tethers) {
maxR = Math.max(maxR, farRadius(t.radius, Math.hypot(t.x - sx, t.y - sy)));
}
}
this.maxR = Math.max(this.minRadius + 1, maxR);
this.chargeT0 = time;
this.frontT0 = time + this.chargeMs;
this.endT = this.frontT0 + this.durationMs;
this.emitted = false;
this.done = false;
this.cleared = false;
this.started = true;
this.ripples.length = 0;
this.camActive = false;
// Reset stale star state (a CANCELLED scan may have left flags behind).
this._resetStars();
return true;
}
/** Drop a hit ripple (shock ring) at an object the front is crossing. */
ripple(x, y, r0, time) {
this.ripples.push({ x, y, r0: Math.max(10, r0 * 0.55), t0: time });
if (this.ripples.length > 24) this.ripples.shift();
}
/**
* Per-frame: charge front absorbed residual ripples/star settle.
* Restores the camera itself (zoom/rotation) when the sweep is over
* so a CANCELLED or FINISHED sweep never leaves the view warped.
*/
update(time, delta) {
if (!this.started || this.cleared) return;
this._now = time;
// Emission moment — the one-shot thump + flash.
if (!this.emitted && time >= this.frontT0) {
this.emitted = true;
const cam = this.scene.cameras.main;
if (cam && typeof cam.shake === 'function') cam.shake(this.thumpMs, this.thumpIntensity);
this.ripple(this.ox, this.oy, this.minRadius, time); // the core flash
try { this.hooks.onEmit?.(); } catch (err) { console.error('[scan] onEmit failed', err); }
}
// The sweep itself (charge ring, then the clipped front + absorb arcs).
if (time < this.frontT0) {
this.drawCharge(time);
} else {
const p = (time - this.frontT0) / this.durationMs;
const master = p < 0.86 ? 1 : Math.max(0, (1 - p) / 0.14); // fade out at the rim
this.drawFront(time, this.radiusAt(time), master, p);
if (!this.done && time >= this.endT) {
this.done = true;
try { this.hooks.onComplete?.(); } catch (err) { console.error('[scan] onComplete failed', err); }
}
}
// Residuals: hit ripples + star settle (run during/after the sweep).
this.drawRipples(time);
this.drawStars(time, this.emitted ? this.radiusAt(time) : 0);
// Camera roll/zoom (or its restoration).
this.applyCamera(time);
if (this.done && this.ripples.length === 0) this.cleared = true;
}
/** Abort mid-sweep (landing/shutdown): clear everything, restore the
* camera, release the star state. */
cancel() {
if (!this.started) return;
this.started = false;
this.done = true;
this.cleared = true;
this.ripples.length = 0;
this.gUnder.clear();
this.gOver.clear();
this.emitterGlow.setAlpha(0);
if (this.camActive) {
const cam = this.scene.cameras.main;
if (cam) { cam.setZoom(1); cam.rotation = 0; }
this.camActive = false;
}
this._resetStars();
}
destroy() {
this.cancel();
this.gUnder.destroy();
this.gOver.destroy();
this.emitterGlow.destroy();
}
// --------------------------------------------------------------------
// Rendering
// --------------------------------------------------------------------
/** 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();
}
drawCharge(time) {
const U = this.gUnder, O = this.gOver;
U.clear(); O.clear();
const c = Phaser.Math.Clamp((time - this.chargeT0) / Math.max(1, this.chargeMs), 0, 1);
const e = c * c;
const rad = Phaser.Math.Linear(84, this.minRadius, e); // converging ring
U.lineStyle(16, this.cGlow, 0.12 * e);
U.strokeCircle(this.ox, this.oy, rad);
O.lineStyle(2.4, this.cFront, 0.55 * e);
O.strokeCircle(this.ox, this.oy, rad);
O.lineStyle(1.2, this.cCore, 0.75 * e);
O.strokeCircle(this.ox, this.oy, Math.max(3, rad * 0.5));
// The light gathering at the hull.
this.emitterGlow.setPosition(this.ox, this.oy)
.setScale((20 + 60 * e) / 64)
.setAlpha(0.55 * e);
}
drawFront(time, r, master, p) {
const U = this.gUnder, O = this.gOver;
U.clear(); O.clear();
this.emitterGlow.setAlpha(0.22 * master * (1 - p)); // hull glow, fading out
const quant = Math.floor(time / 90);
const span = 2 * Math.PI;
// --- per tether: the front window + the absorbing rim arc -----------
for (let i = 0; i < this.tethers.length; i++) {
const A = this.tethers[i];
const d = Math.hypot(A.x - this.ox, A.y - this.oy);
const phi = Math.atan2(A.y - this.oy, A.x - this.ox); // origin → anchor
// The front arc inside this tether (centered on origin → anchor).
const halfF = arcInCircle(r, A.radius, d);
if (halfF >= 0) {
const full = halfF >= Math.PI;
const a0 = full ? 0 : phi - halfF;
const a1 = full ? span : phi + halfF;
U.lineStyle(this.frontGlowWidth, this.cGlow, this.frontGlowAlpha * master);
this.arc(U, this.ox, this.oy, r, a0, a1);
O.lineStyle(this.frontMainWidth, this.cFront, this.frontMainAlpha * master);
this.arc(O, this.ox, this.oy, r, a0, a1);
O.lineStyle(this.frontCoreWidth, this.cCore, this.frontCoreAlpha * master);
this.arc(O, this.ox, this.oy, r, a0, a1);
this.drawTicks(O, r, full ? 0 : phi, full ? span : 2 * halfF, full, quant, master);
}
// The rim segment the wave is swallowing right now (centered on
// anchor → origin).
const halfA = arcInCircle(A.radius, r, d);
if (halfA >= 0) {
const eta = phi + Math.PI;
const full = halfA >= Math.PI;
const b0 = full ? 0 : eta - halfA;
const b1 = full ? span : eta + halfA;
U.lineStyle(this.absorbGlowWidth, this.cAbsorb, this.absorbGlowAlpha * master);
this.arc(U, A.x, A.y, A.radius, b0, b1);
O.lineStyle(this.absorbWidth, this.cAbsorb, this.absorbAlpha * master);
this.arc(O, A.x, A.y, A.radius, b0, b1);
}
// First touch → the barrier shivers (GameScene excites the field).
if (!this.touched[i]) {
const touch = contactRadius(A.radius, d);
if (r >= touch && r >= this.minRadius) {
this.touched[i] = true;
try { this.hooks.onAbsorb?.(i, A); } catch (err) { console.error('[scan] onAbsorb failed', err); }
}
}
}
// --- the trailing sheet (faint arcs riding behind the front) --------
for (let k = 1; k <= this.tailSteps; k++) {
const rt = r - k * this.tailSpacing;
if (rt <= this.minRadius) break;
const a = this.tailAlpha * (1 - (k - 0.5) / this.tailSteps) * master;
O.lineStyle(this.tailWidth, this.cFront, a);
for (const A of this.tethers) {
const half = arcInCircle(rt, A.radius, Math.hypot(A.x - this.ox, A.y - this.oy));
if (half < 0) continue;
const phi = Math.atan2(A.y - this.oy, A.x - this.ox);
this.arc(O, this.ox, this.oy, rt,
half >= Math.PI ? 0 : phi - half,
half >= Math.PI ? span : phi + half);
}
}
// --- the echo rings (thin, lagging) -----------------------------------
for (let e = 1; e <= this.echoCount; e++) {
const re = r - e * this.echoSpacing;
if (re <= this.minRadius) continue;
const a = this.echoAlpha * (1 - e / (this.echoCount + 1)) * master;
O.lineStyle(this.echoWidth, this.cCore, a);
for (const A of this.tethers) {
const half = arcInCircle(re, A.radius, Math.hypot(A.x - this.ox, A.y - this.oy));
if (half < 0) continue;
const phi = Math.atan2(A.y - this.oy, A.x - this.ox);
this.arc(O, this.ox, this.oy, re,
half >= Math.PI ? 0 : phi - half,
half >= Math.PI ? span : phi + half);
}
}
}
/** The angular "data readout" ticks marching along the front. */
drawTicks(O, r, a0, span, full, quant, master) {
const n = this.tickCount;
const step = TAU / n;
for (let i = 0; i < n; i++) {
const th = i * step;
if (!full) {
const w = ((th - a0) % TAU + TAU) % TAU; // offset into the window
if (w >= span) continue;
}
const h = hash01(i, 7, quant);
if (h < this.tickDrop) continue; // data drops
const major = i % 8 === 0;
const len = this.tickLength * (major ? 1.9 : 1);
const ct = Math.cos(th), st = Math.sin(th);
O.lineStyle(major ? 2 : 1.2, major ? this.cCore : this.cFront,
this.tickAlpha * (0.45 + 0.55 * h) * master);
O.lineBetween(
this.ox + ct * r, this.oy + st * r,
this.ox + ct * (r + len), this.oy + st * (r + len),
);
}
}
/** Hit ripples — expanding shock rings with the RGB ghost pair. */
drawRipples(time) {
const U = this.gUnder, O = this.gOver;
for (let i = this.ripples.length - 1; i >= 0; i--) {
const rp = this.ripples[i];
const a = (time - rp.t0) / this.rippleMs;
if (a >= 1) { this.ripples.splice(i, 1); continue; }
const e = 1 - Math.pow(1 - a, 2.4);
const rad = rp.r0 * (0.92 + this.rippleGrowth * e);
const fade = 1 - a;
U.lineStyle(this.rippleGlowWidth, this.cHit, this.rippleGlowAlpha * fade);
U.strokeCircle(rp.x, rp.y, rad);
O.lineStyle(this.rippleWidth, this.cHit, 0.65 * fade);
O.strokeCircle(rp.x, rp.y, rad);
U.lineStyle(1.4, this.cGhostC, 0.35 * fade);
U.strokeCircle(rp.x, rp.y, rad + this.rippleGhost);
U.lineStyle(1.4, this.cGhostM, 0.35 * fade);
U.strokeCircle(rp.x, rp.y, Math.max(1, rad - this.rippleGhost));
}
}
/**
* The starfield ripple: every star gets a radial nudge + brightness flash
* with Gaussian falloff as the front passes it (the "view distortion"
* done on the stars themselves, so it is per-star and self-restoring).
* State is stashed on the star objects (like the drift-velocities are).
*/
drawStars(time, r) {
if (r <= 0) return;
const stars = this.scene.starfield?.stars;
if (!stars || stars.length === 0) return;
const sig = this.starSigma;
const win = 3 * sig;
for (const st of stars) {
if (st._scanDone) continue;
// Capture the star's rest position once (the ripple is a temporary
// radial offset FROM IT, so the direction + distance use the base).
if (!st._scanBase) {
st._scanBase = { x: st.x, y: st.y };
st._scanBaseAlpha = st.alpha;
}
const bx = st._scanBase.x, by = st._scanBase.y;
const dx = bx - this.ox, dy = by - this.oy;
const d = Math.hypot(dx, dy) || 1;
const g = r - d; // >0: the front has passed this star
if (g > win) {
st._scanDone = true;
st.x = bx; st.y = by;
if (st._scanBaseAlpha !== undefined) { st.alpha = st._scanBaseAlpha; delete st._scanBaseAlpha; }
delete st._scanBase;
continue;
}
if (g < -win) continue; // front not near yet
const q = Math.exp(-(g * g) / (2 * sig * sig));
const amp = this.starAmp * (0.35 + 0.65 * (st.parallax ?? 0.5));
st.x = bx + (dx / d) * amp * q;
st.y = by + (dy / d) * amp * q;
st.alpha = st._scanBaseAlpha + this.starFlash * q;
}
}
/**
* The camera wobble a decaying roll + zoom "breath" that rides the
* wave (sin/cos at the scan's carrier frequency, exponential decay).
* Restores zoom=1 / rotation=0 once the sweep is over (or on cancel()).
*/
applyCamera(time) {
const cam = this.scene.cameras.main;
if (!cam) return;
const el = time - this.frontT0;
if (el < 0 || el >= this.camDecay * 2.2) {
if (this.camActive) {
cam.setZoom(1);
cam.rotation = 0;
this.camActive = false;
}
return;
}
const w = Math.exp(-el / this.camDecay);
const ph = el * 0.001 * this.camHz * TAU;
cam.setZoom(1 + this.camZoom * w * Math.sin(ph));
cam.rotation = this.camRoll * w * Math.sin(ph + 1.15);
this.camActive = true;
}
/** Restore any star state we stashed (cancel / re-begin). */
_resetStars() {
const stars = this.scene.starfield?.stars;
if (!stars) return;
for (const st of stars) {
if (st._scanBase) { st.x = st._scanBase.x; st.y = st._scanBase.y; delete st._scanBase; }
if (st._scanBaseAlpha !== undefined) { st.alpha = st._scanBaseAlpha; delete st._scanBaseAlpha; }
delete st._scanDone;
}
}
}

View File

@ -22,8 +22,6 @@ import { consumeRestore } from '../save/SaveData.js';
import { TetherField } from '../tether/TetherField.js'; import { TetherField } from '../tether/TetherField.js';
import { Mining } from '../mining/Mining.js'; import { Mining } from '../mining/Mining.js';
import { MiningPopup } from '../ui/MiningPopup.js'; import { MiningPopup } from '../ui/MiningPopup.js';
import { ScanPulse } from '../scan/ScanPulse.js';
import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js';
import { CommsPanel } from '../ui/CommsPanel.js'; import { CommsPanel } from '../ui/CommsPanel.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
@ -119,7 +117,6 @@ export class GameScene extends Phaser.Scene {
this.load.audio('sfx_deconstruct', config.get('sfx.deconstruct', 'assets/fx/type-deconstruct.mp3')); this.load.audio('sfx_deconstruct', config.get('sfx.deconstruct', 'assets/fx/type-deconstruct.mp3'));
this.load.audio('sfx_discovery', config.get('sfx.discovery', 'assets/fx/discovery.mp3')); this.load.audio('sfx_discovery', config.get('sfx.discovery', 'assets/fx/discovery.mp3'));
this.load.audio('sfx_mining', config.get('sfx.mining', 'assets/fx/system-scan.mp3')); this.load.audio('sfx_mining', config.get('sfx.mining', 'assets/fx/system-scan.mp3'));
this.load.audio('sfx_scan', config.get('sfx.scan', 'assets/fx/system-scan.mp3'));
} }
} }
@ -308,9 +305,10 @@ export class GameScene extends Phaser.Scene {
// The command deck — the cyberpunk action bar across the bottom of the // The command deck — the cyberpunk action bar across the bottom of the
// screen (config: data/actionbar.json). Six evenly spaced slots: // screen (config: data/actionbar.json). Six evenly spaced slots:
// Research, Scan, Ship, two reserved, Menu. The slots are the seams // Research, Build, Ship, two reserved, Menu. The slots are the seams
// for the player's loop — research (time-based, one at a time, see // for the player's loop — research (time-based, one at a time, see
// data/research.json) and scan (its behavior and panel come next). // data/research.json) and building (credits + minerals, see
// data/builds.json) — whose behavior and panels come next.
this.actionBar = deckEnabled this.actionBar = deckEnabled
? new ActionBar(this, { ? new ActionBar(this, {
onAction: (id) => this.deckAction(id), onAction: (id) => this.deckAction(id),
@ -377,31 +375,6 @@ export class GameScene extends Phaser.Scene {
onAction: (id, target) => this.commsAction(id, target), onAction: (id, target) => this.commsAction(id, target),
}); });
// ---- DEEP SCAN (the deck's SCAN button) -------------------------------
// The ship's sonar pulse (js/scan/ScanPulse.js): a charge at the hull,
// then an omnidirectional wavefront expanding across the TETHER REGION
// (clipped to the union boundary — absorbed at the rim). Objects in the
// region ring as the front crosses them (updateScanHits), the starfield
// ripples as the wave passes, the camera thumps + rolls with it, and the
// barrier shivers where the wave is swallowed (TetherField.excite).
// The RESULTS are a seam for now — finishScan logs the in-region objects;
// the next instruction decides what a scan yields.
this.scanPulse = new ScanPulse(this);
this.scanObjects = null;
this.scanObjectScale = config.get('scan.ripple.objectScale', 0.045);
// SIGNAL COMPASS (js/ui/SignalCompass.js) — the secondary compass:
// a faint ring (radius px) around the ship. When a scan COMPLETES,
// every undiscovered in-region object lights up a soft "radio signal"
// on that ring at its bearing (full 20 s, then a 5 s fade). A signal
// drops the instant its object is discovered; re-scanning re-emits the
// set with a fresh clock. `this.scanReveal` = the current emission
// ({ bornAt, ids }) — the clock + which objects it covers.
this.signalCompass = new SignalCompass(this);
this.scanReveal = null;
this.signalFullMs = config.get('signalCompass.lifetime.fullMs', 20000);
this.signalFadeMs = config.get('signalCompass.lifetime.fadeMs', 5000);
// SHIP STATE (js/entities/Ship.js): 'normal' is the default — the // SHIP STATE (js/entities/Ship.js): 'normal' is the default — the
// ship is free; 'mining' — the arm's sequence owns the ship. The // ship is free; 'mining' — the arm's sequence owns the ship. The
// mining visuals live and die with that state: ANY change out of it // mining visuals live and die with that state: ANY change out of it
@ -903,9 +876,6 @@ export class GameScene extends Phaser.Scene {
for (const c of this.asteroidClusters) c.update(_time); for (const c of this.asteroidClusters) c.update(_time);
for (const st of this.systemStations) st.update(_time); // the ring turns, the beacon breathes for (const st of this.systemStations) st.update(_time); // the ring turns, the beacon breathes
this.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks) this.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks)
if (this.scanObjects) this.updateScanHits(_time); // the front crossing objects → ring
this.scanPulse.update(_time, delta); // the pulse: charge → front → absorbed → settle
this.updateSignalCompass(_time); // the secondary compass: bearing signals on the ring
this.updateCamera(delta); this.updateCamera(delta);
this.starfield.update(); // after the camera, so it sees this frame's motion this.starfield.update(); // after the camera, so it sees this frame's motion
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
@ -1320,15 +1290,6 @@ export class GameScene extends Phaser.Scene {
console.info(`[orbit] comms: landing disabled for ${target.name}`); console.info(`[orbit] comms: landing disabled for ${target.name}`);
return; return;
} }
// A live scan would leave the camera roll/zoom mid-wobble — the scene
// sleeps (or shuts down) and never restores it. Cut it, and drop the
// compass emission (a flight-scene navigation aid — no use on the
// surface, and it shouldn't resurface stale on the way back up).
if (this.scanPulse?.busy) {
this.scanPulse.cancel();
this.scanObjects = null;
}
this.scanReveal = null;
this.hideHint(); this.hideHint();
// A previous surface (left sleeping by an earlier Take Off) is shut // A previous surface (left sleeping by an earlier Take Off) is shut
// down + restarted by this — the fresh init gets this world's frame. // down + restarted by this — the fresh init gets this world's frame.
@ -1467,138 +1428,9 @@ export class GameScene extends Phaser.Scene {
this.menuAction(); this.menuAction();
return; return;
} }
if (id === 'scan') {
this.startScan();
return;
}
console.info(`[orbit] command deck: ${id}`); console.info(`[orbit] command deck: ${id}`);
} }
/**
* The SCAN button: fire the deep-scan pulse from the ship across the
* tether region. The sweep's TARGET SET is the discoverable objects
* inside the tether union (the wave is absorbed at the union boundary,
* so nothing beyond it is in range). One sweep at a time (busy guard);
* `enabled: false` in data/scan.json makes the button a no-op.
*/
startScan() {
if (!config.get('scan.enabled', true)) {
console.info('[orbit] scan disabled (data/scan.json)');
return;
}
if (this.scanPulse.busy) return; // a sweep is already out
const ox = this.ship.x;
const oy = this.ship.y;
const tethers = this.tetherField.tethers.map((t) => ({ x: t.x, y: t.y, radius: t.radius }));
this.scanObjects = this.discoverableObjects()
.filter((o) => this.tetherField.contains(o.x, o.y))
.map((o) => ({ ...o, dist: Math.hypot(o.x - ox, o.y - oy), hit: false }));
this.scanPulse.begin(ox, oy, tethers, this.time.now, {
onEmit: () => {
this.playSfx('scan'); // the ping goes out
this.consoleToast('DEEP SCAN — SWEEPING TETHER REGION', {
glyph: '\u25c8',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
durationMs: this.scanPulse.durationMs + 500,
});
},
onAbsorb: () => this.tetherField.excite(1.15), // the barrier shivers as the wave is swallowed
onComplete: () => this.finishScan(),
});
this.hideHint();
}
/** Per-frame: ring every in-region object the moment the front crosses it. */
updateScanHits(time) {
const r = this.scanPulse.radiusAt(time);
if (r <= 0) return;
for (const o of this.scanObjects) {
if (o.hit || r < o.dist) continue;
o.hit = true;
this.scanRippleObject(o);
}
}
/** The hit: the solid pulses in scale (sonar echo) + a shock ring at it. */
scanRippleObject(o) {
const solid = this.solids.find((s) => s.discoveryId === o.id) ?? this.planet;
const s0 = solid.scale;
this.tweens.add({
targets: solid,
scale: s0 * (1 + this.scanObjectScale),
duration: 150,
yoyo: true,
repeat: 1,
ease: 'Sine.easeOut',
});
this.scanPulse.ripple(o.x, o.y, o.radius, this.time.now);
}
/**
* The sweep finished the seam for SCAN RESULTS (what a scan yields is
* the next instruction's call). For now: log the in-region objects and
* report the count in the console slot.
*/
/**
* The sweep finished the scan's OUTPUT. For now this is the seam the
* results will grow out of (what a scan YIELDS is a later instruction's
* call): log the in-region objects + report the count, and the part
* the player can act on light up the SECONDARY COMPASS: every
* in-region object becomes a faint bearing signal on the ring around the
* ship (the compass itself drops any already discovered, and drops them
* the instant they get discovered). A fresh scan re-emits the set with a
* fresh clock (a re-ping).
*/
finishScan() {
const objs = this.scanObjects ?? [];
const hits = objs.filter((o) => o.hit).length;
console.info(`[orbit] scan complete — ${hits}/${objs.length} objects in tether range`, objs);
// The compass emission (fullMs + fadeMs of life; see updateSignalCompass).
this.scanReveal = { bornAt: this.time.now, ids: objs.map((o) => o.id) };
this.consoleToast(`SCAN COMPLETE — ${hits} OBJECT${hits === 1 ? '' : 'S'} DETECTED IN TETHER RANGE`, {
glyph: '\u25c8',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
durationMs: 2600,
});
this.scanObjects = null;
}
/**
* The SECONDARY COMPASS, per frame (js/ui/SignalCompass.js): resolve the
* current scan emission into live bearing signals. A signal exists per
* in-region object that is still UNDISCOVERED; its strength follows the
* fullfade envelope (signalAlpha) from the emission's birth. Positions
* are resolved LIVE, so the bearings track both the ship and any drifting
* objects. Expired (fullMs + fadeMs) or discovered dropped.
*/
updateSignalCompass(time) {
const compass = this.signalCompass;
if (!compass) return;
const sx = this.ship.x, sy = this.ship.y;
const reveal = this.scanReveal;
if (!reveal || !Array.isArray(reveal.ids)) {
compass.refresh([], time, sx, sy);
return;
}
const age = time - reveal.bornAt;
if (age >= this.signalFullMs + this.signalFadeMs) {
this.scanReveal = null; // the emission has fully faded
compass.refresh([], time, sx, sy);
return;
}
const alpha = signalAlpha(age, this.signalFullMs, this.signalFadeMs);
const sysId = this.systemRecord.id;
const objects = this.discoverableObjects(); // one build per frame
const signals = [];
for (const id of reveal.ids) {
if (this.discovery.isDiscovered(sysId, id)) continue; // found it → its signal is done
const o = objects.find((v) => v.id === id);
if (!o) continue;
signals.push({ id, x: o.x, y: o.y, alpha, color: o.color });
}
compass.refresh(signals, time, sx, sy);
}
/** /**
* The MENU button: fold the sub-bar up / fold it back down. * The MENU button: fold the sub-bar up / fold it back down.
* (While the pop-up is open, the button closes the pop-up instead.) * (While the pop-up is open, the button closes the pop-up instead.)
@ -1721,8 +1553,6 @@ export class GameScene extends Phaser.Scene {
this.miningPopup?.destroy(); this.miningPopup?.destroy();
this.commsPanel?.destroy(); this.commsPanel?.destroy();
this.mining?.destroy(); this.mining?.destroy();
this.scanPulse?.destroy();
this.signalCompass?.destroy();
this.tetherField?.destroy(); this.tetherField?.destroy();
} }
} }

View File

@ -258,22 +258,6 @@ export class TetherField {
this.pulses = this.pulses.filter((p) => time - p.t0 < this.contactDuration); this.pulses = this.pulses.filter((p) => time - p.t0 < this.contactDuration);
} }
/**
* External kick to the barrier's glitch (the DEEP SCAN wave being absorbed
* at the rim js/scan/ScanPulse.js): starts a glitch burst RIGHT NOW at
* the given intensity (1.0 = a normal ambient burst; ~1.15 = a shudder),
* and pushes the next ambient burst back so it does not double-fire.
*/
excite(intensity = 1.15) {
if (!this.glitchEnabled) return;
const now = this.lastTime || 0;
this.burstT0 = now;
this.burstDur = Math.max(this.burstDur, 620);
this.burstIntensity = Math.max(this.burstIntensity, intensity);
this.glitchLevel = Math.max(this.glitchLevel, 0.85);
this.nextBurst = now + this.burstDur + this.randRange(this.glitchInterval);
}
/** Redraw the barrier (only the on-screen dots). Call once per frame. */ /** Redraw the barrier (only the on-screen dots). Call once per frame. */
draw(time) { draw(time) {
const cam = this.scene.cameras.main; const cam = this.scene.cameras.main;

View File

@ -33,7 +33,7 @@ const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
* shared from data/actionbar.json. * shared from data/actionbar.json.
* *
* Live slots fire `onAction(id, slot)` on press behavior is the * Live slots fire `onAction(id, slot)` on press behavior is the
* scene's job (the Research/Scan/Ship/Menu panels come next). * scene's job (the Research/Build/Ship/Menu panels come next).
* `bar.contains(px, py)` (screen coords) lets a scene keep its own * `bar.contains(px, py)` (screen coords) lets a scene keep its own
* input e.g. click-to-fly from triggering over the deck. * input e.g. click-to-fly from triggering over the deck.
*/ */
@ -197,7 +197,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
? ab.buttons ? ab.buttons
: [ : [
{ id: 'research', label: 'Research', accent: '#00e5ff' }, { id: 'research', label: 'Research', accent: '#00e5ff' },
{ id: 'scan', label: 'Scan', accent: '#ffc94d' }, { id: 'build', label: 'Build', accent: '#ffc94d' },
{ id: 'ship', label: 'Ship', accent: '#7ce8a4' }, { id: 'ship', label: 'Ship', accent: '#7ce8a4' },
{ id: null, label: null }, { id: null, label: null },
{ id: null, label: null }, { id: null, label: null },

View File

@ -1,202 +0,0 @@
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;
}