orbit/js/mining/MiningBeam.js

517 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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