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; }