518 lines
20 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|