Add deep-scan sonar pulse replacing the Build action button
- Swap the command deck's "Build" slot for a "Scan" slot that fires an omnidirectional sonar pulse from the ship across the tether region. - The wavefront is clipped to the union of tether circles: it is absorbed at the rim (bright absorbing arc + TetherField.excite shiver) instead of flying into empty space. - Objects inside the region ring as the front crosses them (shock rings, RGB ghost, scale pulse); the starfield ripples and the camera thumps / rolls with the wave, then restores itself on completion or cancel. - New modules: js/scan/ScanPulse.js (rendering + lifecycle), js/scan/ScanGeometry.js (pure circle-circle window math). - All tuning lives in data/scan.json; the one SFX is the new "scan" key in data/sfx.json. Results are a seam for now — finishScan logs the in-region objects and reports the count in the console slot. - Tests: dev/scan.test.mjs (geometry unit tests) and dev/scan-test.html/.mjs (headless flow test driving the real scene). - Add Terran shop placeholder assets (two PNGs + one MP4).
This commit is contained in:
parent
cd4d751edd
commit
d0bd840dbc
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 662 KiB |
Binary file not shown.
|
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"buttons": [
|
||||
{ "id": "research", "label": "Research", "accent": "#00e5ff" },
|
||||
{ "id": "build", "label": "Build", "accent": "#ffc94d" },
|
||||
{ "id": "scan", "label": "Scan", "accent": "#ffc94d" },
|
||||
{ "id": "ship", "label": "Ship", "accent": "#7ce8a4" },
|
||||
{ "id": null, "label": null },
|
||||
{ "id": null, "label": null },
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
"actionbar.json",
|
||||
"landing.json",
|
||||
"sfx.json",
|
||||
"scan.json",
|
||||
"signalCompass.json",
|
||||
"save.json"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"_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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
{
|
||||
"_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).",
|
||||
"_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).",
|
||||
"enabled": true,
|
||||
"volume": 0.55,
|
||||
"construct": "assets/fx/type-construct.mp3",
|
||||
"deconstruct": "assets/fx/type-deconstruct.mp3",
|
||||
"discovery": "assets/fx/discovery.mp3",
|
||||
"mining": "assets/fx/system-scan.mp3"
|
||||
"mining": "assets/fx/system-scan.mp3",
|
||||
"scan": "assets/fx/system-scan.mp3"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,8 +77,8 @@ check('builds._template.requires is an array', Array.isArray(bt.requires));
|
|||
// ----------------------------------------------------------------------
|
||||
const slots = actionbar.buttons ?? [];
|
||||
check('actionbar: exactly six slots', slots.length === 6);
|
||||
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 / Build / Ship / · / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Build', 'Ship', null, null, 'Menu']));
|
||||
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: labels (Research / Scan / Ship / · / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', null, null, 'Menu']));
|
||||
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: reserved slots stay null', slots.filter((s) => s.id === null).every((s) => s.label === null));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
// 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`);
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,517 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,8 @@ import { consumeRestore } from '../save/SaveData.js';
|
|||
import { TetherField } from '../tether/TetherField.js';
|
||||
import { Mining } from '../mining/Mining.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';
|
||||
|
||||
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||
|
|
@ -117,6 +119,7 @@ export class GameScene extends Phaser.Scene {
|
|||
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_mining', config.get('sfx.mining', 'assets/fx/system-scan.mp3'));
|
||||
this.load.audio('sfx_scan', config.get('sfx.scan', 'assets/fx/system-scan.mp3'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,10 +308,9 @@ export class GameScene extends Phaser.Scene {
|
|||
|
||||
// The command deck — the cyberpunk action bar across the bottom of the
|
||||
// screen (config: data/actionbar.json). Six evenly spaced slots:
|
||||
// Research, Build, Ship, two reserved, Menu. The slots are the seams
|
||||
// Research, Scan, Ship, two reserved, Menu. The slots are the seams
|
||||
// for the player's loop — research (time-based, one at a time, see
|
||||
// data/research.json) and building (credits + minerals, see
|
||||
// data/builds.json) — whose behavior and panels come next.
|
||||
// data/research.json) and scan (its behavior and panel come next).
|
||||
this.actionBar = deckEnabled
|
||||
? new ActionBar(this, {
|
||||
onAction: (id) => this.deckAction(id),
|
||||
|
|
@ -375,6 +377,31 @@ export class GameScene extends Phaser.Scene {
|
|||
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 is free; 'mining' — the arm's sequence owns the ship. The
|
||||
// mining visuals live and die with that state: ANY change out of it
|
||||
|
|
@ -876,6 +903,9 @@ export class GameScene extends Phaser.Scene {
|
|||
for (const c of this.asteroidClusters) c.update(_time);
|
||||
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)
|
||||
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.starfield.update(); // after the camera, so it sees this frame's motion
|
||||
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
|
||||
|
|
@ -1290,6 +1320,15 @@ export class GameScene extends Phaser.Scene {
|
|||
console.info(`[orbit] comms: landing disabled for ${target.name}`);
|
||||
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();
|
||||
// A previous surface (left sleeping by an earlier Take Off) is shut
|
||||
// down + restarted by this — the fresh init gets this world's frame.
|
||||
|
|
@ -1428,9 +1467,138 @@ export class GameScene extends Phaser.Scene {
|
|||
this.menuAction();
|
||||
return;
|
||||
}
|
||||
if (id === 'scan') {
|
||||
this.startScan();
|
||||
return;
|
||||
}
|
||||
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
|
||||
* full→fade 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.
|
||||
* (While the pop-up is open, the button closes the pop-up instead.)
|
||||
|
|
@ -1553,6 +1721,8 @@ export class GameScene extends Phaser.Scene {
|
|||
this.miningPopup?.destroy();
|
||||
this.commsPanel?.destroy();
|
||||
this.mining?.destroy();
|
||||
this.scanPulse?.destroy();
|
||||
this.signalCompass?.destroy();
|
||||
this.tetherField?.destroy();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -258,6 +258,22 @@ export class TetherField {
|
|||
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. */
|
||||
draw(time) {
|
||||
const cam = this.scene.cameras.main;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
|||
* shared from data/actionbar.json.
|
||||
*
|
||||
* Live slots fire `onAction(id, slot)` on press — behavior is the
|
||||
* scene's job (the Research/Build/Ship/Menu panels come next).
|
||||
* scene's job (the Research/Scan/Ship/Menu panels come next).
|
||||
* `bar.contains(px, py)` (screen coords) lets a scene keep its own
|
||||
* input — e.g. click-to-fly — from triggering over the deck.
|
||||
*/
|
||||
|
|
@ -197,7 +197,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
? ab.buttons
|
||||
: [
|
||||
{ id: 'research', label: 'Research', accent: '#00e5ff' },
|
||||
{ id: 'build', label: 'Build', accent: '#ffc94d' },
|
||||
{ id: 'scan', label: 'Scan', accent: '#ffc94d' },
|
||||
{ id: 'ship', label: 'Ship', accent: '#7ce8a4' },
|
||||
{ id: null, label: null },
|
||||
{ id: null, label: null },
|
||||
|
|
|
|||
Loading…
Reference in New Issue