b25f217e3e
The world stops being a simulation of California and starts being California. **THE PROMOTION GATE WAS THE FIRST COMMIT, BEFORE ANY ORANGE PIXEL EXISTED.** On today's live store the SoCal board contains 22 incidents. Every one has NULL acreage and fifteen are nameless LA County dispatch numbers. Drawn naively that is 22 orange marks over Los Angeles on a day nothing is burning — in a frame that contains no other warm colour, so one glyph would be the most salient object on the board and twenty-two would spend its credibility permanently. `acres >= 10 AND contained < 80 AND type != 'RX' AND last_seen = max(last_seen)` returns 0 on SoCal, exactly 5 on California, 0 on the Bay — same body, same day, three correct answers. The empty board is a deliverable, not a fallback: it says "No active fire on this board — CAL FIRE and WFIGS, just now", states that 21 records were gated and why, lists the largest fires burning OUTSIDE the frame with distances, and counts the hot pixels it is deliberately not drawing. **The privacy leak is structurally impossible rather than carefully avoided.** cloud-1 serves a projection; the four home-relative columns never leave that box. `observations.threat` was the one that nearly got through — it is `(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`, so with acreage and containment public it inverts to a distance circle around a house and three fires give an intersection. A grep of the built bundle for distance_km, bearing_deg, threat, 7762 and the street name returns nothing. **Deliberately not used, and both would have produced a confident wrong answer:** the store's `air` table retains only the last parameter of each poll, so all 78 rows read "Good" while the live feed reports ozone 101 "Unhealthy for Sensitive Groups" — haze driven off it would clear the sky during a smoke event. And `weather` is written only inside the NWS alerts loop, so a quiet day stores no wind at all. Tera's own per-region NWS wind is already correct and already what the clouds drift on. Satellite detections are drawn as evidence and never as incidents. The permanent industrial heat source 4.7 km from the owner's house is flagged persistent and dropped, asserted by a test that first proves it is present in the fixture. MODIS integer confidence and VIIRS string confidence are branched on `sat`. **The LA office is a twin.** Its entire authored second storey — Model Loft, Model Bay, The Materials Room, 430 lines nobody had ever stood in — is reachable on foot: a walker crosses level-1 to level-2 in 73 fixed steps, floorY 0 to 5, verified against the real pack rather than a synthetic plan. Its two studio devices read real hardware through a field-allowlisted bridge: mute, volume and reachability only. Never level, because there is no passive level upstream and obtaining one would record a room with people in it. Never dB, because upstream is gainPct across four different native scales. The bridge refuses all writes. Fixed at its root: an anonymous visitor was getting permanently at-rest instruments backing off against a 401. The tier moves into `createDeviceSource`, so anon gets the living simulator three file headers already promised. **Item 8 is closed, not fixed, and the correction is the point.** The Bay Area "stutter" was GPU power management — the card sat at 500 MHz of 2725 through every run that reproduced it, 4096/2048/1024/256 shadow maps all render in 1.21-1.31 ms, and two consecutive runs over a byte-identical dist gave 33.4 then 16.7. The allowance is removed and the cell is back to 16.7. Geometry is the gate; frame time is advisory. Item 7 was re-scoped after measuring: 1,069,006 of the Bay Area's 2,265,056 triangles were the second submission of the same buildings into the shadow pass. Mobile now has its own triangle caps and bay-area mobile draws 1,266,096. Also: bridges and the freeway corridor light up at night as emission, not lights — 1,614 deck lamps and 18 tower heads on the Bay in two draw calls. The single change that made US-101 legible was moving its edge lines from the lit material to the unlit one: retroreflective paint, the argument the SFO night frame already makes. California went 21,991 lamps to 4,051, clustered at the 17 town districts, because a rural interurban corridor genuinely is unlit. Tests 1137 -> 1340, server 280. All ten budget cells pass on first attempt with no cap raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
620 lines
24 KiB
TypeScript
620 lines
24 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 capture, 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. A caller may
|
|
* attach an already-created face texture; ownership stays with that caller.
|
|
*
|
|
* ### The climb lives here, and it is presentation
|
|
*
|
|
* `WalkerState` has no `y` and is never getting one: height is fully determined
|
|
* by the storey you are on, `Plan` owns that number, and adding a vertical to
|
|
* the controller would ripple into every consumer of `floorY` for a value none
|
|
* of them may disagree about. So a crossing between two storeys is *this* file's
|
|
* job. It interpolates the actor's world height and the follow camera's along
|
|
* `ResolvedTransition.path` — the same list of points the drawn treads are built
|
|
* from — and hands the controller its new storey exactly once, at the midpoint,
|
|
* through `enterLevel`.
|
|
*
|
|
* Three rules the crossing keeps, and each of them is a defect avoided:
|
|
*
|
|
* - **Both levels resolve before it starts.** `sync` and `followPose` used to
|
|
* throw outright on a level they could not find, which for a cross-level
|
|
* handoff means killing the RAF loop mid-frame. They now fall back to the last
|
|
* height they knew, and a crossing refuses to begin at all unless both ends
|
|
* are already resolved — the failure is checked where it can be reported
|
|
* rather than where it would be fatal.
|
|
* - **It always lands on one storey.** Going inactive, resetting or disposing
|
|
* part-way through completes the handover immediately rather than abandoning
|
|
* the actor between two floors.
|
|
* - **You have to leave before you can come back.** Arriving puts the walker
|
|
* inside the far footprint, which is a way back down; without a latch a
|
|
* staircase would be an infinite loop and the actor would oscillate.
|
|
*/
|
|
|
|
import * as THREE from "three";
|
|
import {
|
|
buildDog,
|
|
dogPoseStateForSpeed,
|
|
disposeDog,
|
|
poseDog,
|
|
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, ResolvedTransition, TransitionSide } 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;
|
|
|
|
/**
|
|
* How fast the actor moves along a transition path, as a multiple of its own
|
|
* walking speed.
|
|
*
|
|
* Faster than walking, because the alternative is worse. `mateo-court`'s stair
|
|
* is eleven and a half metres of path — two flights, a half landing and the
|
|
* approach — and traversing that at 1.6 m/s is seven seconds during which
|
|
* nothing the viewer does has any effect. A climb is an event, not a cutscene.
|
|
*/
|
|
const CLIMB_SPEED_FACTOR = 1.45;
|
|
|
|
/**
|
|
* Hard bounds on a crossing, in seconds.
|
|
*
|
|
* The floor stops a one-metre step between a mezzanine and its landing being an
|
|
* instantaneous jump; the ceiling stops a long flight taking the controls away
|
|
* for longer than anybody will sit still for.
|
|
*/
|
|
const MIN_CLIMB_SECONDS = 0.9;
|
|
const MAX_CLIMB_SECONDS = 3.6;
|
|
|
|
/** Below this, the actor is standing still and a crossing has no reason to start. */
|
|
const CLIMB_INPUT_EPSILON = 1e-3;
|
|
|
|
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;
|
|
/**
|
|
* The transition the actor is part-way along, or `null`.
|
|
*
|
|
* `levelId` is still authoritative and still flips exactly once, halfway
|
|
* through — a consumer that only wants to know which storey to draw needs
|
|
* nothing from this field. It is here so that a caller which drives input can
|
|
* see that input is currently going nowhere, and so a test can assert the
|
|
* crossing rather than infer it from a height.
|
|
*/
|
|
crossing: string | null;
|
|
}
|
|
|
|
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;
|
|
/** Rebuild only the procedural skin, retaining this root and all walker/camera state. */
|
|
setAppearance(appearance: OfficeActorAppearance): OfficeWalkerState;
|
|
/** Attach a caller-owned texture. False for an anonymous dog. */
|
|
attachFaceTexture(texture: THREE.Texture): boolean;
|
|
/** Detach the current face without disposing the caller's texture. */
|
|
clearFaceTexture(): void;
|
|
/** A defensive scene-space chase-camera pose for the current actor state. */
|
|
followPose(): Pose;
|
|
dispose(): void;
|
|
}
|
|
|
|
/** One crossing in flight. Presentation only: nothing here is walker state. */
|
|
interface Crossing {
|
|
transition: ResolvedTransition;
|
|
toLevelId: string;
|
|
toPosition: { x: number; z: number };
|
|
/** The direction of the last leg: which way the actor comes off the treads. */
|
|
toFacing: { x: number; z: number };
|
|
/** Foot-to-head or head-to-foot, in office-world metres, current pose first. */
|
|
path: readonly { x: number; y: number; z: number }[];
|
|
/** Cumulative 3-D length at each point. `spans[0]` is 0. */
|
|
spans: readonly number[];
|
|
length: number;
|
|
seconds: number;
|
|
elapsed: number;
|
|
handedOver: boolean;
|
|
}
|
|
|
|
type Actor =
|
|
| { kind: "humanoid"; rig: HumanoidRig }
|
|
| { kind: "anonymous-dog"; rig: DogRig };
|
|
|
|
export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): OfficeWalker {
|
|
let actor = buildActor(options.actor ?? { kind: "humanoid" });
|
|
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 presentationSeconds = 0;
|
|
let previousSpeed = 0;
|
|
const previousFacing = new THREE.Vector2(0, -1);
|
|
let faceTexture: THREE.Texture | null = null;
|
|
let disposed = false;
|
|
let lastFloorY = plan.level(options.levelId)?.floorY ?? 0;
|
|
let warnedLostLevel = false;
|
|
let crossing: Crossing | null = null;
|
|
/**
|
|
* The transition whose footprint the actor is standing in and has not yet
|
|
* left.
|
|
*
|
|
* Arriving from a crossing puts you inside the *far* footprint, which is a way
|
|
* straight back. Without this latch a staircase is an infinite loop: up, down,
|
|
* up, for as long as the key is held.
|
|
*/
|
|
let latched: string | null = null;
|
|
const root = new THREE.Group();
|
|
root.name = "office-walker-actor";
|
|
root.userData.kind = "playable-actor";
|
|
root.userData.forwardAxis = "-Z";
|
|
root.userData.actorType = actor.rig.root.userData.actorType;
|
|
root.add(actor.rig.root);
|
|
const view = { position: new THREE.Vector3() };
|
|
|
|
/**
|
|
* This level's floor height, or the last one we knew.
|
|
*
|
|
* It used to throw. A `throw` from inside `tick` kills the RAF loop for the
|
|
* whole scene, and the case it fires on — a level that stops resolving — is
|
|
* precisely the one a cross-level handoff could produce. Everywhere else in
|
|
* the interiors stack the discipline is to report and carry on, so this
|
|
* carries on at the last known height and says so once. A crossing is refused
|
|
* outright if either end is unresolved, which is where that failure is
|
|
* actually catchable.
|
|
*/
|
|
function floorYOf(levelId: string): number {
|
|
const level = plan.level(levelId);
|
|
if (level) {
|
|
lastFloorY = level.floorY;
|
|
return level.floorY;
|
|
}
|
|
if (!warnedLostLevel) {
|
|
warnedLostLevel = true;
|
|
console.warn(`office walker lost level "${levelId}"; holding the last known floor`);
|
|
}
|
|
return lastFloorY;
|
|
}
|
|
|
|
function sync(state: WalkerState, elapsedSeconds = 0, travelled = 0, override?: {
|
|
x: number;
|
|
y: number;
|
|
z: number;
|
|
}): void {
|
|
const floorY = floorYOf(state.levelId);
|
|
if (override) root.position.set(override.x, override.y, override.z);
|
|
else root.position.set(state.position.x, floorY, state.position.z);
|
|
view.position.copy(root.position);
|
|
root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z);
|
|
|
|
const speed = elapsedSeconds > 0 ? Math.max(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);
|
|
presentationSeconds += elapsedSeconds;
|
|
const phase = speed > 1e-5
|
|
? (state.distance / STRIDE_METRES) * Math.PI * 2
|
|
: presentationSeconds * 2.2;
|
|
const facingDot = THREE.MathUtils.clamp(
|
|
previousFacing.x * state.facing.x + previousFacing.y * state.facing.z,
|
|
-1,
|
|
1,
|
|
);
|
|
const facingCross = previousFacing.x * state.facing.z - previousFacing.y * state.facing.x;
|
|
const turn = elapsedSeconds > 0
|
|
? THREE.MathUtils.clamp(Math.atan2(facingCross, facingDot) / elapsedSeconds / 4, -1, 1)
|
|
: 0;
|
|
const acceleration = elapsedSeconds > 0
|
|
? THREE.MathUtils.clamp((speed - previousSpeed) / elapsedSeconds / 5, -1, 1)
|
|
: 0;
|
|
if (actor.kind === "humanoid") {
|
|
poseHumanoid(actor.rig, { walkPhase: phase, stride: gait * 0.62 });
|
|
} else {
|
|
const dogState = dogPoseStateForSpeed(speed * Math.max(gait, 0.2));
|
|
poseDog(actor.rig, {
|
|
state: dogState,
|
|
phase,
|
|
amount: dogState === "idle" ? 1 : THREE.MathUtils.clamp(0.38 + gait * 0.62, 0, 1),
|
|
turn,
|
|
acceleration,
|
|
attentionYaw: -turn * 0.08,
|
|
});
|
|
}
|
|
previousSpeed = speed;
|
|
previousFacing.set(state.facing.x, state.facing.z);
|
|
}
|
|
|
|
/**
|
|
* Start a crossing, or decline to.
|
|
*
|
|
* Declining is silent and is the ordinary answer: standing still on a stair
|
|
* is standing on a stair. It refuses outright — rather than beginning and
|
|
* failing — when either level is unresolved or the far landing is not a place
|
|
* the controller would accept, because a handover that throws does it inside
|
|
* the frame loop where nothing can recover.
|
|
*/
|
|
function beginCrossing(side: TransitionSide): boolean {
|
|
if (plan.level(side.from.levelId) === null || plan.level(side.to.levelId) === null) return false;
|
|
|
|
const authored = side.transition.path;
|
|
const ordered = side.ascending ? authored : [...authored].reverse();
|
|
// The actor triggers at the edge of the footprint, which is usually a metre
|
|
// or so from the landing the path starts at. Starting the path where the
|
|
// actor actually is turns that gap into the first stride of the climb
|
|
// rather than a jump.
|
|
const here = { x: root.position.x, y: root.position.y, z: root.position.z };
|
|
const head = ordered[0]!;
|
|
const path = Math.hypot(here.x - head.x, here.z - head.z) > 0.05
|
|
? [here, ...ordered]
|
|
: [...ordered];
|
|
|
|
const spans: number[] = [0];
|
|
let length = 0;
|
|
for (let index = 1; index < path.length; index += 1) {
|
|
const a = path[index - 1]!;
|
|
const b = path[index]!;
|
|
length += Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
|
|
spans.push(length);
|
|
}
|
|
if (!(length > 1e-4)) return false;
|
|
|
|
const speed = (options.speed ?? 1.6) * CLIMB_SPEED_FACTOR;
|
|
const penultimate = path[path.length - 2]!;
|
|
const arrival = path[path.length - 1]!;
|
|
const lastX = arrival.x - penultimate.x;
|
|
const lastZ = arrival.z - penultimate.z;
|
|
const lastRun = Math.hypot(lastX, lastZ);
|
|
crossing = {
|
|
transition: side.transition,
|
|
toLevelId: side.to.levelId,
|
|
toPosition: { x: side.to.landing.x, z: side.to.landing.z },
|
|
// A vertical last leg — a lift — has no direction of its own, and the
|
|
// pack's authored `facing` is the answer there. Failing both, the actor
|
|
// keeps what it had.
|
|
toFacing: lastRun > 1e-6
|
|
? { x: lastX / lastRun, z: lastZ / lastRun }
|
|
: { x: Math.sin(side.to.facing), z: -Math.cos(side.to.facing) },
|
|
path,
|
|
spans,
|
|
length,
|
|
seconds: Math.min(MAX_CLIMB_SECONDS, Math.max(MIN_CLIMB_SECONDS, length / speed)),
|
|
elapsed: 0,
|
|
handedOver: false,
|
|
};
|
|
latched = side.transition.id;
|
|
return true;
|
|
}
|
|
|
|
/** The pose at a fraction of the way along a crossing, and the way it faces. */
|
|
function poseAlong(active: Crossing, t: number): {
|
|
position: { x: number; y: number; z: number };
|
|
facing: { x: number; z: number };
|
|
} {
|
|
const target = Math.min(active.length, Math.max(0, t * active.length));
|
|
let index = 1;
|
|
while (index < active.spans.length - 1 && active.spans[index]! < target) index += 1;
|
|
const a = active.path[index - 1]!;
|
|
const b = active.path[index]!;
|
|
const span = active.spans[index]! - active.spans[index - 1]!;
|
|
const local = span > 1e-9 ? (target - active.spans[index - 1]!) / span : 1;
|
|
const position = {
|
|
x: a.x + (b.x - a.x) * local,
|
|
y: a.y + (b.y - a.y) * local,
|
|
z: a.z + (b.z - a.z) * local,
|
|
};
|
|
const dx = b.x - a.x;
|
|
const dz = b.z - a.z;
|
|
const flat = Math.hypot(dx, dz);
|
|
// A lift's legs are vertical and have no heading of their own, so the actor
|
|
// keeps the one it arrived with rather than snapping to an arbitrary axis.
|
|
const facing = flat > 1e-6 ? { x: dx / flat, z: dz / flat } : null;
|
|
return { position, facing: facing ?? { x: 0, z: -1 } };
|
|
}
|
|
|
|
/**
|
|
* The one place `levelId` changes, and it happens exactly once per crossing.
|
|
*
|
|
* At the midpoint rather than at the end so that the storey the rest of the
|
|
* application is told about — the minimap, the presence pose, the occupancy
|
|
* lighting — changes while the actor is visibly between floors, which is the
|
|
* only moment at which either answer is defensible.
|
|
*/
|
|
function handOver(active: Crossing): void {
|
|
if (active.handedOver) return;
|
|
active.handedOver = true;
|
|
try {
|
|
controller.enterLevel(active.toLevelId, active.toPosition, active.toFacing);
|
|
} catch {
|
|
// `Plan` validated this landing when it resolved the transition, so this
|
|
// is unreachable short of a plan swapped underneath a live crossing. Not
|
|
// throwing is the point: the alternative is a dead frame loop.
|
|
crossing = null;
|
|
}
|
|
}
|
|
|
|
/** Finish a crossing now, wherever it had got to. Used by every interruption. */
|
|
function completeCrossing(): void {
|
|
const active = crossing;
|
|
if (!active) return;
|
|
handOver(active);
|
|
crossing = null;
|
|
sync(controller.state());
|
|
}
|
|
|
|
function snapshot(): OfficeWalkerState {
|
|
const state = controller.state();
|
|
return {
|
|
...state,
|
|
position: { ...state.position },
|
|
facing: { ...state.facing },
|
|
active: enabled,
|
|
actor: actor.kind,
|
|
action: { ...desired },
|
|
crossing: crossing?.transition.id ?? null,
|
|
};
|
|
}
|
|
|
|
function replaceActor(appearance: OfficeActorAppearance): void {
|
|
const previous = actor;
|
|
actor = buildActor(appearance);
|
|
root.add(actor.rig.root);
|
|
if (actor.kind === "humanoid" && faceTexture) applyOfficeFaceTexture(actor.rig, faceTexture);
|
|
if (previous.kind === "humanoid") applyOfficeFaceTexture(previous.rig, null);
|
|
if (actor.kind !== "humanoid") faceTexture = null;
|
|
previous.rig.root.removeFromParent();
|
|
if (previous.kind === "humanoid") disposeHumanoid(previous.rig);
|
|
else disposeDog(previous.rig);
|
|
root.userData.actorType = actor.rig.root.userData.actorType;
|
|
sync(controller.state());
|
|
}
|
|
|
|
sync(controller.state());
|
|
|
|
return {
|
|
root,
|
|
view,
|
|
state: snapshot,
|
|
active: () => enabled,
|
|
setActive(active) {
|
|
if (active !== enabled) desired = { x: 0, z: 0 };
|
|
enabled = active;
|
|
// Never leave the actor between two floors. Going inactive part-way up a
|
|
// flight lands it at the top, which is the only place the rest of the
|
|
// application can describe.
|
|
if (!active) completeCrossing();
|
|
},
|
|
action: () => ({ ...desired }),
|
|
setAction(action) {
|
|
desired = normalizeWalkerAction(action);
|
|
return { ...desired };
|
|
},
|
|
tick(elapsedSeconds) {
|
|
if (disposed) return snapshot();
|
|
const dt = Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0;
|
|
|
|
// A crossing owns the actor while it runs. The controller is deliberately
|
|
// not ticked: input during a climb goes nowhere, and letting it accumulate
|
|
// would land the walker somewhere it did not visibly walk to.
|
|
const active = crossing;
|
|
if (active) {
|
|
active.elapsed += dt;
|
|
const t = Math.min(1, active.seconds > 0 ? active.elapsed / active.seconds : 1);
|
|
if (t >= 0.5) handOver(active);
|
|
if (t >= 1) {
|
|
completeCrossing();
|
|
return snapshot();
|
|
}
|
|
const along = poseAlong(active, t);
|
|
const state = controller.state();
|
|
// The climb's own heading, not the one the controller is holding: the
|
|
// actor has to face up the flight it is on.
|
|
sync({ ...state, facing: along.facing }, dt, dt * (active.length / Math.max(active.seconds, 1e-6)), along.position);
|
|
return snapshot();
|
|
}
|
|
|
|
const before = controller.state();
|
|
const next = enabled
|
|
? controller.tick(elapsedSeconds, desired)
|
|
: controller.tick(elapsedSeconds, { x: 0, z: 0 });
|
|
|
|
// Standing on a way up *is* the input. There is no key to press, which is
|
|
// why the footprints in a pack are the shape they are: the bottom of a
|
|
// flight rather than the whole stair, and the gap in a balustrade rather
|
|
// than the whole walkway.
|
|
const side = plan.transitionAt(next.levelId, next.position);
|
|
if (side === null) latched = null;
|
|
else if (
|
|
enabled && latched !== side.transition.id &&
|
|
Math.hypot(desired.x, desired.z) > CLIMB_INPUT_EPSILON &&
|
|
beginCrossing(side)
|
|
) {
|
|
return snapshot();
|
|
}
|
|
|
|
sync(next, dt, next.distance - before.distance);
|
|
return snapshot();
|
|
},
|
|
reset(spawn) {
|
|
// Abandoned rather than completed: `reset` is a teleport to a known place,
|
|
// so finishing the climb first would move the walker somewhere else and
|
|
// then move it again.
|
|
crossing = null;
|
|
latched = null;
|
|
const state = controller.reset(spawn);
|
|
desired = { x: 0, z: 0 };
|
|
gait = 0;
|
|
presentationSeconds = 0;
|
|
previousSpeed = 0;
|
|
previousFacing.set(state.facing.x, state.facing.z);
|
|
sync(state);
|
|
return snapshot();
|
|
},
|
|
setAppearance(appearance) {
|
|
if (disposed) return snapshot();
|
|
replaceActor(appearance);
|
|
return snapshot();
|
|
},
|
|
attachFaceTexture(texture) {
|
|
if (disposed || actor.kind !== "humanoid") return false;
|
|
if (!texture || texture.isTexture !== true) {
|
|
throw new RangeError("office walker: face texture must be a Three.js Texture");
|
|
}
|
|
faceTexture = texture;
|
|
applyOfficeFaceTexture(actor.rig, texture);
|
|
return true;
|
|
},
|
|
clearFaceTexture() {
|
|
faceTexture = null;
|
|
if (!disposed && actor.kind === "humanoid") applyOfficeFaceTexture(actor.rig, null);
|
|
},
|
|
followPose() {
|
|
const state = controller.state();
|
|
// The actor's *drawn* position, so the camera rises with it across a
|
|
// crossing instead of cutting to the destination floor at the midpoint.
|
|
// On every other frame `root.position` is exactly the state's position at
|
|
// this level's floor, so this is the same answer it always gave.
|
|
const base = root.position;
|
|
const facing = crossing ? headingOf(root.rotation.y) : state.facing;
|
|
return {
|
|
position: new THREE.Vector3(
|
|
base.x - facing.x * camera.distance,
|
|
base.y + camera.height,
|
|
base.z - facing.z * camera.distance,
|
|
),
|
|
target: new THREE.Vector3(
|
|
base.x + facing.x * camera.lookAhead,
|
|
base.y + camera.targetHeight,
|
|
base.z + facing.z * camera.lookAhead,
|
|
),
|
|
};
|
|
},
|
|
dispose() {
|
|
if (disposed) return;
|
|
completeCrossing();
|
|
disposed = true;
|
|
root.removeFromParent();
|
|
if (actor.kind === "humanoid") applyOfficeFaceTexture(actor.rig, null);
|
|
faceTexture = null;
|
|
if (actor.kind === "humanoid") disposeHumanoid(actor.rig);
|
|
else disposeDog(actor.rig);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** The inverse of the yaw `sync` writes: a rotation back to a planar heading. */
|
|
function headingOf(yaw: number): { x: number; z: number } {
|
|
return { x: -Math.sin(yaw), z: -Math.cos(yaw) };
|
|
}
|
|
|
|
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 } : {}),
|
|
}),
|
|
};
|
|
}
|
|
|
|
const DEFAULT_OFFICE_FACE_COLOR = 0x18242b;
|
|
|
|
function applyOfficeFaceTexture(rig: HumanoidRig, texture: THREE.Texture | null): void {
|
|
const material = rig.face.material;
|
|
if (!(material instanceof THREE.MeshBasicMaterial)) {
|
|
throw new Error("office walker: humanoid face must use one MeshBasicMaterial");
|
|
}
|
|
material.map = texture;
|
|
material.color.set(texture ? 0xffffff : DEFAULT_OFFICE_FACE_COLOR);
|
|
material.needsUpdate = true;
|
|
}
|
|
|
|
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;
|
|
}
|