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