Back to Mini Samurai
SOURCE / PINNED RELEASE

Made of little things.

Mini Samurai

Release
a7beb798adbc…
Author-recorded commit
4f97be1515b8…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-3b28f0efc5b

Archive hash verified: 401ec9347ab8f8cd…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/fx.ts
import * as THREE from 'three';

interface Particle {
  alive: boolean;
  pos: THREE.Vector3;
  vel: THREE.Vector3;
  life: number;
  maxLife: number;
  size: number;
  grow: number;
  gravity: number;
  drag: number;
  spin: THREE.Vector3;
  rot: THREE.Euler;
  color: THREE.Color;
  floor: number;
}

/**
 * Billowing smoke: soft round puffs that always face the camera, drawn as one
 * instanced quad mesh. Each puff swells, slows, rises and fades on its own, so many
 * overlapping puffs read as a single cloud.
 */
class SmokeCloud {
  readonly mesh: THREE.InstancedMesh;
  private readonly n: number;
  private readonly pos: Float32Array;
  private readonly vel: Float32Array;
  private readonly age: Float32Array;
  private readonly life: Float32Array;
  private readonly size0: Float32Array;
  private readonly size1: Float32Array;
  private readonly peak: Float32Array;
  private readonly alpha: THREE.InstancedBufferAttribute;
  private readonly size: THREE.InstancedBufferAttribute;
  private readonly spin: THREE.InstancedBufferAttribute;
  private cursor = 0;
  private readonly m = new THREE.Matrix4();

  constructor(scene: THREE.Object3D, count: number) {
    this.n = count;
    this.pos = new Float32Array(count * 3);
    this.vel = new Float32Array(count * 3);
    this.age = new Float32Array(count).fill(1);
    this.life = new Float32Array(count).fill(1);
    this.size0 = new Float32Array(count);
    this.size1 = new Float32Array(count);
    this.peak = new Float32Array(count);
    const geo = new THREE.PlaneGeometry(1, 1);
    this.alpha = new THREE.InstancedBufferAttribute(new Float32Array(count), 1).setUsage(THREE.DynamicDrawUsage);
    this.size = new THREE.InstancedBufferAttribute(new Float32Array(count), 1).setUsage(THREE.DynamicDrawUsage);
    this.spin = new THREE.InstancedBufferAttribute(new Float32Array(count).map(() => Math.random() * Math.PI * 2), 1);
    geo.setAttribute('aAlpha', this.alpha);
    geo.setAttribute('aSize', this.size);
    geo.setAttribute('aSpin', this.spin);
    const mat = new THREE.ShaderMaterial({
      transparent: true,
      depthWrite: false,
      uniforms: { uColor: { value: new THREE.Color(0xf4f4f2) }, uShade: { value: new THREE.Color(0xc9cacc) } },
      vertexShader: `
        attribute float aAlpha;
        attribute float aSize;
        attribute float aSpin;
        varying float vAlpha;
        varying vec2 vUv;
        void main() {
          // Billboard: a quad centred on the puff, turned to face the camera.
          vec4 centre = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
          float c = cos(aSpin), s = sin(aSpin);
          vec2 corner = vec2(position.x * c - position.y * s, position.x * s + position.y * c);
          centre.xy += corner * aSize;
          vUv = uv;
          vAlpha = aAlpha;
          gl_Position = projectionMatrix * centre;
        }`,
      fragmentShader: `
        uniform vec3 uColor;
        uniform vec3 uShade;
        varying float vAlpha;
        varying vec2 vUv;
        void main() {
          vec2 p = vUv - 0.5;
          float d = length(p) * 2.0;
          if (d > 1.0) discard;
          // Soft edge, a slightly lumpy outline, and a little shading toward the bottom.
          float lump = 0.88 + 0.12 * sin(atan(p.y, p.x) * 5.0 + vAlpha * 3.0);
          float a = vAlpha * smoothstep(1.0, 0.25, d / lump);
          vec3 col = mix(uShade, uColor, clamp(0.55 + p.y * 1.4 + (1.0 - d) * 0.3, 0.0, 1.0));
          gl_FragColor = vec4(col, a);
        }`,
    });
    this.mesh = new THREE.InstancedMesh(geo, mat, count);
    this.mesh.frustumCulled = false;
    this.mesh.renderOrder = 3;
    for (let i = 0; i < count; i++) this.mesh.setMatrixAt(i, this.m.makeTranslation(0, -999, 0));
    scene.add(this.mesh);
  }

