1
0

feat: add character studio and aircraft foundation

This commit is contained in:
2026-08-11 19:32:32 -07:00
parent 1c37ef8f3e
commit c0c7fcf974
16 changed files with 2250 additions and 25 deletions
+433
View File
@@ -0,0 +1,433 @@
/** Deterministic, renderer-independent fixed-wing flight over California. */
const EARTH_RADIUS_M = 6_371_000;
const TWO_PI = Math.PI * 2;
export type AircraftControlMode = "assisted" | "manual";
export type AircraftModeRequest = "none" | AircraftControlMode;
export interface AircraftGeographicPoint {
lat: number;
lng: number;
}
export interface AircraftWaypoint extends AircraftGeographicPoint {
id: string;
altitudeM: number;
}
export interface AircraftActionSnapshot {
throttle: number;
yaw: number;
pitch: number;
roll: number;
modeRequest: AircraftModeRequest;
reset: boolean;
}
export const NEUTRAL_AIRCRAFT_ACTIONS: Readonly<AircraftActionSnapshot> = Object.freeze({
throttle: 0,
yaw: 0,
pitch: 0,
roll: 0,
modeRequest: "none",
reset: false,
});
export interface CaliforniaFlightEnvelope {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
minAltitudeM: number;
maxAltitudeM: number;
}
export const DEFAULT_CALIFORNIA_FLIGHT_ENVELOPE: Readonly<CaliforniaFlightEnvelope> =
Object.freeze({
minLat: 32.4,
maxLat: 42.1,
minLng: -124.6,
maxLng: -114.0,
minAltitudeM: 75,
maxAltitudeM: 6_000,
});
export interface AircraftControllerOptions {
initialPosition?: Partial<AircraftGeographicPoint>;
initialAltitudeM?: number;
initialHeadingDeg?: number;
initialSpeedMps?: number;
mode?: AircraftControlMode;
route?: readonly AircraftWaypoint[];
envelope?: CaliforniaFlightEnvelope;
minimumSpeedMps?: number;
maximumSpeedMps?: number;
assistedCruiseMps?: number;
assistedAltitudeM?: number;
fixedStepSeconds?: number;
maxFrameDeltaSeconds?: number;
}
export interface AircraftControllerState extends AircraftGeographicPoint {
altitudeM: number;
headingDeg: number;
pitchDeg: number;
rollDeg: number;
speedMps: number;
verticalSpeedMps: number;
mode: AircraftControlMode;
routeWaypointIndex: number;
routeWaypointId: string | null;
throttle: number;
yawInput: number;
pitchInput: number;
rollInput: number;
fanRadians: number;
envelopeContact: boolean;
elapsedSteps: number;
}
export interface AircraftControllerSnapshot extends AircraftControllerState {}
export interface TimedAircraftInputFrame {
steps: number;
actions?: Partial<AircraftActionSnapshot>;
}
export interface AircraftReplayResult {
trajectory: readonly AircraftControllerSnapshot[];
final: AircraftControllerSnapshot;
}
export interface AircraftCameraPose {
position: AircraftGeographicPoint & { altitudeM: number };
target: AircraftGeographicPoint & { altitudeM: number };
rollDeg: number;
}
interface ResolvedOptions {
initialPosition: AircraftGeographicPoint;
initialAltitudeM: number;
initialHeadingDeg: number;
initialSpeedMps: number;
mode: AircraftControlMode;
route: readonly AircraftWaypoint[];
envelope: CaliforniaFlightEnvelope;
minimumSpeedMps: number;
maximumSpeedMps: number;
assistedCruiseMps: number;
assistedAltitudeM: number;
fixedStepSeconds: number;
maxFrameDeltaSeconds: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function finiteOr(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function moveToward(value: number, target: number, maximumDelta: number): number {
if (value < target) return Math.min(value + maximumDelta, target);
if (value > target) return Math.max(value - maximumDelta, target);
return value;
}
function wrapDegrees(value: number): number {
return ((value % 360) + 360) % 360;
}
function signedAngleDegrees(from: number, to: number): number {
return ((to - from + 540) % 360) - 180;
}
function distanceM(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
const mean = ((a.lat + b.lat) / 2) * Math.PI / 180;
const north = (b.lat - a.lat) * Math.PI / 180 * EARTH_RADIUS_M;
const east = (b.lng - a.lng) * Math.PI / 180 * Math.cos(mean) * EARTH_RADIUS_M;
return Math.hypot(north, east);
}
function bearingDeg(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
const mean = ((a.lat + b.lat) / 2) * Math.PI / 180;
return wrapDegrees(Math.atan2((b.lng - a.lng) * Math.cos(mean), b.lat - a.lat) * 180 / Math.PI);
}
function checkedEnvelope(value: CaliforniaFlightEnvelope | undefined): CaliforniaFlightEnvelope {
const envelope = { ...(value ?? DEFAULT_CALIFORNIA_FLIGHT_ENVELOPE) };
if (
!Number.isFinite(envelope.minLat) || !Number.isFinite(envelope.maxLat) ||
!Number.isFinite(envelope.minLng) || !Number.isFinite(envelope.maxLng) ||
!Number.isFinite(envelope.minAltitudeM) || !Number.isFinite(envelope.maxAltitudeM) ||
envelope.minLat >= envelope.maxLat || envelope.minLng >= envelope.maxLng ||
envelope.minAltitudeM >= envelope.maxAltitudeM
) throw new RangeError("aircraft flight envelope must be finite and ordered");
return envelope;
}
function checkedRoute(route: readonly AircraftWaypoint[] | undefined): readonly AircraftWaypoint[] {
if (!route) return [];
const ids = new Set<string>();
return route.map((point) => {
if (
typeof point.id !== "string" || point.id.length === 0 || ids.has(point.id) ||
!Number.isFinite(point.lat) || !Number.isFinite(point.lng) ||
!Number.isFinite(point.altitudeM)
) throw new RangeError("aircraft route waypoints must be finite with unique ids");
ids.add(point.id);
return { ...point };
});
}
function resolveOptions(value: AircraftControllerOptions): ResolvedOptions {
const envelope = checkedEnvelope(value.envelope);
const minimumSpeedMps = clamp(finiteOr(value.minimumSpeedMps, 20), 5, 100);
const maximumSpeedMps = clamp(finiteOr(value.maximumSpeedMps, 95), minimumSpeedMps, 250);
const assistedAltitudeM = clamp(
finiteOr(value.assistedAltitudeM, 1_500),
envelope.minAltitudeM,
envelope.maxAltitudeM,
);
return {
initialPosition: {
lat: clamp(finiteOr(value.initialPosition?.lat, 34.0522), envelope.minLat, envelope.maxLat),
lng: clamp(finiteOr(value.initialPosition?.lng, -118.2437), envelope.minLng, envelope.maxLng),
},
initialAltitudeM: clamp(finiteOr(value.initialAltitudeM, assistedAltitudeM), envelope.minAltitudeM, envelope.maxAltitudeM),
initialHeadingDeg: wrapDegrees(finiteOr(value.initialHeadingDeg, 320)),
initialSpeedMps: clamp(finiteOr(value.initialSpeedMps, 55), minimumSpeedMps, maximumSpeedMps),
mode: value.mode === "manual" ? "manual" : "assisted",
route: checkedRoute(value.route),
envelope,
minimumSpeedMps,
maximumSpeedMps,
assistedCruiseMps: clamp(finiteOr(value.assistedCruiseMps, 62), minimumSpeedMps, maximumSpeedMps),
assistedAltitudeM,
fixedStepSeconds: clamp(finiteOr(value.fixedStepSeconds, 1 / 60), 1 / 240, 0.1),
maxFrameDeltaSeconds: clamp(finiteOr(value.maxFrameDeltaSeconds, 0.25), 0.05, 1),
};
}
export function normalizeAircraftActions(
value: Partial<AircraftActionSnapshot> | undefined,
): AircraftActionSnapshot {
return {
throttle: clamp(finiteOr(value?.throttle, 0), 0, 1),
yaw: clamp(finiteOr(value?.yaw, 0), -1, 1),
pitch: clamp(finiteOr(value?.pitch, 0), -1, 1),
roll: clamp(finiteOr(value?.roll, 0), -1, 1),
modeRequest: value?.modeRequest === "manual" || value?.modeRequest === "assisted"
? value.modeRequest
: "none",
reset: value?.reset === true,
};
}
function hasManualIntent(actions: AircraftActionSnapshot): boolean {
return (
actions.modeRequest === "manual" || actions.throttle > 0.02 ||
Math.abs(actions.yaw) > 0.06 || Math.abs(actions.pitch) > 0.06 || Math.abs(actions.roll) > 0.06
);
}
export class AircraftController {
private readonly options: ResolvedOptions;
private accumulator = 0;
private readonly current: AircraftControllerState;
constructor(options: AircraftControllerOptions = {}) {
this.options = resolveOptions(options);
this.current = {
...this.options.initialPosition,
altitudeM: this.options.initialAltitudeM,
headingDeg: this.options.initialHeadingDeg,
pitchDeg: 0,
rollDeg: 0,
speedMps: this.options.initialSpeedMps,
verticalSpeedMps: 0,
mode: this.options.mode,
routeWaypointIndex: 0,
routeWaypointId: null,
throttle: 0,
yawInput: 0,
pitchInput: 0,
rollInput: 0,
fanRadians: 0,
envelopeContact: false,
elapsedSteps: 0,
};
this.reset();
}
fixedStepSeconds(): number {
return this.options.fixedStepSeconds;
}
state(): Readonly<AircraftControllerState> {
return this.current;
}
snapshot(): AircraftControllerSnapshot {
return { ...this.current };
}
reset(): void {
this.accumulator = 0;
Object.assign(this.current, this.options.initialPosition, {
altitudeM: this.options.initialAltitudeM,
headingDeg: this.options.initialHeadingDeg,
pitchDeg: 0,
rollDeg: 0,
speedMps: this.options.initialSpeedMps,
verticalSpeedMps: 0,
mode: this.options.mode,
routeWaypointIndex: 0,
routeWaypointId: this.options.route[0]?.id ?? null,
throttle: 0,
yawInput: 0,
pitchInput: 0,
rollInput: 0,
fanRadians: 0,
envelopeContact: false,
elapsedSteps: 0,
});
}
tick(deltaSeconds: number, actions: Partial<AircraftActionSnapshot> = NEUTRAL_AIRCRAFT_ACTIONS): number {
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
const normalized = normalizeAircraftActions(actions);
if (normalized.reset) {
this.reset();
return 0;
}
this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds);
let steps = 0;
while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) {
this.stepNormalized(normalized);
this.accumulator -= this.options.fixedStepSeconds;
steps += 1;
}
return steps;
}
stepFixed(actions: Partial<AircraftActionSnapshot> = NEUTRAL_AIRCRAFT_ACTIONS): void {
const normalized = normalizeAircraftActions(actions);
if (normalized.reset) this.reset();
else this.stepNormalized(normalized);
}
private stepNormalized(actions: AircraftActionSnapshot): void {
const dt = this.options.fixedStepSeconds;
const manual = hasManualIntent(actions);
if (manual) this.current.mode = "manual";
else if (actions.modeRequest === "assisted") this.current.mode = "assisted";
let throttle = actions.throttle;
let yaw = actions.yaw;
let pitch = actions.pitch;
let roll = actions.roll;
if (this.current.mode === "assisted") {
throttle = clamp(0.5 + (this.options.assistedCruiseMps - this.current.speedMps) / 18, 0, 1);
const target = this.options.route[this.current.routeWaypointIndex];
if (target && distanceM(this.current, target) < 3_000 && this.options.route.length > 1) {
this.current.routeWaypointIndex = (this.current.routeWaypointIndex + 1) % this.options.route.length;
}
const waypoint = this.options.route[this.current.routeWaypointIndex];
this.current.routeWaypointId = waypoint?.id ?? null;
const desiredHeading = waypoint ? bearingDeg(this.current, waypoint) : this.options.initialHeadingDeg;
const headingError = signedAngleDegrees(this.current.headingDeg, desiredHeading);
roll = clamp(headingError / 38, -1, 1);
yaw = clamp(headingError / 90, -0.45, 0.45);
const altitudeTarget = waypoint?.altitudeM ?? this.options.assistedAltitudeM;
pitch = clamp((altitudeTarget - this.current.altitudeM) / 350, -0.65, 0.65);
}
this.current.throttle = moveToward(this.current.throttle, throttle, 0.8 * dt);
this.current.yawInput = moveToward(this.current.yawInput, yaw, 2.5 * dt);
this.current.pitchInput = moveToward(this.current.pitchInput, pitch, 2.2 * dt);
this.current.rollInput = moveToward(this.current.rollInput, roll, 2.8 * dt);
const targetRoll = this.current.rollInput * 58;
const targetPitch = this.current.pitchInput * 22;
this.current.rollDeg = moveToward(this.current.rollDeg, targetRoll, 55 * dt);
this.current.pitchDeg = moveToward(this.current.pitchDeg, targetPitch, 28 * dt);
const thrust = this.current.throttle * 8.5;
const drag = 1.2 + this.current.speedMps * this.current.speedMps * 0.00075;
this.current.speedMps = clamp(
this.current.speedMps + (thrust - drag) * dt,
this.options.minimumSpeedMps,
this.options.maximumSpeedMps,
);
const bankTurn = Math.sin(this.current.rollDeg * Math.PI / 180) * 24;
this.current.headingDeg = wrapDegrees(
this.current.headingDeg + (bankTurn + this.current.yawInput * 20) * dt,
);
this.current.verticalSpeedMps = Math.sin(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps;
const heading = this.current.headingDeg * Math.PI / 180;
const horizontalSpeed = Math.cos(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps;
const northM = Math.cos(heading) * horizontalSpeed * dt;
const eastM = Math.sin(heading) * horizontalSpeed * dt;
const nextLat = this.current.lat + northM / EARTH_RADIUS_M * 180 / Math.PI;
const nextLng = this.current.lng + eastM /
(EARTH_RADIUS_M * Math.max(0.01, Math.cos(this.current.lat * Math.PI / 180))) * 180 / Math.PI;
const nextAltitude = this.current.altitudeM + this.current.verticalSpeedMps * dt;
const envelope = this.options.envelope;
this.current.envelopeContact =
nextLat < envelope.minLat || nextLat > envelope.maxLat ||
nextLng < envelope.minLng || nextLng > envelope.maxLng ||
nextAltitude < envelope.minAltitudeM || nextAltitude > envelope.maxAltitudeM;
this.current.lat = clamp(nextLat, envelope.minLat, envelope.maxLat);
this.current.lng = clamp(nextLng, envelope.minLng, envelope.maxLng);
this.current.altitudeM = clamp(nextAltitude, envelope.minAltitudeM, envelope.maxAltitudeM);
if (this.current.envelopeContact) {
this.current.speedMps = Math.max(this.options.minimumSpeedMps, this.current.speedMps * 0.92);
this.current.pitchDeg = moveToward(this.current.pitchDeg, 0, 90 * dt);
this.current.rollDeg = moveToward(this.current.rollDeg, 0, 90 * dt);
}
this.current.fanRadians = (this.current.fanRadians + (30 + this.current.throttle * 180) * dt) % TWO_PI;
this.current.elapsedSteps += 1;
}
}
export function replayAircraftInputs(
options: AircraftControllerOptions,
frames: readonly TimedAircraftInputFrame[],
): AircraftReplayResult {
const controller = new AircraftController(options);
const trajectory: AircraftControllerSnapshot[] = [controller.snapshot()];
for (const frame of frames) {
const steps = Number.isFinite(frame.steps) ? Math.max(0, Math.floor(frame.steps)) : 0;
const held = normalizeAircraftActions(frame.actions);
for (let index = 0; index < steps; index += 1) {
controller.stepFixed(index === 0 ? held : { ...held, modeRequest: "none", reset: false });
trajectory.push(controller.snapshot());
}
}
return { trajectory, final: trajectory.at(-1) ?? controller.snapshot() };
}
/** Renderer-neutral geographic chase camera derived from an authoritative pose. */
export function aircraftChaseCameraPose(
state: Readonly<AircraftControllerState>,
distanceBehindM = 24,
heightAboveM = 8,
lookAheadM = 35,
): AircraftCameraPose {
const heading = state.headingDeg * Math.PI / 180;
const geographicOffset = (northM: number, eastM: number): AircraftGeographicPoint => ({
lat: state.lat + northM / EARTH_RADIUS_M * 180 / Math.PI,
lng: state.lng + eastM /
(EARTH_RADIUS_M * Math.max(0.01, Math.cos(state.lat * Math.PI / 180))) * 180 / Math.PI,
});
const behind = geographicOffset(-Math.cos(heading) * distanceBehindM, -Math.sin(heading) * distanceBehindM);
const ahead = geographicOffset(Math.cos(heading) * lookAheadM, Math.sin(heading) * lookAheadM);
return {
position: { ...behind, altitudeM: state.altitudeM + heightAboveM },
target: { ...ahead, altitudeM: state.altitudeM + state.verticalSpeedMps * 0.4 },
rollDeg: state.rollDeg * 0.2,
};
}