Back to Poly Dogfight
SOURCE / PINNED RELEASE

Made of little things.

Poly Dogfight

Release
b8511ee374e3…
Author-recorded commit
5579434b9d8b…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-44f63e5422b

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

src/world/effects.ts
import {
  CircleGeometry,
  Color,
  Euler,
  Group,
  IcosahedronGeometry,
  InstancedBufferAttribute,
  InstancedMesh,
  Matrix4,
  Mesh,
  MeshBasicMaterial,
  MeshLambertMaterial,
  OctahedronGeometry,
  Quaternion,
  TetrahedronGeometry,
  Vector3,
  type BufferGeometry,
  type Material,
} from 'three';
import { GRAVITY } from '../sim/constants.ts';
import { PALETTE as P } from './palette.ts';

type Particle = {
  position: Vector3;
  velocity: Vector3;
  /** Euler angles for debris and sparks. */
  rotation: Vector3;
  spin: Vector3;
  color: number;
  life: number;
  maxLife: number;
  size: number;
};

/** Fading crash or rocket smoke: needs its own opacity, so it keeps a mesh. */
type Smoke = { mesh: Mesh<BufferGeometry, MeshLambertMaterial>; velocity: Vector3; life: number; maxLife: number; size: number };

const MAX_PUFFS = 260;
const MAX_DEBRIS = 320;
const MAX_SPARKS = 160;

/**
 * Many short-lived copies of one shape drawn as a single instanced mesh,
 * so a sky full of smoke is still one draw call per view.
 */
function createPool(geometry: BufferGeometry, material: Material, capacity: number) {
  const mesh = new InstancedMesh(geometry, material, capacity);
  mesh.instanceColor = new InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
  // Instances move every frame, so a cached bounding sphere would be wrong.
  mesh.frustumCulled = false;
  mesh.count = 0;
  mesh.visible = false;
  const live: Particle[] = [];
  const spare: Particle[] = [];
  const matrix = new Matrix4();
  const turn = new Quaternion();
  const euler = new Euler();
  const scale = new Vector3();
  const color = new Color();
  return {
    mesh,
    live,
    /** A fresh particle at `position`, or null when the pool is full. */
    spawn(position: Vector3, color: number, maxLife: number, size: number): Particle | null {
      if (live.length >= capacity) return null;
      const p = spare.pop() ?? {
        position: new Vector3(),
        velocity: new Vector3(),
        rotation: new Vector3(),
        spin: new Vector3(),
        color: 0,
        life: 0,
        maxLife: 1,
        size: 1,
      };
      p.position.copy(position);
      p.velocity.set(0, 0, 0);
      p.rotation.set(0, 0, 0);
      p.spin.set(0, 0, 0);
      p.color = color;
      p.life = 0;
      p.maxLife = maxLife;
      p.size = size;
      live.push(p);
      return p;
    },
    /** Age everything by dt; `step` moves a live particle and returns its scale. */
    update(dt: number, step: (p: Particle, t: number) => number) {
      let n = 0;
      for (let i = 0; i < live.length; i++) {
        const p = live[i]!;
        p.life += dt;
        const t = p.life / p.maxLife;
        if (t >= 1) {
          spare.push(p);
          continue;
        }
        const s = step(p, t);
        turn.setFromEuler(euler.set(p.rotation.x, p.rotation.y, p.rotation.z));
        matrix.compose(p.position, turn, scale.setScalar(s));
        mesh.setMatrixAt(n, matrix);
        mesh.setColorAt(n, color.setHex(p.color));
        live[n++] = p;
      }
      live.length = n;
      mesh.count = n;
      mesh.visible = n > 0;
      mesh.instanceMatrix.needsUpdate = true;
      mesh.instanceColor!.needsUpdate = true;
    },
  };
}

const randomUnit = (out: Vector3) => out.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).normalize();