  /** A puff at (x, y, z) moving at (vx, vy, vz), growing from size a to b over its life. */
  puff(x: number, y: number, z: number, vx: number, vy: number, vz: number, a: number, b: number, life: number, peak: number): void {
    const i = this.cursor;
    this.cursor = (this.cursor + 1) % this.n;
    this.pos.set([x, y, z], i * 3);
    this.vel.set([vx, vy, vz], i * 3);
    this.age[i] = 0;
    this.life[i] = life;
    this.size0[i] = a;
    this.size1[i] = b;
    this.peak[i] = peak;
  }

  update(dt: number): void {
    const alpha = this.alpha.array as Float32Array;
    const size = this.size.array as Float32Array;
    const drag = Math.exp(-1.6 * dt);
    for (let i = 0; i < this.n; i++) {
      if (this.age[i]! >= this.life[i]!) {
        if (alpha[i] !== 0) {
          alpha[i] = 0;
          size[i] = 0;
          this.mesh.setMatrixAt(i, this.m.makeTranslation(0, -999, 0));
        }
        continue;
      }
      this.age[i]! += dt;
      const t = Math.min(1, this.age[i]! / this.life[i]!);
      const k = i * 3;
      // Slows down, drifts upward and keeps spreading.
      this.vel[k]! *= drag;
      this.vel[k + 2]! *= drag;
      this.vel[k + 1] = this.vel[k + 1]! * drag + 0.5 * dt;
      this.pos[k]! += this.vel[k]! * dt;
      this.pos[k + 1]! += this.vel[k + 1]! * dt;
      this.pos[k + 2]! += this.vel[k + 2]! * dt;
      size[i] = this.size0[i]! + (this.size1[i]! - this.size0[i]!) * (1 - (1 - t) * (1 - t));
      // Thickens fast, then thins out slowly.
      alpha[i] = this.peak[i]! * Math.min(1, t * 6) * (1 - t) * (1 - t * 0.3);
      this.mesh.setMatrixAt(i, this.m.makeTranslation(this.pos[k]!, this.pos[k + 1]!, this.pos[k + 2]!));
    }
    this.alpha.needsUpdate = true;
    this.size.needsUpdate = true;
    this.mesh.instanceMatrix.needsUpdate = true;
  }
}

/** Pool of tiny low-poly chips drawn with one instanced mesh. */
class ParticlePool {
  readonly mesh: THREE.InstancedMesh;
  private readonly items: Particle[] = [];
  private cursor = 0;

  constructor(geometry: THREE.BufferGeometry, material: THREE.Material, count: number, scene: THREE.Object3D) {
    this.mesh = new THREE.InstancedMesh(geometry, material, count);
    this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
    this.mesh.frustumCulled = false;
    this.mesh.count = 0;
    for (let i = 0; i < count; i++) {
      this.items.push({
        alive: false,
        pos: new THREE.Vector3(),
        vel: new THREE.Vector3(),
        life: 0,
        maxLife: 1,
        size: 1,
        grow: 0,
        gravity: 0,
        drag: 0,
        spin: new THREE.Vector3(),
        rot: new THREE.Euler(),
        color: new THREE.Color(),
        floor: -99,
      });
    }
    this.mesh.setColorAt(0, new THREE.Color());
    scene.add(this.mesh);
  }

  spawn(init: (p: Particle) => void): void {
    const p = this.items[this.cursor]!;
    this.cursor = (this.cursor + 1) % this.items.length;
    p.alive = true;
    p.grow = 0;
    p.drag = 0;
    p.floor = -99;
    p.rot.set(Math.random() * 6, Math.random() * 6, Math.random() * 6);
    p.spin.set((Math.random() - 0.5) * 20, (Math.random() - 0.5) * 20, (Math.random() - 0.5) * 20);
    init(p);
    p.life = p.maxLife;
  }

