1
0

feat: add playable actors and seamless journey state

This commit is contained in:
2026-08-11 19:10:14 -07:00
parent 4e313c4a79
commit a2a52bfdae
19 changed files with 2778 additions and 24 deletions
+56 -1
View File
@@ -64,6 +64,8 @@ import type {
ScenePalette,
} from "./types.ts";
import { World, type FieldProgress } from "./world.ts";
import { createSceneActor, type SceneActorOptions } from "../actors/sceneActor.ts";
import type { ActorActionSnapshot, ActorControllerSnapshot } from "../actors/controller.ts";
export interface SceneOptions {
city: City;
@@ -79,6 +81,10 @@ export interface SceneOptions {
markers?: Marker[];
/** Optional deterministic road traffic for a state/corridor-scale board. */
roadTraffic?: RoadTrafficOptions;
/** Optional possessed procedural actor for play mode; inactive by default. */
actor?: SceneActorOptions;
/** Geographic spawn anchor for that actor; defaults to the board centre. */
actorAnchor?: { lat: number; lng: number };
flights?: FlightSource;
/**
* Element sets to propagate, if this deployment has any.
@@ -175,6 +181,10 @@ export interface SceneHandle {
vehicleState(): Readonly<VehicleControllerState> | null;
setVehicleCamera(mode: VehicleCameraMode): void;
vehicleCamera(): VehicleCameraMode | null;
setActorActions(actions: Partial<ActorActionSnapshot>): void;
actorState(): Readonly<ActorControllerSnapshot> | null;
setActorActive(active: boolean): void;
actorActive(): boolean;
setMarkers(markers: Marker[]): void;
/** Take this city off the stage and release everything it built. */
dispose(): void;
@@ -254,6 +264,7 @@ export async function createScene(
Math.hypot(eastX, southZ),
);
const orbitMinDistance = Math.max(4, boardSpan * 0.02);
const kit = createSceneKit({
scene,
dom: stage.renderer.domElement,
@@ -272,7 +283,7 @@ export async function createScene(
* the invariant: nothing should be cut off before it has fully faded out.
*/
far: boardSpan * 4,
minDistance: Math.max(4, boardSpan * 0.02),
minDistance: orbitMinDistance,
/**
* Far enough out to be **above the satellites**, which is what the extra
* half-span buys.
@@ -344,6 +355,22 @@ export async function createScene(
? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic)
: null;
if (roadTraffic) scene.add(roadTraffic.group);
const actorAnchor = options.actorAnchor ?? city.center;
const [actorX, actorZ] = world.project(actorAnchor.lat, actorAnchor.lng);
const sceneActor = options.actor
? createSceneActor({
...options.actor,
sceneUnitsPerMetre: options.actor.sceneUnitsPerMetre ?? 1 / world.metresPerUnit,
visualSceneUnitsPerMetre: options.actor.visualSceneUnitsPerMetre ??
(city.id === "california" ? 0.025 : 1 / world.metresPerUnit),
sceneOrigin: options.actor.sceneOrigin ?? {
x: actorX,
y: world.groundAt(actorAnchor.lat, actorAnchor.lng),
z: actorZ,
},
})
: null;
if (sceneActor) scene.add(sceneActor.root);
let flightLayer: FlightLayer | null = null;
let flightTimer = 0;
@@ -408,6 +435,15 @@ export async function createScene(
function flyTo(chapterId: string) {
const ch = chapterById[chapterId];
if (!ch) return;
// Named viewpoints are observe/vehicle destinations. Possessing an actor
// is an explicit UI action, so a chapter selection always hands the camera
// back before it moves anywhere else.
sceneActor?.setActive(false);
kit.controls.minDistance = orbitMinDistance;
if (kit.camera.near !== 0.1) {
kit.camera.near = 0.1;
kit.camera.updateProjectionMatrix();
}
const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
if (route && roadTraffic) {
roadTraffic.setRoute(route.id);
@@ -446,7 +482,11 @@ export async function createScene(
// bug.
onExit: () => kit.resetPick(),
tick(dt) {
const actorPlaying = sceneActor?.active() ?? false;
kit.controls.enabled = !actorPlaying;
kit.tick(dt);
sceneActor?.tick(dt);
if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose());
roadTraffic?.tick(dt);
clouds.tick(dt);
if (options.flights && flightLayer) {
@@ -538,10 +578,25 @@ export async function createScene(
vehicleState: () => roadTraffic?.hero() ?? null,
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
setActorActions: (actions) => { sceneActor?.setActions(actions); },
actorState: () => sceneActor?.state() ?? null,
setActorActive(active) {
sceneActor?.setActive(active);
if (active) roadTraffic?.setFollowing(false);
// State boards compress one real metre to a few hundredths of a scene
// unit. Their possessed actor and chase camera are therefore closer than
// the map camera's 0.1 near plane; lower it only for play mode so the
// procedural rig is not clipped away, then restore the depth precision.
kit.camera.near = active ? 0.001 : 0.1;
kit.controls.minDistance = active ? 0.001 : orbitMinDistance;
kit.camera.updateProjectionMatrix();
},
actorActive: () => sceneActor?.active() ?? false,
setMarkers(markers) {
markerLayer.setMarkers(markers);
},
dispose() {
sceneActor?.dispose();
/**
* Off the stage, then released — and the stage itself is left running.
*