feat: add character studio and aircraft foundation
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
/** Original procedural electric V-tail aircraft, authored in metres and facing -Z. */
|
||||
|
||||
import * as THREE from "three";
|
||||
|
||||
export const ELECTRIC_AIRCRAFT_METRICS = Object.freeze({
|
||||
length: 8.4,
|
||||
wingspan: 11.8,
|
||||
height: 2.45,
|
||||
});
|
||||
|
||||
export interface ElectricAircraftMaterials {
|
||||
body: THREE.Material;
|
||||
accent: THREE.Material;
|
||||
glass: THREE.Material;
|
||||
dark: THREE.Material;
|
||||
}
|
||||
|
||||
export interface ElectricAircraftBuildOptions {
|
||||
materials?: ElectricAircraftMaterials;
|
||||
bodyColor?: THREE.ColorRepresentation;
|
||||
}
|
||||
|
||||
export interface ElectricAircraftRig {
|
||||
root: THREE.Group;
|
||||
leftAileron: THREE.Group;
|
||||
rightAileron: THREE.Group;
|
||||
leftVTail: THREE.Group;
|
||||
rightVTail: THREE.Group;
|
||||
fans: readonly THREE.Group[];
|
||||
ownsMaterials: boolean;
|
||||
}
|
||||
|
||||
export interface AircraftSurfacePose {
|
||||
roll: number;
|
||||
pitch: number;
|
||||
yaw: number;
|
||||
}
|
||||
|
||||
export function createElectricAircraftMaterials(
|
||||
bodyColor: THREE.ColorRepresentation = 0xe9edf0,
|
||||
): ElectricAircraftMaterials {
|
||||
return {
|
||||
body: new THREE.MeshPhysicalMaterial({
|
||||
name: "electric-aircraft.body",
|
||||
color: bodyColor,
|
||||
metalness: 0.35,
|
||||
roughness: 0.3,
|
||||
clearcoat: 0.8,
|
||||
}),
|
||||
accent: new THREE.MeshStandardMaterial({
|
||||
name: "electric-aircraft.accent",
|
||||
color: 0x178f83,
|
||||
metalness: 0.45,
|
||||
roughness: 0.32,
|
||||
}),
|
||||
glass: new THREE.MeshPhysicalMaterial({
|
||||
name: "electric-aircraft.glass",
|
||||
color: 0x19323c,
|
||||
roughness: 0.12,
|
||||
transparent: true,
|
||||
opacity: 0.82,
|
||||
}),
|
||||
dark: new THREE.MeshStandardMaterial({
|
||||
name: "electric-aircraft.dark",
|
||||
color: 0x1a2022,
|
||||
metalness: 0.7,
|
||||
roughness: 0.34,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mesh(
|
||||
geometry: THREE.BufferGeometry,
|
||||
material: THREE.Material,
|
||||
name: string,
|
||||
): THREE.Mesh {
|
||||
const value = new THREE.Mesh(geometry, material);
|
||||
value.name = name;
|
||||
value.castShadow = true;
|
||||
value.receiveShadow = true;
|
||||
return value;
|
||||
}
|
||||
|
||||
function wingGeometry(span: number, rootChord: number, tipChord: number): THREE.BufferGeometry {
|
||||
const half = span / 2;
|
||||
const vertices = new Float32Array([
|
||||
0, 0, -rootChord / 2, half, 0, -tipChord / 2, half, 0, tipChord / 2,
|
||||
0, 0, rootChord / 2, -half, 0, tipChord / 2, -half, 0, -tipChord / 2,
|
||||
]);
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
|
||||
geometry.setIndex([0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 5]);
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function addFan(root: THREE.Group, x: number, materials: ElectricAircraftMaterials): THREE.Group {
|
||||
const fan = new THREE.Group();
|
||||
fan.name = x < 0 ? "electric-aircraft.fan-left" : "electric-aircraft.fan-right";
|
||||
fan.position.set(x, 0.02, -0.48);
|
||||
const nacelle = mesh(new THREE.CapsuleGeometry(0.25, 0.72, 5, 10), materials.accent, `${fan.name}:nacelle`);
|
||||
nacelle.rotation.x = Math.PI / 2;
|
||||
nacelle.position.set(x, 0.02, -0.12);
|
||||
root.add(nacelle);
|
||||
const hub = mesh(new THREE.CylinderGeometry(0.11, 0.11, 0.14, 12), materials.dark, `${fan.name}:hub`);
|
||||
hub.rotation.x = Math.PI / 2;
|
||||
fan.add(hub);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const blade = mesh(new THREE.BoxGeometry(0.07, 0.68, 0.025), materials.dark, `${fan.name}:blade-${index}`);
|
||||
blade.position.y = 0.3;
|
||||
blade.rotation.z = index * TWO_PI / 5;
|
||||
fan.add(blade);
|
||||
}
|
||||
root.add(fan);
|
||||
return fan;
|
||||
}
|
||||
|
||||
const TWO_PI = Math.PI * 2;
|
||||
|
||||
export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {}): ElectricAircraftRig {
|
||||
const ownsMaterials = options.materials === undefined;
|
||||
const materials = options.materials ?? createElectricAircraftMaterials(options.bodyColor);
|
||||
const root = new THREE.Group();
|
||||
root.name = "electric-aircraft";
|
||||
|
||||
const fuselage = mesh(new THREE.CapsuleGeometry(0.66, 5.9, 8, 16), materials.body, "electric-aircraft:fuselage");
|
||||
fuselage.rotation.x = Math.PI / 2;
|
||||
fuselage.position.y = 0.42;
|
||||
root.add(fuselage);
|
||||
const nose = mesh(new THREE.ConeGeometry(0.64, 1.7, 18), materials.accent, "electric-aircraft:nose");
|
||||
nose.rotation.x = -Math.PI / 2;
|
||||
nose.position.set(0, 0.42, -3.8);
|
||||
root.add(nose);
|
||||
const canopy = mesh(new THREE.SphereGeometry(0.68, 16, 8), materials.glass, "electric-aircraft:canopy");
|
||||
canopy.scale.set(0.8, 0.52, 1.55);
|
||||
canopy.position.set(0, 1.02, -0.9);
|
||||
root.add(canopy);
|
||||
const wing = mesh(wingGeometry(11.8, 2.25, 0.72), materials.body, "electric-aircraft:wing");
|
||||
wing.position.set(0, 0.46, 0.05);
|
||||
root.add(wing);
|
||||
|
||||
const leftAileron = new THREE.Group();
|
||||
const rightAileron = new THREE.Group();
|
||||
leftAileron.name = "electric-aircraft.aileron-left";
|
||||
rightAileron.name = "electric-aircraft.aileron-right";
|
||||
for (const [joint, x] of [[leftAileron, -4.2], [rightAileron, 4.2]] as const) {
|
||||
joint.position.set(x, 0.48, 0.72);
|
||||
joint.add(mesh(new THREE.BoxGeometry(2.15, 0.08, 0.45), materials.accent, `${joint.name}:surface`));
|
||||
root.add(joint);
|
||||
}
|
||||
|
||||
const leftVTail = new THREE.Group();
|
||||
const rightVTail = new THREE.Group();
|
||||
leftVTail.name = "electric-aircraft.v-tail-left";
|
||||
rightVTail.name = "electric-aircraft.v-tail-right";
|
||||
for (const [joint, x, tilt] of [
|
||||
[leftVTail, -0.32, -0.72],
|
||||
[rightVTail, 0.32, 0.72],
|
||||
] as const) {
|
||||
joint.position.set(x, 0.63, 3.2);
|
||||
joint.rotation.z = tilt;
|
||||
const surface = mesh(new THREE.BoxGeometry(0.1, 1.85, 1.15), materials.body, `${joint.name}:surface`);
|
||||
surface.position.y = 0.72;
|
||||
joint.add(surface);
|
||||
root.add(joint);
|
||||
}
|
||||
|
||||
const fans = [addFan(root, -2.1, materials), addFan(root, 2.1, materials)];
|
||||
root.traverse((item) => {
|
||||
if (item instanceof THREE.Mesh) {
|
||||
item.geometry.computeBoundingBox();
|
||||
item.geometry.computeBoundingSphere();
|
||||
}
|
||||
});
|
||||
return { root, leftAileron, rightAileron, leftVTail, rightVTail, fans, ownsMaterials };
|
||||
}
|
||||
|
||||
export function setAircraftControlSurfaces(
|
||||
rig: ElectricAircraftRig,
|
||||
pose: Partial<AircraftSurfacePose>,
|
||||
): void {
|
||||
const roll = THREE.MathUtils.clamp(Number.isFinite(pose.roll) ? pose.roll ?? 0 : 0, -1, 1);
|
||||
const pitch = THREE.MathUtils.clamp(Number.isFinite(pose.pitch) ? pose.pitch ?? 0 : 0, -1, 1);
|
||||
const yaw = THREE.MathUtils.clamp(Number.isFinite(pose.yaw) ? pose.yaw ?? 0 : 0, -1, 1);
|
||||
rig.leftAileron.rotation.x = roll * 0.45;
|
||||
rig.rightAileron.rotation.x = -roll * 0.45;
|
||||
rig.leftVTail.rotation.x = pitch * 0.34 + yaw * 0.22;
|
||||
rig.rightVTail.rotation.x = pitch * 0.34 - yaw * 0.22;
|
||||
}
|
||||
|
||||
export function setAircraftFanRotation(rig: ElectricAircraftRig, radians: number): void {
|
||||
const safe = Number.isFinite(radians) ? radians : 0;
|
||||
for (const fan of rig.fans) fan.rotation.z = safe;
|
||||
}
|
||||
|
||||
export function advanceAircraftFans(rig: ElectricAircraftRig, radians: number): void {
|
||||
const delta = Number.isFinite(radians) ? radians : 0;
|
||||
for (const fan of rig.fans) fan.rotation.z = (fan.rotation.z + delta) % TWO_PI;
|
||||
}
|
||||
|
||||
export function disposeElectricAircraft(rig: ElectricAircraftRig): void {
|
||||
const geometries = new Set<THREE.BufferGeometry>();
|
||||
const materials = new Set<THREE.Material>();
|
||||
rig.root.traverse((item) => {
|
||||
if (!(item instanceof THREE.Mesh)) return;
|
||||
geometries.add(item.geometry);
|
||||
const values = Array.isArray(item.material) ? item.material : [item.material];
|
||||
for (const material of values) materials.add(material);
|
||||
});
|
||||
for (const geometry of geometries) geometry.dispose();
|
||||
if (rig.ownsMaterials) for (const material of materials) material.dispose();
|
||||
rig.root.removeFromParent();
|
||||
rig.root.clear();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export {
|
||||
AircraftController,
|
||||
DEFAULT_CALIFORNIA_FLIGHT_ENVELOPE,
|
||||
NEUTRAL_AIRCRAFT_ACTIONS,
|
||||
aircraftChaseCameraPose,
|
||||
normalizeAircraftActions,
|
||||
replayAircraftInputs,
|
||||
type AircraftActionSnapshot,
|
||||
type AircraftCameraPose,
|
||||
type AircraftControlMode,
|
||||
type AircraftControllerOptions,
|
||||
type AircraftControllerSnapshot,
|
||||
type AircraftControllerState,
|
||||
type AircraftGeographicPoint,
|
||||
type AircraftModeRequest,
|
||||
type AircraftReplayResult,
|
||||
type AircraftWaypoint,
|
||||
type CaliforniaFlightEnvelope,
|
||||
type TimedAircraftInputFrame,
|
||||
} from "./controller.ts";
|
||||
export {
|
||||
ELECTRIC_AIRCRAFT_METRICS,
|
||||
advanceAircraftFans,
|
||||
buildElectricAircraft,
|
||||
createElectricAircraftMaterials,
|
||||
disposeElectricAircraft,
|
||||
setAircraftControlSurfaces,
|
||||
setAircraftFanRotation,
|
||||
type AircraftSurfacePose,
|
||||
type ElectricAircraftBuildOptions,
|
||||
type ElectricAircraftMaterials,
|
||||
type ElectricAircraftRig,
|
||||
} from "./asset.ts";
|
||||
Reference in New Issue
Block a user