feat: complete actor handoff and piloted aircraft presence
This commit is contained in:
@@ -40,3 +40,7 @@ export {
|
||||
type SceneAircraftOptions,
|
||||
type SceneAircraftView,
|
||||
} from "./sceneAircraft.ts";
|
||||
export {
|
||||
createAircraftPoseSnapshot,
|
||||
type AircraftRealtimeIdentity,
|
||||
} from "./realtime.ts";
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/** Renderer-independent bridge from deterministic flight state to realtime protocol state. */
|
||||
|
||||
import type { AircraftPoseSnapshot } from "../realtime/types.ts";
|
||||
import type { AircraftControllerSnapshot } from "./controller.ts";
|
||||
|
||||
export interface AircraftRealtimeIdentity {
|
||||
aircraftId: string;
|
||||
pilotActorId: string;
|
||||
}
|
||||
|
||||
function identity(value: string, name: string): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 256) {
|
||||
throw new RangeError(`${name} must be a non-empty string of at most 256 characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts one controller snapshot into the exact geographic wire contract.
|
||||
* Geographic velocity axes are east/up/north; heading zero therefore travels
|
||||
* north even though the Three.js presentation model faces local -Z.
|
||||
*/
|
||||
export function createAircraftPoseSnapshot(
|
||||
identityValue: AircraftRealtimeIdentity,
|
||||
state: AircraftControllerSnapshot,
|
||||
sequence: number,
|
||||
timestampMs: number,
|
||||
): AircraftPoseSnapshot {
|
||||
const heading = state.headingDeg * Math.PI / 180;
|
||||
const pitch = state.pitchDeg * Math.PI / 180;
|
||||
const horizontalSpeedMps = state.speedMps * Math.cos(pitch);
|
||||
return {
|
||||
entity: "aircraft",
|
||||
aircraftId: identity(identityValue.aircraftId, "aircraftId"),
|
||||
kind: "electric-vtail",
|
||||
pilotActorId: identity(identityValue.pilotActorId, "pilotActorId"),
|
||||
sequence,
|
||||
timestampMs,
|
||||
pose: {
|
||||
space: "geographic",
|
||||
lat: state.lat,
|
||||
lng: state.lng,
|
||||
altitudeM: state.altitudeM,
|
||||
headingDeg: state.headingDeg,
|
||||
pitchDeg: state.pitchDeg,
|
||||
},
|
||||
velocity: {
|
||||
xMps: Math.sin(heading) * horizontalSpeedMps,
|
||||
yMps: state.verticalSpeedMps,
|
||||
zMps: Math.cos(heading) * horizontalSpeedMps,
|
||||
yawDegPerSec: 0,
|
||||
},
|
||||
rollDeg: state.rollDeg,
|
||||
throttle: state.throttle,
|
||||
rollInput: state.rollInput,
|
||||
pitchInput: state.pitchInput,
|
||||
yawInput: state.yawInput,
|
||||
fanRadians: state.fanRadians,
|
||||
};
|
||||
}
|
||||
+52
-19
@@ -83,6 +83,7 @@ import type { ActorIdentity } from "./actors/controller.ts";
|
||||
import { SRGBColorSpace, VideoTexture } from "three";
|
||||
import {
|
||||
CALIFORNIA_AIR_ROUTE,
|
||||
createAircraftPoseSnapshot,
|
||||
type AircraftActionSnapshot,
|
||||
} from "./aircraft/index.ts";
|
||||
import {
|
||||
@@ -254,6 +255,8 @@ let cityId = "california";
|
||||
*/
|
||||
let wantedCity = "california";
|
||||
let office: OfficeScene | null = null;
|
||||
/** The pack used by `office`; kept separate from the currently selected door. */
|
||||
let builtOfficeId: string | null = null;
|
||||
let inside = false;
|
||||
let markers: Marker[] = SAMPLE_MARKERS;
|
||||
let realtimeClient: RealtimeClient | null = null;
|
||||
@@ -265,6 +268,7 @@ let realtimePageActive = true;
|
||||
/** Page-scoped wire identities; auth subject never enters the spatial protocol. */
|
||||
const realtimeActorId = crypto.randomUUID();
|
||||
const realtimeVehicleId = crypto.randomUUID();
|
||||
const realtimeAircraftId = crypto.randomUUID();
|
||||
|
||||
/**
|
||||
* The buildings you can walk into, as procedural glyphs on the city.
|
||||
@@ -773,13 +777,7 @@ async function mountCity(id: string) {
|
||||
weatherWatch = null;
|
||||
poseEditor?.destroy();
|
||||
poseEditor = null;
|
||||
stopWatchingOccupancy();
|
||||
disposeOfficeScreenUi();
|
||||
officePlan?.dispose();
|
||||
officePlan = null;
|
||||
office?.dispose();
|
||||
office = null;
|
||||
officeAtmosphere = null;
|
||||
disposeLoadedOffice();
|
||||
inside = false;
|
||||
minimap?.dispose();
|
||||
minimap = null;
|
||||
@@ -1185,6 +1183,12 @@ function syncJourneyVehicle(now: number): void {
|
||||
*/
|
||||
async function enterOffice() {
|
||||
if (!city) return;
|
||||
// A city can have more than one authored door. Leaving one office parks its
|
||||
// scene for a cheap re-entry, but clicking a different door must not reopen
|
||||
// that cached building under the new office id. Tear down every resource
|
||||
// owned by the old room before loading the requested one; the parked city
|
||||
// actor, journey identity and local profile deliberately live elsewhere.
|
||||
if (office && builtOfficeId !== officeId) disposeLoadedOffice();
|
||||
if (!office) {
|
||||
const built = await loadOffice();
|
||||
// The chunk arrived after the user had already left for the other city, or
|
||||
@@ -1272,6 +1276,7 @@ async function enterOffice() {
|
||||
: { onPlacePick: (place) => showDetail(place ? place.label : null) }),
|
||||
...(createOfficePeers ? { realtimePeers: { create: createOfficePeers } } : {}),
|
||||
});
|
||||
builtOfficeId = officeId;
|
||||
office.onViewChange(() => renderLegend());
|
||||
officePlan = buildOfficePlan(createOfficeMinimap, office);
|
||||
}
|
||||
@@ -1410,6 +1415,18 @@ function leaveOffice() {
|
||||
renderLegend();
|
||||
}
|
||||
|
||||
/** Dispose everything whose coordinates or subscriptions belong to one office. */
|
||||
function disposeLoadedOffice(): void {
|
||||
stopWatchingOccupancy();
|
||||
disposeOfficeScreenUi();
|
||||
officePlan?.dispose();
|
||||
officePlan = null;
|
||||
office?.dispose();
|
||||
office = null;
|
||||
builtOfficeId = null;
|
||||
officeAtmosphere = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Spaces.
|
||||
*
|
||||
@@ -1634,14 +1651,8 @@ async function switchOffice(id: string) {
|
||||
const previous = officeId;
|
||||
officeId = id;
|
||||
|
||||
// The roster belongs to the building you have left.
|
||||
stopWatchingOccupancy();
|
||||
disposeOfficeScreenUi();
|
||||
officePlan?.dispose();
|
||||
officePlan = null;
|
||||
office?.dispose();
|
||||
office = null;
|
||||
officeAtmosphere = null;
|
||||
// The roster, screen session, plan and scene all belong to the building left.
|
||||
disposeLoadedOffice();
|
||||
|
||||
entering = true;
|
||||
try {
|
||||
@@ -2015,6 +2026,16 @@ function realtimePose(): EntityPoseSnapshot | null {
|
||||
};
|
||||
}
|
||||
|
||||
const aircraft = city?.aircraftActive() ? city.aircraftState() : null;
|
||||
if (aircraft) {
|
||||
return createAircraftPoseSnapshot(
|
||||
{ aircraftId: realtimeAircraftId, pilotActorId: realtimeActorId },
|
||||
aircraft,
|
||||
0,
|
||||
timestampMs,
|
||||
);
|
||||
}
|
||||
|
||||
const vehicle = journey.vehicle !== null ? city?.vehicleState() : null;
|
||||
if (vehicle) {
|
||||
const heading = vehicle.headingDeg * Math.PI / 180;
|
||||
@@ -2080,7 +2101,11 @@ function realtimePose(): EntityPoseSnapshot | null {
|
||||
|
||||
function ownRealtimeEntity(snapshot: EntityPoseSnapshot): boolean {
|
||||
if (snapshot.entity === "actor") return snapshot.actorId === realtimeClient?.state().actorId;
|
||||
return snapshot.driverActorId === realtimeClient?.state().actorId || snapshot.vehicleId === realtimeVehicleId;
|
||||
if (snapshot.entity === "vehicle") {
|
||||
return snapshot.driverActorId === realtimeClient?.state().actorId || snapshot.vehicleId === realtimeVehicleId;
|
||||
}
|
||||
return snapshot.pilotActorId === realtimeClient?.state().actorId ||
|
||||
snapshot.aircraftId === realtimeAircraftId;
|
||||
}
|
||||
|
||||
function clearRealtimePeers(): void {
|
||||
@@ -2107,7 +2132,11 @@ function applyRealtimeMessage(message: ServerRealtimeMessage): void {
|
||||
}
|
||||
if (message.type === "pose-delta") {
|
||||
for (const id of message.removedEntityIds) {
|
||||
if (id !== `actor:${realtimeClient?.state().actorId}` && id !== `vehicle:${realtimeVehicleId}`) {
|
||||
if (
|
||||
id !== `actor:${realtimeClient?.state().actorId}` &&
|
||||
id !== `vehicle:${realtimeVehicleId}` &&
|
||||
id !== `aircraft:${realtimeAircraftId}`
|
||||
) {
|
||||
target?.removeRemoteEntity(id);
|
||||
}
|
||||
}
|
||||
@@ -2804,9 +2833,13 @@ canvas.addEventListener("click", () => {
|
||||
const marker = hoveredMarker;
|
||||
if (!marker) return;
|
||||
const id = officeIdOf(marker);
|
||||
if (id === null) return;
|
||||
if (id === null || entering) return;
|
||||
entering = true;
|
||||
officeId = id;
|
||||
void building(`Opening ${marker.label}…`, () => enterOffice());
|
||||
void building(`Opening ${marker.label}…`, () => enterOffice()).finally(() => {
|
||||
entering = false;
|
||||
renderLegend();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Panels, plan and overlays ----------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import { createSceneActor, type ActorIdentity } from "../actors/index.ts";
|
||||
import { createOfficeWalker, type OfficeActorAppearance } from "../interiors/officeWalker.ts";
|
||||
import { Plan } from "../interiors/plan.ts";
|
||||
import type { Level, Office, Room } from "../interiors/types.ts";
|
||||
import { createJourney, journeyReducer, type JourneyActor } from "../journey/index.ts";
|
||||
import {
|
||||
actorKindForPresence,
|
||||
createDefaultLocalProfile,
|
||||
resolveHumanoidAppearance,
|
||||
} from "../profile/index.ts";
|
||||
|
||||
const FLOOR: Room = {
|
||||
id: "floor",
|
||||
name: "Floor",
|
||||
floor: "floor" as never,
|
||||
outline: [{ x: 0, z: 0 }, { x: 10, z: 0 }, { x: 10, z: 8 }, { x: 0, z: 8 }],
|
||||
};
|
||||
|
||||
function officePlan(): Plan {
|
||||
const level: Level = {
|
||||
id: "ground",
|
||||
name: "Ground",
|
||||
elevation: 0,
|
||||
wallHeight: 3,
|
||||
wallThickness: 0.12,
|
||||
floorplan: {
|
||||
rooms: [FLOOR],
|
||||
walls: [{
|
||||
id: "dividing-wall",
|
||||
from: { x: 5, z: 0 },
|
||||
to: { x: 5, z: 8 },
|
||||
openings: [{ kind: "door", start: 3.4, width: 1.2, sill: 0, head: 2.1 }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const office: Office = {
|
||||
id: "handoff-office",
|
||||
name: "Handoff Office",
|
||||
levels: [level],
|
||||
viewpoints: [],
|
||||
};
|
||||
return new Plan(office, { warn: false });
|
||||
}
|
||||
|
||||
function materialColor(root: THREE.Object3D, meshName: string): string {
|
||||
const mesh = root.getObjectByName(meshName);
|
||||
assert.ok(mesh instanceof THREE.Mesh, `${meshName} exists`);
|
||||
assert.ok(mesh.material instanceof THREE.MeshStandardMaterial, `${meshName} has a standard material`);
|
||||
return `#${mesh.material.color.getHexString()}`;
|
||||
}
|
||||
|
||||
describe("city and office actor acceptance handoff", () => {
|
||||
it("hands an anonymous crow to an office dog, walks through doors, and returns to the same crow", () => {
|
||||
const journeyActor: JourneyActor = {
|
||||
id: "anonymous",
|
||||
kind: "crow",
|
||||
signedIn: false,
|
||||
profile: { displayName: "Guest" },
|
||||
};
|
||||
const identity: ActorIdentity = {
|
||||
id: journeyActor.id,
|
||||
displayName: journeyActor.profile.displayName,
|
||||
authenticated: false,
|
||||
profile: {},
|
||||
};
|
||||
let journey = createJourney({ actor: journeyActor });
|
||||
journey = journeyReducer(journey, { type: "navigate-to-city", city: "bay-area" });
|
||||
|
||||
assert.equal(actorKindForPresence(false, "outdoors"), "crow");
|
||||
const cityActor = createSceneActor({
|
||||
kind: "crow",
|
||||
mode: "flight",
|
||||
identity,
|
||||
position: { x: 12, y: 30, z: -8 },
|
||||
active: true,
|
||||
fixedStepSeconds: 0.1,
|
||||
minFlightAltitude: 5,
|
||||
maxFlightAltitude: 60,
|
||||
});
|
||||
cityActor.setActions({ forward: 1, climb: 0.2 });
|
||||
cityActor.tick(0.5);
|
||||
cityActor.setActive(false);
|
||||
const parkedCityState = cityActor.state();
|
||||
|
||||
journey = journeyReducer(journey, { type: "enter-office", officeId: "lumbridge-hq" });
|
||||
assert.equal(journey.location.scale, "office");
|
||||
assert.deepEqual(journey.actor, journeyActor, "entering never rewrites the serializable identity");
|
||||
assert.equal(actorKindForPresence(false, "office"), "dog");
|
||||
|
||||
const walker = createOfficeWalker(officePlan(), {
|
||||
levelId: "ground",
|
||||
position: { x: 4, z: 4 },
|
||||
actor: { kind: "anonymous-dog" },
|
||||
active: true,
|
||||
speed: 2,
|
||||
fixedStep: 0.1,
|
||||
});
|
||||
walker.setAction({ x: 1, z: 0 });
|
||||
for (let step = 0; step < 10; step += 1) walker.tick(0.1);
|
||||
assert.ok(walker.state().position.x > 5.5, "the possessed dog passes through the authored door gap");
|
||||
|
||||
walker.reset({ levelId: "ground", position: { x: 4, z: 1 } });
|
||||
walker.setAction({ x: 1, z: 0 });
|
||||
for (let step = 0; step < 10; step += 1) walker.tick(0.1);
|
||||
assert.ok(walker.state().position.x < 5, "the same controller cannot cross the solid wall");
|
||||
|
||||
journey = journeyReducer(journey, { type: "leave-office" });
|
||||
assert.equal(journey.location.scale, "bay-area");
|
||||
assert.deepEqual(journey.actor, journeyActor);
|
||||
assert.deepEqual(cityActor.state(), parkedCityState, "the outdoor actor stays parked during the office visit");
|
||||
assert.deepEqual(cityActor.state().identity, identity);
|
||||
|
||||
cityActor.setActive(true);
|
||||
cityActor.setActions({ forward: 1 });
|
||||
cityActor.tick(0.2);
|
||||
assert.ok(cityActor.state().distanceM > parkedCityState.distanceM, "the returned crow is playable");
|
||||
walker.dispose();
|
||||
cityActor.dispose();
|
||||
});
|
||||
|
||||
it("renders one signed-in humanoid appearance on both sides of the office door", () => {
|
||||
const profile = createDefaultLocalProfile("member-acceptance", "Morgan");
|
||||
const appearance = resolveHumanoidAppearance(profile.appearance);
|
||||
const identity: ActorIdentity = {
|
||||
id: "member-acceptance",
|
||||
displayName: profile.displayName,
|
||||
authenticated: true,
|
||||
profile: {
|
||||
appearance: {
|
||||
skinTone: appearance.skinTone,
|
||||
primaryColor: appearance.outfitColor,
|
||||
accentColor: appearance.accentColor,
|
||||
hairColor: appearance.hairColor,
|
||||
bodyShape: appearance.bodyShape,
|
||||
},
|
||||
},
|
||||
};
|
||||
const officeAppearance: OfficeActorAppearance = {
|
||||
kind: "humanoid",
|
||||
skinTone: appearance.skinTone,
|
||||
outfitColor: appearance.outfitColor,
|
||||
accentColor: appearance.accentColor,
|
||||
hairColor: appearance.hairColor,
|
||||
bodyShape: appearance.bodyShape,
|
||||
};
|
||||
|
||||
assert.equal(actorKindForPresence(true, "outdoors"), "humanoid");
|
||||
assert.equal(actorKindForPresence(true, "office"), "humanoid");
|
||||
const cityActor = createSceneActor({ kind: "humanoid", identity });
|
||||
const walker = createOfficeWalker(officePlan(), {
|
||||
levelId: "ground",
|
||||
position: { x: 2, z: 2 },
|
||||
actor: officeAppearance,
|
||||
});
|
||||
|
||||
assert.equal(cityActor.state().identity.id, "member-acceptance");
|
||||
assert.equal(cityActor.state().identity.displayName, "Morgan");
|
||||
assert.ok(cityActor.root.getObjectByName("humanoid"));
|
||||
assert.equal(walker.root.userData.actorType, "humanoid");
|
||||
assert.equal(materialColor(cityActor.root, "humanoid.chest"), appearance.outfitColor);
|
||||
assert.equal(materialColor(walker.root, "humanoid.chest"), appearance.outfitColor);
|
||||
assert.equal(materialColor(cityActor.root, "humanoid.chest.accent"), appearance.accentColor);
|
||||
assert.equal(materialColor(walker.root, "humanoid.chest.accent"), appearance.accentColor);
|
||||
assert.equal(materialColor(cityActor.root, "humanoid.hair"), appearance.hairColor);
|
||||
assert.equal(materialColor(walker.root, "humanoid.hair"), appearance.hairColor);
|
||||
|
||||
walker.dispose();
|
||||
cityActor.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
AircraftController,
|
||||
createAircraftPoseSnapshot,
|
||||
} from "../aircraft/index.ts";
|
||||
import {
|
||||
PoseInterpolationBuffer,
|
||||
isEntityPoseSnapshot,
|
||||
} from "../realtime/index.ts";
|
||||
|
||||
describe("playable aircraft realtime bridge", () => {
|
||||
it("publishes a strict geographic snapshot with deterministic flight axes", () => {
|
||||
const controller = new AircraftController({
|
||||
initialHeadingDeg: 90,
|
||||
initialSpeedMps: 60,
|
||||
initialAltitudeM: 1_500,
|
||||
});
|
||||
const state = controller.snapshot();
|
||||
const snapshot = createAircraftPoseSnapshot(
|
||||
{ aircraftId: "aircraft-page-1", pilotActorId: "actor-page-1" },
|
||||
state,
|
||||
7,
|
||||
10_000,
|
||||
);
|
||||
assert.equal(isEntityPoseSnapshot(snapshot), true);
|
||||
assert.equal(snapshot.entity, "aircraft");
|
||||
assert.equal(snapshot.pose.space, "geographic");
|
||||
assert.ok(Math.abs(snapshot.velocity.xMps - state.speedMps) < 1e-10);
|
||||
assert.ok(Math.abs(snapshot.velocity.zMps) < 1e-10);
|
||||
assert.equal(snapshot.velocity.yMps, state.verticalSpeedMps);
|
||||
});
|
||||
|
||||
it("feeds aircraft samples through the shared deterministic interpolation buffer", () => {
|
||||
const controller = new AircraftController({ initialHeadingDeg: 0, initialSpeedMps: 50 });
|
||||
const first = createAircraftPoseSnapshot(
|
||||
{ aircraftId: "aircraft-page-1", pilotActorId: "actor-page-1" },
|
||||
controller.snapshot(),
|
||||
1,
|
||||
1_000,
|
||||
);
|
||||
controller.tick(1, { throttle: 1 });
|
||||
const second = createAircraftPoseSnapshot(
|
||||
{ aircraftId: "aircraft-page-1", pilotActorId: "actor-page-1" },
|
||||
controller.snapshot(),
|
||||
2,
|
||||
2_000,
|
||||
);
|
||||
const buffer = new PoseInterpolationBuffer({ interpolationDelayMs: 0 });
|
||||
assert.equal(buffer.push(first), true);
|
||||
assert.equal(buffer.push(second), true);
|
||||
const sample = buffer.sample(1_500);
|
||||
assert.equal(sample?.mode, "interpolated");
|
||||
assert.equal(sample?.pose.space, "geographic");
|
||||
if (sample?.pose.space === "geographic" && first.pose.space === "geographic" && second.pose.space === "geographic") {
|
||||
assert.ok(sample.pose.lat >= Math.min(first.pose.lat, second.pose.lat));
|
||||
assert.ok(sample.pose.lat <= Math.max(first.pose.lat, second.pose.lat));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-wire-safe identity and aircraft controls", () => {
|
||||
const controller = new AircraftController();
|
||||
assert.throws(() => createAircraftPoseSnapshot(
|
||||
{ aircraftId: "", pilotActorId: "actor-page-1" },
|
||||
controller.snapshot(),
|
||||
1,
|
||||
1_000,
|
||||
), RangeError);
|
||||
const valid = createAircraftPoseSnapshot(
|
||||
{ aircraftId: "aircraft-page-1", pilotActorId: "actor-page-1" },
|
||||
controller.snapshot(),
|
||||
1,
|
||||
1_000,
|
||||
);
|
||||
assert.equal(isEntityPoseSnapshot({ ...valid, throttle: 1.01 }), false);
|
||||
assert.equal(isEntityPoseSnapshot({ ...valid, rollDeg: Number.NaN }), false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createScenePeers,
|
||||
scenePeerId,
|
||||
type ActorPoseSnapshot,
|
||||
type AircraftPoseSnapshot,
|
||||
type EntityPoseSnapshot,
|
||||
type InterestCell,
|
||||
type ScenePeersOptions,
|
||||
@@ -48,6 +49,25 @@ function vehicle(sequence: number, timestampMs: number, lat: number): VehiclePos
|
||||
};
|
||||
}
|
||||
|
||||
function aircraft(sequence: number, timestampMs: number, lat: number): AircraftPoseSnapshot {
|
||||
return {
|
||||
entity: "aircraft",
|
||||
aircraftId: "evtol-1",
|
||||
kind: "electric-vtail",
|
||||
pilotActorId: "pilot-1",
|
||||
sequence,
|
||||
timestampMs,
|
||||
pose: { space: "geographic", lat, lng: -121, altitudeM: 1_500, headingDeg: 30, pitchDeg: 5 },
|
||||
velocity: { xMps: 30, yMps: 2, zMps: 52, yawDegPerSec: 4 },
|
||||
rollDeg: 18,
|
||||
throttle: 0.72,
|
||||
rollInput: 0.4,
|
||||
pitchInput: -0.2,
|
||||
yawInput: 0.1,
|
||||
fanRadians: 2.4,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(over: Partial<ScenePeersOptions> = {}) {
|
||||
return createScenePeers({
|
||||
project: (lat, lng) => [lng * 2, -lat * 3],
|
||||
@@ -120,6 +140,23 @@ describe("remote scene peers", () => {
|
||||
peers.dispose();
|
||||
});
|
||||
|
||||
it("renders a remote piloted aircraft with authoritative bank and rig state", () => {
|
||||
const peers = fixture();
|
||||
const snapshot = aircraft(1, 1_000, 37.7);
|
||||
assert.equal(scenePeerId(snapshot), "aircraft:evtol-1");
|
||||
assert.equal(peers.upsert(snapshot), true);
|
||||
assert.equal(peers.tick(1_000), 1);
|
||||
const root = peers.root.children[0] as THREE.Group;
|
||||
assert.deepEqual(root.position.toArray(), [-242, 155, -113.10000000000001]);
|
||||
assert.ok(Math.abs(root.rotation.x - 5 * Math.PI / 180) < 1e-12);
|
||||
assert.ok(Math.abs(root.rotation.y + 30 * Math.PI / 180) < 1e-12);
|
||||
assert.ok(Math.abs(root.rotation.z + 18 * Math.PI / 180) < 1e-12);
|
||||
assert.ok(root.getObjectByName("electric-aircraft"));
|
||||
assert.equal(root.getObjectByName("electric-aircraft.fan-left")?.rotation.z, 2.4);
|
||||
assert.ok(Math.abs((root.getObjectByName("electric-aircraft.aileron-left")?.rotation.x ?? 0) - 0.18) < 1e-12);
|
||||
peers.dispose();
|
||||
});
|
||||
|
||||
it("shares prototype resources while kind changes retain the entity root", () => {
|
||||
const peers = fixture();
|
||||
peers.upsert(actor("one", "humanoid", 1, 1_000, 0));
|
||||
|
||||
Reference in New Issue
Block a user