183 lines
6.9 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
}
|