feat: add character studio and aircraft foundation
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
AircraftController,
|
||||
ELECTRIC_AIRCRAFT_METRICS,
|
||||
aircraftChaseCameraPose,
|
||||
advanceAircraftFans,
|
||||
buildElectricAircraft,
|
||||
createElectricAircraftMaterials,
|
||||
disposeElectricAircraft,
|
||||
normalizeAircraftActions,
|
||||
replayAircraftInputs,
|
||||
setAircraftControlSurfaces,
|
||||
setAircraftFanRotation,
|
||||
} from "../aircraft/index.ts";
|
||||
|
||||
const ROUTE = [
|
||||
{ id: "los-angeles", lat: 34.0522, lng: -118.2437, altitudeM: 1_300 },
|
||||
{ id: "san-francisco", lat: 37.7749, lng: -122.4194, altitudeM: 1_700 },
|
||||
] as const;
|
||||
|
||||
describe("procedural electric aircraft", () => {
|
||||
it("builds an original metre-scale fixed wing facing -Z with articulated parts", () => {
|
||||
const rig = buildElectricAircraft();
|
||||
assert.equal(rig.root.name, "electric-aircraft");
|
||||
assert.equal(rig.fans.length, 2);
|
||||
assert.equal(rig.ownsMaterials, true);
|
||||
assert.ok(ELECTRIC_AIRCRAFT_METRICS.wingspan > ELECTRIC_AIRCRAFT_METRICS.length);
|
||||
const bounds = new THREE.Box3().setFromObject(rig.root);
|
||||
const size = bounds.getSize(new THREE.Vector3());
|
||||
assert.ok(size.x > 10);
|
||||
assert.ok(size.z > 7);
|
||||
assert.ok(size.y > 1.5);
|
||||
disposeElectricAircraft(rig);
|
||||
assert.equal(rig.root.children.length, 0);
|
||||
});
|
||||
|
||||
it("animates opposing ailerons, V-tail surfaces, and electric fans", () => {
|
||||
const rig = buildElectricAircraft();
|
||||
setAircraftControlSurfaces(rig, { roll: 0.8, pitch: 0.5, yaw: -0.4 });
|
||||
assert.ok(rig.leftAileron.rotation.x > 0);
|
||||
assert.ok(rig.rightAileron.rotation.x < 0);
|
||||
assert.notEqual(rig.leftVTail.rotation.x, rig.rightVTail.rotation.x);
|
||||
setAircraftFanRotation(rig, 1);
|
||||
advanceAircraftFans(rig, 0.5);
|
||||
assert.equal(rig.fans[0]?.rotation.z, 1.5);
|
||||
assert.equal(rig.fans[1]?.rotation.z, 1.5);
|
||||
disposeElectricAircraft(rig);
|
||||
});
|
||||
|
||||
it("does not dispose caller-owned materials", () => {
|
||||
const materials = createElectricAircraftMaterials();
|
||||
let disposals = 0;
|
||||
for (const material of Object.values(materials)) {
|
||||
material.addEventListener("dispose", () => { disposals += 1; });
|
||||
}
|
||||
const rig = buildElectricAircraft({ materials });
|
||||
assert.equal(rig.ownsMaterials, false);
|
||||
disposeElectricAircraft(rig);
|
||||
assert.equal(disposals, 0);
|
||||
for (const material of Object.values(materials)) material.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("aircraft controller", () => {
|
||||
it("normalizes device-neutral flight axes", () => {
|
||||
assert.deepEqual(normalizeAircraftActions({
|
||||
throttle: 5,
|
||||
yaw: -4,
|
||||
pitch: Number.NaN,
|
||||
roll: 2,
|
||||
modeRequest: "manual",
|
||||
reset: true,
|
||||
}), {
|
||||
throttle: 1,
|
||||
yaw: -1,
|
||||
pitch: 0,
|
||||
roll: 1,
|
||||
modeRequest: "manual",
|
||||
reset: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("follows route and altitude deterministically in assisted mode", () => {
|
||||
const options = {
|
||||
route: ROUTE,
|
||||
initialPosition: ROUTE[0],
|
||||
initialHeadingDeg: 0,
|
||||
initialAltitudeM: 900,
|
||||
assistedAltitudeM: 1_300,
|
||||
};
|
||||
const a = new AircraftController(options);
|
||||
const b = new AircraftController(options);
|
||||
for (let index = 0; index < 600; index += 1) {
|
||||
a.stepFixed();
|
||||
b.stepFixed();
|
||||
}
|
||||
assert.deepEqual(a.snapshot(), b.snapshot());
|
||||
assert.equal(a.state().mode, "assisted");
|
||||
assert.equal(a.state().routeWaypointId, "san-francisco");
|
||||
assert.ok(a.state().altitudeM > 900);
|
||||
assert.ok(a.state().headingDeg > 180, "aircraft turns northwest toward San Francisco");
|
||||
});
|
||||
|
||||
it("takes manual input immediately and resumes assistance without a state jump", () => {
|
||||
const controller = new AircraftController({ route: ROUTE });
|
||||
for (let index = 0; index < 120; index += 1) {
|
||||
controller.stepFixed({ throttle: 0.8, roll: 0.7, pitch: -0.3 });
|
||||
}
|
||||
assert.equal(controller.state().mode, "manual");
|
||||
const before = controller.snapshot();
|
||||
controller.stepFixed({ modeRequest: "assisted", roll: 0.8 });
|
||||
assert.equal(controller.state().mode, "manual", "manual controls beat simultaneous resume");
|
||||
controller.stepFixed({ modeRequest: "assisted" });
|
||||
assert.equal(controller.state().mode, "assisted");
|
||||
assert.ok(Math.abs(controller.state().rollDeg - before.rollDeg) < 3);
|
||||
assert.ok(Math.abs(controller.state().pitchDeg - before.pitchDeg) < 3);
|
||||
});
|
||||
|
||||
it("enforces geographic, altitude, and speed boundaries", () => {
|
||||
const controller = new AircraftController({
|
||||
mode: "manual",
|
||||
initialPosition: { lat: 100, lng: -200 },
|
||||
initialAltitudeM: 100_000,
|
||||
initialSpeedMps: 1_000,
|
||||
maximumSpeedMps: 80,
|
||||
});
|
||||
assert.equal(controller.state().lat, 42.1);
|
||||
assert.equal(controller.state().lng, -124.6);
|
||||
assert.equal(controller.state().altitudeM, 6_000);
|
||||
assert.equal(controller.state().speedMps, 80);
|
||||
for (let index = 0; index < 180; index += 1) {
|
||||
controller.stepFixed({ throttle: 1, pitch: 1, roll: 1 });
|
||||
}
|
||||
assert.ok(controller.state().lat >= 32.4 && controller.state().lat <= 42.1);
|
||||
assert.ok(controller.state().lng >= -124.6 && controller.state().lng <= -114);
|
||||
assert.ok(controller.state().altitudeM >= 75 && controller.state().altitudeM <= 6_000);
|
||||
assert.ok(controller.state().speedMps <= 80);
|
||||
assert.equal(controller.state().envelopeContact, true);
|
||||
});
|
||||
|
||||
it("resets exactly, caps sleeping-tab time, and replays bit-for-bit", () => {
|
||||
const options = { route: ROUTE, initialAltitudeM: 1_200, initialSpeedMps: 48 } as const;
|
||||
const controller = new AircraftController(options);
|
||||
const spawn = controller.snapshot();
|
||||
assert.ok(controller.tick(600) <= 15);
|
||||
controller.stepFixed({ throttle: 1, roll: -1 });
|
||||
controller.stepFixed({ reset: true });
|
||||
assert.deepEqual(controller.snapshot(), spawn);
|
||||
|
||||
const frames = [
|
||||
{ steps: 90, actions: { throttle: 0.9, roll: 0.4 } },
|
||||
{ steps: 1, actions: { modeRequest: "assisted" as const } },
|
||||
{ steps: 180 },
|
||||
];
|
||||
assert.deepEqual(
|
||||
replayAircraftInputs(options, frames),
|
||||
replayAircraftInputs(options, frames),
|
||||
);
|
||||
});
|
||||
|
||||
it("derives a finite geographic chase camera behind the aircraft", () => {
|
||||
const controller = new AircraftController({ initialHeadingDeg: 0 });
|
||||
const camera = aircraftChaseCameraPose(controller.state());
|
||||
assert.ok(camera.position.lat < controller.state().lat);
|
||||
assert.ok(camera.target.lat > controller.state().lat);
|
||||
assert.ok(camera.position.altitudeM > controller.state().altitudeM);
|
||||
assert.ok(Object.values(camera.position).every(Number.isFinite));
|
||||
assert.ok(Object.values(camera.target).every(Number.isFinite));
|
||||
});
|
||||
|
||||
it("rejects malformed route and envelope configuration", () => {
|
||||
assert.throws(() => new AircraftController({
|
||||
route: [{ id: "bad", lat: Number.NaN, lng: 0, altitudeM: 1_000 }],
|
||||
}), /waypoints/);
|
||||
assert.throws(() => new AircraftController({
|
||||
envelope: {
|
||||
minLat: 40,
|
||||
maxLat: 30,
|
||||
minLng: -124,
|
||||
maxLng: -114,
|
||||
minAltitudeM: 10,
|
||||
maxAltitudeM: 100,
|
||||
},
|
||||
}), /envelope/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user