1
0

feat: unify life-sim play controls

This commit is contained in:
2026-08-19 03:04:53 -07:00
parent 120eac878a
commit 264daaab61
14 changed files with 1697 additions and 234 deletions
+49 -34
View File
@@ -80,6 +80,7 @@ import type {
} from "../aircraft/controller.ts";
import type { ScenePeers, ScenePeersOptions } from "../realtime/scenePeers.ts";
import type { EntityPoseSnapshot } from "../realtime/types.ts";
import { cityControlOwnership, type CityControlMode } from "../play/controlMode.ts";
export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;
@@ -195,6 +196,10 @@ export interface SceneHandle {
flyTo(chapterId: string): void;
current(): string;
onChapterChange(fn: (id: string) => void): void;
/** Atomically hands local input and follow-camera ownership to one subsystem. */
setControlMode(mode: CityControlMode): void;
controlMode(): CityControlMode;
onControlModeChange(fn: (mode: CityControlMode) => void): void;
/** Device-neutral input for the corridor hero; a no-op on boards without one. */
setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
/** Current playable corridor state, or null on a city-scale board. */
@@ -482,6 +487,33 @@ export async function createScene(
let currentChapter = first.id;
const chapterListeners: ((id: string) => void)[] = [];
let controlMode: CityControlMode = "overview";
const controlModeListeners: ((mode: CityControlMode) => void)[] = [];
function applyControlMode(requested: CityControlMode): CityControlMode {
const next: CityControlMode =
requested === "drive" && !roadTraffic ? "overview"
: requested === "actor" && !sceneActor ? "overview"
: requested === "aircraft" && !sceneAircraft ? "overview"
: requested;
const ownership = cityControlOwnership(next);
sceneActor?.setActive(ownership.actor);
sceneAircraft?.setActive(ownership.aircraft);
roadTraffic?.setFollowing(ownership.drive);
kit.controls.enabled = ownership.orbit;
kit.camera.near = next === "actor" ? 0.001 : next === "aircraft" ? 0.01 : 0.1;
kit.controls.minDistance = next === "actor"
? 0.001
: next === "aircraft"
? 0.01
: orbitMinDistance;
kit.camera.updateProjectionMatrix();
if (next !== controlMode) {
controlMode = next;
for (const fn of controlModeListeners) fn(next);
}
return next;
}
function chapterPose(ch: Chapter): Pose {
const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
@@ -502,19 +534,12 @@ export async function createScene(
// 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);
sceneAircraft?.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);
roadTraffic.setFollowing(true);
applyControlMode("drive");
} else {
roadTraffic?.setFollowing(false);
applyControlMode("overview");
roadTraffic?.setVehicleActions({});
kit.flyTo(chapterPose(ch));
}
@@ -547,14 +572,15 @@ export async function createScene(
// bug.
onExit: () => kit.resetPick(),
tick(dt) {
const actorPlaying = sceneActor?.active() ?? false;
const aircraftPlaying = sceneAircraft?.active() ?? false;
kit.controls.enabled = !actorPlaying && !aircraftPlaying;
// Exactly one subsystem owns the camera. In particular, OrbitControls
// must stay disabled while the road layer writes its follow pose.
const ownership = cityControlOwnership(controlMode);
kit.controls.enabled = ownership.orbit;
kit.tick(dt);
sceneActor?.tick(dt);
if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose());
if (ownership.actor && sceneActor) kit.setPose(sceneActor.followPose());
sceneAircraft?.tick(dt);
if (aircraftPlaying && sceneAircraft) kit.setPose(sceneAircraft.followPose());
if (ownership.aircraft && sceneAircraft) kit.setPose(sceneAircraft.followPose());
realtimePeers?.tick(Date.now());
roadTraffic?.tick(dt);
clouds.tick(dt);
@@ -645,6 +671,11 @@ export async function createScene(
onChapterChange(fn) {
chapterListeners.push(fn);
},
setControlMode: (mode) => { applyControlMode(mode); },
controlMode: () => controlMode,
onControlModeChange(fn) {
controlModeListeners.push(fn);
},
setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions),
vehicleState: () => roadTraffic?.hero() ?? null,
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
@@ -655,31 +686,15 @@ export async function createScene(
attachActorFaceTexture: (texture) => sceneActor?.attachFaceTexture(texture) ?? false,
clearActorFaceTexture: () => { sceneActor?.clearFaceTexture(); },
setActorActive(active) {
sceneActor?.setActive(active);
if (active) {
sceneAircraft?.setActive(false);
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();
if (active) applyControlMode("actor");
else if (controlMode === "actor") applyControlMode("overview");
},
actorActive: () => sceneActor?.active() ?? false,
setAircraftActions: (actions) => { sceneAircraft?.setActions(actions); },
aircraftState: () => sceneAircraft?.state() ?? null,
setAircraftActive(active) {
sceneAircraft?.setActive(active);
if (active) {
sceneActor?.setActive(false);
roadTraffic?.setFollowing(false);
}
kit.camera.near = active ? 0.01 : 0.1;
kit.controls.minDistance = active ? 0.01 : orbitMinDistance;
kit.camera.updateProjectionMatrix();
if (active) applyControlMode("aircraft");
else if (controlMode === "aircraft") applyControlMode("overview");
},
aircraftActive: () => sceneAircraft?.active() ?? false,
upsertRemoteSnapshot: (snapshot) => {