orbit/js/scan/ScanPulse.js

518 lines
20 KiB
JavaScript

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