feat: add playable actors and seamless journey state
This commit is contained in:
@@ -29,13 +29,12 @@
|
||||
* station said so is a nice idea and a bad dependency for a room that has to
|
||||
* render with no network at all.
|
||||
*
|
||||
* ### Orbit dollhouse is the only navigation mode
|
||||
* ### Orbit dollhouse remains the default navigation mode
|
||||
*
|
||||
* Walk mode is not built here. `Plan` already produces the collision segments it
|
||||
* will need, which is the point of doing the wall split once, but v1 orbits: the
|
||||
* ceilings come off, the walls between you and what you are looking at go
|
||||
* translucent, and the existing camera, flight and picking machinery is reused
|
||||
* verbatim.
|
||||
* An optional `OfficeWalker` can temporarily possess a local actor and publish a
|
||||
* chase-camera pose. It is inactive by default; without one, or while inactive,
|
||||
* the ceilings, occlusion fading, named views and orbit controls behave exactly
|
||||
* as before.
|
||||
*
|
||||
* ### Two depths, and the public one is the architecture without the people
|
||||
*
|
||||
@@ -83,6 +82,11 @@ import { Plan, type Depth, type PlanOptions } from "./plan.ts";
|
||||
import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts";
|
||||
import { createShell, type Shell, type WallInfo } from "./shell.ts";
|
||||
import { createLuminaires, type Luminaires, type Walker } from "./luminaires.ts";
|
||||
import {
|
||||
createOfficeWalker,
|
||||
type OfficeWalker,
|
||||
type OfficeWalkerOptions,
|
||||
} from "./officeWalker.ts";
|
||||
import {
|
||||
createRobotLayer,
|
||||
type RobotLayer,
|
||||
@@ -210,6 +214,8 @@ export interface OfficeSceneOptions {
|
||||
* withhold, so a stranger gets them too.
|
||||
*/
|
||||
robots?: readonly RobotSpec[];
|
||||
/** Optional local walk actor. Constructed inactive unless `walker.active` says otherwise. */
|
||||
walker?: OfficeWalkerOptions;
|
||||
/** Defaults to false — the lid comes off, because that is the whole view. */
|
||||
showCeilings?: boolean;
|
||||
/** Fade the walls you are looking through. Defaults to true. */
|
||||
@@ -230,6 +236,8 @@ export interface OfficeScene extends StageScene {
|
||||
* print the "no presence" badge and whether to offer a sign-in.
|
||||
*/
|
||||
depth: Depth;
|
||||
/** Local walk-mode actor, or null when this scene was built as dollhouse-only. */
|
||||
walker: OfficeWalker | null;
|
||||
/** The pack's viewpoints, as the thing a legend prints and `flyTo` is keyed on. */
|
||||
views: View[];
|
||||
flyTo(viewId: string): void;
|
||||
@@ -486,6 +494,8 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
options.robots && options.robots.length > 0
|
||||
? createRobotLayer(plan, { materials, robots: options.robots })
|
||||
: null;
|
||||
const officeWalker = options.walker ? createOfficeWalker(plan, options.walker) : null;
|
||||
if (officeWalker) scene.add(officeWalker.root);
|
||||
if (robots) {
|
||||
scene.add(robots.group);
|
||||
/**
|
||||
@@ -496,7 +506,12 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
* through a reference taken at setup. Calling it every frame would allocate
|
||||
* nothing extra but would imply the array were a snapshot, which it is not.
|
||||
*/
|
||||
luminaires.setWalkers(robots.robots());
|
||||
}
|
||||
if (robots || officeWalker) {
|
||||
luminaires.setWalkers([
|
||||
...(robots?.robots() ?? NO_ROBOTS),
|
||||
...(officeWalker ? [officeWalker.view] : []),
|
||||
]);
|
||||
}
|
||||
// A public office has no presence layer, rather than an empty one. The
|
||||
// difference is not cosmetic: an empty `PresenceLayer` is a `THREE.Group`
|
||||
@@ -749,13 +764,18 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
controls: kit.controls,
|
||||
plan,
|
||||
depth,
|
||||
walker: officeWalker,
|
||||
views,
|
||||
// A public office anchors nothing, because it has nobody to anchor. The
|
||||
// empty map is this scene's own rather than a shared module-level one: an
|
||||
// HTML overlay that writes into what it was handed should not be able to
|
||||
// reach across into another office.
|
||||
anchors: presence?.anchors ?? new Map<string, THREE.Vector3>(),
|
||||
flyTo,
|
||||
flyTo(viewId) {
|
||||
officeWalker?.setActive(false);
|
||||
kit.controls.enabled = true;
|
||||
flyTo(viewId);
|
||||
},
|
||||
current: () => currentView,
|
||||
onViewChange(fn) {
|
||||
viewListeners.push(fn);
|
||||
@@ -799,7 +819,11 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
onExit: () => kit.resetPick(),
|
||||
tick(dt) {
|
||||
if (disposed) return;
|
||||
const walking = officeWalker?.active() ?? false;
|
||||
kit.controls.enabled = !walking;
|
||||
kit.tick(dt);
|
||||
officeWalker?.tick(dt);
|
||||
if (walking && officeWalker) kit.setPose(officeWalker.followPose());
|
||||
updateOcclusion();
|
||||
// Robots first: the lights above them should respond to where they are
|
||||
// *now*, not to where they were last frame.
|
||||
@@ -807,6 +831,7 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
luminaires.tick(dt);
|
||||
},
|
||||
dispose() {
|
||||
officeWalker?.dispose();
|
||||
robots?.dispose();
|
||||
luminaires.dispose();
|
||||
if (horizonPlane) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+11
-2
@@ -33,6 +33,8 @@ export interface WalkerSpawn {
|
||||
}
|
||||
|
||||
export interface WalkerOptions extends WalkerSpawn {
|
||||
/** Initial unit direction; defaults to north / local -Z. */
|
||||
facing?: Point2;
|
||||
/** Circular footprint radius, in metres. */
|
||||
radius?: number;
|
||||
/** Metres per second at full input. */
|
||||
@@ -86,7 +88,8 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
|
||||
|
||||
let spawn = checkedSpawn(plan, options, radius);
|
||||
let position = copy(spawn.position);
|
||||
let facing: Point2 = { x: 0, z: -1 };
|
||||
const initialFacing = normalizedFacing(options.facing);
|
||||
let facing: Point2 = copy(initialFacing);
|
||||
let distance = 0;
|
||||
let accumulator = 0;
|
||||
|
||||
@@ -102,7 +105,7 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
|
||||
function reset(next = spawn): WalkerState {
|
||||
spawn = checkedSpawn(plan, next, radius);
|
||||
position = copy(spawn.position);
|
||||
facing = { x: 0, z: -1 };
|
||||
facing = copy(initialFacing);
|
||||
distance = 0;
|
||||
accumulator = 0;
|
||||
return snapshot();
|
||||
@@ -138,6 +141,12 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
|
||||
return { state: snapshot, tick, reset };
|
||||
}
|
||||
|
||||
function normalizedFacing(value: Point2 | undefined): Point2 {
|
||||
if (!value || !finitePoint(value)) return { x: 0, z: -1 };
|
||||
const length = Math.hypot(value.x, value.z);
|
||||
return length > EPSILON ? { x: value.x / length, z: value.z / length } : { x: 0, z: -1 };
|
||||
}
|
||||
|
||||
function moveWithSliding(
|
||||
plan: WalkerPlan,
|
||||
levelId: string,
|
||||
|
||||
Reference in New Issue
Block a user