feat: add playable actors and seamless journey state
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user