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.
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;
}
},
};
}
