/** * 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 capture, 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. A caller may * attach an already-created face texture; ownership stays with that caller. * * ### The climb lives here, and it is presentation * * `WalkerState` has no `y` and is never getting one: height is fully determined * by the storey you are on, `Plan` owns that number, and adding a vertical to * the controller would ripple into every consumer of `floorY` for a value none * of them may disagree about. So a crossing between two storeys is *this* file's * job. It interpolates the actor's world height and the follow camera's along * `ResolvedTransition.path` — the same list of points the drawn treads are built * from — and hands the controller its new storey exactly once, at the midpoint, * through `enterLevel`. * * Three rules the crossing keeps, and each of them is a defect avoided: * * - **Both levels resolve before it starts.** `sync` and `followPose` used to * throw outright on a level they could not find, which for a cross-level * handoff means killing the RAF loop mid-frame. They now fall back to the last * height they knew, and a crossing refuses to begin at all unless both ends * are already resolved — the failure is checked where it can be reported * rather than where it would be fatal. * - **It always lands on one storey.** Going inactive, resetting or disposing * part-way through completes the handover immediately rather than abandoning * the actor between two floors. * - **You have to leave before you can come back.** Arriving puts the walker * inside the far footprint, which is a way back down; without a latch a * staircase would be an infinite loop and the actor would oscillate. */ import * as THREE from "three"; import { buildDog, dogPoseStateForSpeed, disposeDog, poseDog, 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, ResolvedTransition, TransitionSide } 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; /** * How fast the actor moves along a transition path, as a multiple of its own * walking speed. * * Faster than walking, because the alternative is worse. `mateo-court`'s stair * is eleven and a half metres of path — two flights, a half landing and the * approach — and traversing that at 1.6 m/s is seven seconds during which * nothing the viewer does has any effect. A climb is an event, not a cutscene. */ const CLIMB_SPEED_FACTOR = 1.45; /** * Hard bounds on a crossing, in seconds. * * The floor stops a one-metre step between a mezzanine and its landing being an * instantaneous jump; the ceiling stops a long flight taking the controls away * for longer than anybody will sit still for. */ const MIN_CLIMB_SECONDS = 0.9; const MAX_CLIMB_SECONDS = 3.6; /** Below this, the actor is standing still and a crossing has no reason to start. */ const CLIMB_INPUT_EPSILON = 1e-3; 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; /** * The transition the actor is part-way along, or `null`. * * `levelId` is still authoritative and still flips exactly once, halfway * through — a consumer that only wants to know which storey to draw needs * nothing from this field. It is here so that a caller which drives input can * see that input is currently going nowhere, and so a test can assert the * crossing rather than infer it from a height. */ crossing: string | null; } 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; /** Rebuild only the procedural skin, retaining this root and all walker/camera state. */ setAppearance(appearance: OfficeActorAppearance): OfficeWalkerState; /** Attach a caller-owned texture. False for an anonymous dog. */ attachFaceTexture(texture: THREE.Texture): boolean; /** Detach the current face without disposing the caller's texture. */ clearFaceTexture(): void; /** A defensive scene-space chase-camera pose for the current actor state. */ followPose(): Pose; dispose(): void; } /** One crossing in flight. Presentation only: nothing here is walker state. */ interface Crossing { transition: ResolvedTransition; toLevelId: string; toPosition: { x: number; z: number }; /** The direction of the last leg: which way the actor comes off the treads. */ toFacing: { x: number; z: number }; /** Foot-to-head or head-to-foot, in office-world metres, current pose first. */ path: readonly { x: number; y: number; z: number }[]; /** Cumulative 3-D length at each point. `spans[0]` is 0. */ spans: readonly number[]; length: number; seconds: number; elapsed: number; handedOver: boolean; } type Actor = | { kind: "humanoid"; rig: HumanoidRig } | { kind: "anonymous-dog"; rig: DogRig }; export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): OfficeWalker { let actor = buildActor(options.actor ?? { kind: "humanoid" }); 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 presentationSeconds = 0; let previousSpeed = 0; const previousFacing = new THREE.Vector2(0, -1); let faceTexture: THREE.Texture | null = null; let disposed = false; let lastFloorY = plan.level(options.levelId)?.floorY ?? 0; let warnedLostLevel = false; let crossing: Crossing | null = null; /** * The transition whose footprint the actor is standing in and has not yet * left. * * Arriving from a crossing puts you inside the *far* footprint, which is a way * straight back. Without this latch a staircase is an infinite loop: up, down, * up, for as long as the key is held. */ let latched: string | null = null; const root = new THREE.Group(); root.name = "office-walker-actor"; root.userData.kind = "playable-actor"; root.userData.forwardAxis = "-Z"; root.userData.actorType = actor.rig.root.userData.actorType; root.add(actor.rig.root); const view = { position: new THREE.Vector3() }; /** * This level's floor height, or the last one we knew. * * It used to throw. A `throw` from inside `tick` kills the RAF loop for the * whole scene, and the case it fires on — a level that stops resolving — is * precisely the one a cross-level handoff could produce. Everywhere else in * the interiors stack the discipline is to report and carry on, so this * carries on at the last known height and says so once. A crossing is refused * outright if either end is unresolved, which is where that failure is * actually catchable. */ function floorYOf(levelId: string): number { const level = plan.level(levelId); if (level) { lastFloorY = level.floorY; return level.floorY; } if (!warnedLostLevel) { warnedLostLevel = true; console.warn(`office walker lost level "${levelId}"; holding the last known floor`); } return lastFloorY; } function sync(state: WalkerState, elapsedSeconds = 0, travelled = 0, override?: { x: number; y: number; z: number; }): void { const floorY = floorYOf(state.levelId); if (override) root.position.set(override.x, override.y, override.z); else root.position.set(state.position.x, floorY, state.position.z); view.position.copy(root.position); root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z); const speed = elapsedSeconds > 0 ? Math.max(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); presentationSeconds += elapsedSeconds; const phase = speed > 1e-5 ? (state.distance / STRIDE_METRES) * Math.PI * 2 : presentationSeconds * 2.2; const facingDot = THREE.MathUtils.clamp( previousFacing.x * state.facing.x + previousFacing.y * state.facing.z, -1, 1, ); const facingCross = previousFacing.x * state.facing.z - previousFacing.y * state.facing.x; const turn = elapsedSeconds > 0 ? THREE.MathUtils.clamp(Math.atan2(facingCross, facingDot) / elapsedSeconds / 4, -1, 1) : 0; const acceleration = elapsedSeconds > 0 ? THREE.MathUtils.clamp((speed - previousSpeed) / elapsedSeconds / 5, -1, 1) : 0; if (actor.kind === "humanoid") { poseHumanoid(actor.rig, { walkPhase: phase, stride: gait * 0.62 }); } else { const dogState = dogPoseStateForSpeed(speed * Math.max(gait, 0.2)); poseDog(actor.rig, { state: dogState, phase, amount: dogState === "idle" ? 1 : THREE.MathUtils.clamp(0.38 + gait * 0.62, 0, 1), turn, acceleration, attentionYaw: -turn * 0.08, }); } previousSpeed = speed; previousFacing.set(state.facing.x, state.facing.z); } /** * Start a crossing, or decline to. * * Declining is silent and is the ordinary answer: standing still on a stair * is standing on a stair. It refuses outright — rather than beginning and * failing — when either level is unresolved or the far landing is not a place * the controller would accept, because a handover that throws does it inside * the frame loop where nothing can recover. */ function beginCrossing(side: TransitionSide): boolean { if (plan.level(side.from.levelId) === null || plan.level(side.to.levelId) === null) return false; const authored = side.transition.path; const ordered = side.ascending ? authored : [...authored].reverse(); // The actor triggers at the edge of the footprint, which is usually a metre // or so from the landing the path starts at. Starting the path where the // actor actually is turns that gap into the first stride of the climb // rather than a jump. const here = { x: root.position.x, y: root.position.y, z: root.position.z }; const head = ordered[0]!; const path = Math.hypot(here.x - head.x, here.z - head.z) > 0.05 ? [here, ...ordered] : [...ordered]; const spans: number[] = [0]; let length = 0; for (let index = 1; index < path.length; index += 1) { const a = path[index - 1]!; const b = path[index]!; length += Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z); spans.push(length); } if (!(length > 1e-4)) return false; const speed = (options.speed ?? 1.6) * CLIMB_SPEED_FACTOR; const penultimate = path[path.length - 2]!; const arrival = path[path.length - 1]!; const lastX = arrival.x - penultimate.x; const lastZ = arrival.z - penultimate.z; const lastRun = Math.hypot(lastX, lastZ); crossing = { transition: side.transition, toLevelId: side.to.levelId, toPosition: { x: side.to.landing.x, z: side.to.landing.z }, // A vertical last leg — a lift — has no direction of its own, and the // pack's authored `facing` is the answer there. Failing both, the actor // keeps what it had. toFacing: lastRun > 1e-6 ? { x: lastX / lastRun, z: lastZ / lastRun } : { x: Math.sin(side.to.facing), z: -Math.cos(side.to.facing) }, path, spans, length, seconds: Math.min(MAX_CLIMB_SECONDS, Math.max(MIN_CLIMB_SECONDS, length / speed)), elapsed: 0, handedOver: false, }; latched = side.transition.id; return true; } /** The pose at a fraction of the way along a crossing, and the way it faces. */ function poseAlong(active: Crossing, t: number): { position: { x: number; y: number; z: number }; facing: { x: number; z: number }; } { const target = Math.min(active.length, Math.max(0, t * active.length)); let index = 1; while (index < active.spans.length - 1 && active.spans[index]! < target) index += 1; const a = active.path[index - 1]!; const b = active.path[index]!; const span = active.spans[index]! - active.spans[index - 1]!; const local = span > 1e-9 ? (target - active.spans[index - 1]!) / span : 1; const position = { x: a.x + (b.x - a.x) * local, y: a.y + (b.y - a.y) * local, z: a.z + (b.z - a.z) * local, }; const dx = b.x - a.x; const dz = b.z - a.z; const flat = Math.hypot(dx, dz); // A lift's legs are vertical and have no heading of their own, so the actor // keeps the one it arrived with rather than snapping to an arbitrary axis. const facing = flat > 1e-6 ? { x: dx / flat, z: dz / flat } : null; return { position, facing: facing ?? { x: 0, z: -1 } }; } /** * The one place `levelId` changes, and it happens exactly once per crossing. * * At the midpoint rather than at the end so that the storey the rest of the * application is told about — the minimap, the presence pose, the occupancy * lighting — changes while the actor is visibly between floors, which is the * only moment at which either answer is defensible. */ function handOver(active: Crossing): void { if (active.handedOver) return; active.handedOver = true; try { controller.enterLevel(active.toLevelId, active.toPosition, active.toFacing); } catch { // `Plan` validated this landing when it resolved the transition, so this // is unreachable short of a plan swapped underneath a live crossing. Not // throwing is the point: the alternative is a dead frame loop. crossing = null; } } /** Finish a crossing now, wherever it had got to. Used by every interruption. */ function completeCrossing(): void { const active = crossing; if (!active) return; handOver(active); crossing = null; sync(controller.state()); } function snapshot(): OfficeWalkerState { const state = controller.state(); return { ...state, position: { ...state.position }, facing: { ...state.facing }, active: enabled, actor: actor.kind, action: { ...desired }, crossing: crossing?.transition.id ?? null, }; } function replaceActor(appearance: OfficeActorAppearance): void { const previous = actor; actor = buildActor(appearance); root.add(actor.rig.root); if (actor.kind === "humanoid" && faceTexture) applyOfficeFaceTexture(actor.rig, faceTexture); if (previous.kind === "humanoid") applyOfficeFaceTexture(previous.rig, null); if (actor.kind !== "humanoid") faceTexture = null; previous.rig.root.removeFromParent(); if (previous.kind === "humanoid") disposeHumanoid(previous.rig); else disposeDog(previous.rig); root.userData.actorType = actor.rig.root.userData.actorType; sync(controller.state()); } sync(controller.state()); return { root, view, state: snapshot, active: () => enabled, setActive(active) { if (active !== enabled) desired = { x: 0, z: 0 }; enabled = active; // Never leave the actor between two floors. Going inactive part-way up a // flight lands it at the top, which is the only place the rest of the // application can describe. if (!active) completeCrossing(); }, action: () => ({ ...desired }), setAction(action) { desired = normalizeWalkerAction(action); return { ...desired }; }, tick(elapsedSeconds) { if (disposed) return snapshot(); const dt = Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0; // A crossing owns the actor while it runs. The controller is deliberately // not ticked: input during a climb goes nowhere, and letting it accumulate // would land the walker somewhere it did not visibly walk to. const active = crossing; if (active) { active.elapsed += dt; const t = Math.min(1, active.seconds > 0 ? active.elapsed / active.seconds : 1); if (t >= 0.5) handOver(active); if (t >= 1) { completeCrossing(); return snapshot(); } const along = poseAlong(active, t); const state = controller.state(); // The climb's own heading, not the one the controller is holding: the // actor has to face up the flight it is on. sync({ ...state, facing: along.facing }, dt, dt * (active.length / Math.max(active.seconds, 1e-6)), along.position); return snapshot(); } const before = controller.state(); const next = enabled ? controller.tick(elapsedSeconds, desired) : controller.tick(elapsedSeconds, { x: 0, z: 0 }); // Standing on a way up *is* the input. There is no key to press, which is // why the footprints in a pack are the shape they are: the bottom of a // flight rather than the whole stair, and the gap in a balustrade rather // than the whole walkway. const side = plan.transitionAt(next.levelId, next.position); if (side === null) latched = null; else if ( enabled && latched !== side.transition.id && Math.hypot(desired.x, desired.z) > CLIMB_INPUT_EPSILON && beginCrossing(side) ) { return snapshot(); } sync(next, dt, next.distance - before.distance); return snapshot(); }, reset(spawn) { // Abandoned rather than completed: `reset` is a teleport to a known place, // so finishing the climb first would move the walker somewhere else and // then move it again. crossing = null; latched = null; const state = controller.reset(spawn); desired = { x: 0, z: 0 }; gait = 0; presentationSeconds = 0; previousSpeed = 0; previousFacing.set(state.facing.x, state.facing.z); sync(state); return snapshot(); }, setAppearance(appearance) { if (disposed) return snapshot(); replaceActor(appearance); return snapshot(); }, attachFaceTexture(texture) { if (disposed || actor.kind !== "humanoid") return false; if (!texture || texture.isTexture !== true) { throw new RangeError("office walker: face texture must be a Three.js Texture"); } faceTexture = texture; applyOfficeFaceTexture(actor.rig, texture); return true; }, clearFaceTexture() { faceTexture = null; if (!disposed && actor.kind === "humanoid") applyOfficeFaceTexture(actor.rig, null); }, followPose() { const state = controller.state(); // The actor's *drawn* position, so the camera rises with it across a // crossing instead of cutting to the destination floor at the midpoint. // On every other frame `root.position` is exactly the state's position at // this level's floor, so this is the same answer it always gave. const base = root.position; const facing = crossing ? headingOf(root.rotation.y) : state.facing; return { position: new THREE.Vector3( base.x - facing.x * camera.distance, base.y + camera.height, base.z - facing.z * camera.distance, ), target: new THREE.Vector3( base.x + facing.x * camera.lookAhead, base.y + camera.targetHeight, base.z + facing.z * camera.lookAhead, ), }; }, dispose() { if (disposed) return; completeCrossing(); disposed = true; root.removeFromParent(); if (actor.kind === "humanoid") applyOfficeFaceTexture(actor.rig, null); faceTexture = null; if (actor.kind === "humanoid") disposeHumanoid(actor.rig); else disposeDog(actor.rig); }, }; } /** The inverse of the yaw `sync` writes: a rotation back to a planar heading. */ function headingOf(yaw: number): { x: number; z: number } { return { x: -Math.sin(yaw), z: -Math.cos(yaw) }; } 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 } : {}), }), }; } const DEFAULT_OFFICE_FACE_COLOR = 0x18242b; function applyOfficeFaceTexture(rig: HumanoidRig, texture: THREE.Texture | null): void { const material = rig.face.material; if (!(material instanceof THREE.MeshBasicMaterial)) { throw new Error("office walker: humanoid face must use one MeshBasicMaterial"); } material.map = texture; material.color.set(texture ? 0xffffff : DEFAULT_OFFICE_FACE_COLOR); material.needsUpdate = true; } 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; }