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.
/**
* Momentum-based biplane flight on a spherical world. Pure simulation: no scene,
* DOM or timing dependencies, so it runs identically in tests.
*
* Body axes (three.js convention): nose = -Z, up = +Y, right wing = +X.
* Body angular velocity: x = pitch (positive nose up), y = yaw (positive nose
* left), z = roll (positive right wing up / roll left).
*/
import { Matrix4, Quaternion, Vector3 } from 'three';
import { DAMAGE, FLIGHT, GRAVITY, PLANET_RADIUS, ROCKETS } from './constants.ts';
import { clamp, smoothstep } from './noise.ts';
export type ControlInput = {
/** Optional beginner recovery blend; ordinary flight keeps full trim damping. */
pitchRecovery?: number;
/** -1..1, positive = nose up. */
pitch: number;
/** -1..1, positive = roll right. */
roll: number;
/** -1..1, positive = yaw right. */
yaw: number;
/** -1..1 rate of throttle change. */
throttleDelta: number;
/** Optional absolute throttle 0..1 (touch slider); overrides throttleDelta. */
throttleSet: number | null;
boost: boolean;
/** Hold to fire the guns. */
fire: boolean;
/** Press to launch a rocket (one per press). */
rocket: boolean;
};
export const neutralInput = (): ControlInput => ({
pitch: 0,
roll: 0,
yaw: 0,
throttleDelta: 0,
throttleSet: null,
boost: false,
fire: false,
rocket: false,
});
export type CrashCause = 'ground' | 'water' | 'obstacle' | 'midair' | 'shotdown';
export type DamagePart = 'left' | 'right' | 'tail';
export type Damage = Record<DamagePart, number>;
export type FlightState = {
/** Last active planet-relative pitch command, in simulation seconds. */
beginnerPitchTime: number;
position: Vector3;
velocity: Vector3;
orientation: Quaternion;
angularVelocity: Vector3;
throttle: number;
boost: number;
boosting: boolean;
boostLockout: number;
alive: boolean;
crashTimer: number;
crashCause: CrashCause | null;
/** Telemetry for HUD/effects. */
airspeed: number;
aoa: number;
sideslip: number;
stalled: boolean;
/** Direction the wing drops when the stall breaks (±1). */
wingDrop: number;
/** 0..1 how deep into the stall/buffet. */
stallAmount: number;
altitude: number;
groundClearance: number;
gForce: number;
/** Simulated seconds, used for deterministic buffet. */
time: number;
/** Battle damage per part, 0..1. */
damage: Damage;
/** A part is destroyed: the plane can no longer fly. */
crippled: boolean;
gunCooldown: number;
/** Which gun fires next (±1). */
gunSide: number;
/** Rockets ready on the wings. */
rockets: number;
/** 0..1 progress reloading the next rocket. */
rocketCharge: number;
/** Seconds until another rocket can launch. */
rocketCooldown: number;
/** Which wing launches next (±1, right is +1). */
rocketSide: number;
/** Trigger held last step: one rocket per press. */
rocketHeld: boolean;
};
export type World = {
/** Height of ground/sea surface above sea level along a unit direction. */
surfaceHeight(x: number, y: number, z: number): number;
/** Seabed/terrain height (negative under sea). */
terrainHeight(x: number, y: number, z: number): number;
/** Returns true when a sphere at point p with radius r overlaps an obstacle. */
hitsObstacle(p: Vector3, r: number): boolean;
/** Upper bound on surfaceHeight anywhere; lets points above it skip the terrain lookup. */
maxSurfaceHeight?: number;
};
/** True when point p (at distance r from the centre) is below the ground or sea. */
export function underSurface(world: World, p: Vector3, r = p.length()): boolean {
if (r > PLANET_RADIUS + (world.maxSurfaceHeight ?? Infinity)) return false;
return r < PLANET_RADIUS + world.surfaceHeight(p.x / r, p.y / r, p.z / r);
}
export function createFlightState(): FlightState {
return {
beginnerPitchTime: -Infinity,
position: new Vector3(0, PLANET_RADIUS + FLIGHT.spawnAltitude, 0),
velocity: new Vector3(0, 0, -FLIGHT.cruiseSpeed),
orientation: new Quaternion(),
angularVelocity: new Vector3(),
throttle: 0.85,
boost: 1,
boosting: false,
boostLockout: 0,
alive: true,
crashTimer: 0,
crashCause: null,
airspeed: FLIGHT.cruiseSpeed,
aoa: 0,
sideslip: 0,
stalled: false,
wingDrop: 1,
stallAmount: 0,
altitude: FLIGHT.spawnAltitude,
groundClearance: FLIGHT.spawnAltitude,
gForce: 1,
time: 0,
damage: { left: 0, right: 0, tail: 0 },
crippled: false,
gunCooldown: 0,
gunSide: 1,
rockets: ROCKETS.capacity,
rocketCharge: 0,
rocketCooldown: 0,
rocketSide: -1,
rocketHeld: true,
};
}
/** Lift coefficient with a sharp post-stall drop and flat-plate behaviour at high AoA. */
export function liftCoefficient(aoa: number): number {
const a = Math.abs(aoa);
const sign = Math.sign(aoa);
const clMax = FLIGHT.liftSlope * FLIGHT.stallAoA;
if (a <= FLIGHT.stallAoA) return FLIGHT.liftSlope * aoa;
const decayed = clMax * (0.4 + 0.6 * Math.exp(-(a - FLIGHT.stallAoA) * 25));
const flatPlate = 0.75 * Math.sin(2 * Math.min(a, Math.PI / 2));
return sign * Math.max(decayed * (a < Math.PI / 2 ? 1 : 0), flatPlate);
}
export function airDensity(altitude: number): number {
return Math.exp(-Math.max(0, altitude - FLIGHT.thinAirAltitude) / FLIGHT.densityScale);
}
const tmp = {
up: new Vector3(),
forward: new Vector3(),
right: new Vector3(),
bodyUp: new Vector3(),
velBody: new Vector3(),
velDir: new Vector3(),
lift: new Vector3(),
force: new Vector3(),
inv: new Quaternion(),
dq: new Quaternion(),
axis: new Vector3(),
};
/** Place the plane in the air above direction `dir`, flying level along `heading`. */
export function spawnAt(state: FlightState, world: World, dir: Vector3, heading: Vector3): void {
state.beginnerPitchTime = -Infinity;
const up = dir.clone().normalize();
const ground = world.surfaceHeight(up.x, up.y, up.z);
const radius = PLANET_RADIUS + Math.max(ground + 60, FLIGHT.spawnAltitude);
state.position.copy(up).multiplyScalar(radius);
state.altitude = radius - PLANET_RADIUS;
state.groundClearance = state.altitude - Math.max(0, ground);
const forward = heading.clone().addScaledVector(up, -heading.dot(up)).normalize();
const right = new Vector3().crossVectors(forward, up).normalize();
const back = forward.clone().negate();
// Rotation whose columns are right, up, back (nose = -Z).
state.orientation.setFromRotationMatrix(new Matrix4().makeBasis(right, up, back));
state.velocity.copy(forward).multiplyScalar(FLIGHT.cruiseSpeed);
state.angularVelocity.set(0, 0, 0);
state.throttle = 0.85;
state.boost = 1;
state.boosting = false;
state.boostLockout = 0;
state.alive = true;
state.crashTimer = 0;
state.crashCause = null;
state.stalled = false;
state.stallAmount = 0;
repair(state);
}
export function repair(state: FlightState): void {
state.damage.left = 0;
state.damage.right = 0;
state.damage.tail = 0;
state.crippled = false;
state.gunCooldown = 0;
state.rockets = ROCKETS.capacity;
state.rocketCharge = 0;
state.rocketCooldown = 0;
state.rocketHeld = true;
}
/** Apply one hit to a part. Returns true when this hit destroyed the part. */
export function applyHit(state: FlightState, part: DamagePart, amount: number = part === 'tail' ? DAMAGE.perHitTail : DAMAGE.perHitWing): boolean {
if (!state.alive) return false;
const before = state.damage[part];
state.damage[part] = Math.min(1, before + amount);
if (state.damage[part] >= 1) state.crippled = true;
return before < 1 && state.damage[part] >= 1;
}
export function stepFlight(state: FlightState, input: ControlInput, world: World, dt: number): void {
state.time += dt;
if (!state.alive) {
state.crashTimer = Math.max(0, state.crashTimer - dt);
return;
}
const { up, forward, right, bodyUp, velBody, velDir, lift, force, inv, dq, axis } = tmp;
const radius = state.position.length();
up.copy(state.position).divideScalar(radius);
state.altitude = radius - PLANET_RADIUS;
forward.set(0, 0, -1).applyQuaternion(state.orientation);
right.set(1, 0, 0).applyQuaternion(state.orientation);
bodyUp.set(0, 1, 0).applyQuaternion(state.orientation);
// --- Throttle and boost ---------------------------------------------------
if (input.throttleSet !== null) state.throttle = clamp(input.throttleSet, 0, 1);
else state.throttle = clamp(state.throttle + input.throttleDelta * FLIGHT.throttleRate * dt, 0, 1);
state.boostLockout = Math.max(0, state.boostLockout - dt);
state.boosting = input.boost && state.boost > 0 && state.boostLockout <= 0;
if (state.boosting) {
state.boost = Math.max(0, state.boost - FLIGHT.boostDrain * dt);
if (state.boost <= 0) state.boostLockout = FLIGHT.boostCooldown;
} else {
state.boost = Math.min(1, state.boost + FLIGHT.boostRecharge * dt);
}
// --- Relative wind --------------------------------------------------------
const speed = state.velocity.length();
state.airspeed = speed;
inv.copy(state.orientation).invert();
velBody.copy(state.velocity).applyQuaternion(inv);
const aoa = speed > 0.5 ? Math.atan2(-velBody.y, -velBody.z) : 0;
const sideslip = speed > 0.5 ? Math.atan2(velBody.x, Math.hypot(velBody.y, velBody.z)) : 0;
state.aoa = aoa;
state.sideslip = sideslip;
const rho = airDensity(state.altitude);
const q = FLIGHT.aeroK * rho * speed * speed;
const qNorm = (rho * speed * speed) / (FLIGHT.cruiseSpeed * FLIGHT.cruiseSpeed);
const absAoa = Math.abs(aoa);
// Stall latch with hysteresis: once the wing breaks it stays stalled until
// the angle of attack is well below critical, i.e. the nose has come down.
if (!state.stalled && absAoa > FLIGHT.stallAoA * 1.03 && speed > 1) {
state.stalled = true;
state.wingDrop = sideslip >= 0 ? 1 : -1;
} else if (state.stalled && absAoa < FLIGHT.stallAoA * 0.7) {
state.stalled = false;
}
state.stallAmount = state.stalled
? Math.max(0.55, smoothstep(FLIGHT.stallAoA, FLIGHT.stallAoA * 1.5, absAoa))
: 0.3 * smoothstep(FLIGHT.stallAoA * 0.8, FLIGHT.stallAoA, absAoa);
// --- Battle damage ----------------------------------------------------------
const { left, right: rightDamage, tail } = state.damage;
const damageSum = left + rightDamage + tail;
const crippled = state.crippled;
const leftGone = left >= 1;
const rightGone = rightDamage >= 1;
const tailGone = tail >= 1;
const liftFactor = crippled
? leftGone || rightGone
? DAMAGE.crippledLift
: 0.75
: 1 - DAMAGE.wingLift * (left + rightDamage);
// --- Forces (per unit mass) -----------------------------------------------
force.set(0, 0, 0);
if (speed > 0.5) {
velDir.copy(state.velocity).divideScalar(speed);
const cl = liftCoefficient(aoa) * (state.stalled ? 0.7 : 1) * liftFactor;
lift.crossVectors(right, velDir);
if (lift.lengthSq() > 1e-8) force.addScaledVector(lift.normalize(), q * cl);
const stallDrag = absAoa > FLIGHT.stallAoA ? 0.9 * Math.sin(Math.min(absAoa, Math.PI / 2)) : 0;
const cd =
FLIGHT.cd0 + FLIGHT.inducedK * cl * cl + stallDrag + 0.35 * Math.abs(sideslip) + DAMAGE.extraDrag * damageSum;
force.addScaledVector(velDir, -q * cd);
force.addScaledVector(right, -q * FLIGHT.sideForce * sideslip);
}
const forwardSpeed = Math.max(0, state.velocity.dot(forward));
const thrust =
FLIGHT.staticThrust * state.throttle * Math.max(0, 1 - forwardSpeed / FLIGHT.propMaxSpeed) * Math.sqrt(rho);
force.addScaledVector(forward, thrust + (state.boosting ? FLIGHT.boostThrust : 0));
force.addScaledVector(up, -GRAVITY);
// Curvature compensation: on a planet this small, cruise speed is close to
// orbital. Pull the plane around the curve so "level" follows the surface and
// flight feels like it does over a flat world.
const radialSpeed = state.velocity.dot(up);
const tangentialSq = Math.max(0, speed * speed - radialSpeed * radialSpeed);
force.addScaledVector(up, -tangentialSq / radius);
state.velocity.addScaledVector(force, dt);
// Felt load: everything but gravity, along the body up axis, in g.
state.gForce = (force.dot(bodyUp) + GRAVITY * up.dot(bodyUp)) / GRAVITY;
// --- Rotation -------------------------------------------------------------
const authority =
clamp((rho * speed * speed) / (FLIGHT.authoritySpeed * FLIGHT.authoritySpeed), 0.12, 1.15) *
(crippled ? DAMAGE.crippledAuthority : 1);
// AoA limiter: at speed a hard pull turns tightly on the edge of the stall;
// when slow the limiter fades out and over-pulling stalls the wings.
let pitchCmd = input.pitch * (1 - DAMAGE.tailAuthority * Math.min(1, tail));
// A stalled wing leaves the elevator little pull authority.
if (state.stalled && pitchCmd > 0) pitchCmd *= 0.25;
else if (pitchCmd > 0) {
const limiter = 0.15 + 0.82 * smoothstep(FLIGHT.stallSpeed * 1.15, FLIGHT.stallSpeed * 1.75, speed);
pitchCmd *= 1 - limiter * smoothstep(FLIGHT.stallAoA * 0.55, FLIGHT.stallAoA * 0.95, aoa);
}
// How fast the flight path itself is turning, in body axes. The nose follows it.
let pathPitchRate = 0;
let pathYawRate = 0;
if (speed > 1) {
axis.crossVectors(velDir, force).divideScalar(speed);
pathPitchRate = axis.dot(right);
pathYawRate = axis.dot(bodyUp);
}
// Auto-trim: stability holds the angle of attack that supports the plane in
// level flight, capped below the stall so slow flight sinks instead of stalling.
// In a moderate bank it adds pull like a coordinated turn; inverted it neutralizes.
const bankCos = up.dot(bodyUp);
const turnFactor = bankCos > 0.15 ? 1 / Math.max(bankCos, 0.65) : 0;
// Vertical-speed damping levels the plane off gently when the stick is released.
const climbRate = state.velocity.dot(up);
const trimLift = Math.max(0, GRAVITY * turnFactor - FLIGHT.climbDamping * climbRate * (input.pitchRecovery ?? 1));
const trimAoa =
speed > 1 ? clamp(trimLift / Math.max(q * FLIGHT.liftSlope, 1e-3), 0, FLIGHT.stallAoA * 0.75) : 0;
// Weathervane stability stays partly effective in a stall, so the nose drops
// and pushing forward regains airspeed.
const stab = clamp(qNorm, 0.2, 1.2);
// The stall break: the nose drops and a wing goes down, stronger when slow.
const breakStrength = state.stalled ? 1 - Math.min(1, qNorm) : 0;
// Damage: a holed wing lifts less, so the plane rolls toward it (positive w.z
// rolls left). A shot-up tail trims the nose down. Destroyed parts overwhelm
// what's left of the controls and the plane spirals or tumbles down.
const rollBias = crippled
? (leftGone ? DAMAGE.crippledRoll : 0) - (rightGone ? DAMAGE.crippledRoll : 0)
: DAMAGE.wingRoll * (left - rightDamage) * Math.min(1, qNorm + 0.3);
const pitchBias = tailGone
? DAMAGE.crippledPitch * (1 + 0.6 * Math.sin(state.time * 2.3))
: DAMAGE.tailPitch * tail;
const trimWeight = crippled ? 0.25 : 1;
const targetPitch =
pitchCmd * FLIGHT.maxPitchRate * authority +
pathPitchRate +
FLIGHT.pitchStability * (trimAoa - aoa) * stab * trimWeight -
0.9 * breakStrength -
pitchBias;
const targetRoll =
-input.roll * FLIGHT.maxRollRate * authority + state.wingDrop * 0.9 * breakStrength + rollBias;
// Auto-coordination: a little rudder goes with roll input.
const targetYaw =
-(input.yaw + input.roll * 0.18) * FLIGHT.maxYawRate * authority +
pathYawRate -
FLIGHT.yawStability * sideslip * Math.max(qNorm, 0.3);
const w = state.angularVelocity;
w.x += (targetPitch - w.x) * Math.min(1, 8 * dt);
w.z += (targetRoll - w.z) * Math.min(1, 6 * dt);
w.y += (targetYaw - w.y) * Math.min(1, 6 * dt);
// Stall buffet and wing drop: a deep stall with the stick held back wobbles.
if (state.stallAmount > 0) {
w.z += Math.sin(state.time * 7.3 + aoa * 10) * 1.6 * state.stallAmount * dt;
}
// Gentle wings-level tendency when the stick is centered.
if (Math.abs(input.roll) < 0.1 && !crippled) {
const bank = Math.asin(clamp(right.dot(up), -1, 1));
w.z += clamp(bank, -0.6, 0.6) * 0.9 * dt;
}
const angle = w.length() * dt;
if (angle > 0) {
axis.copy(w).normalize();
dq.setFromAxisAngle(axis, angle);
state.orientation.multiply(dq).normalize();
}
// --- Integrate position and check collisions --------------------------------
state.position.addScaledVector(state.velocity, dt);
const r2 = state.position.length();
up.copy(state.position).divideScalar(r2);
const ground = world.terrainHeight(up.x, up.y, up.z);
const surface = Math.max(0, ground);
state.altitude = r2 - PLANET_RADIUS;
state.groundClearance = state.altitude - surface;
if (state.groundClearance < FLIGHT.crashClearance) {
crash(state, state.crippled ? 'shotdown' : ground < 0 ? 'water' : 'ground');
} else if (world.hitsObstacle(state.position, 1.6)) {
crash(state, state.crippled ? 'shotdown' : 'obstacle');
}
}
export function crash(state: FlightState, cause: CrashCause): void {
state.alive = false;
state.crashCause = cause;
state.crashTimer = FLIGHT.respawnDelay;
state.boosting = false;
state.stalled = false;
state.stallAmount = 0;
}
/** Collision sphere radius around each plane for plane-to-plane contact. */
export const PLANE_RADIUS = 2.3;
/**
* Mid-air collisions between pilots: any two live planes whose spheres touch
* both crash. Returns the index pairs that collided this step.
*/
export function collidePlanes(states: readonly FlightState[]): [number, number][] {
const hits: [number, number][] = [];
const reach = (PLANE_RADIUS * 2) ** 2;
for (let i = 0; i < states.length; i++) {
for (let j = i + 1; j < states.length; j++) {
const a = states[i]!;
const b = states[j]!;
if (a.alive && b.alive && a.position.distanceToSquared(b.position) < reach) hits.push([i, j]);
}
}
for (const [i, j] of hits) {
crash(states[i]!, 'midair');
crash(states[j]!, 'midair');
}
return hits;
}
/** Speed the plane settles at in level flight at full throttle, for reference. */
export function estimateLevelSpeed(): number {
let v = 30;
for (let i = 0; i < 200; i++) {
const cl = GRAVITY / (FLIGHT.aeroK * v * v);
const drag = FLIGHT.aeroK * v * v * (FLIGHT.cd0 + FLIGHT.inducedK * cl * cl);
const thrust = FLIGHT.staticThrust * Math.max(0, 1 - v / FLIGHT.propMaxSpeed);
v += (thrust - drag) * 0.5;
}
return v;
}
