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.
/** Beginner steering feeds the existing physics; the cockpit stays attached to the plane. */
import { Vector3 } from 'three';
import { neutralInput, type ControlInput, type FlightState, type CrashCause } from './flight.ts';
import { clamp, smoothstep } from './noise.ts';
import { FLIGHT } from './constants.ts';
/** Outside the cockpit, the pilot steers against the planet's local horizon. */
export function planetBeginnerControls(state: FlightState, steering: ControlInput): ControlInput {
const input = beginnerControls(state, steering);
if (!state.alive || state.crippled) return input;
const up = state.position.clone().normalize();
const right = new Vector3(1, 0, 0).applyQuaternion(state.orientation);
const bodyUp = new Vector3(0, 1, 0).applyQuaternion(state.orientation);
const forward = new Vector3(0, 0, -1).applyQuaternion(state.orientation);
if (Math.abs(steering.pitch) > 0.05) {
state.beginnerPitchTime = state.time;
// Keep the wing's horizontal axis through the top of a loop: deriving it
// from forward × planet-up would reverse at vertical and prevent loops.
const pitchAxis = right.clone().projectOnPlane(up);
if (pitchAxis.lengthSq() < 1e-6) pitchAxis.crossVectors(forward, up);
if (pitchAxis.lengthSq() < 1e-6) pitchAxis.copy(right);
pitchAxis.normalize();
const pull = -steering.pitch;
// Compose climb and heading requests before translating to the plane's
// controls. Banking for a turn must not cancel its horizontal steering
// just because the pilot is also asking to climb or dive.
const rotation = pitchAxis.multiplyScalar(pull * FLIGHT.maxPitchRate)
.addScaledVector(up, -steering.roll * FLIGHT.maxPitchRate);
input.pitch = clamp(rotation.dot(right) / FLIGHT.maxPitchRate, -1, 1);
input.yaw = clamp(steering.yaw - rotation.dot(bodyUp) / FLIGHT.maxYawRate, -1, 1);
// Align the wings for the requested maneuver as well as redirecting the
// nose. Use the nearest horizontal wing attitude, including inverted:
// ordinary upright bank error would roll the plane over at a loop's top.
const wingElevation = right.dot(up);
const verticalAlignment = bodyUp.dot(up);
const horizonStrength = Math.hypot(wingElevation, verticalAlignment);
const bank = Math.atan2(-wingElevation, Math.abs(verticalAlignment));
// Pushing into a dive reverses the bank needed for the same heading turn.
const targetBank = steering.roll * Math.sign(pull) * Math.PI / 2 * (1 - 0.65 * Math.abs(steering.pitch));
input.roll = clamp((targetBank - bank) * 2 * horizonStrength + state.angularVelocity.z * 0.4, -1, 1);
} else {
// Let the pilot keep the chosen climb/dive for 1.5 seconds, then blend
// recovery in over another two seconds. Trim's own climb damping must
// follow the same blend or it would level off before this controller does.
const recovery = smoothstep(1.5, 3.5, state.time - state.beginnerPitchTime);
input.pitchRecovery = recovery * 0.35;
const climb = state.velocity.dot(up) / Math.max(state.velocity.length(), 1);
const elevation = Math.asin(clamp(forward.dot(up), -1, 1));
// Velocity alone misses a nose that is still rising ahead of its flight
// path. Normalize the correction axis: its magnitude must not vanish as
// body-up becomes horizontal when the nose points straight up or down.
const levelAxis = new Vector3().crossVectors(forward, up);
if (levelAxis.lengthSq() < 1e-6) levelAxis.copy(right).projectOnPlane(up);
levelAxis.normalize();
const correction = clamp(-elevation * 0.45 - climb * 0.15, -0.4, 0.4) * recovery;
input.pitch = clamp(input.pitch + correction * levelAxis.dot(right), -1, 1);
input.yaw = clamp(input.yaw - correction * levelAxis.dot(bodyUp) * FLIGHT.maxPitchRate / FLIGHT.maxYawRate, -1, 1);
}
return input;
}
/** Bank (rad) given back from a full turn while sinking, and pull eased per unit climb. */
const LEVEL_BANK = 0.5;
const LEVEL_PULL = 12;
/** Horizontal steering combines bank and elevator at normal combat authority. */
export function beginnerControls(state: FlightState, steering: ControlInput): ControlInput {
const input = { ...steering, pitch: -steering.pitch, throttleSet: 1, throttleDelta: 0 };
if (!state.alive) return { ...neutralInput(), fire: steering.fire };
if (state.crippled) return input;
const up = state.position.clone().normalize();
const right = new Vector3(1, 0, 0).applyQuaternion(state.orientation);
const bodyUp = new Vector3(0, 1, 0).applyQuaternion(state.orientation);
const bank = Math.atan2(-right.dot(up), bodyUp.dot(up));
const turn = Math.abs(steering.roll);
if (turn < 0.05) {
input.roll = clamp(-bank * 0.75, -1, 1);
} else {
// Roll into the requested bank at normal authority, then hold it instead
// of rolling through it. Full horizontal stick requests a 90-degree bank,
// shallowed a little while sinking so the wing can hold the turn level.
const climb = state.velocity.dot(up) / Math.max(state.velocity.length(), 1);
const targetBank = steering.roll * (Math.PI / 2 - LEVEL_BANK * clamp(-climb * 4, 0, 1));
input.roll = clamp((targetBank - bank) * 3 + state.angularVelocity.z * 0.5, -turn, turn);
// A bank alone changes attitude, not direction quickly. Pull into the
// turn as the wing comes down, just like bank + elevator in normal mode.
const alignment = Math.max(0, Math.sin(bank) * Math.sign(steering.roll));
// Ease the pull while climbing so a partial bank turns instead of looping.
const pull = turn * alignment * clamp(1 - climb * LEVEL_PULL, 0, 1);
// Vertical stick remains authoritative: full up/down overrides turn pull.
input.pitch = clamp(input.pitch + pull * (1 - Math.abs(steering.pitch)), -1, 1);
}
return input;
}
export type CrashHistory = { times: number[]; nextOffer: number; offered: boolean };
export const createCrashHistory = (): CrashHistory => ({ times: [], nextOffer: 0, offered: false });
export function recordBeginnerCrash(history: CrashHistory, cause: CrashCause | null, time: number): boolean {
history.times = history.times.filter((t) => time - t <= 120);
if (cause !== 'ground' && cause !== 'water' && cause !== 'obstacle') return false;
history.times.push(time);
if (history.times.length < 3 || time < history.nextOffer || history.offered) return false;
history.offered = true;
return true;
}
export function dismissBeginnerOffer(history: CrashHistory, time: number): void {
history.offered = false;
history.times = [];
history.nextOffer = time + 300;
}
