240 lines
8.2 KiB
TypeScript
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;
|
|
}
|