1
0

feat: complete actor handoff and piloted aircraft presence

This commit is contained in:
2026-08-11 22:17:06 -07:00
parent 655c383061
commit 3326d2e6d0
20 changed files with 907 additions and 41 deletions
+3 -1
View File
@@ -96,7 +96,9 @@ function predictPose(pose: SpatialPose, velocity: PoseVelocity, seconds: number)
}
function id(snapshot: EntityPoseSnapshot): string {
return snapshot.entity === "actor" ? `actor:${snapshot.actorId}` : `vehicle:${snapshot.vehicleId}`;
if (snapshot.entity === "actor") return `actor:${snapshot.actorId}`;
if (snapshot.entity === "vehicle") return `vehicle:${snapshot.vehicleId}`;
return `aircraft:${snapshot.aircraftId}`;
}
export class PoseInterpolationBuffer {
+22 -7
View File
@@ -114,13 +114,26 @@ export function isEntityPoseSnapshot(value: unknown): value is EntityPoseSnapsho
(value.kind === "humanoid" || value.kind === "dog" || value.kind === "crow")
);
}
if (value.entity === "vehicle") {
return (
string(value.vehicleId) &&
value.kind === "model-x" &&
(value.driverActorId === null || string(value.driverActorId)) &&
finite(value.steering) && value.steering >= -1 && value.steering <= 1 &&
finite(value.wheelRadians) && Math.abs(value.wheelRadians) <= 1_000_000
);
}
return (
value.entity === "vehicle" &&
string(value.vehicleId) &&
value.kind === "model-x" &&
(value.driverActorId === null || string(value.driverActorId)) &&
finite(value.steering) && value.steering >= -1 && value.steering <= 1 &&
finite(value.wheelRadians) && Math.abs(value.wheelRadians) <= 1_000_000
value.entity === "aircraft" &&
string(value.aircraftId) &&
value.kind === "electric-vtail" &&
string(value.pilotActorId) &&
finite(value.rollDeg) && value.rollDeg >= -90 && value.rollDeg <= 90 &&
finite(value.throttle) && value.throttle >= 0 && value.throttle <= 1 &&
finite(value.rollInput) && value.rollInput >= -1 && value.rollInput <= 1 &&
finite(value.pitchInput) && value.pitchInput >= -1 && value.pitchInput <= 1 &&
finite(value.yawInput) && value.yawInput >= -1 && value.yawInput <= 1 &&
finite(value.fanRadians) && Math.abs(value.fanRadians) <= 1_000_000
);
}
@@ -208,7 +221,9 @@ export function validateServerRealtimeMessage(
}
function entityId(snapshot: EntityPoseSnapshot): string {
return snapshot.entity === "actor" ? `actor:${snapshot.actorId}` : `vehicle:${snapshot.vehicleId}`;
if (snapshot.entity === "actor") return `actor:${snapshot.actorId}`;
if (snapshot.entity === "vehicle") return `vehicle:${snapshot.vehicleId}`;
return `aircraft:${snapshot.aircraftId}`;
}
function localCellKey(pose: SpatialPose): string | null {
+54 -4
View File
@@ -33,6 +33,13 @@ import {
setModelXWheelRotation,
type ModelXRig,
} from "../assets/vehicles/index.ts";
import {
buildElectricAircraft,
disposeElectricAircraft,
setAircraftControlSurfaces,
setAircraftFanRotation,
type ElectricAircraftRig,
} from "../aircraft/asset.ts";
import { PoseInterpolationBuffer } from "./interpolation.ts";
import { isEntityPoseSnapshot } from "./protocol.ts";
import type {
@@ -79,13 +86,15 @@ type PeerVisual =
| { kind: "humanoid"; rig: HumanoidRig }
| { kind: "dog"; rig: DogRig }
| { kind: "crow"; rig: CrowRig }
| { kind: "model-x"; rig: ModelXRig };
| { kind: "model-x"; rig: ModelXRig }
| { kind: "electric-vtail"; rig: ElectricAircraftRig };
interface Prototypes {
humanoid: HumanoidRig;
dog: DogRig;
crow: CrowRig;
vehicle: ModelXRig;
aircraft: ElectricAircraftRig;
}
interface Peer {
@@ -101,7 +110,9 @@ interface Peer {
}
export function scenePeerId(snapshot: EntityPoseSnapshot): string {
return snapshot.entity === "actor" ? `actor:${snapshot.actorId}` : `vehicle:${snapshot.vehicleId}`;
if (snapshot.entity === "actor") return `actor:${snapshot.actorId}`;
if (snapshot.entity === "vehicle") return `vehicle:${snapshot.vehicleId}`;
return `aircraft:${snapshot.aircraftId}`;
}
function poseSpaceKey(pose: SpatialPose): string {
@@ -130,10 +141,36 @@ function makePrototypes(): Prototypes {
dog: buildDog(),
crow: buildCrow(),
vehicle,
aircraft: buildElectricAircraft(),
};
}
function childGroup(root: THREE.Group, name: string): THREE.Group {
const value = root.getObjectByName(name);
if (!(value instanceof THREE.Group)) throw new Error(`aircraft prototype is missing ${name}`);
return value;
}
function cloneAircraft(prototype: ElectricAircraftRig): ElectricAircraftRig {
const root = prototype.root.clone(true);
return {
root,
leftAileron: childGroup(root, "electric-aircraft.aileron-left"),
rightAileron: childGroup(root, "electric-aircraft.aileron-right"),
leftVTail: childGroup(root, "electric-aircraft.v-tail-left"),
rightVTail: childGroup(root, "electric-aircraft.v-tail-right"),
fans: [
childGroup(root, "electric-aircraft.fan-left"),
childGroup(root, "electric-aircraft.fan-right"),
],
ownsMaterials: false,
};
}
function cloneVisual(snapshot: EntityPoseSnapshot, prototypes: Prototypes): PeerVisual {
if (snapshot.entity === "aircraft") {
return { kind: "electric-vtail", rig: cloneAircraft(prototypes.aircraft) };
}
if (snapshot.entity === "vehicle") {
const rig = cloneModelX(prototypes.vehicle);
rig.root.name = "generic-black-ev";
@@ -157,7 +194,7 @@ function replaceVisual(peer: Peer, snapshot: EntityPoseSnapshot, prototypes: Pro
}
function animateActor(
visual: Exclude<PeerVisual, { kind: "model-x" }>,
visual: Extract<PeerVisual, { kind: "humanoid" | "dog" | "crow" }>,
sample: BufferedPoseSample,
): void {
const speed = Math.hypot(sample.velocity.xMps, sample.velocity.zMps);
@@ -262,7 +299,9 @@ export function createScenePeers(options: ScenePeersOptions): ScenePeers {
peer.root.rotation.set(
THREE.MathUtils.degToRad(pose.pitchDeg),
-THREE.MathUtils.degToRad(pose.headingDeg),
0,
peer.latest.entity === "aircraft"
? -THREE.MathUtils.degToRad(peer.latest.rollDeg)
: 0,
);
if (peer.visual.kind === "model-x") {
const latest = peer.latest.entity === "vehicle" ? peer.latest : null;
@@ -272,6 +311,16 @@ export function createScenePeers(options: ScenePeersOptions): ScenePeers {
// wheel geometry rolls forward with negative local-X rotation.
setModelXWheelRotation(peer.visual.rig, -latest.wheelRadians);
}
} else if (peer.visual.kind === "electric-vtail") {
const latest = peer.latest.entity === "aircraft" ? peer.latest : null;
if (latest) {
setAircraftControlSurfaces(peer.visual.rig, {
roll: latest.rollInput,
pitch: latest.pitchInput,
yaw: latest.yawInput,
});
setAircraftFanRotation(peer.visual.rig, latest.fanRadians);
}
} else {
animateActor(peer.visual, sample);
}
@@ -353,6 +402,7 @@ export function createScenePeers(options: ScenePeersOptions): ScenePeers {
disposeDog(prototypes.dog);
disposeCrow(prototypes.crow);
disposeModelX(prototypes.vehicle);
disposeElectricAircraft(prototypes.aircraft);
disposed = true;
},
};
+16 -1
View File
@@ -2,6 +2,7 @@
export type RealtimeActorKind = "humanoid" | "dog" | "crow";
export type RealtimeVehicleKind = "model-x";
export type RealtimeAircraftKind = "electric-vtail";
export type RealtimeMemberRole = "visitor" | "member" | "admin";
export type RealtimeRevocationReason =
| "signed-out"
@@ -104,7 +105,21 @@ export interface VehiclePoseSnapshot extends EntityPoseSnapshotBase {
wheelRadians: number;
}
export type EntityPoseSnapshot = ActorPoseSnapshot | VehiclePoseSnapshot;
/** Authoritative state needed to reproduce a piloted aircraft on another client. */
export interface AircraftPoseSnapshot extends EntityPoseSnapshotBase {
entity: "aircraft";
aircraftId: string;
kind: RealtimeAircraftKind;
pilotActorId: string;
rollDeg: number;
throttle: number;
rollInput: number;
pitchInput: number;
yawInput: number;
fanRadians: number;
}
export type EntityPoseSnapshot = ActorPoseSnapshot | VehiclePoseSnapshot | AircraftPoseSnapshot;
export interface JoinRequest {
type: "join-request";