fertig-classic-games/src/games/geometrywars/GeometryWarsFx.js

183 lines
6.9 KiB
JavaScript

// Particle field + shockwave rings for Geometry Wars.
//
// Particles are a fixed-size struct-of-arrays pool (oldest slot reused when
// full) drawn as velocity-aligned streaks — the look of a Geometry Wars
// explosion is thousands of short neon dashes slowing down, bouncing off the
// arena walls and getting sucked into black holes. Drawing is batched by
// (colour, alpha band, width) so a full screen costs a few dozen
// lineStyle switches rather than one per particle.
//
// Phaser-free: draw() only calls Graphics methods on the object it is given.
export const FX_MAX = 2800;
const ALPHA_BANDS = 4;
const WIDTHS = [2, 3.4];
export default class ParticleField {
constructor(bounds) {
this.bounds = bounds; // { w, h }
const n = FX_MAX;
this.x = new Float32Array(n); this.y = new Float32Array(n);
this.vx = new Float32Array(n); this.vy = new Float32Array(n);
this.life = new Float32Array(n); this.maxLife = new Float32Array(n);
this.len = new Float32Array(n); this.damp = new Float32Array(n);
this.col = new Uint8Array(n); this.wid = new Uint8Array(n);
this.cursor = 0;
this.palette = [];
this.paletteIndex = new Map();
this.rings = [];
this.buckets = new Map();
}
colorId(color) {
let id = this.paletteIndex.get(color);
if (id === undefined) {
id = this.palette.length;
if (id >= 255) return 0;
this.palette.push(color);
this.paletteIndex.set(color, id);
}
return id;
}
liveCount() {
let n = 0;
for (let i = 0; i < FX_MAX; i += 1) if (this.life[i] > 0) n += 1;
return n;
}
clear() {
this.life.fill(0);
this.rings.length = 0;
}
emit(x, y, vx, vy, lifeMs, color, opts = {}) {
const i = this.cursor;
this.cursor = (i + 1) % FX_MAX;
this.x[i] = x; this.y[i] = y;
this.vx[i] = vx; this.vy[i] = vy;
this.life[i] = lifeMs; this.maxLife[i] = lifeMs;
this.len[i] = opts.len ?? 1;
this.damp[i] = opts.damp ?? 0.955;
this.col[i] = this.colorId(color);
this.wid[i] = opts.wide ? 1 : 0;
}
// Classic radial burst. Speeds follow 1 - 1/rand so most sparks are fast
// and a few linger near the centre, which reads as a "hot core".
explosion(x, y, color, opts = {}) {
const count = opts.count ?? 60;
const speed = opts.speed ?? 1000;
const life = opts.life ?? 1100;
const color2 = opts.color2 ?? color;
for (let k = 0; k < count; k += 1) {
const a = Math.random() * Math.PI * 2;
const sp = speed * (1 - 1 / (1 + Math.random() * 9));
const c = Math.random() < 0.5 ? color : color2;
this.emit(x, y, Math.cos(a) * sp, Math.sin(a) * sp, life * (0.55 + Math.random() * 0.6), c,
{ len: opts.len ?? 1, wide: opts.wide && Math.random() < 0.5 });
}
}
// Sparks thrown back off a wall hit, reflected about the wall normal.
wallSparks(x, y, angle, color, count = 18) {
const { w, h } = this.bounds;
let nx = 0; let ny = 0;
if (x <= 1) nx = 1; else if (x >= w - 1) nx = -1;
if (y <= 1) ny = 1; else if (y >= h - 1) ny = -1;
let dx = Math.cos(angle); let dy = Math.sin(angle);
if (nx) dx = -dx;
if (ny) dy = -dy;
const base = Math.atan2(dy, dx);
for (let k = 0; k < count; k += 1) {
const a = base + (Math.random() - 0.5) * 1.6;
const sp = 200 + Math.random() * 700;
this.emit(x + nx * 2, y + ny * 2, Math.cos(a) * sp, Math.sin(a) * sp, 380 + Math.random() * 380, color);
}
}
ring(x, y, maxR, lifeMs, color, width = 3, startR = 0) {
if (this.rings.length > 60) this.rings.shift();
this.rings.push({ x, y, r: startR, startR, maxR, life: lifeMs, maxLife: lifeMs, color, width });
}
// holes: [{x, y, r}] — active black holes pull sparks in and spin them.
update(dtMs, holes = []) {
const dt = dtMs / 1000;
if (dt <= 0) return;
const { w, h } = this.bounds;
const f60 = dt * 60;
for (let i = 0; i < FX_MAX; i += 1) {
if (this.life[i] <= 0) continue;
this.life[i] -= dtMs;
if (this.life[i] <= 0) continue;
let vx = this.vx[i]; let vy = this.vy[i];
for (let k = 0; k < holes.length; k += 1) {
const hole = holes[k];
const dx = hole.x - this.x[i]; const dy = hole.y - this.y[i];
const d2 = dx * dx + dy * dy;
if (d2 > 160000) continue;
const d = Math.sqrt(d2) || 1;
if (d < hole.r * 0.6) { this.life[i] = 0; break; }
const pull = 2600 * (1 - d / 400) * dt;
vx += (dx / d) * pull - (dy / d) * pull * 0.9;
vy += (dy / d) * pull + (dx / d) * pull * 0.9;
}
const damp = Math.pow(this.damp[i], f60);
vx *= damp; vy *= damp;
let x = this.x[i] + vx * dt; let y = this.y[i] + vy * dt;
if (x < 0) { x = -x; vx = -vx * 0.6; } else if (x > w) { x = 2 * w - x; vx = -vx * 0.6; }
if (y < 0) { y = -y; vy = -vy * 0.6; } else if (y > h) { y = 2 * h - y; vy = -vy * 0.6; }
this.x[i] = x; this.y[i] = y; this.vx[i] = vx; this.vy[i] = vy;
}
for (const r of this.rings) {
r.life -= dtMs;
const t = 1 - Math.max(0, r.life) / r.maxLife;
r.r = r.startR + (r.maxR - r.startR) * (1 - (1 - t) * (1 - t));
}
if (this.rings.length && this.rings[0].life <= 0) this.rings = this.rings.filter((r) => r.life > 0);
}
draw(g, view) {
const x0 = view.x - 40; const x1 = view.x + view.w + 40;
const y0 = view.y - 40; const y1 = view.y + view.h + 40;
for (const list of this.buckets.values()) list.length = 0;
for (let i = 0; i < FX_MAX; i += 1) {
if (this.life[i] <= 0) continue;
const x = this.x[i]; const y = this.y[i];
if (x < x0 || x > x1 || y < y0 || y > y1) continue;
const f = this.life[i] / this.maxLife[i];
const band = Math.min(ALPHA_BANDS - 1, Math.floor(Math.min(1, f * 1.6) * ALPHA_BANDS));
const key = (this.col[i] * ALPHA_BANDS + band) * 2 + this.wid[i];
let list = this.buckets.get(key);
if (!list) { list = []; this.buckets.set(key, list); }
list.push(i);
}
for (const [key, list] of this.buckets) {
if (list.length === 0) continue;
const wid = key % 2;
const band = Math.floor(key / 2) % ALPHA_BANDS;
const col = Math.floor(key / 2 / ALPHA_BANDS);
g.lineStyle(WIDTHS[wid], this.palette[col], (band + 1) / ALPHA_BANDS);
g.beginPath();
for (const i of list) {
const vx = this.vx[i]; const vy = this.vy[i];
const sp = Math.sqrt(vx * vx + vy * vy);
const L = Math.min(28, Math.max(2.5, sp * 0.02)) * this.len[i];
const ux = sp > 1 ? vx / sp : 1; const uy = sp > 1 ? vy / sp : 0;
g.moveTo(this.x[i] - ux * L, this.y[i] - uy * L);
g.lineTo(this.x[i], this.y[i]);
}
g.strokePath();
}
for (const r of this.rings) {
if (r.life <= 0) continue;
const f = Math.max(0, r.life) / r.maxLife;
g.lineStyle(r.width * 3, r.color, 0.18 * f);
g.strokeCircle(r.x, r.y, r.r);
g.lineStyle(r.width, r.color, 0.85 * f);
g.strokeCircle(r.x, r.y, r.r);
}
}
}