203 lines
7.8 KiB
JavaScript
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;
|
|
}
|