1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/interiors/officeWalker.ts
T

240 lines
8.2 KiB
TypeScript

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