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/plane-model.ts
import {
  BoxGeometry,
  CircleGeometry,
  ConeGeometry,
  CylinderGeometry,
  DoubleSide,
  Group,
  IcosahedronGeometry,
  Mesh,
  MeshBasicMaterial,
  MeshLambertMaterial,
  OctahedronGeometry,
  Vector3,
  type Object3D,
} from 'three';
import { clamp } from '../sim/noise.ts';
import type { Damage, DamagePart } from '../sim/flight.ts';
import { ROCKET_MOUNT } from '../sim/rockets.ts';
import { merge, part, T } from './geometry.ts';
import { rocketGeometry } from './rockets.ts';
import { PALETTE as P } from './palette.ts';

const PI = Math.PI;

export type Livery = { body: number; wing: number; trim: number };

const DEFAULT_LIVERY: Livery = { body: P.planeBody, wing: P.planeWing, trim: P.planeTrim };

export type PlaneModel = {
  group: Group;
  /** Floating beacon in the livery colour so other pilots can spot this plane from afar. */
  marker: Mesh;
  /** Hide the pilot's head when the camera sits inside it (cockpit view). */
  setCockpitView(on: boolean): void;
  /** Show battle damage: scorching, broken-off wing tips and a drooping tail. Zero damage repairs. */
  setDamage(damage: Damage): void;
  /** Show the rockets still on the rails: `count` loaded, `next` the wing (±1) that fires next. */
  setRockets(count: number, next: number): void;
  /** Punch a bullet hole where a round struck (body-frame point). */
  addHole(part: DamagePart, local: Vector3): void;
  update(dt: number, throttle: number, boosting: boolean): void;
};

/** One damageable section: its own material so it can scorch independently. */
type Section = { group: Group; material: MeshLambertMaterial; pivot: Vector3 };

const holeGeometry = new BoxGeometry(0.22, 0.13, 0.2);
const holeMaterial = new MeshBasicMaterial({ color: 0x1c1612 });
const finHoleGeometry = new BoxGeometry(0.13, 0.18, 0.18);
const MAX_HOLES = 24;

