Back to Pickle Rustlers
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.

src/game/fx.ts
// 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);
    }
  }
}