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
+33 -8
View File
@@ -29,13 +29,12 @@
* station said so is a nice idea and a bad dependency for a room that has to
* render with no network at all.
*
* ### Orbit dollhouse is the only navigation mode
* ### Orbit dollhouse remains the default navigation mode
*
* Walk mode is not built here. `Plan` already produces the collision segments it
* will need, which is the point of doing the wall split once, but v1 orbits: the
* ceilings come off, the walls between you and what you are looking at go
* translucent, and the existing camera, flight and picking machinery is reused
* verbatim.
* An optional `OfficeWalker` can temporarily possess a local actor and publish a
* chase-camera pose. It is inactive by default; without one, or while inactive,
* the ceilings, occlusion fading, named views and orbit controls behave exactly
* as before.
*
* ### Two depths, and the public one is the architecture without the people
*
@@ -83,6 +82,11 @@ import { Plan, type Depth, type PlanOptions } from "./plan.ts";
import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts";
import { createShell, type Shell, type WallInfo } from "./shell.ts";
import { createLuminaires, type Luminaires, type Walker } from "./luminaires.ts";
import {
createOfficeWalker,
type OfficeWalker,
type OfficeWalkerOptions,
} from "./officeWalker.ts";
import {
createRobotLayer,
type RobotLayer,
@@ -210,6 +214,8 @@ export interface OfficeSceneOptions {
* withhold, so a stranger gets them too.
*/
robots?: readonly RobotSpec[];
/** Optional local walk actor. Constructed inactive unless `walker.active` says otherwise. */
walker?: OfficeWalkerOptions;
/** Defaults to false — the lid comes off, because that is the whole view. */
showCeilings?: boolean;
/** Fade the walls you are looking through. Defaults to true. */
@@ -230,6 +236,8 @@ export interface OfficeScene extends StageScene {
* print the "no presence" badge and whether to offer a sign-in.
*/
depth: Depth;
/** Local walk-mode actor, or null when this scene was built as dollhouse-only. */
walker: OfficeWalker | null;
/** The pack's viewpoints, as the thing a legend prints and `flyTo` is keyed on. */
views: View[];
flyTo(viewId: string): void;
@@ -486,6 +494,8 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
options.robots && options.robots.length > 0
? createRobotLayer(plan, { materials, robots: options.robots })
: null;
const officeWalker = options.walker ? createOfficeWalker(plan, options.walker) : null;
if (officeWalker) scene.add(officeWalker.root);
if (robots) {
scene.add(robots.group);
/**
@@ -496,7 +506,12 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
* through a reference taken at setup. Calling it every frame would allocate
* nothing extra but would imply the array were a snapshot, which it is not.
*/
luminaires.setWalkers(robots.robots());
}
if (robots || officeWalker) {
luminaires.setWalkers([
...(robots?.robots() ?? NO_ROBOTS),
...(officeWalker ? [officeWalker.view] : []),
]);
}
// A public office has no presence layer, rather than an empty one. The
// difference is not cosmetic: an empty `PresenceLayer` is a `THREE.Group`
@@ -749,13 +764,18 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
controls: kit.controls,
plan,
depth,
walker: officeWalker,
views,
// A public office anchors nothing, because it has nobody to anchor. The
// empty map is this scene's own rather than a shared module-level one: an
// HTML overlay that writes into what it was handed should not be able to
// reach across into another office.
anchors: presence?.anchors ?? new Map<string, THREE.Vector3>(),
flyTo,
flyTo(viewId) {
officeWalker?.setActive(false);
kit.controls.enabled = true;
flyTo(viewId);
},
current: () => currentView,
onViewChange(fn) {
viewListeners.push(fn);
@@ -799,7 +819,11 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
onExit: () => kit.resetPick(),
tick(dt) {
if (disposed) return;
const walking = officeWalker?.active() ?? false;
kit.controls.enabled = !walking;
kit.tick(dt);
officeWalker?.tick(dt);
if (walking && officeWalker) kit.setPose(officeWalker.followPose());
updateOcclusion();
// Robots first: the lights above them should respond to where they are
// *now*, not to where they were last frame.
@@ -807,6 +831,7 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
luminaires.tick(dt);
},
dispose() {
officeWalker?.dispose();
robots?.dispose();
luminaires.dispose();
if (horizonPlane) {
+239
View File
@@ -0,0 +1,239 @@
/**
* The office-facing half of walk mode: a numeric `WalkerController` wearing an
* original actor rig and publishing a third-person camera pose.
*
* There is deliberately no identity, webcam, keyboard or network code here.
* Callers choose humanoid or anonymous dog, translate their own input device to
* a normalized planar action, and decide when walk mode is active.
*/
import * as THREE from "three";
import {
buildDog,
disposeDog,
poseDogAttention,
poseDogWalk,
type DogRig,
} from "../assets/actors/dog.ts";
import {
buildHumanoid,
disposeHumanoid,
poseHumanoid,
type HumanoidRig,
} from "../assets/actors/humanoid.ts";
import type { Pose } from "../engine/scenekit.ts";
import type { Plan } from "./plan.ts";
import {
createWalker,
normalizeWalkerAction,
type WalkerAction,
type WalkerController,
type WalkerOptions,
type WalkerSpawn,
type WalkerState,
} from "./walker.ts";
const HUMANOID_CAMERA = { distance: 3.2, height: 2.25, targetHeight: 1.25, lookAhead: 0.7 };
const DOG_CAMERA = { distance: 2.6, height: 1.45, targetHeight: 0.48, lookAhead: 0.55 };
const STRIDE_METRES = 0.72;
const GAIT_EASE_SECONDS = 0.16;
export type OfficeActorKind = "humanoid" | "anonymous-dog";
export interface OfficeActorAppearance {
kind: OfficeActorKind;
skinTone?: THREE.ColorRepresentation;
outfitColor?: THREE.ColorRepresentation;
accentColor?: THREE.ColorRepresentation;
hairColor?: THREE.ColorRepresentation;
bodyShape?: "slim" | "average" | "broad";
coatColor?: THREE.ColorRepresentation;
markingsColor?: THREE.ColorRepresentation;
collarColor?: THREE.ColorRepresentation;
}
export interface FollowCameraOptions {
/** Metres behind the actor. */
distance?: number;
/** Camera height above this level's floor, in metres. */
height?: number;
/** Look target height above this level's floor, in metres. */
targetHeight?: number;
/** Metres ahead of the actor to aim. */
lookAhead?: number;
}
export interface OfficeWalkerOptions extends WalkerOptions {
actor?: OfficeActorAppearance;
camera?: FollowCameraOptions;
/** False by default: constructing an actor must not change dollhouse controls. */
active?: boolean;
}
export interface OfficeWalkerState extends WalkerState {
active: boolean;
actor: OfficeActorKind;
action: WalkerAction;
}
export interface OfficeWalker {
/** Floor-centred, scene-ready actor root. */
root: THREE.Group;
/** Stable live position for occupancy-responsive office lighting. */
view: { position: THREE.Vector3 };
state(): OfficeWalkerState;
active(): boolean;
setActive(active: boolean): void;
action(): WalkerAction;
setAction(action: WalkerAction): WalkerAction;
tick(elapsedSeconds: number): OfficeWalkerState;
reset(spawn?: WalkerSpawn): OfficeWalkerState;
/** A defensive scene-space chase-camera pose for the current actor state. */
followPose(): Pose;
dispose(): void;
}
type Actor =
| { kind: "humanoid"; rig: HumanoidRig }
| { kind: "anonymous-dog"; rig: DogRig };
export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): OfficeWalker {
const appearance = options.actor ?? { kind: "humanoid" };
const actor = buildActor(appearance);
const controller: WalkerController = createWalker(plan, options);
const baseCamera = actor.kind === "humanoid" ? HUMANOID_CAMERA : DOG_CAMERA;
const camera = {
distance: positive(options.camera?.distance ?? baseCamera.distance, "camera.distance"),
height: positive(options.camera?.height ?? baseCamera.height, "camera.height"),
targetHeight: nonNegative(options.camera?.targetHeight ?? baseCamera.targetHeight, "camera.targetHeight"),
lookAhead: nonNegative(options.camera?.lookAhead ?? baseCamera.lookAhead, "camera.lookAhead"),
};
let enabled = options.active ?? false;
let desired: WalkerAction = { x: 0, z: 0 };
let gait = 0;
let disposed = false;
const view = { position: new THREE.Vector3() };
function sync(state: WalkerState, elapsedSeconds = 0, travelled = 0): void {
const level = plan.level(state.levelId);
if (!level) throw new Error(`office walker lost level "${state.levelId}"`);
actor.rig.root.position.set(state.position.x, level.floorY, state.position.z);
view.position.copy(actor.rig.root.position);
actor.rig.root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z);
const speed = elapsedSeconds > 0 ? travelled / elapsedSeconds : 0;
const targetGait = enabled && speed > 1e-5 ? Math.min(1, speed / (options.speed ?? 1.6)) : 0;
gait += (targetGait - gait) * Math.min(1, elapsedSeconds / GAIT_EASE_SECONDS);
const phase = (state.distance / STRIDE_METRES) * Math.PI * 2;
if (actor.kind === "humanoid") {
poseHumanoid(actor.rig, { walkPhase: phase, stride: gait * 0.62 });
} else {
poseDogWalk(actor.rig, phase, gait * 0.62);
poseDogAttention(actor.rig, 0, state.distance * 5);
}
}
function snapshot(): OfficeWalkerState {
const state = controller.state();
return {
...state,
position: { ...state.position },
facing: { ...state.facing },
active: enabled,
actor: actor.kind,
action: { ...desired },
};
}
sync(controller.state());
return {
root: actor.rig.root,
view,
state: snapshot,
active: () => enabled,
setActive(active) {
if (active !== enabled) desired = { x: 0, z: 0 };
enabled = active;
},
action: () => ({ ...desired }),
setAction(action) {
desired = normalizeWalkerAction(action);
return { ...desired };
},
tick(elapsedSeconds) {
if (disposed) return snapshot();
const before = controller.state();
const next = enabled
? controller.tick(elapsedSeconds, desired)
: controller.tick(elapsedSeconds, { x: 0, z: 0 });
sync(next, Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0, next.distance - before.distance);
return snapshot();
},
reset(spawn) {
const state = controller.reset(spawn);
desired = { x: 0, z: 0 };
gait = 0;
sync(state);
return snapshot();
},
followPose() {
const state = controller.state();
const level = plan.level(state.levelId);
if (!level) throw new Error(`office walker lost level "${state.levelId}"`);
return {
position: new THREE.Vector3(
state.position.x - state.facing.x * camera.distance,
level.floorY + camera.height,
state.position.z - state.facing.z * camera.distance,
),
target: new THREE.Vector3(
state.position.x + state.facing.x * camera.lookAhead,
level.floorY + camera.targetHeight,
state.position.z + state.facing.z * camera.lookAhead,
),
};
},
dispose() {
if (disposed) return;
disposed = true;
actor.rig.root.removeFromParent();
if (actor.kind === "humanoid") disposeHumanoid(actor.rig);
else disposeDog(actor.rig);
},
};
}
function buildActor(appearance: OfficeActorAppearance): Actor {
if (appearance.kind === "anonymous-dog") {
return {
kind: "anonymous-dog",
rig: buildDog({
...(appearance.coatColor !== undefined ? { coatColor: appearance.coatColor } : {}),
...(appearance.markingsColor !== undefined ? { markingsColor: appearance.markingsColor } : {}),
...(appearance.collarColor !== undefined ? { collarColor: appearance.collarColor } : {}),
}),
};
}
return {
kind: "humanoid",
rig: buildHumanoid({
...(appearance.skinTone !== undefined ? { skinTone: appearance.skinTone } : {}),
...(appearance.outfitColor !== undefined ? { outfitColor: appearance.outfitColor } : {}),
...(appearance.accentColor !== undefined ? { accentColor: appearance.accentColor } : {}),
...(appearance.hairColor !== undefined ? { hairColor: appearance.hairColor } : {}),
...(appearance.bodyShape !== undefined ? { bodyShape: appearance.bodyShape } : {}),
}),
};
}
function positive(value: number, name: string): number {
if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`);
return value;
}
function nonNegative(value: number, name: string): number {
if (value < 0 || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and non-negative`);
return value;
}
+11 -2
View File
@@ -33,6 +33,8 @@ export interface WalkerSpawn {
}
export interface WalkerOptions extends WalkerSpawn {
/** Initial unit direction; defaults to north / local -Z. */
facing?: Point2;
/** Circular footprint radius, in metres. */
radius?: number;
/** Metres per second at full input. */
@@ -86,7 +88,8 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
let spawn = checkedSpawn(plan, options, radius);
let position = copy(spawn.position);
let facing: Point2 = { x: 0, z: -1 };
const initialFacing = normalizedFacing(options.facing);
let facing: Point2 = copy(initialFacing);
let distance = 0;
let accumulator = 0;
@@ -102,7 +105,7 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
function reset(next = spawn): WalkerState {
spawn = checkedSpawn(plan, next, radius);
position = copy(spawn.position);
facing = { x: 0, z: -1 };
facing = copy(initialFacing);
distance = 0;
accumulator = 0;
return snapshot();
@@ -138,6 +141,12 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
return { state: snapshot, tick, reset };
}
function normalizedFacing(value: Point2 | undefined): Point2 {
if (!value || !finitePoint(value)) return { x: 0, z: -1 };
const length = Math.hypot(value.x, value.z);
return length > EPSILON ? { x: value.x / length, z: value.z / length } : { x: 0, z: -1 };
}
function moveWithSliding(
plan: WalkerPlan,
levelId: string,