SOURCE / PINNED RELEASE
Made of little things.
Pickle Rustlers
- Release
- 039e51f55e34…
- Author-recorded commit
- 40608586a8a1…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-bad44e7ab35
Archive hash verified: f38e99afa0992052…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Pooled low-poly particles and expanding blast flashes.
import * as THREE from 'three';
interface Particle {
mesh: THREE.Object3D;
color: THREE.Color;
cube: boolean;
vel: THREE.Vector3;
spin: THREE.Vector3;
life: number;
max: number;
gravity: number;
size: number;
shrink: boolean;
}
interface Flash {
mesh: THREE.Mesh;
life: number;
max: number;
size: number;
}
export interface Hazard {
x: number;
z: number;
life: number;
}
const POOL = 360;
export class Effects {
private particles: Particle[] = [];
private next = 0;
private flashes: Flash[] = [];
private geo = new THREE.TetrahedronGeometry(1, 0);
private boxGeo = new THREE.BoxGeometry(1, 1, 1);
private batches: THREE.InstancedMesh[];
private target = new THREE.Vector3();
private dirty = true;
private flashGeo = new THREE.IcosahedronGeometry(1, 1);
readonly flames: Hazard[] = [];
constructor(private scene: THREE.Scene) {
const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
this.batches = [this.geo, this.boxGeo].map(geometry => {
const batch = new THREE.InstancedMesh(geometry, material, POOL);
batch.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
batch.setColorAt(0, material.color);
batch.instanceColor!.setUsage(THREE.DynamicDrawUsage);
batch.count = 0;
// Particles move across the whole arena; stale aggregate bounds would hide them.
batch.frustumCulled = false;
scene.add(batch);
return batch;
});
for (let i = 0; i < POOL; i++) {
const mesh = new THREE.Object3D();
mesh.visible = false;
this.particles.push({
mesh,
color: new THREE.Color(),
cube: false,
vel: new THREE.Vector3(),
spin: new THREE.Vector3(),
life: 0,
max: 1,
gravity: 0,
size: 1,
shrink: true,
});
}
}
spawn(
pos: THREE.Vector3,
vel: THREE.Vector3,
color: number,
life = 0.8,
size = 0.25,
gravity = 14,
cube = false,
): void {
this.dirty = true;
const p = this.particles[this.next];
this.next = (this.next + 1) % POOL;
p.cube = cube;
p.color.setHex(color);
p.mesh.position.copy(pos);
p.mesh.scale.setScalar(size);
p.mesh.visible = true;
p.vel.copy(vel);
p.spin.set(Math.random() * 8 - 4, Math.random() * 8 - 4, Math.random() * 8 - 4);
p.life = p.max = life;
p.gravity = gravity;
p.size = size;
p.shrink = true;
}
burst(pos: THREE.Vector3, color: number, count: number, speed: number, size = 0.25, life = 0.8, up = 6, gravity = 14): void {
const v = new THREE.Vector3();
for (let i = 0; i < count; i++) {
v.set(Math.random() * 2 - 1, Math.random(), Math.random() * 2 - 1).normalize().multiplyScalar(speed * (0.4 + Math.random() * 0.6));
v.y += up * Math.random();
this.spawn(pos, v, color, life * (0.6 + Math.random() * 0.6), size * (0.6 + Math.random() * 0.8), gravity);
}
}
/** A pellet streak: a thin stretched box flying in a direction. */
streak(from: THREE.Vector3, dir: THREE.Vector3, speed: number, color = 0xffe08a): void {
this.dirty = true;
const p = this.particles[this.next];
this.next = (this.next + 1) % POOL;
p.cube = true;
p.color.setHex(color);
p.mesh.position.copy(from);
p.mesh.scale.set(0.08, 0.08, 1.4);
p.mesh.lookAt(this.target.copy(from).add(dir));
p.mesh.visible = true;
p.vel.copy(dir).multiplyScalar(speed);
p.spin.set(0, 0, 0);
p.life = p.max = 0.28;
p.gravity = 0;
p.size = 1;
p.shrink = false;
}
flash(pos: THREE.Vector3, size: number, color = 0xffb703, life = 0.45): void {
const m = new THREE.Mesh(
this.flashGeo,
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9, depthWrite: false }),
);
m.position.copy(pos);
m.scale.setScalar(size * 0.3);
this.scene.add(m);
this.flashes.push({ mesh: m, life, max: life, size });
}
/** A round firework shell: an even sphere of sparks that drift down slowly. */
firework(pos: THREE.Vector3, color: number): void {
const v = new THREE.Vector3();
for (let i = 0; i < 48; i++) {
v.set(Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() * 2 - 1).normalize().multiplyScalar(13 + Math.random() * 4);
this.spawn(pos, v, i % 5 === 0 ? 0xffffff : color, 1.5 + Math.random() * 0.5, 0.7, 5);
}
this.flash(pos, 6, color, 0.35);
}
flame(x: number, z: number, y: number): void {
this.flames.push({ x, z, life: 1.6 });
const v = new THREE.Vector3((Math.random() - 0.5) * 1.5, 2 + Math.random() * 2, (Math.random() - 0.5) * 1.5);
const pos = new THREE.Vector3(x + (Math.random() - 0.5), y + 0.3, z + (Math.random() - 0.5));
this.spawn(pos, v, Math.random() > 0.5 ? 0xff7b00 : 0xffd000, 1.0, 0.6, -1);
if (Math.random() < 0.4) this.spawn(pos, v.multiplyScalar(0.6), 0xd62828, 1.2, 0.6, -2);
}
breath(from: THREE.Vector3, vel: THREE.Vector3): void {
for (let i = 0; i < 2; i++) {
const v = vel.clone().add(new THREE.Vector3((Math.random() - 0.5) * 4, Math.random() * 2, (Math.random() - 0.5) * 4));
this.spawn(from, v, i ? 0xffd000 : 0xff5400, 0.45, 0.55, -2);
}
}
syncInstances(): void {
if (!this.dirty) return;
this.dirty = false;
const counts = [0, 0];
for (const p of this.particles) {
if (!p.mesh.visible) continue;
const type = p.cube ? 1 : 0;
const batch = this.batches[type];
const index = counts[type]++;
p.mesh.updateMatrix();
batch.setMatrixAt(index, p.mesh.matrix);
batch.setColorAt(index, p.color);
}
this.batches.forEach((batch, i) => {
batch.count = counts[i];
batch.instanceMatrix.needsUpdate = true;
if (batch.instanceColor) batch.instanceColor.needsUpdate = true;
});
}
update(dt: number): void {
for (const p of this.particles) {
if (!p.mesh.visible) continue;
this.dirty = true;
p.life -= dt;
if (p.life <= 0) {
p.mesh.visible = false;
continue;
}
p.vel.y -= p.gravity * dt;
p.mesh.position.addScaledVector(p.vel, dt);
if (p.shrink) {
p.mesh.rotation.x += p.spin.x * dt;
p.mesh.rotation.y += p.spin.y * dt;
p.mesh.scale.setScalar(p.size * Math.max(0.05, p.life / p.max));
}
}
for (let i = this.flashes.length - 1; i >= 0; i--) {
const f = this.flashes[i];
f.life -= dt;
const k = 1 - f.life / f.max;
f.mesh.scale.setScalar(f.size * (0.3 + k * 0.9));
(f.mesh.material as THREE.MeshBasicMaterial).opacity = Math.max(0, 0.9 * (1 - k));
if (f.life <= 0) {
this.scene.remove(f.mesh);
(f.mesh.material as THREE.Material).dispose();
this.flashes.splice(i, 1);
}
}
for (let i = this.flames.length - 1; i >= 0; i--) {
this.flames[i].life -= dt;
if (this.flames[i].life <= 0) this.flames.splice(i, 1);
}
}
}