  update(dt: number): void {
    let n = 0;
    for (const p of this.items) {
      if (!p.alive) continue;
      p.life -= dt;
      if (p.life <= 0) {
        p.alive = false;
        continue;
      }
      p.vel.y -= p.gravity * dt;
      p.vel.multiplyScalar(Math.exp(-p.drag * dt));
      p.pos.addScaledVector(p.vel, dt);
      if (p.pos.y < p.floor) {
        p.pos.y = p.floor;
        p.vel.y *= -0.3;
        p.vel.x *= 0.5;
        p.vel.z *= 0.5;
      }
      p.rot.x += p.spin.x * dt;
      p.rot.y += p.spin.y * dt;
      p.rot.z += p.spin.z * dt;
      const k = p.life / p.maxLife;
      const s = Math.max(0.0001, p.size * (p.grow ? 1 + (1 - k) * p.grow : k ** 0.5));
      _m.compose(p.pos, _q.setFromEuler(p.rot), _s.set(s, s, s));
      this.mesh.setMatrixAt(n, _m);
      this.mesh.setColorAt(n, p.color);
      n++;
    }
    this.mesh.count = n;
    this.mesh.instanceMatrix.needsUpdate = true;
    if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true;
  }
}

const _m = new THREE.Matrix4();
const _q = new THREE.Quaternion();
const _s = new THREE.Vector3();

/** Fading ribbon swept by a blade. */
class Trail {
  private readonly bases: THREE.Vector3[] = [];
  private readonly tips: THREE.Vector3[] = [];
  private readonly ages: number[] = [];
  readonly mesh: THREE.Mesh;
  private readonly positions: Float32Array;
  private readonly colors: Float32Array;
  private static readonly MAX = 14;

  constructor(scene: THREE.Object3D, private readonly tint: THREE.Color) {
    const n = Trail.MAX;
    this.positions = new Float32Array(n * 2 * 3);
    this.colors = new Float32Array(n * 2 * 3);
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(this.positions, 3).setUsage(THREE.DynamicDrawUsage));
    geo.setAttribute('color', new THREE.BufferAttribute(this.colors, 3).setUsage(THREE.DynamicDrawUsage));
    const index: number[] = [];
    for (let i = 0; i < n - 1; i++) {
      const a = i * 2;
      index.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
    }
    geo.setIndex(index);
    this.mesh = new THREE.Mesh(
      geo,
      new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide }),
    );
    this.mesh.frustumCulled = false;
    scene.add(this.mesh);
  }

  push(base: THREE.Vector3, tip: THREE.Vector3, emit: boolean): void {
    if (emit) {
      // Add an interpolated midpoint so fast swings stay smooth.
      const last = this.tips.length - 1;
      if (last >= 0 && this.ages[last]! < 0.02) {
        const mb = this.bases[last]!.clone().lerp(base, 0.5);
        const mt = this.tips[last]!.clone().lerp(tip, 0.5);
        const len = mt.distanceTo(mb);
        mt.sub(mb).setLength(len).add(mb);
        this.bases.push(mb);
        this.tips.push(mt);
        this.ages.push(0);
      }
      this.bases.push(base.clone());
      this.tips.push(tip.clone());
      this.ages.push(0);
      while (this.tips.length > Trail.MAX) {
        this.bases.shift();
        this.tips.shift();
        this.ages.shift();
      }
    }
  }

  update(dt: number): void {
    for (let i = 0; i < this.ages.length; i++) this.ages[i]! += dt;
    while (this.ages.length && this.ages[0]! > 0.16) {
      this.ages.shift();
      this.bases.shift();
      this.tips.shift();
    }
    const n = this.tips.length;
    for (let i = 0; i < Trail.MAX; i++) {
      const j = Math.min(i, n - 1);
      const base = n ? this.bases[j]! : _zero;
      const tip = n ? this.tips[j]! : _zero;
      const fade = n ? Math.max(0, 1 - this.ages[j]! / 0.16) * (j / Math.max(1, n - 1)) : 0;
      this.positions.set([base.x, base.y, base.z, tip.x, tip.y, tip.z], i * 6);
      const c = this.tint;
      this.colors.set([c.r * fade * 0.15, c.g * fade * 0.15, c.b * fade * 0.15, c.r * fade, c.g * fade, c.b * fade], i * 6);
    }
    const geo = this.mesh.geometry;
    geo.attributes.position!.needsUpdate = true;
    geo.attributes.color!.needsUpdate = true;
    this.mesh.visible = n > 1;
  }
}

const _zero = new THREE.Vector3();

interface Flash {
  mesh: THREE.Mesh;
  life: number;
  maxLife: number;
  size: number;
  kind: 'star' | 'ring';
}

export class Effects {
  private readonly chips: ParticlePool;
  private readonly glow: ParticlePool;
  private readonly dustPool: ParticlePool;
  private readonly smoke: SmokeCloud;
  private readonly trails = new Map<number, Trail>();
  private readonly flashes: Flash[] = [];
  private readonly starGeo: THREE.BufferGeometry;
  private readonly ringGeo = new THREE.RingGeometry(0.85, 1, 24);

