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.
/**
* Heat-seeking rockets. Pure simulation like guns.ts. A rocket locks onto the
* plane nearest its nose inside a narrow cone, chases it with a limited turn
* rate (so a hard turn can shake it), and without a lock coasts along the
* planet at the altitude it was flying, ready to pick up whoever it meets next.
*/
import { Quaternion, Vector3 } from 'three';
import { clearPath } from './aim.ts';
import { HIT_BOXES, segmentBoxEntry } from './hit-boxes.ts';
import { ROCKETS } from './constants.ts';
import { applyHit, underSurface, type DamagePart, type FlightState, type World } from './flight.ts';
export type Rocket = {
position: Vector3;
velocity: Vector3;
owner: FlightState;
age: number;
/** The plane it is chasing, or null while coasting. */
target: FlightState | null;
/** Distance from the planet's centre it holds while coasting. */
holdRadius: number;
/** Seconds until the seeker looks for a new target. */
seekTimer: number;
};
export type RocketHit = {
/** Index of the plane hit, in the `planes` array passed to stepRockets. */
target: number;
owner: FlightState;
part: DamagePart;
/** Impact point in the target's body frame (for scorch holes). */
local: Vector3;
world: Vector3;
destroyed: boolean;
};
export type RocketEvents = {
/** A rocket left a wing: the plane's index and the wing (±1). */
launches: { plane: number; side: number }[];
hits: RocketHit[];
/** Rockets that struck the ground, sea or an obstacle, or burnt out. */
explosions: Vector3[];
};
/** Where the rockets hang, under the lower wings (body frame, x mirrored per side). */
export const ROCKET_MOUNT = new Vector3(1.2, -0.55, -0.6);
const SEEK_INTERVAL = 1 / 20;
const tmp = {
inv: new Quaternion(),
a: new Vector3(),
b: new Vector3(),
rel: new Vector3(),
dir: new Vector3(),
want: new Vector3(),
axis: new Vector3(),
up: new Vector3(),
to: new Vector3(),
hitLocal: new Vector3(),
};
/** Hit boxes grown by the rocket's own size. */
const ROCKET_BOXES = HIT_BOXES.map((box) => ({
part: box.part,
min: box.min.map((v) => v - ROCKETS.radius),
max: box.max.map((v) => v + ROCKETS.radius),
}));
export function createRockets() {
return { rockets: [] as Rocket[] };
}
export type Rockets = ReturnType<typeof createRockets>;
/**
* The plane a seeker at `origin` looking along unit `heading` would lock onto:
* alive, inside `cone`, within range and in plain sight; nearest the nose wins.
* `exclude` (the launcher, early in a rocket's flight) is never picked.
*/
export function seekerTarget(
origin: Vector3,
heading: Vector3,
planes: readonly FlightState[],
exclude: FlightState | null,
world: World,
cone: number = ROCKETS.seekerAngle,
): FlightState | null {
let best: FlightState | null = null;
let bestDot = Math.cos(cone);
const to = new Vector3();
for (const plane of planes) {
if (plane === exclude || !plane.alive) continue;
to.copy(plane.position).sub(origin);
const distance = to.length();
if (distance > ROCKETS.lockRange || distance < 1e-6) continue;
const dot = to.dot(heading) / distance;
if (dot <= bestDot) continue;
if (!clearPath(origin, plane.position, world)) continue;
best = plane;
bestDot = dot;
}
return best;
}
/** Turn unit vector `dir` toward unit `want` by at most `maxAngle` radians. */
export function turnToward(dir: Vector3, want: Vector3, maxAngle: number): Vector3 {
const cos = Math.max(-1, Math.min(1, dir.dot(want)));
const angle = Math.acos(cos);
if (angle <= maxAngle) return dir.copy(want);
const axis = tmp.axis.crossVectors(dir, want);
if (axis.lengthSq() < 1e-12) {
// Straight away from the goal: turn about any perpendicular.
axis.set(1, 0, 0).cross(dir);
if (axis.lengthSq() < 1e-6) axis.set(0, 1, 0).cross(dir);
}
return dir.applyAxisAngle(axis.normalize(), maxAngle).normalize();
}
/** Reload, launch on fresh trigger presses, then fly every rocket and resolve hits. */
export function stepRockets(
state: Rockets,
planes: readonly FlightState[],
triggers: readonly boolean[],
world: World,
dt: number,
): RocketEvents {
const events: RocketEvents = { launches: [], hits: [], explosions: [] };
const { inv, a, b, rel, dir, want, up, to, hitLocal } = tmp;
const rockets = state.rockets;
planes.forEach((plane, index) => {
plane.rocketCooldown = Math.max(0, plane.rocketCooldown - dt);
if (plane.rockets < ROCKETS.capacity) {
plane.rocketCharge += dt / ROCKETS.rechargeTime;
if (plane.rocketCharge >= 1) {
plane.rockets++;
plane.rocketCharge = plane.rockets < ROCKETS.capacity ? plane.rocketCharge - 1 : 0;
}
} else plane.rocketCharge = 0;
const trigger = !!triggers[index];
const pressed = trigger && !plane.rocketHeld;
plane.rocketHeld = trigger;
if (!pressed || !plane.alive || plane.rockets <= 0 || plane.rocketCooldown > 0) return;
plane.rockets--;
plane.rocketCooldown = ROCKETS.launchCooldown;
const side = plane.rocketSide;
plane.rocketSide = -side;
const forward = new Vector3(0, 0, -1).applyQuaternion(plane.orientation);
const position = new Vector3(ROCKET_MOUNT.x * side, ROCKET_MOUNT.y, ROCKET_MOUNT.z)
.applyQuaternion(plane.orientation)
.add(plane.position);
// Leave along the nose, whatever the plane's drift.
const speed = Math.max(20, plane.velocity.dot(forward) + ROCKETS.launchKick);
if (rockets.length >= ROCKETS.maxInFlight) events.explosions.push(rockets.shift()!.position);
rockets.push({
position,
velocity: forward.multiplyScalar(speed),
owner: plane,
age: 0,
target: null,
holdRadius: position.length(),
seekTimer: 0,
});
events.launches.push({ plane: index, side });
});
for (let i = rockets.length - 1; i >= 0; i--) {
const rocket = rockets[i]!;
rocket.age += dt;
const ownerOff = rocket.age < ROCKETS.ownerGrace;
let speed = rocket.velocity.length();
dir.copy(rocket.velocity).divideScalar(speed);
// --- Seeker ----------------------------------------------------------------
const target = rocket.target;
if (target) {
to.copy(target.position).sub(rocket.position);
const distance = to.length();
const keep =
target.alive &&
!(ownerOff && target === rocket.owner) &&
distance < ROCKETS.lockRange * 1.3 &&
to.dot(dir) > Math.cos(ROCKETS.trackAngle) * distance;
if (!keep) {
rocket.target = null;
rocket.holdRadius = rocket.position.length();
rocket.seekTimer = SEEK_INTERVAL;
}
} else {
rocket.seekTimer -= dt;
if (rocket.seekTimer <= 0) {
rocket.seekTimer = SEEK_INTERVAL;
rocket.target = seekerTarget(rocket.position, dir, planes, ownerOff ? rocket.owner : null, world);
}
}
// --- Steering: pure pursuit when locked, follow the planet when coasting ---
if (rocket.target) {
want.copy(rocket.target.position).sub(rocket.position).normalize();
} else {
up.copy(rocket.position).normalize();
want.copy(dir).addScaledVector(up, -dir.dot(up));
if (want.lengthSq() < 1e-8) want.set(1, 0, 0).addScaledVector(up, -up.x);
want.normalize();
const climb = Math.max(-0.3, Math.min(0.3, (rocket.holdRadius - rocket.position.length()) / 40));
want.addScaledVector(up, climb).normalize();
}
turnToward(dir, want, ROCKETS.turnRate * dt);
speed = Math.min(ROCKETS.speed, speed + ROCKETS.accel * dt);
rocket.velocity.copy(dir).multiplyScalar(speed);
// --- Planes: sweep in each plane's frame, first contact wins ---------------
let best = Infinity;
let bestPart: DamagePart | null = null;
let bestTarget = -1;
planes.forEach((plane, index) => {
if (!plane.alive || (ownerOff && plane === rocket.owner)) return;
rel.copy(rocket.velocity).sub(plane.velocity).multiplyScalar(dt);
if (rocket.position.distanceToSquared(plane.position) > (rel.length() + 7) ** 2) return;
inv.copy(plane.orientation).invert();
a.copy(rocket.position).sub(plane.position).applyQuaternion(inv);
b.copy(rocket.position).add(rel).sub(plane.position).applyQuaternion(inv);
for (const box of ROCKET_BOXES) {
const t = segmentBoxEntry(a, b, box.min, box.max);
if (t === null || t >= best) continue;
best = t;
bestTarget = index;
hitLocal.copy(a).lerp(b, t);
// The armoured nose doesn't stop a rocket: the blast takes the nearest part.
bestPart = box.part !== 'armor' ? box.part : hitLocal.z > 0 ? 'tail' : hitLocal.x < 0 ? 'left' : 'right';
}
});
if (bestPart) {
const plane = planes[bestTarget]!;
const bestLocal = hitLocal.clone();
const world = bestLocal.clone().applyQuaternion(plane.orientation).add(plane.position);
const destroyed = applyHit(plane, bestPart, ROCKETS.damage);
events.hits.push({ target: bestTarget, owner: rocket.owner, part: bestPart, local: bestLocal, world, destroyed });
rockets.splice(i, 1);
continue;
}
rocket.position.addScaledVector(rocket.velocity, dt);
if (
underSurface(world, rocket.position) ||
world.hitsObstacle(rocket.position, ROCKETS.radius) ||
rocket.age > ROCKETS.life
) {
events.explosions.push(rocket.position.clone());
rockets.splice(i, 1);
}
}
return events;
}
/** Rockets locked onto `plane`, for its "incoming" warning. */
export function rocketsChasing(state: Rockets, plane: FlightState): Rocket[] {
return state.rockets.filter((rocket) => rocket.target === plane);
}
