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.
/** Shared, shot-time assistance. Bullets remain straight after leaving the muzzle. */
import { Quaternion, Vector3 } from 'three';
import { HIT_BOXES, segmentBoxEntry } from './hit-boxes.ts';
import { GUNS } from './constants.ts';
import { underSurface, type FlightState, type World } from './flight.ts';
export type AimSolution = { target: FlightState; direction: Vector3; point: Vector3; time: number; angle: number };
const points = [new Vector3(-1.9, 0.4, -0.55), new Vector3(1.9, 0.4, -0.55), new Vector3(0, 0.5, 2.45)];
export function gunMuzzle(plane: FlightState, side = -plane.gunSide): Vector3 {
return new Vector3(0.3 * side, 0.55, -2).applyQuaternion(plane.orientation).add(plane.position);
}
/** Smallest positive intercept time, including the shooter's inherited velocity. */
export function interceptTime(relative: Vector3, velocity: Vector3, speed: number): number | null {
const a = velocity.lengthSq() - speed * speed;
const b = 2 * relative.dot(velocity);
const c = relative.lengthSq();
if (c < 1e-9) return null;
if (Math.abs(a) < 1e-9) return b < 0 ? -c / b : null;
const discriminant = b * b - 4 * a * c;
if (discriminant < 0) return null;
const root = Math.sqrt(discriminant);
const times = [(-b - root) / (2 * a), (-b + root) / (2 * a)].filter(t => t > 0);
return times.length ? Math.min(...times) : null;
}
/** Sample spacing for line of sight; terrain facets are ~9 m across. */
const PATH_STEP = 2;
const pathPoint = new Vector3();
export function clearPath(from: Vector3, to: Vector3, world: World): boolean {
const distance = from.distanceTo(to);
const steps = Math.max(1, Math.ceil(distance / PATH_STEP));
// Pad the probe by half a step so obstacles between samples still block.
const probe = 0.7 + (PATH_STEP - 1) / 2;
for (let i = 0; i <= steps; i++) {
pathPoint.copy(from).lerp(to, i / steps);
if (underSurface(world, pathPoint) || world.hitsObstacle(pathPoint, probe)) return false;
}
return true;
}
const rayPoint = new Vector3();
/**
* Distance along a straight ray to the first ground or obstacle contact,
* or `max` when it stays clear. `direction` must be normalised.
*/
export function castRay(from: Vector3, direction: Vector3, max: number, world: World): number {
for (let d = PATH_STEP; d < max; d += PATH_STEP) {
rayPoint.copy(from).addScaledVector(direction, d);
if (underSurface(world, rayPoint) || world.hitsObstacle(rayPoint, 0.5)) return Math.max(0, d - PATH_STEP / 2);
}
return max;
}
export function findAim(plane: FlightState, planes: readonly FlightState[], world: World, muzzle = gunMuzzle(plane)): AimSolution | null {
if (!plane.alive) return null;
const forward = new Vector3(0, 0, -1).applyQuaternion(plane.orientation);
let best: AimSolution | null = null;
let bestDot = Math.cos(GUNS.aimAssistAngle);
for (const target of planes) {
if (target === plane || !target.alive) continue;
// Reject distant/behind targets before solving and checking terrain.
const centre = target.position.clone().sub(muzzle);
const depth = centre.dot(forward);
if (depth <= 0) continue;
const relativeVelocity = target.velocity.clone().sub(plane.velocity);
const speed = relativeVelocity.length();
if (centre.lengthSq() > ((GUNS.muzzleSpeed + speed) * GUNS.life + 4) ** 2) continue;
// Generous front cone plus possible transverse travel: no roots/trig solves
// for planes well off the nose, without discarding valid crossing shots.
const lateralTravel = relativeVelocity.clone().addScaledVector(forward, -relativeVelocity.dot(forward)).length() * GUNS.life;
if (centre.lengthSq() - depth * depth > (depth * 0.1 + lateralTravel + 4) ** 2) continue;
for (const local of points) {
const origin = local.clone().applyQuaternion(target.orientation).add(target.position);
const relative = origin.clone().sub(muzzle);
const velocity = relativeVelocity;
const time = interceptTime(relative, velocity, GUNS.muzzleSpeed);
if (time === null || time > GUNS.life) continue;
const direction = relative.addScaledVector(velocity, time).normalize();
const dot = forward.dot(direction);
if (dot < bestDot || (best && Math.abs(dot - bestDot) < 1e-12 && time >= best.time)) continue;
// Reject aim points hidden behind another hit zone (especially front armor).
const inverse = new Quaternion().copy(target.orientation).invert();
const start = muzzle.clone().sub(target.position).applyQuaternion(inverse);
let first = Infinity;
let armored = false;
for (const box of HIT_BOXES) {
const entry = segmentBoxEntry(start, local, box.min, box.max);
if (entry !== null && entry < first) { first = entry; armored = box.part === 'armor'; }
}
if (armored) continue;
const point = origin.addScaledVector(target.velocity, time);
if (!clearPath(muzzle, point, world)) continue;
bestDot = dot;
best = { target, direction, point, time, angle: 0 };
}
}
if (best) best.angle = Math.acos(Math.min(1, bestDot));
return best;
}