/** A chunky low-poly biplane, nose toward -Z, about 5 m long and 6 m span. */
export function createPlaneModel(livery: Livery = DEFAULT_LIVERY): PlaneModel {
  const material = new MeshLambertMaterial({ vertexColors: true, flatShading: true });
  const body = merge([
    // Fuselage: boxy cabin tapering to the tail.
    part(new BoxGeometry(0.8, 0.8, 2.2), livery.body, T(0, 0.1, -0.5)),
    part(new CylinderGeometry(0.4, 0.12, 2.2, 6), livery.body, T(0, 0.15, 1.7, PI / 2, 0, PI / 6)),
    part(new CylinderGeometry(0.5, 0.48, 0.45, 8), livery.trim, T(0, 0.1, -1.75, PI / 2)),
    // Cabane struts hold the upper wing over the cockpit.
    part(new BoxGeometry(0.06, 0.7, 0.06), livery.trim, T(0.35, 0.72, -0.7, 0, 0, -0.3)),
    part(new BoxGeometry(0.06, 0.7, 0.06), livery.trim, T(-0.35, 0.72, -0.7, 0, 0, 0.3)),
    // Windscreen frame (the glass is a separate see-through mesh).
    part(new BoxGeometry(0.52, 0.03, 0.05), livery.trim, T(0, 0.69, -0.05, -0.55)),
    // Landing gear.
    part(new BoxGeometry(0.06, 0.6, 0.06), livery.trim, T(0.45, -0.55, -0.9, 0, 0, 0.3)),
    part(new BoxGeometry(0.06, 0.6, 0.06), livery.trim, T(-0.45, -0.55, -0.9, 0, 0, -0.3)),
    part(new CylinderGeometry(0.22, 0.22, 0.12, 8), livery.trim, T(0.58, -0.82, -0.9, 0, 0, PI / 2)),
    part(new CylinderGeometry(0.22, 0.22, 0.12, 8), livery.trim, T(-0.58, -0.82, -0.9, 0, 0, PI / 2)),
  ]);
  const group = new Group();
  group.add(new Mesh(body, material));
  const glass = new Mesh(
    new BoxGeometry(0.5, 0.2, 0.02),
    new MeshBasicMaterial({ color: 0xcfeaff, transparent: true, opacity: 0.28, depthWrite: false }),
  );
  glass.position.set(0, 0.6, 0);
  glass.rotation.x = -0.55;
  group.add(glass);
  const head = new Mesh(merge([part(new IcosahedronGeometry(0.2, 0), P.wood, T(0, 0.72, 0.35))]), material);
  group.add(head);

  const section = (pivot: Vector3): Section => {
    const s: Section = {
      group: new Group(),
      material: new MeshLambertMaterial({ vertexColors: true, flatShading: true }),
      pivot,
    };
    s.group.position.copy(pivot);
    return s;
  };
  /** Geometry authored in body coordinates, shifted into a section's pivot frame. */
  const at = (pivot: Vector3, x: number, y: number, z: number, rx = 0) => T(x - pivot.x, y - pivot.y, z - pivot.z, rx);

  // Wings: each side splits into an inner panel (pivot at the root, so it can
  // sag) and an outer panel with the struts that snaps off when destroyed.
  const wings = ([-1, 1] as const).map((side) => {
    const root = new Vector3(0, 0.4, -0.5);
    const inner = section(root);
    inner.group.add(
      new Mesh(
        merge([
          part(new BoxGeometry(1.6, 0.1, 1.05), livery.wing, at(root, side * 0.8, 1.05, -0.55)),
          part(new BoxGeometry(1.4, 0.1, 0.95), livery.wing, at(root, side * 0.7, -0.3, -0.45)),
        ]),
        inner.material,
      ),
    );
    const outer = new Group();
    outer.add(
      new Mesh(
        merge([
          part(new BoxGeometry(1.5, 0.1, 1.05), livery.wing, at(root, side * 2.35, 1.05, -0.55)),
          part(new BoxGeometry(0.5, 0.11, 1.06), livery.body, at(root, side * 2.86, 1.05, -0.55)),
          part(new BoxGeometry(1.4, 0.1, 0.95), livery.wing, at(root, side * 2.1, -0.3, -0.45)),
          part(new BoxGeometry(0.07, 1.35, 0.07), livery.trim, at(root, side * 2.2, 0.38, -0.3)),
          part(new BoxGeometry(0.07, 1.35, 0.07), livery.trim, at(root, side * 2.2, 0.38, -0.85)),
        ]),
        inner.material,
      ),
    );
    inner.group.add(outer);
    // A rocket on a short pylon under the inner lower wing.
    const pylon = new Mesh(part(new BoxGeometry(0.06, 0.14, 0.45), livery.trim, at(root, side * ROCKET_MOUNT.x, -0.42, ROCKET_MOUNT.z)), inner.material);
    const rocket = new Mesh(rocketGeometry(), material);
    rocket.position.set(side * ROCKET_MOUNT.x, ROCKET_MOUNT.y, ROCKET_MOUNT.z).sub(root);
    inner.group.add(pylon, rocket);
    group.add(inner.group);
    return { ...inner, side, outer, rocket };
  });

  // Tail: stabilizer and fin hinge on the rear fuselage so a wrecked tail droops.
  const tailPivot = new Vector3(0, 0.2, 1.9);
  const tail = section(tailPivot);
  tail.group.add(new Mesh(part(new BoxGeometry(2.1, 0.07, 0.65), livery.wing, at(tailPivot, 0, 0.2, 2.55)), tail.material));
  const fin = new Group();
  fin.add(new Mesh(part(new BoxGeometry(0.08, 0.8, 0.7), livery.body, at(tailPivot, 0, 0.6, 2.6)), tail.material));
  tail.group.add(fin);
  group.add(tail.group);

  const holes: Mesh[] = [];
  const clearHoles = () => {
    for (const hole of holes) hole.removeFromParent();
    holes.length = 0;
  };
  const scorch = (s: Section, amount: number) => s.material.color.setScalar(1 - 0.55 * Math.min(1, amount));
  let damaged = false;

  const prop = new Group();
  prop.position.set(0, 0.1, -2.05);
  const blades = new Mesh(
    merge([
      part(new BoxGeometry(0.16, 2.1, 0.05), P.propeller),
      part(new ConeGeometry(0.18, 0.35, 6), livery.wing, T(0, 0, -0.12, -PI / 2)),
    ]),
    material,
  );
  prop.add(blades);
  const blur = new Mesh(
    new CircleGeometry(1.05, 16),
    new MeshBasicMaterial({ color: 0xd9d2c4, transparent: true, opacity: 0.22, side: DoubleSide, depthWrite: false }),
  );
  prop.add(blur);
  group.add(prop);

  const flameMaterial = new MeshBasicMaterial({ color: P.flame, transparent: true, opacity: 0.85 });
  const flames = [0.48, -0.48].map((x) => {
    const flame = new Mesh(new ConeGeometry(0.16, 1.2, 6), flameMaterial);
    flame.rotation.x = -PI / 2;
    flame.position.set(x, -0.05, -0.9);
    group.add(flame);
    return flame;
  });

  const marker = new Mesh(
    new OctahedronGeometry(1, 0),
    new MeshBasicMaterial({ color: livery.body, fog: false, transparent: true, opacity: 0.9 }),
  );
  marker.scale.set(1, 1.6, 1);
  marker.visible = false;

  let propAngle = 0;
  let flameT = 0;
  return {
    group,
    marker,
    setCockpitView(on) {
      head.visible = !on;
    },
    setDamage(damage) {
      const any = damage.left > 0 || damage.right > 0 || damage.tail > 0;
      if (!any && !damaged) return;
      damaged = any;
      if (!any) clearHoles();
      for (const wing of wings) {
        const amount = wing.side < 0 ? damage.left : damage.right;
        scorch(wing, amount);
        const broken = amount >= 1;
        wing.outer.visible = !broken;
        // A failing wing sags at the root; a broken one hangs down.
        wing.group.rotation.z = -wing.side * (broken ? 0.32 : amount > 0.5 ? (amount - 0.5) * 0.12 : 0);
      }
      scorch(tail, damage.tail);
      const tailBroken = damage.tail >= 1;
      fin.visible = !tailBroken;
      tail.group.rotation.x = tailBroken ? 0.45 : damage.tail > 0.5 ? (damage.tail - 0.5) * 0.15 : 0;
      tail.group.rotation.z = tailBroken ? 0.2 : 0;
    },
    setRockets(count, next) {
      for (const wing of wings) wing.rocket.visible = count >= 2 || (count === 1 && wing.side === next);
    },
    addHole(which, local) {
      if (holes.length >= MAX_HOLES) holes.shift()!.removeFromParent();
      const p = local.clone();
      let parent: Object3D;
      let pivot: Vector3;
      let geometry = holeGeometry;
      if (which === 'tail') {
        pivot = tailPivot;
        if (Math.abs(p.x) < 0.2 && p.y > 0.3 && p.z > 2.1) {
          // Through the fin.
          parent = fin;
          geometry = finHoleGeometry;
          p.set(0, clamp(p.y, 0.3, 0.92), clamp(p.z, 2.3, 2.9));
        } else if (p.z > 2.1) {
          parent = tail.group;
          p.set(clamp(p.x, -0.95, 0.95), 0.2, clamp(p.z, 2.3, 2.82));
        } else {
          // Rear fuselage.
          parent = tail.group;
          p.x = clamp(p.x, -0.3, 0.3);
        }
      } else {
        const wing = wings[which === 'left' ? 0 : 1]!;
        pivot = wing.pivot;
        const upper = p.y > 0.38;
        const span = clamp(Math.abs(p.x), 0.55, upper ? 3.0 : 2.7);
        p.set(wing.side * span, upper ? 1.05 : -0.3, clamp(p.z, upper ? -1.0 : -0.85, upper ? -0.1 : -0.05));
        parent = span > (upper ? 1.6 : 1.4) ? wing.outer : wing.group;
      }
      const hole = new Mesh(geometry, holeMaterial);
      hole.position.copy(p).sub(pivot);
      hole.rotation.y = Math.random() * PI;
      parent.add(hole);
      holes.push(hole);
    },
    update(dt, throttle, boosting) {
      const rate = 6 + throttle * 55 + (boosting ? 25 : 0);
      propAngle += rate * dt;
      prop.rotation.z = propAngle;
      blades.visible = rate < 30;
      blur.visible = rate >= 22;
      flameT += dt;
      for (const flame of flames) {
        flame.visible = boosting;
        const s = 0.8 + 0.35 * Math.sin(flameT * 40 + flame.position.x * 7);
        flame.scale.set(1, s, 1);
        flame.position.z = -0.9 + 0.6 * s;
      }
    },
  };
}