  constructor(private readonly scene: THREE.Object3D) {
    this.chips = new ParticlePool(
      new THREE.TetrahedronGeometry(0.05),
      new THREE.MeshStandardMaterial({ flatShading: true, roughness: 0.6 }),
      220,
      scene,
    );
    this.glow = new ParticlePool(new THREE.OctahedronGeometry(0.025), new THREE.MeshBasicMaterial({ toneMapped: false }), 260, scene);
    this.dustPool = new ParticlePool(
      new THREE.IcosahedronGeometry(0.04, 0),
      new THREE.MeshStandardMaterial({ flatShading: true, roughness: 1, transparent: true, opacity: 0.6, depthWrite: false }),
      200,
      scene,
    );
    this.smoke = new SmokeCloud(scene, 600);
    // Four-point impact star.
    const shape = new THREE.Shape();
    for (let i = 0; i < 8; i++) {
      const a = (i / 8) * Math.PI * 2;
      const r = i % 2 ? 0.18 : 1;
      if (i === 0) shape.moveTo(Math.cos(a) * r, Math.sin(a) * r);
      else shape.lineTo(Math.cos(a) * r, Math.sin(a) * r);
    }
    this.starGeo = new THREE.ShapeGeometry(shape);
  }

  /** Blade trail for a fighter; call every frame with emit=true while striking. */
  trail(id: number, base: THREE.Vector3, tip: THREE.Vector3, emit: boolean, tint = 0xdff4ff): void {
    let t = this.trails.get(id);
    if (!t) {
      t = new Trail(this.scene, new THREE.Color(tint));
      this.trails.set(id, t);
    }
    t.push(base, tip, emit);
  }

  removeTrail(id: number): void {
    const t = this.trails.get(id);
    if (!t) return;
    t.mesh.removeFromParent();
    t.mesh.geometry.dispose();
    (t.mesh.material as THREE.Material).dispose();
    this.trails.delete(id);
  }

  sparks(at: THREE.Vector3, count: number, power = 1, color = 0xffd27a): void {
    for (let i = 0; i < count; i++) {
      this.glow.spawn((p) => {
        p.pos.copy(at);
        p.vel.set(Math.random() - 0.5, Math.random() * 0.9 - 0.2, Math.random() - 0.5).normalize().multiplyScalar((2 + Math.random() * 5) * power);
        p.maxLife = 0.18 + Math.random() * 0.25;
        p.size = 0.8 + Math.random() * 0.8;
        p.gravity = 14;
        p.drag = 2;
        p.color.set(Math.random() < 0.3 ? 0xffffff : color);
      });
    }
  }

  /** Armour chips and cloth bits in the victim's colours. */
  chipsBurst(at: THREE.Vector3, dir: THREE.Vector3, colors: number[], count: number, floor: number): void {
    for (let i = 0; i < count; i++) {
      this.chips.spawn((p) => {
        p.pos.copy(at);
        p.vel
          .set(dir.x + (Math.random() - 0.5) * 1.4, 0.6 + Math.random() * 1.2, dir.z + (Math.random() - 0.5) * 1.4)
          .multiplyScalar(1.5 + Math.random() * 2.5);
        p.maxLife = 0.5 + Math.random() * 0.6;
        p.size = 0.5 + Math.random() * 0.8;
        p.gravity = 16;
        p.floor = floor;
        p.color.set(colors[i % colors.length]!);
      });
    }
  }

  dust(at: THREE.Vector3, count: number, color = 0xd9c9ad): void {
    for (let i = 0; i < count; i++) {
      this.dustPool.spawn((p) => {
        const a = Math.random() * Math.PI * 2;
        p.pos.set(at.x + Math.cos(a) * 0.15, at.y + 0.04, at.z + Math.sin(a) * 0.15);
        p.vel.set(Math.cos(a) * (0.6 + Math.random()), 0.3 + Math.random() * 0.4, Math.sin(a) * (0.6 + Math.random()));
        p.maxLife = 0.45 + Math.random() * 0.35;
        p.size = 0.5 + Math.random() * 0.5;
        p.grow = 1.6;
        p.gravity = -0.4;
        p.drag = 3.5;
        p.color.set(color);
      });
    }
  }

