orbit/js/ui/SignalCompass.js

203 lines
7.8 KiB
JavaScript

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js';
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
/**
* SIGNAL COMPASS — the secondary compass (config: data/signalCompass.json).
*
* A faint radius ring (radius px) drawn AROUND THE SHIP. After a SCAN, every
* UNDISCOVERED object in the tether region shows a soft "radio signal" hugging
* that ring, at the object's bearing (its direction from the ship) — no
* arrows, just a diffuse signal: a soft arc on the line + a core dot + a few
* faint concentric "radio wave" arcs just outside it, breathing slowly.
*
* The signals are a BEARING indicator: they are recomputed from the ship's
* LIVE position every frame, so as the player flies the signals rotate to
* keep pointing at their objects. Each is full strength for lifetime.fullMs,
* then fades linearly over lifetime.fadeMs; a signal drops the instant its
* object is discovered; a new scan re-emits the whole set with a fresh clock.
*
* The SCENE drives this (it owns the game rules — which objects are revealed,
* their live positions, discovery, and the age/alpha clock); this class is a
* stateless renderer: `refresh(signals, time, shipX, shipY)` clears + redraws.
* It is drawn in WORLD space centered on the ship, so it tracks the ship and
* stays on it while the camera follows (depths 14/15 — above the scan pulse
* 12/13, below the HUD dossier 30 / compass arrows 40).
*
* Pure + exported for Node testing (dev/signal-compass.test.mjs):
* signalAlpha(ageMs, fullMs, fadeMs) — the full→fade→gone envelope
* bearingAngle(sx, sy, ox, oy) — the ship→object direction (y-down)
*/
/**
* The signal strength envelope: 1.0 for the first `fullMs`, then a linear
* fade to 0 over the next `fadeMs`, then gone (0). Pure (no Phaser).
*
* @returns {number} alpha in [0, 1]
*/
export function signalAlpha(ageMs, fullMs, fadeMs) {
if (!Number.isFinite(ageMs) || ageMs < 0) ageMs = 0;
fullMs = Math.max(0, fullMs);
if (fadeMs <= 0) return ageMs < fullMs ? 1 : 0;
if (ageMs < fullMs) return 1;
const t = (ageMs - fullMs) / fadeMs;
if (t >= 1) return 0;
return 1 - t;
}
/** Direction from (sx, sy) to (ox, oy), radians, screen/world y-down. */
export function bearingAngle(sx, sy, ox, oy) {
return Math.atan2(oy - sy, ox - sx);
}
export class SignalCompass {
constructor(scene) {
this.scene = scene;
const c = config.section('signalCompass', {});
this.enabled = c.enabled !== false;
this.radius = Math.max(24, c.radius ?? 128);
const col = c.colors ?? {};
this.cSignal = toColor(col.signal, 0x00e5ff);
this.cGlow = toColor(col.glow, 0x0090ff);
this.cRing = toColor(col.ring, 0x3d4c74);
const s = c.signal ?? {};
this.arcHalf = (s.arcHalfDeg ?? 13) * DEG;
this.coreWidth = s.coreWidth ?? 3;
this.coreAlpha = s.coreAlpha ?? 0.7;
this.glowWidth = s.glowWidth ?? 14;
this.glowAlpha = s.glowAlpha ?? 0.16;
this.dotRadius = s.dotRadius ?? 3.2;
this.dotAlpha = s.dotAlpha ?? 0.95;
const rp = s.ripples ?? {};
this.rippleCount = rp.count ?? 3;
this.rippleSpacing = rp.spacingPx ?? 9;
this.rippleWidth = rp.width ?? 1.5;
this.rippleAlpha = rp.alpha ?? 0.3;
this.rippleHalf = (rp.arcHalfDeg ?? 8) * DEG;
const pu = s.pulse ?? {};
this.pulseHz = pu.hz ?? 1.1;
this.pulseAmp = pu.amp ?? 0.16;
const rg = c.ring ?? {};
this.ringWidth = rg.width ?? 1;
this.ringAlpha = rg.alpha ?? 0.18;
this.ringGlowWidth = rg.glowWidth ?? 6;
this.ringGlowAlpha = rg.glowAlpha ?? 0.05;
// Two persistent layers, cleared + redrawn each frame (the ScanPulse
// idiom): a soft ADDITIVE glow UNDER, crisp lines OVER.
this.depth = 14;
this.gUnder = scene.add.graphics().setDepth(this.depth).setBlendMode(Phaser.BlendModes.ADD);
this.gOver = scene.add.graphics().setDepth(this.depth + 1);
}
/**
* Draw the ring + one soft radio-signal per entry. Call once per frame.
*
* @param {Array<{id: string, x: number, y: number, alpha: number, color?: (number|string)}>} signals
* live (world) positions + a pre-computed alpha (the scene's clock);
* `color` optionally overrides the shared signal color (clusters).
* @param {number} time scene clock (ms) — drives the beacon pulse
* @param {number} shipX ship world x (the ring's center + bearing origin)
* @param {number} shipY ship world y
*/
refresh(signals, time, shipX, shipY) {
const U = this.gUnder, O = this.gOver;
U.clear(); O.clear();
if (!this.enabled || !signals || signals.length === 0) return;
const R = this.radius;
// The baseline radius line: present while any signal lives, scaled with
// the strongest one so the whole instrument fades out together.
let aMax = 0;
for (const s of signals) aMax = Math.max(aMax, s.alpha);
if (aMax > 0) {
U.lineStyle(this.ringGlowWidth, this.cRing, this.ringGlowAlpha * aMax);
U.strokeCircle(shipX, shipY, R);
O.lineStyle(this.ringWidth, this.cRing, this.ringAlpha * aMax);
O.strokeCircle(shipX, shipY, R);
}
for (const s of signals) {
if (s.alpha <= 0) continue;
const ang = bearingAngle(shipX, shipY, s.x, s.y);
this.drawSignal(U, O, shipX, shipY, R, ang, s.alpha, s.color, time, phaseOf(s.id));
}
}
/**
* One radio signal at bearing `ang` on ring radius `R`, strength `a`
* (0..1). A diffuse glow arc + a crisp core arc HUG the radius line; a
* core dot sits at its center; faint concentric arcs just outside read as
* the radio wave emanating. A slow per-signal pulse (phased by id so a
* fan of signals doesn't breathe in unison) gives it life.
*/
drawSignal(U, O, cx, cy, R, ang, a, color, time, phase) {
const pulse = 1 + this.pulseAmp * Math.sin(time * 0.001 * TAU * this.pulseHz + phase);
const A = a * pulse;
const sig = color !== undefined ? toColor(color) : this.cSignal;
const half = this.arcHalf;
// Diffuse halo under (additive) + the crisp core, both on the radius line.
U.lineStyle(this.glowWidth, this.cGlow, this.glowAlpha * A);
this.arc(U, cx, cy, R, ang - half, ang + half);
O.lineStyle(this.coreWidth, sig, this.coreAlpha * A);
this.arc(O, cx, cy, R, ang - half, ang + half);
// The "radio wave": a few faint concentric arcs just OUTSIDE the line,
// each a little narrower + dimmer the further out (a fading emission).
for (let i = 1; i <= this.rippleCount; i++) {
const rr = R + i * this.rippleSpacing;
const rh = this.rippleHalf * (1 - i * 0.12);
const ra = this.rippleAlpha * A * (1 - i / (this.rippleCount + 1));
if (ra <= 0.004) continue;
U.lineStyle(this.rippleWidth, this.cGlow, ra);
this.arc(U, cx, cy, rr, ang - rh, ang + rh);
}
// The transmitter point: a small soft dot at the signal's center on the line.
const dx = cx + Math.cos(ang) * R;
const dy = cy + Math.sin(ang) * R;
U.fillStyle(this.cGlow, 0.4 * A);
U.fillCircle(dx, dy, this.dotRadius + 3);
O.fillStyle(sig, this.dotAlpha * A);
O.fillCircle(dx, dy, this.dotRadius);
}
/** An arc, or a full circle when the window spans the whole thing. */
arc(g, x, y, rad, a0, a1) {
if (a1 - a0 >= TAU - 1e-6) {
g.strokeCircle(x, y, rad);
return;
}
g.beginPath();
g.arc(x, y, rad, a0, a1);
g.strokePath();
}
destroy() {
this.gUnder.clear();
this.gOver.clear();
this.gUnder.destroy();
this.gOver.destroy();
}
}
/**
* A stable pseudo-phase in [0, 2π) per signal id — staggers the beacon pulse
* so a cluster of signals breathes out of step (same trick as the compass).
*/
function phaseOf(id) {
let h = 0;
const s = String(id ?? '');
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 997;
return (h / 997) * TAU;
}