/** Crash debris, smoke puffs and a ground shadow blob under the plane. */
export function createEffects() {
  const group = new Group();
  const smokeGeometry = new IcosahedronGeometry(1, 0);

  const debris = createPool(new TetrahedronGeometry(0.6, 0), new MeshLambertMaterial({ flatShading: true }), MAX_DEBRIS);
  const puffs = createPool(
    smokeGeometry,
    new MeshLambertMaterial({ flatShading: true, transparent: true, opacity: 0.5, depthWrite: false }),
    MAX_PUFFS,
  );
  const sparks = createPool(new OctahedronGeometry(0.22, 0), new MeshBasicMaterial(), MAX_SPARKS);
  group.add(debris.mesh, puffs.mesh, sparks.mesh);
  const puffColors = [0xe4e0d8, 0x8a857f, 0x2e2b29];

  const scratch = new Vector3();
  const offset = new Vector3();
  const radial = new Vector3();

  // Crash and rocket smoke fade individually; recycle their meshes and materials.
  const smoke: Smoke[] = [];
  const spareSmoke: Smoke[] = [];
  const addSmoke = (position: Vector3, spread: number, color: number, velocity: Vector3, maxLife: number, size: number) => {
    const s =
      spareSmoke.pop() ??
      ({
        mesh: new Mesh(smokeGeometry, new MeshLambertMaterial({ flatShading: true, transparent: true, opacity: 0.85 })),
        velocity: new Vector3(),
        life: 0,
        maxLife: 1,
        size: 1,
      } satisfies Smoke);
    s.velocity.copy(velocity);
    s.mesh.material.color.setHex(color);
    s.mesh.material.opacity = 0.85;
    offset.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5);
    s.mesh.position.copy(position).addScaledVector(offset, spread);
    s.mesh.scale.setScalar(size * 1.5);
    s.life = 0;
    s.maxLife = maxLife;
    s.size = size;
    s.mesh.visible = true;
    if (!s.mesh.parent) group.add(s.mesh);
    smoke.push(s);
  };

  const shadowGeometry = new CircleGeometry(2.6, 12);
  const shadows: Mesh[] = [];
  const shadowFor = (index: number) => {
    let shadow = shadows[index];
    if (!shadow) {
      shadow = new Mesh(
        shadowGeometry,
        new MeshBasicMaterial({ color: 0x1b2a1e, transparent: true, opacity: 0.35, depthWrite: false }),
      );
      shadow.renderOrder = 2;
      group.add(shadow);
      shadows[index] = shadow;
    }
    return shadow;
  };
  const zAxis = new Vector3(0, 0, 1);
  const turn = new Quaternion();


  return {
    group,
    /** Bright flash where a round strikes a plane, the ground or an obstacle. */
    spark(position: Vector3) {
      sparks.spawn(position, 0xffe08a, 0.14, 1);
    },
    /** One puff of a damage smoke trail; darkness 0 (light) .. 2 (black). */
    puff(position: Vector3, drift: Vector3, darkness: number, size = 1) {
      const p = puffs.spawn(position, puffColors[Math.max(0, Math.min(2, darkness))]!, 1.2 + Math.random() * 0.8, size);
      p?.velocity.copy(drift);
    },
    /** A few pieces breaking off a damaged part. */
    shards(position: Vector3, velocity: Vector3, colors: readonly number[], count = 8) {
      for (let i = 0; i < count; i++) {
        const p = debris.spawn(position, colors[i % colors.length]!, 3 + Math.random(), 0.6);
        if (!p) return;
        randomUnit(p.velocity).multiplyScalar(4 + Math.random() * 6).addScaledVector(velocity, 0.6);
        p.spin.set(Math.random() * 10, Math.random() * 10, Math.random() * 10);
      }
    },
    explode(position: Vector3, velocity: Vector3, up: Vector3, colors: readonly number[] = [P.planeBody, P.planeWing, P.planeTrim]) {
      const debrisColors = [...colors, P.flame];
      for (let i = 0; i < 26; i++) {
        const p = debris.spawn(position, debrisColors[i % debrisColors.length]!, 2.5 + Math.random() * 1.5, 1);
        if (!p) break;
        randomUnit(p.velocity)
          .multiplyScalar(8 + Math.random() * 16)
          .addScaledVector(up, 10)
          .addScaledVector(velocity, 0.15);
        p.spin.set(Math.random() * 8, Math.random() * 8, Math.random() * 8);
      }
      for (let i = 0; i < 14; i++) {
        addSmoke(position, 3, i < 5 ? P.flame : 0x5a5652, scratch.copy(up).multiplyScalar(3 + Math.random() * 4), 2 + Math.random() * 2.5, 1);
      }
    },
    /** A rocket going off: a quick fireball and a grey cloud, no debris. */
    blast(position: Vector3, up: Vector3) {
      for (let i = 0; i < 9; i++) {
        addSmoke(
          position,
          1.6,
          i < 4 ? P.flame : i < 6 ? 0xffd36b : 0x6a6560,
          scratch.copy(up).multiplyScalar(1.5 + Math.random() * 2),
          i < 6 ? 0.45 + Math.random() * 0.3 : 1.4 + Math.random(),
          i < 6 ? 0.7 : 0.5,
        );
      }
      this.spark(position);
    },
    /** Advance particles and place one ground shadow per live plane. */
    update(dt: number, planes: readonly { groundPoint: Vector3; up: Vector3; clearance: number; alive: boolean }[]) {
      debris.update(dt, (p, t) => {
        radial.copy(p.position).normalize();
        p.velocity.addScaledVector(radial, -GRAVITY * dt);
        p.position.addScaledVector(p.velocity, dt);
        p.rotation.x += p.spin.x * dt;
        p.rotation.y += p.spin.y * dt;
        return p.size * (1 - t * 0.6);
      });
      puffs.update(dt, (p, t) => {
        p.velocity.multiplyScalar(Math.exp(-dt * 1.5));
        p.position.addScaledVector(p.velocity, dt);
        // Grow, then shrink away (no per-puff opacity fade).
        return p.size * (0.2 + 0.7 * Math.sin(Math.PI * Math.min(1, t * 1.1)) + 0.4 * t);
      });
      sparks.update(dt, (p, t) => {
        p.rotation.z += dt * 20;
        return 1 + t * 1.5;
      });
      let n = 0;
      for (const s of smoke) {
        s.life += dt;
        const t = s.life / s.maxLife;
        if (t >= 1) {
          s.mesh.visible = false;
          spareSmoke.push(s);
          continue;
        }
        s.mesh.position.addScaledVector(s.velocity, dt);
        s.mesh.scale.setScalar(s.size * (1.5 + t * 6));
        s.mesh.material.opacity = 0.85 * (1 - t);
        smoke[n++] = s;
      }
      smoke.length = n;

      for (let i = 0; i < Math.max(shadows.length, planes.length); i++) {
        const plane = planes[i];
        const shadow = shadowFor(i);
        shadow.visible = !!plane && plane.alive && plane.clearance < 80;
        if (!plane || !shadow.visible) continue;
        shadow.position.copy(plane.groundPoint).addScaledVector(plane.up, 0.4);
        shadow.quaternion.copy(turn.setFromUnitVectors(zAxis, plane.up));
        const fade = 1 - Math.min(1, plane.clearance / 80);
        (shadow.material as MeshBasicMaterial).opacity = 0.4 * fade;
        shadow.scale.setScalar(1 + plane.clearance / 60);
      }
    },
  };
}