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