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.
/**
* Fixed forward guns and bullet hits. Pure simulation like flight.ts: bullets
* are short segments swept each step against hit boxes in each plane's body
* frame, so fast rounds cannot tunnel through a wing.
*/
import { Quaternion, Vector3 } from 'three';
import { findAim } from './aim.ts';
import { HIT_BOXES, segmentBoxEntry, type Box } from './hit-boxes.ts';
import { GUNS } from './constants.ts';
import { applyHit, underSurface, type DamagePart, type FlightState, type World } from './flight.ts';
export type Bullet = {
position: Vector3;
velocity: Vector3;
/** The plane that fired it; it never hits its own shooter. */
owner: FlightState;
age: number;
};
export type BulletHit = {
/** Index of the plane hit, in the `planes` array passed to stepGuns. */
target: number;
owner: FlightState;
part: DamagePart;
/** Impact point in the target's body frame (for bullet holes). */
local: Vector3;
/** Impact point in world space (for sparks). */
world: Vector3;
/** This hit destroyed the part. */
destroyed: boolean;
};
export type GunEvents = {
/** Indices of planes that fired a round this step. */
shots: number[];
hits: BulletHit[];
/** Rounds that struck the ground, sea or an obstacle. */
impacts: Vector3[];
confirmations: { owner: FlightState; armored: boolean }[];
};
export { HIT_BOXES, segmentBoxEntry } from './hit-boxes.ts';
const MUZZLE = new Vector3(0.3, 0.55, -2.0);
const tmp = {
inv: new Quaternion(),
a: new Vector3(),
b: new Vector3(),
rel: new Vector3(),
muzzle: new Vector3(),
forward: new Vector3(),
hitLocal: new Vector3(),
};
export function createGuns() {
return { bullets: [] as Bullet[] };
}
export type Guns = ReturnType<typeof createGuns>;
/** Fire for every plane whose trigger is held, then move bullets and resolve hits. */
export function stepGuns(guns: Guns, planes: readonly FlightState[], triggers: readonly boolean[], world: World, dt: number): GunEvents {
const events: GunEvents = { shots: [], hits: [], impacts: [], confirmations: [] };
const { inv, a, b, rel, muzzle, forward, hitLocal } = tmp;
planes.forEach((plane, index) => {
plane.gunCooldown = Math.max(0, plane.gunCooldown - dt);
if (!plane.alive || !triggers[index] || plane.gunCooldown > 0) return;
plane.gunCooldown += GUNS.interval;
plane.gunSide = -plane.gunSide;
muzzle.set(MUZZLE.x * plane.gunSide, MUZZLE.y, MUZZLE.z).applyQuaternion(plane.orientation).add(plane.position);
forward.set(0, 0, -1).applyQuaternion(plane.orientation);
const aim = findAim(plane, planes, world, muzzle);
if (aim) forward.copy(aim.direction);
guns.bullets.push({
position: muzzle.clone(),
velocity: plane.velocity.clone().addScaledVector(forward, GUNS.muzzleSpeed),
owner: plane,
age: 0,
});
events.shots.push(index);
});
const bullets = guns.bullets;
for (let i = bullets.length - 1; i >= 0; i--) {
const bullet = bullets[i]!;
bullet.age += dt;
let best = Infinity;
let bestBox: Box | null = null;
let bestTarget = -1;
planes.forEach((plane, index) => {
if (plane === bullet.owner || !plane.alive) return;
// Sweep in the target's frame using the relative velocity.
rel.copy(bullet.velocity).sub(plane.velocity).multiplyScalar(dt);
if (bullet.position.distanceToSquared(plane.position) > (rel.length() + 6) ** 2) return;
inv.copy(plane.orientation).invert();
a.copy(bullet.position).sub(plane.position).applyQuaternion(inv);
b.copy(bullet.position).add(rel).sub(plane.position).applyQuaternion(inv);
for (const box of HIT_BOXES) {
const t = segmentBoxEntry(a, b, box.min, box.max);
if (t === null || t >= best) continue;
best = t;
bestBox = box;
bestTarget = index;
hitLocal.copy(a).lerp(b, t);
}
});
if (bestBox) {
const target = planes[bestTarget]!;
const bestLocal = hitLocal.clone();
const world = bestLocal.clone().applyQuaternion(target.orientation).add(target.position);
const part = (bestBox as Box).part;
events.confirmations.push({ owner: bullet.owner, armored: part === 'armor' });
if (part === 'armor') events.impacts.push(world);
else {
const destroyed = applyHit(target, part);
events.hits.push({ owner: bullet.owner, target: bestTarget, part, local: bestLocal, world, destroyed });
}
bullets.splice(i, 1);
continue;
}
bullet.position.addScaledVector(bullet.velocity, dt);
if (underSurface(world, bullet.position) || world.hitsObstacle(bullet.position, 0.2)) {
events.impacts.push(bullet.position.clone());
bullets.splice(i, 1);
} else if (bullet.age > GUNS.life) {
bullets.splice(i, 1);
}
}
return events;
}