  /**
   * A skidding tyre: thick white smoke that billows out behind it, carried a little by
   * the car, then slows, swells, rises and hangs in the air as it thins; and tiny black
   * bits of rubber flicked off behind. back points away from the way the car faces.
   */
  tyreSmoke(at: THREE.Vector3, back: THREE.Vector3, floor: number, carVel?: THREE.Vector3): void {
    const cvx = carVel ? carVel.x * 0.35 : 0;
    const cvz = carVel ? carVel.z * 0.35 : 0;
    for (let i = 0; i < 2; i++) {
      this.smoke.puff(
        at.x + (Math.random() - 0.5) * 0.3,
        at.y + 0.15 + Math.random() * 0.15,
        at.z + (Math.random() - 0.5) * 0.3,
        cvx + back.x * (0.6 + Math.random() * 1.2) + (Math.random() - 0.5) * 1.4,
        0.25 + Math.random() * 0.45,
        cvz + back.z * (0.6 + Math.random() * 1.2) + (Math.random() - 0.5) * 1.4,
        0.5 + Math.random() * 0.4,
        3 + Math.random() * 2.5,
        1.8 + Math.random() * 1.6,
        0.32 + Math.random() * 0.18,
      );
    }
    for (let i = 0; i < 2; i++) {
      this.chips.spawn((p) => {
        p.pos.set(at.x, at.y + 0.08, at.z);
        p.vel.set(back.x * (1.5 + Math.random() * 2) + (Math.random() - 0.5) * 2.4, 1 + Math.random() * 1.8, back.z * (1.5 + Math.random() * 2) + (Math.random() - 0.5) * 2.4);
        p.maxLife = 0.5 + Math.random() * 0.5;
        p.size = 0.25 + Math.random() * 0.25;
        p.gravity = 16;
        p.floor = floor;
        p.color.set(Math.random() < 0.5 ? 0x151517 : 0x2a2a2d);
      });
    }
  }

  /** Bright star flash at the point of impact, facing the camera. */
  impact(at: THREE.Vector3, size: number, color = 0xffffff): void {
    const mesh = new THREE.Mesh(
      this.starGeo,
      new THREE.MeshBasicMaterial({ color, transparent: true, depthTest: false, toneMapped: false, side: THREE.DoubleSide }),
    );
    mesh.position.copy(at);
    mesh.renderOrder = 10;
    mesh.rotation.z = Math.random() * Math.PI;
    this.scene.add(mesh);
    this.flashes.push({ mesh, life: 0.14, maxLife: 0.14, size, kind: 'star' });
  }

  shockwave(at: THREE.Vector3, size: number, color = 0xfff2d6): void {
    const mesh = new THREE.Mesh(
      this.ringGeo,
      new THREE.MeshBasicMaterial({ color, transparent: true, depthWrite: false, side: THREE.DoubleSide }),
    );
    mesh.position.copy(at).y += 0.03;
    mesh.rotation.x = -Math.PI / 2;
    this.scene.add(mesh);
    this.flashes.push({ mesh, life: 0.35, maxLife: 0.35, size, kind: 'ring' });
  }

  update(dt: number, camera: THREE.Camera): void {
    this.chips.update(dt);
    this.glow.update(dt);
    this.dustPool.update(dt);
    this.smoke.update(dt);
    for (const t of this.trails.values()) t.update(dt);
    for (let i = this.flashes.length - 1; i >= 0; i--) {
      const f = this.flashes[i]!;
      f.life -= dt;
      const k = 1 - f.life / f.maxLife;
      const mat = f.mesh.material as THREE.MeshBasicMaterial;
      if (f.kind === 'star') {
        f.mesh.quaternion.copy(camera.quaternion);
        f.mesh.rotateZ(k * 0.6);
        const s = f.size * (0.4 + 1.2 * Math.sin(Math.min(1, k * 1.4) * Math.PI * 0.5)) * (1 - k * 0.6);
        f.mesh.scale.set(s, s * 0.8, s);
      } else {
        const s = f.size * (0.2 + k);
        f.mesh.scale.set(s, s, s);
      }
      mat.opacity = 1 - k * k;
      if (f.life <= 0) {
        f.mesh.removeFromParent();
        mat.dispose();
        this.flashes.splice(i, 1);
      }
    }
  }

  clear(): void {
    for (const f of this.flashes) {
      f.mesh.removeFromParent();
      (f.mesh.material as THREE.Material).dispose();
    }
    this.flashes.length = 0;
  }
}