import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; import { toColor } from '../utils/Color.js'; import { assignWorld } from '../visuals/UiCameras.js'; const SOFT_KEY = '__mining_soft'; /** * A soft radial-falloff dot (white, 64 px) — the beam's light "brush". * Generated once per scene (texture FIRST, then images — the v4 * __MISSING quirk), tinted per use. Same recipe as the cluster halo. */ function ensureSoftTexture(scene) { if (scene.textures.exists(SOFT_KEY)) return; const S = 64; const C = S / 2; const g = scene.make.graphics({ add: false }); const steps = 22; for (let i = steps; i >= 1; i--) { const t = i / steps; g.fillStyle(0xffffff, Math.pow(1 - t, 2.1) * 0.9); g.fillCircle(C, C, C * t); } g.generateTexture(SOFT_KEY, S, S); g.destroy(); } /** * The mining beam — the ship's energy arm latched onto a rock. * * What it is, layer by layer (all world space, redrawn per frame with * the TetherField graphics pattern — two Graphics passes + soft-light * sprites on top): * * in the beam EXTENDS from the ship's hull to the rock (a bright * head races the line), then slams on: arrival flash, shock * rings, and the first ore stream leaves the surface. * steady a white core in a blue glow, with the console's RGB channel * fringe split off to the sides; the surface stays molten * (hot core + pulsing halo); ore motes stream along the beam * asteroid → ship (each with a wobble and a tail) and are * absorbed at the hull (the emitter flashes); shock rings * peel off the impact point; sparks chip off the rock; and * every few seconds a SURGE — a bright packet of ore — rides * the beam home while the whole arm brightens. * out the beam retracts rock → ship and the whole thing dies in * ~380 ms (the "retracting arm" state of the ship). * * The beam tracks its target every frame: the rock keeps drifting * (AsteroidCluster.update refreshes m.wx/m.wy) and the ship may be held * at a rim — the geometry is re-derived from live positions, never * cached. * * Tuning: data/asteroids.json → mining (beam/ore/impact/surge blocks). */ export class MiningBeam { /** * @param {Phaser.Scene} scene * @param {object} mining — the Mining controller (its .member is the rock) */ constructor(scene, mining) { this.scene = scene; this.ship = scene.ship; this.member = mining.member; const cfg = config.section('asteroids.mining', {}); const b = cfg.beam ?? {}; const ore = cfg.ore ?? {}; const imp = cfg.impact ?? {}; const sur = cfg.surge ?? {}; this.inMs = b.inMs ?? 420; this.outMs = b.outMs ?? 380; this.glowWidth = b.glowWidth ?? 30; this.glowColor = toColor(b.glowColor, 0x2f7bff); this.glowAlpha = b.glowAlpha ?? 0.1; this.midWidth = b.midWidth ?? 15; this.midColor = toColor(b.midColor, 0x00b3ff); this.midAlpha = b.midAlpha ?? 0.22; this.mainWidth = b.mainWidth ?? 7; this.mainColor = toColor(b.mainColor, 0x00e5ff); this.mainAlpha = b.mainAlpha ?? 0.5; this.coreWidth = b.coreWidth ?? 2.6; this.coreColor = toColor(b.coreColor, 0xffffff); this.coreAlpha = b.coreAlpha ?? 0.95; this.ghostOffset = b.ghostOffset ?? 2.6; this.ghostCyan = toColor(b.ghostCyan, 0x00e5ff); this.ghostMag = toColor(b.ghostMag, 0xff2d6f); this.ghostAlpha = b.ghostAlpha ?? 0.2; this.oreMax = Math.max(4, ore.maxParticles ?? 28); this.oreSpawn = Array.isArray(ore.spawnEveryMs) ? ore.spawnEveryMs : [55, 110]; this.oreSpeed = Array.isArray(ore.speedPxPerSec) ? ore.speedPxPerSec : [140, 280]; this.oreWobble = Array.isArray(ore.wobblePx) ? ore.wobblePx : [1.5, 4]; this.oreSize = Array.isArray(ore.sizePx) ? ore.sizePx : [5, 12]; this.oreTail = Array.isArray(ore.tailPx) ? ore.tailPx : [8, 16]; this.impactGlowScale = imp.glowScale ?? 0.6; // × rock radius this.impactGlowMin = imp.glowMinPx ?? 30; this.impactCoreSize = imp.coreSizePx ?? 12; this.ringEvery = Array.isArray(imp.ringEveryMs) ? imp.ringEveryMs : [550, 1100]; this.sparkEvery = Array.isArray(imp.sparkEveryMs) ? imp.sparkEveryMs : [90, 240]; this.surgeEnabled = sur.enabled !== false; this.surgeEvery = Array.isArray(sur.everyMs) ? sur.everyMs : [1800, 4200]; this.surgeDur = sur.durationMs ?? 320; this.surgeBand = sur.bandLen ?? 46; ensureSoftTexture(scene); // ---- the two graphics passes (the TetherField pattern) ------------- this.gUnder = scene.add.graphics().setDepth(11).setBlendMode(Phaser.BlendModes.ADD); this.gOver = scene.add.graphics().setDepth(12); // ---- the soft-light sprites (radial texture, additive) ------------- this.impactGlow = this._soft(0xcfeaff, 11); this.impactCore = this._soft(0xffffff, 12.5); this.emitter = this._soft(0x9fd8ff, 12.5); this.head = this._soft(0xffffff, 12.5).setAlpha(0); // World-anchored (the beam spans ship→rock in world space): keep all // six passes off the UI camera while the UI/world split is live — // the additive glow would otherwise double-draw on the second pass. assignWorld(scene, this.gUnder); assignWorld(scene, this.gOver); assignWorld(scene, this.impactGlow); assignWorld(scene, this.impactCore); assignWorld(scene, this.emitter); assignWorld(scene, this.head); // ---- particle / event state ---------------------------------------- this.particles = []; // { active, u, t0, v, amp, phase, size, tail, tint, dot, px, py, pa } this.rings = []; // { t0, dur, r0, r1, w, a, color } this.sparks = []; // { t0, dur, x, y, vx, vy, size, color } this.surge = null; // { t0 } this.nextOre = 0; this.nextRing = 0; this.nextSpark = 0; this.nextSurge = 0; this.impactPulse = 0; // decaying flash at the rock (arrival / surges) this.emitterPulse = 0; // decaying flash at the ship (ore absorbed) this.state = 'in'; // 'in' | 'steady' | 'out' this.t0 = scene.time.now; this.inP = 0; this.outP = 0; this.outRunning = false; this.finished = false; } /** A soft additive sprite (the light brush), hidden until first use. */ _soft(tint, depth) { const img = this.scene.add.image(0, 0, SOFT_KEY).setTint(tint).setDepth(depth); img.setBlendMode(Phaser.BlendModes.ADD); img.setAlpha(0); return img; } /** * Begin the retract (rock → ship). No-op unless the beam is in or * steady — a beam cut off mid-extension (ESC right after the fire) * fades from its CURRENT reach, so it never pops longer first. */ beginOut(time) { if ((this.state !== 'steady' && this.state !== 'in') || this.finished) return; const extent = this.state === 'in' ? this.inP : 1; this.state = 'out'; this.outRunning = true; this.outP = 1 - extent; // continuous with the extent the beam already had this.t0 = time; // Kill the ambient schedules — nothing new is born while it dies. this.nextOre = time + 1e9; this.nextRing = time + 1e9; this.nextSpark = time + 1e9; this.nextSurge = time + 1e9; this.surge = null; } // ------------------------------------------------------------------ // Per-frame (Mining.update drives this) // ------------------------------------------------------------------ update(time, delta) { if (this.finished) return; const dt = Math.min(delta, 64) / 1000; const t = time * 0.001; // ---- geometry (live: the rock drifts, the ship is held) ------------ const ship = this.ship; const ex = this.member.wx - ship.x; const ey = this.member.wy - ship.y; const el = Math.hypot(ex, ey) || 1; const unx = ex / el; // ship → rock const uny = ey / el; const px = -uny; // perpendicular const py = unx; const rr = this.member.radius; // The impact point: on the rock's surface, facing the ship (slightly // inside the rim so the glow sits ON the stone, not floating off it). const ix = this.member.wx - unx * rr * 0.94; const iy = this.member.wy - uny * rr * 0.94; const sx = ship.x; const sy = ship.y; const len = Math.max(12, Math.hypot(ix - sx, iy - sy)); // ---- phase machine -------------------------------------------------- if (this.state === 'in') { const p = Phaser.Math.Clamp((time - this.t0) / this.inMs, 0, 1); this.inP = 1 - Math.pow(1 - p, 3); // ease-out: the head races, then settles if (p >= 1) { this.state = 'steady'; this.impactPulse = 1.6; // arrival flash this.spawnRing(1.25); for (let i = 0; i < 5; i++) this.spawnOre(0.04 + i * 0.13); // the first stream } } else if (this.state === 'out') { this.outP = Phaser.Math.Clamp((time - this.t0) / this.outMs, 0, 1); if (this.outP >= 1) { this.finished = true; return; } } // ---- decays ---------------------------------------------------------- this.impactPulse *= Math.exp(-(delta / 130)); this.emitterPulse *= Math.exp(-(delta / 110)); // ---- ambient schedules (steady only) --------------------------------- const steady = this.state === 'steady'; if (steady) { if (time >= this.nextOre) { this.spawnOre(0); this.nextOre = time + this.range(this.oreSpawn[0], this.oreSpawn[1]); } if (time >= this.nextRing) { this.spawnRing(1); this.nextRing = time + this.range(this.ringEvery[0], this.ringEvery[1]); } if (time >= this.nextSpark) { this.spawnSpark(ix, iy); this.nextSpark = time + this.range(this.sparkEvery[0], this.sparkEvery[1]); } if (this.surgeEnabled && time >= this.nextSurge) { this.surge = { t0: time }; this.impactPulse = Math.max(this.impactPulse, 1); this.spawnRing(0.9); this.nextSurge = time + this.range(this.surgeEvery[0], this.surgeEvery[1]); } } // ---- advance the ore stream ------------------------------------------ for (const o of this.particles) { if (!o.active) continue; o.u += (o.v / len) * dt; if (o.u >= 1) { o.active = false; o.dot.setAlpha(0); this.emitterPulse = Math.min(1.3, this.emitterPulse + 0.28); // absorbed at the hull } } for (const s of this.sparks) { s.vx *= Math.exp(-2.4 * dt); s.vy *= Math.exp(-2.4 * dt); s.x += s.vx * dt; s.y += s.vy * dt; } this.sparks = this.sparks.filter((s) => time - s.t0 < s.dur); this.draw(time, { sx, sy, ix, iy, unx, uny, px, py, len, rr, t }); } // ------------------------------------------------------------------ // Spawners // ------------------------------------------------------------------ /** An ore mote leaving the rock and riding the beam toward the ship. */ spawnOre(u0 = 0) { let o = this.particles.find((p) => !p.active); if (!o) { if (this.particles.length >= this.oreMax) return; o = { dot: this._soft(0xffffff, 12.5), px: 0, py: 0, pa: 0, }; this.particles.push(o); } o.active = true; o.u = u0; o.t0 = this.scene.time.now; o.v = this.range(this.oreSpeed[0], this.oreSpeed[1]); o.amp = this.range(this.oreWobble[0], this.oreWobble[1]); o.phase = Math.random() * Math.PI * 2; o.size = this.range(this.oreSize[0], this.oreSize[1]); o.tail = this.range(this.oreTail[0], this.oreTail[1]); o.tint = [0xffffff, 0xbfefff, 0x9fdcff, 0xffffff][(Math.random() * 4) | 0]; o.dot.setTint(o.tint).setScale(o.size / 64).setAlpha(0); } /** A shock ring peeling off the impact point (cyan + a hot white inner). */ spawnRing(strength = 1) { const rr = this.member.radius; this.rings.push({ t0: this.scene.time.now, dur: 700, r0: rr * 0.5, r1: rr * 2.1, w: 2.2, a: 0.5 * strength, color: 0x7fe8ff, }); this.rings.push({ t0: this.scene.time.now, dur: 430, r0: rr * 0.28, r1: rr * 1.2, w: 1.6, a: 0.7 * strength, color: 0xffffff, }); if (this.rings.length > 8) this.rings.splice(0, this.rings.length - 8); } /** A spark chipping off the rock (energy fleck, radial). */ spawnSpark(ix, iy) { const a = Math.random() * Math.PI * 2; const v = this.range(46, 150); this.sparks.push({ t0: this.scene.time.now, dur: this.range(260, 640), x: ix, y: iy, vx: Math.cos(a) * v, vy: Math.sin(a) * v, size: this.range(1.2, 2.6), color: Math.random() < 0.5 ? 0xdff4ff : 0xffffff, }); if (this.sparks.length > 14) this.sparks.splice(0, this.sparks.length - 14); } // ------------------------------------------------------------------ // Drawing // ------------------------------------------------------------------ /** * One frame of the beam. `g` holds this frame's geometry (see update). * extent = how much of the beam exists (in/out animation), master = * the global alpha envelope. */ draw(time, g) { const { sx, sy, ix, iy, unx, uny, px, py, t } = g; const U = this.gUnder; const O = this.gOver; U.clear(); O.clear(); let extent = 1; let master = 1; if (this.state === 'in') { extent = Math.max(0.02, this.inP); master = 0.3 + 0.7 * this.inP; } else if (this.state === 'out') { extent = 1 - this.outP; master = 1 - this.outP; } // The beam's live end (the head while it extends/retracts). const hx = sx + (ix - sx) * extent; const hy = sy + (iy - sy) * extent; // A living beam: slow breath + a 12 Hz console flicker (subtle — // power hum, not strobe). const quant = Math.floor(time / 80); const f = hash01(quant, 7); const flick = 0.86 + 0.28 * f; const breath = 0.92 + 0.08 * Math.sin(t * 2.4); const A = master * flick * breath; // The surge packet (a bright ore charge riding impact → ship). let surge = 0; let band = null; if (this.surge) { const sp = (time - this.surge.t0) / this.surgeDur; if (sp >= 1) { this.surge = null; } else { surge = 1 - sp; const bp = 1 - Math.pow(1 - sp, 2.2); // leaves fast, settles at the hull band = { x: ix + (sx - ix) * bp, y: iy + (sy - iy) * bp }; } } if (extent > 0.01) { // ---- the glow stack (additive): wide blue → mid → fringe --------- U.lineStyle( this.glowWidth * (1 + 0.1 * Math.sin(t * 3.1)) * (1 + surge * 0.35), this.glowColor, A * this.glowAlpha * (1 + surge * 0.9), ); U.lineBetween(sx, sy, hx, hy); U.lineStyle(this.midWidth * (1 + surge * 0.3), this.midColor, A * this.midAlpha * (1 + surge * 0.7)); U.lineBetween(sx, sy, hx, hy); // The console's RGB channel split — the fringe rides perpendicular // to the beam (cyan up, magenta down), widening during a surge. const go = this.ghostOffset * (1 + surge * 0.9); U.lineStyle(1.8, this.ghostCyan, A * this.ghostAlpha * (1 + surge * 0.5)); U.lineBetween(sx + px * go, sy + py * go, hx + px * go, hy + py * go); U.lineStyle(1.8, this.ghostMag, A * this.ghostAlpha * 0.9 * (1 + surge * 0.5)); U.lineBetween(sx - px * go, sy - py * go, hx - px * go, hy - py * go); // ---- the surge band (a bright packet on the beam) ----------------- if (band) { const hl = (this.surgeBand * 0.5) / extent; // keep the band on the live part U.lineStyle(this.glowWidth * 0.8, this.midColor, 0.35 * surge); U.lineBetween(band.x - unx * hl, band.y - uny * hl, band.x + unx * hl, band.y + uny * hl); U.lineStyle(this.mainWidth * 2.4, 0xffffff, 0.55 * surge); U.lineBetween(band.x - unx * hl * 0.7, band.y - uny * hl * 0.7, band.x + unx * hl * 0.7, band.y + uny * hl * 0.7); } // ---- the hot core (over layer): white over cyan -------------------- O.lineStyle(this.mainWidth, this.mainColor, A * this.mainAlpha * (1 + surge * 0.4)); O.lineBetween(sx, sy, hx, hy); O.lineStyle(this.coreWidth * 3.2, 0xffffff, A * 0.18); O.lineBetween(sx, sy, hx, hy); O.lineStyle(this.coreWidth, this.coreColor, A * this.coreAlpha); O.lineBetween(sx, sy, hx, hy); } // ---- shock rings at the impact point -------------------------------- this.rings = this.rings.filter((r) => { const p = (time - r.t0) / r.dur; if (p >= 1) return false; if (p < 0) return true; const rad = Phaser.Math.Linear(r.r0, r.r1, 1 - Math.pow(1 - p, 3)); U.lineStyle(r.w, r.color, r.a * (1 - p) * master); U.strokeCircle(ix, iy, rad); return true; }); // ---- sparks (energy flecks chipping off the stone) ------------------- for (const s of this.sparks) { const p = (time - s.t0) / s.dur; if (p < 0 || p >= 1) continue; U.lineStyle(s.size, s.color, (1 - p) * 0.85 * master); U.lineBetween(s.x, s.y, s.x - s.vx * 0.05, s.y - s.vy * 0.05); } // ---- the ore stream (motes + tails, asteroid → ship) ------------------ for (const o of this.particles) { if (!o.active) continue; const age = time - o.t0; const inF = Math.min(1, age / 110); // fade in at the surface const outF = o.u > 0.8 ? (1 - o.u) / 0.2 : 1; // absorbed at the hull const a = inF * outF * 0.9 * master; if (a < 0.02) { o.dot.setAlpha(0); continue; } const wob = Math.sin(o.phase + o.u * 14) * o.amp; // a wave travelling with the mote const ox = ix + (sx - ix) * o.u + px * wob; const oy = iy + (sy - iy) * o.u + py * wob; o.dot.setPosition(ox, oy).setAlpha(Math.min(1, a * 1.1)); U.lineStyle(1.5, o.tint, a * 0.5); U.lineBetween(ox - unx * o.tail, oy - uny * o.tail, ox, oy); } // ---- the impact point (molten where the arm bites the stone) ----------- const igD = Math.max(this.impactGlowMin, this.member.radius * this.impactGlowScale) * (1 + this.impactPulse * 0.9); this.impactGlow.setPosition(ix, iy).setScale(igD / 64).setAlpha(Math.min(1, A * 0.4 + this.impactPulse * 0.45)); this.impactCore .setPosition(ix, iy) .setScale((this.impactCoreSize * (1 + this.impactPulse * 0.7)) / 64) .setAlpha(Math.min(1, A * 0.75 + this.impactPulse * 0.3)); // ---- the emitter (where the arm leaves the hull) ------------------------ this.emitter .setPosition(sx, sy) .setScale((18 * (1 + this.emitterPulse)) / 64) .setAlpha(Math.min(1, A * 0.3 + this.emitterPulse * 0.55)); // ---- the head (only while the beam extends/retracts) -------------------- if (this.state !== 'steady') { const hp = this.state === 'in' ? this.inP : 1 - this.outP; this.head.setPosition(hx, hy).setScale(22 / 64).setAlpha(Math.min(1, master * (0.4 + 0.6 * (1 - Math.abs(hp - 0.5) * 2)))); } else { this.head.setAlpha(0); } } // ------------------------------------------------------------------ range(lo, hi) { return lo + Math.random() * (hi - lo); } destroy() { this.gUnder?.destroy(); this.gOver?.destroy(); this.impactGlow?.destroy(); this.impactCore?.destroy(); this.emitter?.destroy(); this.head?.destroy(); for (const o of this.particles) o.dot?.destroy(); this.particles.length = 0; this.rings.length = 0; this.sparks.length = 0; this.gUnder = this.gOver = null; this.impactGlow = this.impactCore = this.emitter = this.head = null; } } /** Deterministic 0..1 hash (the same recipe as the tether's flicker). */ function hash01(i, salt) { const s = Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453; return s - Math.floor(s); }