/** * 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; }