517 lines
19 KiB
JavaScript
517 lines
19 KiB
JavaScript
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);
|
||
}
|