1
0

feat: add playable actors and seamless journey state

This commit is contained in:
2026-08-11 19:10:14 -07:00
parent 4e313c4a79
commit a2a52bfdae
19 changed files with 2778 additions and 24 deletions
+596
View File
@@ -0,0 +1,596 @@
/**
* Renderer-independent playable actor simulation.
*
* DOM, touch and gamepad adapters reduce their input to `ActorActionSnapshot`.
* This state machine then advances on a deterministic fixed clock and publishes
* metre-scale transforms that any Three.js rig can consume. At yaw zero actors
* face -Z, matching the procedural assets under `assets/actors`.
*/
const TWO_PI = Math.PI * 2;
export type ActorKind = "humanoid" | "dog" | "crow";
export type ActorMode = "ground" | "flight";
export type ActorModeRequest = "none" | ActorMode;
export type ActorKindRequest = "none" | ActorKind;
/** JSON-safe appearance fields. URLs are references; render resources stay outside simulation. */
export interface ActorProfile {
handle?: string;
pronouns?: string;
faceImageUrl?: string;
appearance?: {
skinTone?: string;
primaryColor?: string;
accentColor?: string;
};
}
/** Identity follows the player when their visible actor kind changes. */
export interface ActorIdentity {
id: string;
displayName: string;
authenticated: boolean;
profile: ActorProfile;
}
/** Device-neutral held inputs and one-shot requests. All axes normalize to [-1, 1]. */
export interface ActorActionSnapshot {
/** Ground forward/back; flight airspeed demand. */
forward: number;
/** Ground strafe. Ignored in flight. */
right: number;
/** Yaw left/right. */
turn: number;
/** Crow nose-up/nose-down request. */
pitch: number;
/** Crow vertical thrust independent of its nose. */
climb: number;
sprint: boolean;
glide: boolean;
modeRequest: ActorModeRequest;
kindRequest: ActorKindRequest;
reset: boolean;
}
export const NEUTRAL_ACTOR_ACTIONS: Readonly<ActorActionSnapshot> = Object.freeze({
forward: 0,
right: 0,
turn: 0,
pitch: 0,
climb: 0,
sprint: false,
glide: false,
modeRequest: "none",
kindRequest: "none",
reset: false,
});
export interface ActorPosition {
x: number;
y: number;
z: number;
}
export interface ActorHorizontalBounds {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}
export interface ActorControllerOptions {
kind: ActorKind;
identity: ActorIdentity;
position?: Partial<ActorPosition>;
yaw?: number;
/** Used only when spawning a crow in flight. */
pitch?: number;
mode?: ActorMode;
groundY?: number;
minFlightAltitude?: number;
maxFlightAltitude?: number;
walkSpeedMps?: number;
runSpeedMps?: number;
dogSpeedScale?: number;
groundTurnRateRadPerSecond?: number;
flightTurnRateRadPerSecond?: number;
maximumFlightPitchRad?: number;
minimumFlightSpeedMps?: number;
maximumFlightSpeedMps?: number;
glideSpeedMps?: number;
maximumClimbSpeedMps?: number;
/** Optional rectangle for a board or office adapter without collision geometry. */
horizontalBounds?: ActorHorizontalBounds;
fixedStepSeconds?: number;
/** Caps catch-up after a sleeping tab. */
maxFrameDeltaSeconds?: number;
}
export interface ActorControllerState extends ActorPosition {
kind: ActorKind;
mode: ActorMode;
identity: Readonly<ActorIdentity>;
yaw: number;
pitch: number;
speedMps: number;
verticalSpeedMps: number;
/** Renderer request for a cyclic walk/flap pose, wrapped to [0, 2π). */
posePhase: number;
/** Renderer request for pose intensity, in [0, 1]. */
poseAmount: number;
gliding: boolean;
altitudeBoundContact: "none" | "minimum" | "maximum";
distanceM: number;
elapsedSteps: number;
}
export interface ActorControllerSnapshot extends Omit<ActorControllerState, "identity"> {
identity: ActorIdentity;
}
export interface TimedActorInputFrame {
steps: number;
actions?: Partial<ActorActionSnapshot>;
}
export interface ActorReplayResult {
trajectory: readonly ActorControllerSnapshot[];
final: ActorControllerSnapshot;
}
interface ResolvedOptions {
kind: ActorKind;
identity: Readonly<ActorIdentity>;
position: ActorPosition;
yaw: number;
pitch: number;
mode: ActorMode;
groundY: number;
minFlightAltitude: number;
maxFlightAltitude: number;
walkSpeedMps: number;
runSpeedMps: number;
dogSpeedScale: number;
groundTurnRateRadPerSecond: number;
flightTurnRateRadPerSecond: number;
maximumFlightPitchRad: number;
minimumFlightSpeedMps: number;
maximumFlightSpeedMps: number;
glideSpeedMps: number;
maximumClimbSpeedMps: number;
horizontalBounds?: ActorHorizontalBounds;
fixedStepSeconds: number;
maxFrameDeltaSeconds: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function finiteOr(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function positive(value: number | undefined, fallback: number, name: string): number {
const result = finiteOr(value, fallback);
if (!(result > 0)) throw new RangeError(`${name} must be finite and positive`);
return result;
}
function wrapAngle(value: number): number {
return ((value + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI;
}
function wrapPhase(value: number): number {
return ((value % TWO_PI) + TWO_PI) % TWO_PI;
}
function moveToward(value: number, target: number, maximumDelta: number): number {
if (value < target) return Math.min(value + maximumDelta, target);
if (value > target) return Math.max(value - maximumDelta, target);
return value;
}
function validKind(value: unknown): value is ActorKind {
return value === "humanoid" || value === "dog" || value === "crow";
}
function copyProfile(profile: ActorProfile): ActorProfile {
const appearance = profile.appearance
? {
...(profile.appearance.skinTone === undefined ? {} : { skinTone: profile.appearance.skinTone }),
...(profile.appearance.primaryColor === undefined ? {} : { primaryColor: profile.appearance.primaryColor }),
...(profile.appearance.accentColor === undefined ? {} : { accentColor: profile.appearance.accentColor }),
}
: undefined;
return {
...(profile.handle === undefined ? {} : { handle: profile.handle }),
...(profile.pronouns === undefined ? {} : { pronouns: profile.pronouns }),
...(profile.faceImageUrl === undefined ? {} : { faceImageUrl: profile.faceImageUrl }),
...(appearance === undefined ? {} : { appearance }),
};
}
function checkedIdentity(identity: ActorIdentity): Readonly<ActorIdentity> {
if (!identity || typeof identity.id !== "string" || identity.id.length === 0) {
throw new RangeError("actor identity must have a non-empty string id");
}
if (typeof identity.displayName !== "string" || typeof identity.authenticated !== "boolean") {
throw new RangeError("actor identity must contain a display name and authentication flag");
}
const profile = identity.profile ?? {};
const strings = [
profile.handle,
profile.pronouns,
profile.faceImageUrl,
profile.appearance?.skinTone,
profile.appearance?.primaryColor,
profile.appearance?.accentColor,
];
if (strings.some((item) => item !== undefined && typeof item !== "string")) {
throw new RangeError("actor profile fields must be strings");
}
const copy: ActorIdentity = {
id: identity.id,
displayName: identity.displayName,
authenticated: identity.authenticated,
profile: copyProfile(profile),
};
if (copy.profile.appearance) Object.freeze(copy.profile.appearance);
Object.freeze(copy.profile);
return Object.freeze(copy);
}
function copyIdentity(identity: Readonly<ActorIdentity>): ActorIdentity {
return {
id: identity.id,
displayName: identity.displayName,
authenticated: identity.authenticated,
profile: copyProfile(identity.profile),
};
}
function checkedBounds(bounds: ActorHorizontalBounds | undefined): ActorHorizontalBounds | undefined {
if (!bounds) return undefined;
if (
!Number.isFinite(bounds.minX) ||
!Number.isFinite(bounds.maxX) ||
!Number.isFinite(bounds.minZ) ||
!Number.isFinite(bounds.maxZ) ||
bounds.minX >= bounds.maxX ||
bounds.minZ >= bounds.maxZ
) {
throw new RangeError("actor horizontal bounds must be finite and ordered");
}
return { ...bounds };
}
/** Clamp malformed adapter input before it can poison authoritative state. */
export function normalizeActorActions(
actions: Partial<ActorActionSnapshot> | undefined,
): ActorActionSnapshot {
let forward = clamp(finiteOr(actions?.forward, 0), -1, 1);
let right = clamp(finiteOr(actions?.right, 0), -1, 1);
const groundLength = Math.hypot(forward, right);
if (groundLength > 1) {
forward /= groundLength;
right /= groundLength;
}
const mode = actions?.modeRequest;
const kind = actions?.kindRequest;
return {
forward,
right,
turn: clamp(finiteOr(actions?.turn, 0), -1, 1),
pitch: clamp(finiteOr(actions?.pitch, 0), -1, 1),
climb: clamp(finiteOr(actions?.climb, 0), -1, 1),
sprint: actions?.sprint === true,
glide: actions?.glide === true,
modeRequest: mode === "ground" || mode === "flight" ? mode : "none",
kindRequest: validKind(kind) ? kind : "none",
reset: actions?.reset === true,
};
}
function resolveOptions(options: ActorControllerOptions): ResolvedOptions {
if (!validKind(options.kind)) throw new RangeError("unknown actor kind");
const groundY = finiteOr(options.groundY, 0);
const minFlightAltitude = positive(options.minFlightAltitude, 0.75, "minFlightAltitude");
const maxFlightAltitude = positive(options.maxFlightAltitude, 120, "maxFlightAltitude");
if (maxFlightAltitude <= minFlightAltitude) {
throw new RangeError("maxFlightAltitude must exceed minFlightAltitude");
}
const mode: ActorMode = options.kind === "crow" && options.mode === "flight" ? "flight" : "ground";
const position = {
x: finiteOr(options.position?.x, 0),
y: mode === "flight"
? clamp(finiteOr(options.position?.y, groundY + minFlightAltitude), groundY + minFlightAltitude, groundY + maxFlightAltitude)
: groundY,
z: finiteOr(options.position?.z, 0),
};
const maximumFlightSpeedMps = positive(options.maximumFlightSpeedMps, 16, "maximumFlightSpeedMps");
const minimumFlightSpeedMps = positive(options.minimumFlightSpeedMps, 4, "minimumFlightSpeedMps");
if (maximumFlightSpeedMps < minimumFlightSpeedMps) {
throw new RangeError("maximumFlightSpeedMps must not be below minimumFlightSpeedMps");
}
const bounds = checkedBounds(options.horizontalBounds);
if (bounds) {
position.x = clamp(position.x, bounds.minX, bounds.maxX);
position.z = clamp(position.z, bounds.minZ, bounds.maxZ);
}
return {
kind: options.kind,
identity: checkedIdentity(options.identity),
position,
yaw: wrapAngle(finiteOr(options.yaw, 0)),
pitch: mode === "flight" ? finiteOr(options.pitch, 0) : 0,
mode,
groundY,
minFlightAltitude,
maxFlightAltitude,
walkSpeedMps: positive(options.walkSpeedMps, 1.7, "walkSpeedMps"),
runSpeedMps: positive(options.runSpeedMps, 4.5, "runSpeedMps"),
dogSpeedScale: positive(options.dogSpeedScale, 1.25, "dogSpeedScale"),
groundTurnRateRadPerSecond: positive(options.groundTurnRateRadPerSecond, 2.8, "groundTurnRateRadPerSecond"),
flightTurnRateRadPerSecond: positive(options.flightTurnRateRadPerSecond, 1.75, "flightTurnRateRadPerSecond"),
maximumFlightPitchRad: clamp(positive(options.maximumFlightPitchRad, 0.72, "maximumFlightPitchRad"), 0.1, Math.PI / 2 - 0.05),
minimumFlightSpeedMps,
maximumFlightSpeedMps,
glideSpeedMps: clamp(positive(options.glideSpeedMps, 7.5, "glideSpeedMps"), minimumFlightSpeedMps, maximumFlightSpeedMps),
maximumClimbSpeedMps: positive(options.maximumClimbSpeedMps, 5, "maximumClimbSpeedMps"),
horizontalBounds: bounds,
fixedStepSeconds: clamp(positive(options.fixedStepSeconds, 1 / 60, "fixedStepSeconds"), 1 / 240, 0.1),
maxFrameDeltaSeconds: clamp(positive(options.maxFrameDeltaSeconds, 0.25, "maxFrameDeltaSeconds"), 0.05, 1),
};
}
export class ActorController {
private readonly options: ResolvedOptions;
private readonly current: ActorControllerState;
private accumulator = 0;
constructor(options: ActorControllerOptions) {
this.options = resolveOptions(options);
this.options.pitch = clamp(this.options.pitch, -this.options.maximumFlightPitchRad, this.options.maximumFlightPitchRad);
this.current = {
kind: this.options.kind,
mode: this.options.mode,
identity: this.options.identity,
...this.options.position,
yaw: this.options.yaw,
pitch: this.options.pitch,
speedMps: 0,
verticalSpeedMps: 0,
posePhase: 0,
poseAmount: 0,
gliding: false,
altitudeBoundContact: "none",
distanceM: 0,
elapsedSteps: 0,
};
}
fixedStepSeconds(): number {
return this.options.fixedStepSeconds;
}
/** Stable allocation-free view. Treat nested identity as read-only and frozen. */
state(): Readonly<ActorControllerState> {
return this.current;
}
/** Detached JSON-safe state for persistence, networking and tests. */
snapshot(): ActorControllerSnapshot {
return { ...this.current, identity: copyIdentity(this.current.identity) };
}
/** Replace profile/identity without changing kind, pose or position. */
setIdentity(identity: ActorIdentity): void {
this.current.identity = checkedIdentity(identity);
}
/** Change visible actor while preserving identity and finite world position. */
setActorKind(kind: ActorKind): void {
if (!validKind(kind)) throw new RangeError("unknown actor kind");
if (kind === this.current.kind) return;
this.current.kind = kind;
this.current.speedMps = 0;
this.current.verticalSpeedMps = 0;
this.current.pitch = 0;
this.current.poseAmount = 0;
this.current.gliding = false;
this.current.altitudeBoundContact = "none";
if (kind !== "crow") this.land();
}
/** Request a locomotion mode without consuming a simulation step. Non-crows cannot fly. */
setMode(mode: ActorMode): void {
if (mode === "flight" && this.current.kind === "crow") this.takeOff();
else this.land();
}
/** Restore the original spawn, identity and kind and clear fractional time. */
reset(): void {
this.accumulator = 0;
Object.assign(this.current, this.options.position, {
kind: this.options.kind,
mode: this.options.mode,
identity: this.options.identity,
yaw: this.options.yaw,
pitch: this.options.pitch,
speedMps: 0,
verticalSpeedMps: 0,
posePhase: 0,
poseAmount: 0,
gliding: false,
altitudeBoundContact: "none",
distanceM: 0,
elapsedSteps: 0,
});
}
tick(deltaSeconds: number, actions: Partial<ActorActionSnapshot> = NEUTRAL_ACTOR_ACTIONS): number {
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
const normalized = normalizeActorActions(actions);
if (normalized.reset) {
this.reset();
return 0;
}
this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds);
let steps = 0;
while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) {
this.stepNormalized(normalized);
this.accumulator -= this.options.fixedStepSeconds;
steps += 1;
}
return steps;
}
stepFixed(actions: Partial<ActorActionSnapshot> = NEUTRAL_ACTOR_ACTIONS): void {
const normalized = normalizeActorActions(actions);
if (normalized.reset) {
this.reset();
return;
}
this.stepNormalized(normalized);
}
private stepNormalized(actions: ActorActionSnapshot): void {
if (actions.kindRequest !== "none") this.setActorKind(actions.kindRequest);
if (actions.modeRequest === "ground") this.land();
else if (actions.modeRequest === "flight" && this.current.kind === "crow") this.takeOff();
if (this.current.mode === "flight" && this.current.kind === "crow") this.stepFlight(actions);
else this.stepGround(actions);
this.current.elapsedSteps += 1;
}
private stepGround(actions: ActorActionSnapshot): void {
const dt = this.options.fixedStepSeconds;
this.current.yaw = wrapAngle(
this.current.yaw + actions.turn * this.options.groundTurnRateRadPerSecond * dt,
);
const input = Math.hypot(actions.forward, actions.right);
const kindScale = this.current.kind === "dog" ? this.options.dogSpeedScale : 1;
const maximum = (actions.sprint ? this.options.runSpeedMps : this.options.walkSpeedMps) * kindScale;
const speed = maximum * input;
const dx = (actions.right * Math.cos(this.current.yaw) - actions.forward * Math.sin(this.current.yaw)) * maximum * dt;
const dz = (-actions.right * Math.sin(this.current.yaw) - actions.forward * Math.cos(this.current.yaw)) * maximum * dt;
const beforeX = this.current.x;
const beforeZ = this.current.z;
this.current.x += dx;
this.current.z += dz;
this.applyHorizontalBounds();
const moved = Math.hypot(this.current.x - beforeX, this.current.z - beforeZ);
this.current.distanceM += moved;
this.current.speedMps = speed;
this.current.verticalSpeedMps = 0;
this.current.y = this.options.groundY;
this.current.pitch = 0;
this.current.gliding = false;
this.current.altitudeBoundContact = "none";
this.current.poseAmount = input;
const strideLength = this.current.kind === "dog" ? 0.54 : 0.78;
this.current.posePhase = wrapPhase(this.current.posePhase + (moved / strideLength) * TWO_PI);
}
private stepFlight(actions: ActorActionSnapshot): void {
const dt = this.options.fixedStepSeconds;
this.current.yaw = wrapAngle(
this.current.yaw + actions.turn * this.options.flightTurnRateRadPerSecond * dt,
);
const targetPitch = actions.pitch * this.options.maximumFlightPitchRad;
this.current.pitch = moveToward(this.current.pitch, targetPitch, 1.9 * dt);
const throttle = (actions.forward + 1) / 2;
const poweredTarget = this.options.minimumFlightSpeedMps +
(this.options.maximumFlightSpeedMps - this.options.minimumFlightSpeedMps) * throttle;
const targetSpeed = actions.glide ? this.options.glideSpeedMps : poweredTarget;
this.current.speedMps = moveToward(this.current.speedMps, targetSpeed, (actions.glide ? 2.2 : 5.5) * dt);
const pitchLift = Math.sin(this.current.pitch) * this.current.speedMps;
const glideSink = actions.glide ? 0.7 : 0.15;
const targetVertical = pitchLift + actions.climb * this.options.maximumClimbSpeedMps - glideSink;
this.current.verticalSpeedMps = moveToward(
this.current.verticalSpeedMps,
clamp(targetVertical, -this.options.maximumClimbSpeedMps, this.options.maximumClimbSpeedMps),
8 * dt,
);
const horizontalSpeed = this.current.speedMps * Math.cos(this.current.pitch);
const beforeX = this.current.x;
const beforeY = this.current.y;
const beforeZ = this.current.z;
this.current.x -= Math.sin(this.current.yaw) * horizontalSpeed * dt;
this.current.z -= Math.cos(this.current.yaw) * horizontalSpeed * dt;
this.current.y += this.current.verticalSpeedMps * dt;
this.applyHorizontalBounds();
this.applyAltitudeBounds();
this.current.distanceM += Math.hypot(
this.current.x - beforeX,
this.current.y - beforeY,
this.current.z - beforeZ,
);
this.current.gliding = actions.glide;
this.current.poseAmount = actions.glide ? 0.22 : 0.55 + throttle * 0.45;
if (!actions.glide) {
const flapRate = 3.5 + throttle * 4;
this.current.posePhase = wrapPhase(this.current.posePhase + flapRate * TWO_PI * dt);
}
}
private land(): void {
this.current.mode = "ground";
this.current.y = this.options.groundY;
this.current.pitch = 0;
this.current.verticalSpeedMps = 0;
this.current.gliding = false;
this.current.altitudeBoundContact = "none";
}
private takeOff(): void {
this.current.mode = "flight";
this.current.y = Math.max(this.current.y, this.options.groundY + this.options.minFlightAltitude);
this.current.altitudeBoundContact = "none";
}
private applyHorizontalBounds(): void {
const bounds = this.options.horizontalBounds;
if (!bounds) return;
this.current.x = clamp(this.current.x, bounds.minX, bounds.maxX);
this.current.z = clamp(this.current.z, bounds.minZ, bounds.maxZ);
}
private applyAltitudeBounds(): void {
const minimum = this.options.groundY + this.options.minFlightAltitude;
const maximum = this.options.groundY + this.options.maxFlightAltitude;
if (this.current.y <= minimum) {
this.current.y = minimum;
this.current.verticalSpeedMps = Math.max(0, this.current.verticalSpeedMps);
this.current.altitudeBoundContact = "minimum";
} else if (this.current.y >= maximum) {
this.current.y = maximum;
this.current.verticalSpeedMps = Math.min(0, this.current.verticalSpeedMps);
this.current.altitudeBoundContact = "maximum";
} else {
this.current.altitudeBoundContact = "none";
}
}
}
/** Execute an exact, allocation-friendly input recording for tests or playback. */
export function replayActorInputs(
options: ActorControllerOptions,
frames: readonly TimedActorInputFrame[],
): ActorReplayResult {
const controller = new ActorController(options);
const trajectory: ActorControllerSnapshot[] = [controller.snapshot()];
for (const frame of frames) {
const steps = Number.isSafeInteger(frame.steps) && frame.steps > 0 ? frame.steps : 0;
for (let index = 0; index < steps; index += 1) {
controller.stepFixed(frame.actions);
trajectory.push(controller.snapshot());
}
}
return { trajectory, final: controller.snapshot() };
}
+28
View File
@@ -0,0 +1,28 @@
export {
ActorController,
NEUTRAL_ACTOR_ACTIONS,
normalizeActorActions,
replayActorInputs,
type ActorActionSnapshot,
type ActorControllerOptions,
type ActorControllerSnapshot,
type ActorControllerState,
type ActorHorizontalBounds,
type ActorIdentity,
type ActorKind,
type ActorKindRequest,
type ActorMode,
type ActorModeRequest,
type ActorPosition,
type ActorProfile,
type ActorReplayResult,
type TimedActorInputFrame,
} from "./controller.ts";
export {
createSceneActor,
type SceneActor,
type SceneActorCameraOptions,
type SceneActorOptions,
type SceneActorView,
} from "./sceneActor.ts";
+307
View File
@@ -0,0 +1,307 @@
/**
* Three.js presentation adapter for a playable city/corridor actor.
*
* `ActorController` remains the sole owner of motion and identity. This module
* only turns its metre-space state into a procedural rig, pose joints and a
* third-person scene camera contract. A state-scale California board can set a
* small `sceneUnitsPerMetre`; a metre-scale plaza leaves it at one.
*/
import * as THREE from "three";
import {
buildCrow,
buildDog,
buildHumanoid,
disposeCrow,
disposeDog,
disposeHumanoid,
poseCrowFlight,
poseDogAttention,
poseDogWalk,
poseHumanoid,
type CrowRig,
type DogRig,
type HumanoidRig,
} from "../assets/actors/index.ts";
import type { Pose } from "../engine/scenekit.ts";
import {
ActorController,
NEUTRAL_ACTOR_ACTIONS,
normalizeActorActions,
type ActorActionSnapshot,
type ActorControllerOptions,
type ActorControllerSnapshot,
type ActorIdentity,
type ActorKind,
type ActorMode,
} from "./controller.ts";
export interface SceneActorCameraOptions {
/** All camera dimensions are actor-space metres and share the render scale. */
distance?: number;
height?: number;
targetHeight?: number;
lookAhead?: number;
}
export interface SceneActorOptions extends ActorControllerOptions {
/** Actor metres to scene units. Defaults to one. */
sceneUnitsPerMetre?: number;
/** Optional representational scale for the rig/camera on very coarse boards. */
visualSceneUnitsPerMetre?: number;
/** Scene-space anchor for the controller's local metre origin. */
sceneOrigin?: { x: number; y: number; z: number };
camera?: SceneActorCameraOptions;
/** False by default so constructing a layer never steals orbit navigation. */
active?: boolean;
}
export interface SceneActorView {
/** Stable live vector in scene coordinates for camera/light consumers. */
position: THREE.Vector3;
}
export interface SceneActor {
/** Stable outer group; changing actor kind only replaces its rig child. */
root: THREE.Group;
view: SceneActorView;
state(): ActorControllerSnapshot;
actions(): ActorActionSnapshot;
setActions(actions: Partial<ActorActionSnapshot>): ActorActionSnapshot;
tick(elapsedSeconds: number): ActorControllerSnapshot;
/** Defensive scene-space third-person pose. */
followPose(): Pose;
active(): boolean;
setActive(active: boolean): void;
/** Change rig and optional identity/mode without replacing the stable root. */
switchActor(kind: ActorKind, identity?: ActorIdentity, mode?: ActorMode): ActorControllerSnapshot;
/** Rebuild the visible skin for a new serializable identity, preserving motion and kind. */
setIdentity(identity: ActorIdentity): ActorControllerSnapshot;
dispose(): void;
}
type RiggedActor =
| { kind: "humanoid"; rig: HumanoidRig }
| { kind: "dog"; rig: DogRig }
| { kind: "crow"; rig: CrowRig };
const CAMERA_BY_KIND: Record<ActorKind, Required<SceneActorCameraOptions>> = {
humanoid: { distance: 3.5, height: 2.25, targetHeight: 1.25, lookAhead: 0.75 },
dog: { distance: 2.8, height: 1.35, targetHeight: 0.48, lookAhead: 0.65 },
crow: { distance: 4.2, height: 1.15, targetHeight: 0.18, lookAhead: 2.2 },
};
function color(value: string | undefined): THREE.ColorRepresentation | undefined {
return value;
}
function buildActor(kind: ActorKind, identity: Readonly<ActorIdentity>): RiggedActor {
const appearance = identity.profile.appearance;
if (kind === "dog") {
return {
kind,
rig: buildDog({
...(color(appearance?.primaryColor) === undefined ? {} : { coatColor: color(appearance?.primaryColor) }),
...(color(appearance?.skinTone) === undefined ? {} : { markingsColor: color(appearance?.skinTone) }),
...(color(appearance?.accentColor) === undefined ? {} : { collarColor: color(appearance?.accentColor) }),
}),
};
}
if (kind === "crow") {
return {
kind,
rig: buildCrow({
...(color(appearance?.primaryColor) === undefined ? {} : { featherColor: color(appearance?.primaryColor) }),
...(color(appearance?.accentColor) === undefined ? {} : { sheenColor: color(appearance?.accentColor) }),
}),
};
}
// `faceImageUrl` intentionally remains data, not an implicit network fetch.
// A realtime/video integration may attach its caller-owned texture to the
// named `humanoid.face` mesh without changing simulation or this lifecycle.
return {
kind,
rig: buildHumanoid({
...(color(appearance?.skinTone) === undefined ? {} : { skinTone: color(appearance?.skinTone) }),
...(color(appearance?.primaryColor) === undefined ? {} : { outfitColor: color(appearance?.primaryColor) }),
...(color(appearance?.accentColor) === undefined ? {} : { accentColor: color(appearance?.accentColor) }),
}),
};
}
function disposeRig(actor: RiggedActor): void {
actor.rig.root.removeFromParent();
if (actor.kind === "humanoid") disposeHumanoid(actor.rig);
else if (actor.kind === "dog") disposeDog(actor.rig);
else disposeCrow(actor.rig);
}
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;
}
function finiteOrigin(origin: SceneActorOptions["sceneOrigin"]): THREE.Vector3 {
if (!origin) return new THREE.Vector3();
if (![origin.x, origin.y, origin.z].every(Number.isFinite)) {
throw new RangeError("sceneOrigin must contain finite coordinates");
}
return new THREE.Vector3(origin.x, origin.y, origin.z);
}
export function createSceneActor(options: SceneActorOptions): SceneActor {
const scale = positive(options.sceneUnitsPerMetre ?? 1, "sceneUnitsPerMetre");
const visualScale = positive(options.visualSceneUnitsPerMetre ?? scale, "visualSceneUnitsPerMetre");
const origin = finiteOrigin(options.sceneOrigin);
const cameraOverrides = options.camera ?? {};
for (const [name, value] of Object.entries(cameraOverrides)) {
if (value === undefined) continue;
if (name === "distance" || name === "height") positive(value, `camera.${name}`);
else nonNegative(value, `camera.${name}`);
}
const controller = new ActorController(options);
const root = new THREE.Group();
root.name = "playable-scene-actor";
root.userData.kind = "playable-actor";
root.userData.forwardAxis = "-Z";
root.scale.setScalar(visualScale);
const view = { position: new THREE.Vector3() };
let actor = buildActor(controller.state().kind, controller.state().identity);
root.add(actor.rig.root);
let enabled = options.active ?? false;
let desired: ActorActionSnapshot = { ...NEUTRAL_ACTOR_ACTIONS };
let disposed = false;
function sync(): void {
const state = controller.state();
root.position.set(
origin.x + state.x * scale,
origin.y + state.y * scale,
origin.z + state.z * scale,
);
root.rotation.order = "YXZ";
root.rotation.set(state.mode === "flight" ? state.pitch : 0, state.yaw, 0);
view.position.copy(root.position);
if (actor.kind === "humanoid") {
poseHumanoid(actor.rig, { walkPhase: state.posePhase, stride: state.poseAmount * 0.68 });
} else if (actor.kind === "dog") {
poseDogWalk(actor.rig, state.posePhase, state.poseAmount * 0.68);
poseDogAttention(actor.rig, 0, state.posePhase * 0.7);
} else if (state.mode === "flight") {
// A possessed crow is airborne even before the first key arrives. Keep a
// readable soaring silhouette at neutral input; the controller still
// owns phase/intensity as soon as movement begins.
const idleSoar = enabled && state.poseAmount < 1e-4;
poseCrowFlight(
actor.rig,
idleSoar ? -Math.PI / 2 : state.posePhase,
Math.max(state.poseAmount, idleSoar ? 0.58 : 0),
);
if (idleSoar) {
// The authored wing sheet already extends along local X. A neutral
// chase view needs that broad plan silhouette, not the edge-on middle
// of a flap cycle.
actor.rig.joints.wingLeft.rotation.set(-0.08, 0, 0.08);
actor.rig.joints.wingRight.rotation.set(-0.08, 0, -0.08);
}
}
}
function replaceActor(kind: ActorKind): void {
const previous = actor;
actor = buildActor(kind, controller.state().identity);
root.add(actor.rig.root);
disposeRig(previous);
sync();
}
function clearEdges(): void {
desired.modeRequest = "none";
desired.kindRequest = "none";
desired.reset = false;
}
sync();
return {
root,
view,
state: () => controller.snapshot(),
actions: () => ({ ...desired }),
setActions(actions) {
desired = normalizeActorActions(actions);
return { ...desired };
},
tick(elapsedSeconds) {
if (!disposed && enabled) {
const beforeKind = controller.state().kind;
controller.tick(elapsedSeconds, desired);
if (controller.state().kind !== beforeKind) replaceActor(controller.state().kind);
else sync();
clearEdges();
}
return controller.snapshot();
},
followPose() {
const state = controller.state();
const defaults = CAMERA_BY_KIND[state.kind];
const camera = {
distance: cameraOverrides.distance ?? defaults.distance,
height: cameraOverrides.height ?? defaults.height,
targetHeight: cameraOverrides.targetHeight ?? defaults.targetHeight,
lookAhead: cameraOverrides.lookAhead ?? defaults.lookAhead,
};
const forwardX = -Math.sin(state.yaw);
const forwardZ = -Math.cos(state.yaw);
const forwardY = state.mode === "flight" ? Math.sin(state.pitch) : 0;
const atX = origin.x + state.x * scale;
const atY = origin.y + state.y * scale;
const atZ = origin.z + state.z * scale;
return {
position: new THREE.Vector3(
atX - forwardX * camera.distance * visualScale,
atY + camera.height * visualScale,
atZ - forwardZ * camera.distance * visualScale,
),
target: new THREE.Vector3(
atX + forwardX * camera.lookAhead * visualScale,
atY + (camera.targetHeight + forwardY * camera.lookAhead) * visualScale,
atZ + forwardZ * camera.lookAhead * visualScale,
),
};
},
active: () => enabled,
setActive(active) {
if (active !== enabled) desired = { ...NEUTRAL_ACTOR_ACTIONS };
enabled = active;
},
switchActor(kind, identity, mode) {
if (disposed) return controller.snapshot();
if (identity) controller.setIdentity(identity);
const changed = kind !== controller.state().kind || identity !== undefined;
controller.setActorKind(kind);
if (mode) controller.setMode(mode);
desired = { ...NEUTRAL_ACTOR_ACTIONS };
if (changed) replaceActor(kind);
else sync();
return controller.snapshot();
},
setIdentity(identity) {
if (disposed) return controller.snapshot();
controller.setIdentity(identity);
replaceActor(controller.state().kind);
return controller.snapshot();
},
dispose() {
if (disposed) return;
disposed = true;
root.removeFromParent();
disposeRig(actor);
},
};
}
+3 -1
View File
@@ -38,7 +38,9 @@ export interface CrowRig extends ActorRigBase {
export function createCrowMaterials(options: CrowBuildOptions = {}): CrowMaterials {
return {
feather: new THREE.MeshStandardMaterial({ name: "crow.feather", color: options.featherColor ?? 0x111519, roughness: 0.62, metalness: 0.12 }),
// Wing sheets are intentionally thin. Both faces must render because a
// chase camera sees their backs while a flyover camera sees their fronts.
feather: new THREE.MeshStandardMaterial({ name: "crow.feather", color: options.featherColor ?? 0x111519, roughness: 0.62, metalness: 0.12, side: THREE.DoubleSide }),
sheen: new THREE.MeshStandardMaterial({ name: "crow.sheen", color: options.sheenColor ?? 0x1f2c36, roughness: 0.4, metalness: 0.28 }),
beak: new THREE.MeshStandardMaterial({ name: "crow.beak", color: 0x202327, roughness: 0.78 }),
eye: new THREE.MeshBasicMaterial({ name: "crow.eye", color: 0xd4b168, toneMapped: false }),
+5 -1
View File
@@ -333,7 +333,11 @@ export class TextureBin {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
// Every material passes through `grain()`, which reads the full canvas back
// before uploading it to Three.js. Declare that workload so Chromium keeps
// the 2D surface on its read-optimised path instead of warning once per
// generated office material.
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
DRAW[kind](ctx, size);
+56 -1
View File
@@ -64,6 +64,8 @@ import type {
ScenePalette,
} from "./types.ts";
import { World, type FieldProgress } from "./world.ts";
import { createSceneActor, type SceneActorOptions } from "../actors/sceneActor.ts";
import type { ActorActionSnapshot, ActorControllerSnapshot } from "../actors/controller.ts";
export interface SceneOptions {
city: City;
@@ -79,6 +81,10 @@ export interface SceneOptions {
markers?: Marker[];
/** Optional deterministic road traffic for a state/corridor-scale board. */
roadTraffic?: RoadTrafficOptions;
/** Optional possessed procedural actor for play mode; inactive by default. */
actor?: SceneActorOptions;
/** Geographic spawn anchor for that actor; defaults to the board centre. */
actorAnchor?: { lat: number; lng: number };
flights?: FlightSource;
/**
* Element sets to propagate, if this deployment has any.
@@ -175,6 +181,10 @@ export interface SceneHandle {
vehicleState(): Readonly<VehicleControllerState> | null;
setVehicleCamera(mode: VehicleCameraMode): void;
vehicleCamera(): VehicleCameraMode | null;
setActorActions(actions: Partial<ActorActionSnapshot>): void;
actorState(): Readonly<ActorControllerSnapshot> | null;
setActorActive(active: boolean): void;
actorActive(): boolean;
setMarkers(markers: Marker[]): void;
/** Take this city off the stage and release everything it built. */
dispose(): void;
@@ -254,6 +264,7 @@ export async function createScene(
Math.hypot(eastX, southZ),
);
const orbitMinDistance = Math.max(4, boardSpan * 0.02);
const kit = createSceneKit({
scene,
dom: stage.renderer.domElement,
@@ -272,7 +283,7 @@ export async function createScene(
* the invariant: nothing should be cut off before it has fully faded out.
*/
far: boardSpan * 4,
minDistance: Math.max(4, boardSpan * 0.02),
minDistance: orbitMinDistance,
/**
* Far enough out to be **above the satellites**, which is what the extra
* half-span buys.
@@ -344,6 +355,22 @@ export async function createScene(
? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic)
: null;
if (roadTraffic) scene.add(roadTraffic.group);
const actorAnchor = options.actorAnchor ?? city.center;
const [actorX, actorZ] = world.project(actorAnchor.lat, actorAnchor.lng);
const sceneActor = options.actor
? createSceneActor({
...options.actor,
sceneUnitsPerMetre: options.actor.sceneUnitsPerMetre ?? 1 / world.metresPerUnit,
visualSceneUnitsPerMetre: options.actor.visualSceneUnitsPerMetre ??
(city.id === "california" ? 0.025 : 1 / world.metresPerUnit),
sceneOrigin: options.actor.sceneOrigin ?? {
x: actorX,
y: world.groundAt(actorAnchor.lat, actorAnchor.lng),
z: actorZ,
},
})
: null;
if (sceneActor) scene.add(sceneActor.root);
let flightLayer: FlightLayer | null = null;
let flightTimer = 0;
@@ -408,6 +435,15 @@ export async function createScene(
function flyTo(chapterId: string) {
const ch = chapterById[chapterId];
if (!ch) return;
// Named viewpoints are observe/vehicle destinations. Possessing an actor
// is an explicit UI action, so a chapter selection always hands the camera
// back before it moves anywhere else.
sceneActor?.setActive(false);
kit.controls.minDistance = orbitMinDistance;
if (kit.camera.near !== 0.1) {
kit.camera.near = 0.1;
kit.camera.updateProjectionMatrix();
}
const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
if (route && roadTraffic) {
roadTraffic.setRoute(route.id);
@@ -446,7 +482,11 @@ export async function createScene(
// bug.
onExit: () => kit.resetPick(),
tick(dt) {
const actorPlaying = sceneActor?.active() ?? false;
kit.controls.enabled = !actorPlaying;
kit.tick(dt);
sceneActor?.tick(dt);
if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose());
roadTraffic?.tick(dt);
clouds.tick(dt);
if (options.flights && flightLayer) {
@@ -538,10 +578,25 @@ export async function createScene(
vehicleState: () => roadTraffic?.hero() ?? null,
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
setActorActions: (actions) => { sceneActor?.setActions(actions); },
actorState: () => sceneActor?.state() ?? null,
setActorActive(active) {
sceneActor?.setActive(active);
if (active) roadTraffic?.setFollowing(false);
// State boards compress one real metre to a few hundredths of a scene
// unit. Their possessed actor and chase camera are therefore closer than
// the map camera's 0.1 near plane; lower it only for play mode so the
// procedural rig is not clipped away, then restore the depth precision.
kit.camera.near = active ? 0.001 : 0.1;
kit.controls.minDistance = active ? 0.001 : orbitMinDistance;
kit.camera.updateProjectionMatrix();
},
actorActive: () => sceneActor?.active() ?? false,
setMarkers(markers) {
markerLayer.setMarkers(markers);
},
dispose() {
sceneActor?.dispose();
/**
* Off the stage, then released — and the stage itself is left running.
*
+33 -8
View File
@@ -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) {
+239
View File
@@ -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
View File
@@ -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,
+40
View File
@@ -0,0 +1,40 @@
export {
createJourney,
decodeJourneySnapshot,
encodeJourneySnapshot,
endpointCity,
isJourneyState,
journeyReducer,
officeCity,
transitionJourney,
type CreateJourneyOptions,
} from "./state.ts";
export {
clearJourneySession,
loadJourneySession,
saveJourneySession,
} from "./persistence.ts";
export type {
JourneyActor,
JourneyActorKind,
JourneyActorProfile,
JourneyCity,
JourneyEvent,
JourneyLocation,
JourneyMode,
JourneyOfficeId,
JourneyRouteEndpoint,
JourneyRouteId,
JourneyRouteProgress,
JourneyScale,
JourneySessionClearResult,
JourneySessionLoadResult,
JourneySessionSaveResult,
JourneySnapshotDecodeResult,
JourneySnapshotV1,
JourneyState,
JourneyTransition,
JourneyTransitionRejection,
JourneyVehiclePossession,
JourneyStorageAdapter,
} from "./types.ts";
+67
View File
@@ -0,0 +1,67 @@
/** Failure-contained session persistence with no dependency on browser globals. */
import { decodeJourneySnapshot, encodeJourneySnapshot } from "./state.ts";
import type {
JourneySessionClearResult,
JourneySessionLoadResult,
JourneySessionSaveResult,
JourneyState,
JourneyStorageAdapter,
} from "./types.ts";
function validKey(key: string): boolean {
return typeof key === "string" && key.trim().length > 0;
}
export function saveJourneySession(
storage: JourneyStorageAdapter,
key: string,
state: JourneyState,
): JourneySessionSaveResult {
if (!validKey(key)) return { ok: false, error: "journey: session key is empty" };
let encoded: string;
try {
encoded = encodeJourneySnapshot(state);
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : "journey: snapshot encoding failed",
};
}
try {
storage.setItem(key, encoded);
return { ok: true };
} catch {
return { ok: false, error: "journey: session storage is unavailable" };
}
}
export function loadJourneySession(
storage: JourneyStorageAdapter,
key: string,
): JourneySessionLoadResult {
if (!validKey(key)) return { status: "invalid", error: "journey: session key is empty" };
let encoded: string | null;
try {
encoded = storage.getItem(key);
} catch {
return { status: "unavailable", error: "journey: session storage is unavailable" };
}
if (encoded === null) return { status: "missing" };
const decoded = decodeJourneySnapshot(encoded);
if (!decoded.ok) return { status: "invalid", error: decoded.error };
return { status: "loaded", state: decoded.state };
}
export function clearJourneySession(
storage: JourneyStorageAdapter,
key: string,
): JourneySessionClearResult {
if (!validKey(key)) return { ok: false, error: "journey: session key is empty" };
try {
storage.removeItem(key);
return { ok: true };
} catch {
return { ok: false, error: "journey: session storage is unavailable" };
}
}
+290
View File
@@ -0,0 +1,290 @@
/**
* Pure journey state machine spanning corridor, city boards, vehicles, and offices.
* No render or browser types belong here; the same reducer can run on a server.
*/
import type {
JourneyActor,
JourneyCity,
JourneyEvent,
JourneyLocation,
JourneyMode,
JourneyOfficeId,
JourneyRouteEndpoint,
JourneyRouteId,
JourneySnapshotDecodeResult,
JourneySnapshotV1,
JourneyState,
JourneyTransition,
JourneyTransitionRejection,
} from "./types.ts";
const ROUTES: ReadonlySet<string> = new Set<JourneyRouteId>([
"la-sf-us-101",
"la-sf-i-5",
]);
const OFFICES: Readonly<Record<JourneyOfficeId, JourneyCity>> = Object.freeze({
"lumbridge-hq": "bay-area",
"frontier-valley": "bay-area",
"mateo-court": "socal",
});
const ENDPOINT_CITY: Readonly<Record<JourneyRouteEndpoint, JourneyCity>> = Object.freeze({
"los-angeles": "socal",
"san-francisco": "bay-area",
});
export interface CreateJourneyOptions {
actor: JourneyActor;
location?: JourneyLocation;
mode?: JourneyMode;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function finiteUnit(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function isActor(value: unknown): value is JourneyActor {
if (!isRecord(value) || !isNonEmptyString(value.id)) return false;
if (value.kind !== "humanoid" && value.kind !== "dog" && value.kind !== "crow") return false;
if (typeof value.signedIn !== "boolean" || !isRecord(value.profile)) return false;
if (!isNonEmptyString(value.profile.displayName)) return false;
if (value.profile.face !== undefined && !isNonEmptyString(value.profile.face)) return false;
if (value.profile.color !== undefined && !isNonEmptyString(value.profile.color)) return false;
return true;
}
function isOfficeId(value: unknown): value is JourneyOfficeId {
return value === "lumbridge-hq" || value === "frontier-valley" || value === "mateo-court";
}
function isLocation(value: unknown): value is JourneyLocation {
if (!isRecord(value)) return false;
if (value.scale === "california" || value.scale === "bay-area" || value.scale === "socal") {
return true;
}
return (
value.scale === "office" &&
isOfficeId(value.officeId) &&
(value.priorCity === "bay-area" || value.priorCity === "socal") &&
OFFICES[value.officeId] === value.priorCity
);
}
/** Runtime validation at persistence and multiplayer trust boundaries. */
export function isJourneyState(value: unknown): value is JourneyState {
if (!isRecord(value) || !isActor(value.actor) || !isLocation(value.location)) return false;
if (value.mode !== "observe" && value.mode !== "play") return false;
if (value.vehicle !== null) {
if (!isRecord(value.vehicle) || !isNonEmptyString(value.vehicle.vehicleId)) return false;
if (value.mode !== "play" || value.location.scale === "office") return false;
}
if (value.route !== null) {
if (!isRecord(value.route) || !ROUTES.has(String(value.route.routeId))) return false;
if (value.route.direction !== 1 && value.route.direction !== -1) return false;
if (!finiteUnit(value.route.progress)) return false;
}
return true;
}
export function createJourney(options: CreateJourneyOptions): JourneyState {
const state: JourneyState = {
actor: cloneActor(options.actor),
location: options.location ? { ...options.location } : { scale: "california" },
mode: options.mode ?? "observe",
vehicle: null,
route: null,
};
if (!isJourneyState(state)) throw new Error("journey: invalid initial actor or location");
return state;
}
function cloneActor(actor: JourneyActor): JourneyActor {
return { ...actor, profile: { ...actor.profile } };
}
function reject(state: JourneyState, reason: JourneyTransitionRejection): JourneyTransition {
return { accepted: false, state, reason };
}
function accept(state: JourneyState): JourneyTransition {
return { accepted: true, state };
}
function routeStartFor(location: JourneyLocation, direction: 1 | -1): number | null {
if (location.scale === "socal") return direction === 1 ? 0 : null;
if (location.scale === "bay-area") return direction === -1 ? 1 : null;
if (location.scale === "california") return direction === 1 ? 0 : 1;
return null;
}
/**
* Explainable pure transition. Rejections retain the exact input state object,
* making malformed or out-of-order multiplayer events safe to ignore.
*/
export function transitionJourney(state: JourneyState, event: JourneyEvent): JourneyTransition {
if (!isJourneyState(state)) return reject(state, "invalid-state");
if (!isRecord(event) || !isNonEmptyString(event.type)) return reject(state, "invalid-event");
switch (event.type) {
case "set-mode": {
if (event.mode !== "observe" && event.mode !== "play") return reject(state, "invalid-event");
if (event.mode === "observe" && state.vehicle) return reject(state, "exit-vehicle-first");
if (event.mode === state.mode) return accept(state);
return accept({ ...state, mode: event.mode });
}
case "navigate-to-city": {
if (event.city !== "bay-area" && event.city !== "socal") {
return reject(state, "invalid-event");
}
if (state.location.scale === "office") return reject(state, "leave-office-first");
if (state.location.scale !== "california") {
return reject(state, state.location.scale === event.city ? "already-there" : "wrong-scale");
}
if (state.vehicle) return reject(state, "exit-vehicle-first");
return accept({ ...state, location: { scale: event.city } });
}
case "return-to-california": {
if (state.location.scale === "office") return reject(state, "leave-office-first");
if (state.location.scale === "california") return reject(state, "already-there");
if (state.vehicle) return reject(state, "exit-vehicle-first");
return accept({ ...state, location: { scale: "california" } });
}
case "select-route": {
if (!ROUTES.has(event.routeId) || (event.direction !== 1 && event.direction !== -1)) {
return reject(state, "invalid-event");
}
const progress = routeStartFor(state.location, event.direction);
if (progress === null) {
return reject(state, state.location.scale === "office" ? "wrong-scale" : "wrong-city");
}
return accept({
...state,
location: { scale: "california" },
route: { routeId: event.routeId, direction: event.direction, progress },
});
}
case "update-route-progress": {
if (state.location.scale !== "california") return reject(state, "wrong-scale");
if (!state.route) return reject(state, "route-required");
if (!state.vehicle) return reject(state, "vehicle-required");
if (!finiteUnit(event.progress)) return reject(state, "invalid-event");
return accept({ ...state, route: { ...state.route, progress: event.progress } });
}
case "reach-route-endpoint": {
if (state.location.scale !== "california") return reject(state, "wrong-scale");
if (!state.route) return reject(state, "route-required");
const expected: JourneyRouteEndpoint =
state.route.direction === 1 ? "san-francisco" : "los-angeles";
if (event.endpoint !== expected) return reject(state, "wrong-endpoint");
const progress = event.endpoint === "san-francisco" ? 1 : 0;
return accept({
...state,
location: { scale: ENDPOINT_CITY[event.endpoint] },
route: { ...state.route, progress },
});
}
case "enter-vehicle": {
if (state.mode !== "play") return reject(state, "observe-only");
if (state.location.scale === "office") return reject(state, "wrong-scale");
if (state.vehicle) return reject(state, "vehicle-occupied");
if (!isNonEmptyString(event.vehicleId)) return reject(state, "invalid-event");
if (state.location.scale === "california" && !state.route) {
return reject(state, "route-required");
}
return accept({ ...state, vehicle: { vehicleId: event.vehicleId } });
}
case "exit-vehicle": {
if (!state.vehicle) return reject(state, "vehicle-required");
return accept({ ...state, vehicle: null });
}
case "enter-office": {
if (!isOfficeId(event.officeId)) return reject(state, "invalid-event");
if (state.location.scale !== "bay-area" && state.location.scale !== "socal") {
return reject(state, "wrong-scale");
}
if (state.vehicle) return reject(state, "exit-vehicle-first");
if (OFFICES[event.officeId] !== state.location.scale) return reject(state, "wrong-city");
return accept({
...state,
location: {
scale: "office",
officeId: event.officeId,
priorCity: state.location.scale,
},
});
}
case "leave-office": {
if (state.location.scale !== "office") return reject(state, "already-outside");
return accept({ ...state, location: { scale: state.location.priorCity } });
}
case "sign-in-actor-swap": {
if (!isActor(event.actor) || !event.actor.signedIn) return reject(state, "invalid-event");
return accept({ ...state, actor: cloneActor(event.actor) });
}
default:
return reject(state, "invalid-event");
}
}
/** Conventional reducer form for stores: rejected events are safe no-ops. */
export function journeyReducer(state: JourneyState, event: JourneyEvent): JourneyState {
return transitionJourney(state, event).state;
}
/** Encode an owned deep copy so callers cannot mutate the persisted snapshot. */
export function encodeJourneySnapshot(state: JourneyState): string {
if (!isJourneyState(state)) throw new Error("journey: cannot encode invalid state");
const snapshot: JourneySnapshotV1 = { version: 1, state };
return JSON.stringify(snapshot);
}
/** Decode JSON or an already-parsed network payload through strict V1 validation. */
export function decodeJourneySnapshot(payload: string | unknown): JourneySnapshotDecodeResult {
let parsed: unknown = payload;
if (typeof payload === "string") {
try {
parsed = JSON.parse(payload) as unknown;
} catch {
return { ok: false, error: "journey: snapshot is not valid JSON" };
}
}
if (!isRecord(parsed) || parsed.version !== 1) {
return { ok: false, error: "journey: unsupported snapshot version" };
}
if (!isJourneyState(parsed.state)) {
return { ok: false, error: "journey: invalid snapshot state" };
}
// JSON round-tripping is intentional: it strips aliases across a reconnect boundary.
const state = JSON.parse(JSON.stringify(parsed.state)) as JourneyState;
const snapshot: JourneySnapshotV1 = { version: 1, state };
return { ok: true, snapshot, state };
}
export function officeCity(officeId: JourneyOfficeId): JourneyCity {
return OFFICES[officeId];
}
export function endpointCity(endpoint: JourneyRouteEndpoint): JourneyCity {
return ENDPOINT_CITY[endpoint];
}
+113
View File
@@ -0,0 +1,113 @@
/** Serializable contracts for moving one actor between Tera's world scales. */
export type JourneyScale = "california" | "bay-area" | "socal" | "office";
export type JourneyCity = "bay-area" | "socal";
export type JourneyMode = "observe" | "play";
export type JourneyActorKind = "humanoid" | "dog" | "crow";
export type JourneyRouteId = "la-sf-us-101" | "la-sf-i-5";
export type JourneyRouteEndpoint = "los-angeles" | "san-francisco";
export interface JourneyActorProfile {
displayName: string;
/** Optional URL or application-owned asset key; never interpreted by the reducer. */
face?: string;
color?: string;
}
export interface JourneyActor {
id: string;
kind: JourneyActorKind;
signedIn: boolean;
profile: JourneyActorProfile;
}
export type JourneyLocation =
| { scale: "california" }
| { scale: "bay-area" }
| { scale: "socal" }
| {
scale: "office";
officeId: JourneyOfficeId;
/** The detailed board to restore when the actor leaves through the door. */
priorCity: JourneyCity;
};
export interface JourneyRouteProgress {
routeId: JourneyRouteId;
/** 1 follows the authored LA-to-SF route; -1 travels back toward LA. */
direction: 1 | -1;
/** Normalized distance measured from Los Angeles, regardless of direction. */
progress: number;
}
export interface JourneyVehiclePossession {
vehicleId: string;
}
export interface JourneyState {
actor: JourneyActor;
location: JourneyLocation;
mode: JourneyMode;
vehicle: JourneyVehiclePossession | null;
route: JourneyRouteProgress | null;
}
export type JourneyOfficeId = "lumbridge-hq" | "frontier-valley" | "mateo-court";
export type JourneyEvent =
| { type: "set-mode"; mode: JourneyMode }
| { type: "navigate-to-city"; city: JourneyCity }
| { type: "return-to-california" }
| { type: "select-route"; routeId: JourneyRouteId; direction: 1 | -1 }
| { type: "update-route-progress"; progress: number }
| { type: "reach-route-endpoint"; endpoint: JourneyRouteEndpoint }
| { type: "enter-vehicle"; vehicleId: string }
| { type: "exit-vehicle" }
| { type: "enter-office"; officeId: JourneyOfficeId }
| { type: "leave-office" }
| { type: "sign-in-actor-swap"; actor: JourneyActor };
export type JourneyTransitionRejection =
| "invalid-state"
| "invalid-event"
| "wrong-scale"
| "wrong-city"
| "wrong-endpoint"
| "observe-only"
| "route-required"
| "vehicle-required"
| "vehicle-occupied"
| "exit-vehicle-first"
| "leave-office-first"
| "already-there"
| "already-outside";
export type JourneyTransition =
| { accepted: true; state: JourneyState }
| { accepted: false; state: JourneyState; reason: JourneyTransitionRejection };
export interface JourneySnapshotV1 {
version: 1;
state: JourneyState;
}
export type JourneySnapshotDecodeResult =
| { ok: true; snapshot: JourneySnapshotV1; state: JourneyState }
| { ok: false; error: string };
/** Smallest common contract implemented by localStorage, sessionStorage, and server adapters. */
export interface JourneyStorageAdapter {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export type JourneySessionSaveResult = { ok: true } | { ok: false; error: string };
export type JourneySessionLoadResult =
| { status: "loaded"; state: JourneyState }
| { status: "missing" }
| { status: "invalid"; error: string }
| { status: "unavailable"; error: string };
export type JourneySessionClearResult = { ok: true } | { ok: false; error: string };
+237 -5
View File
@@ -60,6 +60,15 @@ import { OFFICE_SITES } from "./offices/sites.ts";
import { authFetch } from "./session.ts";
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
import { createMinimap, type Minimap } from "./engine/minimap.ts";
import {
createJourney,
journeyReducer,
loadJourneySession,
saveJourneySession,
type JourneyCity,
type JourneyEvent,
type JourneyState,
} from "./journey/index.ts";
/**
* Three type-only imports and not one value among them, which is what keeps the
* office and the instruments out of the entry chunk.
@@ -91,6 +100,39 @@ const CITIES: { id: string; label: string; city: City }[] = [
{ id: "socal", label: "SoCal", city: SOCAL },
];
/** The corridor's scale doors, and the office each detailed board arrives near. */
const CALIFORNIA_DESTINATIONS = new Map<string, { cityId: string; officeId: string }>([
["los-angeles", { cityId: "socal", officeId: "mateo-court" }],
["san-francisco", { cityId: "sf", officeId: "lumbridge-hq" }],
]);
const JOURNEY_SESSION_KEY = "tera:journey:v1";
const restoredJourney = loadJourneySession(sessionStorage, JOURNEY_SESSION_KEY);
let journey: JourneyState = restoredJourney.status === "loaded"
? restoredJourney.state
: createJourney({
actor: {
id: "anonymous",
kind: "crow",
signedIn: false,
profile: { displayName: "Guest" },
},
});
function dispatchJourney(event: JourneyEvent): boolean {
const next = journeyReducer(journey, event);
if (next === journey) return false;
journey = next;
saveJourneySession(sessionStorage, JOURNEY_SESSION_KEY, journey);
return true;
}
function journeyToCity(city: JourneyCity): void {
if (journey.location.scale === "office") dispatchJourney({ type: "leave-office" });
if (journey.location.scale !== "california") dispatchJourney({ type: "return-to-california" });
dispatchJourney({ type: "navigate-to-city", city });
}
/**
* The buildings this page can walk into.
*
@@ -758,6 +800,29 @@ async function mountCity(id: string) {
const handle = await createScene(stage, {
city: entry.city,
actor: {
kind: access.subject === null ? "crow" : "humanoid",
identity: {
id: access.subject ?? "anonymous",
displayName: access.subject ?? "Guest",
authenticated: access.subject !== null,
profile: {
appearance: access.subject === null
? { primaryColor: "#11151a", accentColor: "#f2b134" }
: { primaryColor: "#151a20", accentColor: "#f2b134" },
},
},
mode: access.subject === null ? "flight" : "ground",
position: access.subject === null ? { y: 500 } : { y: 0 },
minFlightAltitude: 20,
maxFlightAltitude: 1_500,
...(access.subject === null
? { camera: { distance: 1.55, height: 1.35, targetHeight: 0.18, lookAhead: 0 } }
: {}),
},
...(id === "california"
? { actorAnchor: { lat: 35.5, lng: -119.5 } }
: {}),
markerPalette: palette,
markers: initialMarkers,
...(id === "california"
@@ -1030,6 +1095,23 @@ async function enterOffice() {
office = createOfficeScene(pack, {
dom: city.stage.renderer.domElement,
// A visitor owns one local actor. Anonymous visitors are the promised
// office dog; a signed-in visitor gets the procedural humanoid. The
// arrival viewpoint is already a pack-authored clear point on a floor,
// which makes it the honest spawn and keeps coordinates out of the app.
walker: {
levelId: pack.viewpoints[0]?.levelId ?? pack.levels[0]?.id ?? "level-1",
position: pack.viewpoints[0]?.focus.at ?? { x: 0, z: 0 },
facing: pack.viewpoints[0]
? {
x: -Math.sin(pack.viewpoints[0].focus.rotation),
z: -Math.cos(pack.viewpoints[0].focus.rotation),
}
: { x: 0, z: -1 },
actor: access.subject === null
? { kind: "anonymous-dog", coatColor: 0x17191c, collarColor: 0xf2b134 }
: { kind: "humanoid", outfitColor: 0x151a20, accentColor: 0xf2b134 },
},
// Only when there is no sky to put behind it. A sited office computes a
// gradient and a horizon; painting the old flat colour over that is the
// bug that looks exactly like the sky not working.
@@ -1061,6 +1143,9 @@ async function enterOffice() {
}
city.stage.setScene(office);
inside = true;
const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area";
journeyToCity(desiredCity);
dispatchJourney({ type: "enter-office", officeId: officeId as "lumbridge-hq" | "frontier-valley" | "mateo-court" });
showPlan();
showDetail(null);
refreshGodmodePlace();
@@ -1177,6 +1262,9 @@ function leaveOffice() {
// Before the scene swap, so the last thing the watch can do is abort a request
// rather than publish into a room the user has already left.
stopWatchingOccupancy();
office?.walker?.setActive(false);
office?.walker?.setAction({ x: 0, z: 0 });
dispatchJourney({ type: "leave-office" });
city.stage.setScene(city.stageScene);
inside = false;
showPlan();
@@ -1338,6 +1426,9 @@ const planToggle = document.querySelector<HTMLButtonElement>("#plan-toggle");
const credits = document.querySelector<HTMLElement>("#credits");
const driveControls = document.querySelector<HTMLElement>("#drive-controls");
const driveHint = document.querySelector<HTMLElement>("#drive-hint");
const walkButton = document.querySelector<HTMLButtonElement>("#walk");
const walkControls = document.querySelector<HTMLElement>("#walk-controls");
const walkHint = document.querySelector<HTMLElement>("#walk-hint");
function showDetail(text: string | null) {
const card = document.querySelector<HTMLElement>("#detail");
@@ -1467,14 +1558,31 @@ function renderLegend() {
// what is behind it, and that is the badge's job to say, not the button's.
enterButton.textContent = inside ? "← Back to the city" : "Enter the office →";
}
const walking = inside && (office?.walker?.active() ?? false);
const exploring = !inside && (city.actorActive() ?? false);
if (walkButton) {
walkButton.hidden = inside ? office?.walker === null : city.actorState() === null;
walkButton.setAttribute("aria-pressed", String(walking || exploring));
if (inside && office?.walker) {
const actor = office.walker.state().actor === "anonymous-dog" ? "your dog" : "your humanoid";
walkButton.textContent = walking ? "Return to overview ↑" : `Walk as ${actor}`;
} else if (city.actorState()) {
const actor = city.actorState()?.kind === "crow" ? "your crow" : "your humanoid";
walkButton.textContent = exploring ? "Return to flyover ↑" : `Explore as ${actor}`;
}
}
renderSource();
if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
if (canvas) {
canvas.setAttribute(
"aria-label",
inside
? `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
? walking
? `${officeName()}, following your ${office?.walker?.state().actor === "anonymous-dog" ? "dog" : "humanoid"}. Use W A S D to move.`
: `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
: exploring
? `${cityLabel}, following your ${city.actorState()?.kind ?? "actor"}. Use W A S D to move.`
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
);
}
// Each plan rings the entry its own legend is showing as current. Both are
@@ -1484,7 +1592,9 @@ function renderLegend() {
officePlan?.setActiveView(office?.current() ?? null);
renderOfficeBadge();
if (driveControls) driveControls.hidden = !routeDriveIsActive();
if (walkControls) walkControls.hidden = !(walking || exploring);
if (driveHint) driveHint.hidden = inside || cityId !== "california";
if (walkHint) walkHint.hidden = !(inside || city.actorState());
}
/**
@@ -1679,7 +1789,21 @@ function flyToIndex(index: number) {
const view = currentViews()[index];
if (!view) return;
if (inside && office) office.flyTo(view.id);
else city?.flyTo(view.id);
else {
const destination = cityId === "california" ? CALIFORNIA_DESTINATIONS.get(view.id) : undefined;
if (destination) {
officeId = destination.officeId;
journeyToCity(destination.cityId === "socal" ? "socal" : "bay-area");
switchCity(destination.cityId);
return;
}
if (view.id === "la-sf-us-101" || view.id === "la-sf-i-5") {
dispatchJourney({ type: "set-mode", mode: "play" });
dispatchJourney({ type: "select-route", routeId: view.id, direction: 1 });
dispatchJourney({ type: "enter-vehicle", vehicleId: "model-x-black" });
}
city?.flyTo(view.id);
}
}
/**
@@ -1713,6 +1837,18 @@ function switchCity(id: string) {
if (inside && leaveToCity(id)) return;
if (inside) leaveOffice();
if (id === wantedCity) return;
// Arriving at a detailed board should put its local front door under the
// existing Office button. The California overview keeps whichever building
// the traveller last visited; it is a scale, not a fourth office location.
if (id === "socal") officeId = "mateo-court";
else if (id === "sf" && officeId === "mateo-court") officeId = "lumbridge-hq";
if (id === "california") {
if (journey.vehicle) dispatchJourney({ type: "exit-vehicle" });
if (journey.location.scale === "office") dispatchJourney({ type: "leave-office" });
if (journey.location.scale !== "california") dispatchJourney({ type: "return-to-california" });
} else {
journeyToCity(id === "socal" ? "socal" : "bay-area");
}
wantedCity = id;
const label = CITIES.find((c) => c.id === id)?.label ?? id;
void building(`Building ${label}`, () => mountCity(id));
@@ -1774,6 +1910,27 @@ async function toggleOffice() {
enterButton?.addEventListener("click", () => void toggleOffice());
/** Switch between the authored dollhouse camera and the local possessed actor. */
function toggleOfficeWalk(): boolean {
if (!inside) {
if (!city?.actorState()) return false;
const active = !city.actorActive();
city.setActorActive(active);
if (!active) city.setActorActions({});
renderLegend();
return true;
}
const walker = office?.walker;
if (!walker) return false;
const active = !walker.active();
walker.setActive(active);
if (!active) walker.setAction({ x: 0, z: 0 });
renderLegend();
return true;
}
walkButton?.addEventListener("click", () => toggleOfficeWalk());
/**
* Clicking a building on the city walks into it.
*
@@ -1894,7 +2051,7 @@ const heldDriveKeys = new Set<string>();
function routeDriveIsActive(): boolean {
const state = !inside ? city?.vehicleState() : null;
return state !== null && state !== undefined && city?.current() === state.routeId;
return state !== null && state !== undefined && !city?.actorActive() && city?.current() === state.routeId;
}
function publishVehicleActions(
@@ -1917,6 +2074,28 @@ function publishVehicleActions(
return true;
}
function publishOfficeWalkActions(): boolean {
const walker = inside ? office?.walker : null;
if (!walker?.active()) return false;
walker.setAction({
x: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
z: (heldDriveKeys.has("s") ? 1 : 0) - (heldDriveKeys.has("w") ? 1 : 0),
});
return true;
}
function publishCityActorActions(): boolean {
if (inside || !city?.actorActive()) return false;
city.setActorActions({
forward: (heldDriveKeys.has("w") ? 1 : 0) - (heldDriveKeys.has("s") ? 1 : 0),
right: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
turn: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
sprint: heldDriveKeys.has(" "),
climb: heldDriveKeys.has(" ") ? 1 : 0,
});
return true;
}
function toggleVehicleCamera(): boolean {
if (!routeDriveIsActive() || !city) return false;
city.setVehicleCamera(city.vehicleCamera() === "driver" ? "chase" : "driver");
@@ -1944,6 +2123,29 @@ for (const button of driveControls?.querySelectorAll<HTMLButtonElement>("[data-d
button.addEventListener("lostpointercapture", release);
}
for (const button of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
const key = button.dataset.walkKey;
if (key === undefined) continue;
const release = (event: PointerEvent) => {
heldDriveKeys.delete(key);
button.setAttribute("aria-pressed", "false");
publishOfficeWalkActions();
publishCityActorActions();
event.preventDefault();
};
button.addEventListener("pointerdown", (event) => {
button.setPointerCapture(event.pointerId);
heldDriveKeys.add(key);
button.setAttribute("aria-pressed", "true");
publishOfficeWalkActions();
publishCityActorActions();
event.preventDefault();
});
button.addEventListener("pointerup", release);
button.addEventListener("pointercancel", release);
button.addEventListener("lostpointercapture", release);
}
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='assist']")
?.addEventListener("click", () => publishVehicleActions({ modeRequest: "assisted" }));
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
@@ -1954,12 +2156,14 @@ driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
window.addEventListener("keyup", (event) => {
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (!heldDriveKeys.delete(key)) return;
if (publishVehicleActions()) event.preventDefault();
if (publishVehicleActions() || publishOfficeWalkActions() || publishCityActorActions()) event.preventDefault();
});
window.addEventListener("blur", () => {
heldDriveKeys.clear();
publishVehicleActions();
publishOfficeWalkActions();
publishCityActorActions();
});
let gamepadButtons: GamepadButtonState = { assist: false, reset: false };
@@ -2034,6 +2238,14 @@ window.addEventListener("keydown", (event) => {
event.preventDefault();
return;
}
if (publishOfficeWalkActions()) {
event.preventDefault();
return;
}
if (publishCityActorActions()) {
event.preventDefault();
return;
}
}
if (lower === "p" && publishVehicleActions({ modeRequest: "assisted" })) {
event.preventDefault();
@@ -2047,6 +2259,10 @@ window.addEventListener("keydown", (event) => {
event.preventDefault();
return;
}
if (lower === "v" && toggleOfficeWalk()) {
event.preventDefault();
return;
}
if (lower === "m") {
togglePlan();
return;
@@ -2482,6 +2698,22 @@ async function boot() {
if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
access = await resolveAccess();
dispatchJourney({
type: "sign-in-actor-swap",
actor: access.subject === null
? {
id: "anonymous",
kind: "crow",
signedIn: false,
profile: { displayName: "Guest" },
}
: {
id: access.subject,
kind: "humanoid",
signedIn: true,
profile: { displayName: access.subject },
},
});
applyTimeControl();
renderTierBadge();
+187
View File
@@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
ActorController,
normalizeActorActions,
replayActorInputs,
type ActorControllerOptions,
type ActorIdentity,
} from "../actors/index.ts";
const IDENTITY: ActorIdentity = {
id: "person-42",
displayName: "River",
authenticated: true,
profile: {
handle: "river",
pronouns: "they/them",
faceImageUrl: "/profiles/42/face",
appearance: { skinTone: "#8f6048", primaryColor: "#17324d", accentColor: "#64b5c8" },
},
};
function groundOptions(over: Partial<ActorControllerOptions> = {}): ActorControllerOptions {
return {
kind: "humanoid",
identity: IDENTITY,
position: { x: 2, y: 99, z: 3 },
fixedStepSeconds: 0.1,
...over,
};
}
describe("playable actor input and ground mode", () => {
it("normalizes every adapter axis and the ground movement disc", () => {
const action = normalizeActorActions({
forward: 3,
right: 4,
turn: -9,
pitch: Number.NaN,
climb: Infinity,
sprint: true,
glide: true,
kindRequest: "crow",
modeRequest: "flight",
});
assert.ok(Math.abs(Math.hypot(action.forward, action.right) - 1) < 1e-12);
assert.equal(action.turn, -1);
assert.equal(action.pitch, 0);
assert.equal(action.climb, 0);
assert.equal(action.kindRequest, "crow");
assert.equal(action.modeRequest, "flight");
assert.equal(action.sprint, true);
assert.deepEqual(normalizeActorActions(undefined), {
forward: 0,
right: 0,
turn: 0,
pitch: 0,
climb: 0,
sprint: false,
glide: false,
modeRequest: "none",
kindRequest: "none",
reset: false,
});
});
it("moves and turns in deterministic fixed steps while requesting a gait phase", () => {
const one = new ActorController(groundOptions());
const many = new ActorController(groundOptions());
one.tick(0.05, { forward: 1 });
assert.equal(one.state().z, 3);
one.tick(0.15, { forward: 1, turn: 0.25 });
one.tick(0.2, { forward: 1, turn: 0.25 });
for (let index = 0; index < 4; index++) many.tick(0.1, { forward: 1, turn: 0.25 });
assert.deepEqual(one.snapshot(), many.snapshot());
assert.ok(one.state().z < 3);
assert.ok(one.state().yaw > 0);
assert.ok(one.state().posePhase > 0 && one.state().posePhase < Math.PI * 2);
assert.equal(one.state().poseAmount, 1);
assert.equal(one.state().y, 0, "ground datum wins over a stale spawn y");
});
it("bounds a ground actor and applies dog gait speed without producing non-finite state", () => {
const controller = new ActorController(groundOptions({
kind: "dog",
position: { x: 0.95, z: 0.95 },
horizontalBounds: { minX: -1, maxX: 1, minZ: -1, maxZ: 1 },
}));
for (let index = 0; index < 20; index++) controller.stepFixed({ right: 1, forward: -1, sprint: true });
assert.equal(controller.state().x, 1);
assert.equal(controller.state().z, 1);
assert.ok(Number.isFinite(controller.state().distanceM));
assert.equal(controller.state().kind, "dog");
});
});
describe("crow flight", () => {
it("turns, pitches, climbs and glides inside strict altitude bounds", () => {
const controller = new ActorController(groundOptions({
kind: "crow",
mode: "flight",
position: { x: 0, y: 1, z: 0 },
minFlightAltitude: 1,
maxFlightAltitude: 3,
}));
for (let index = 0; index < 30; index++) {
controller.stepFixed({ forward: 1, turn: 0.5, pitch: 1, climb: 1 });
}
assert.equal(controller.state().y, 3);
assert.equal(controller.state().altitudeBoundContact, "maximum");
assert.ok(controller.state().yaw !== 0);
assert.ok(controller.state().pitch > 0 && controller.state().pitch < Math.PI / 2);
const phase = controller.state().posePhase;
controller.stepFixed({ glide: true, climb: -1, pitch: -1 });
assert.equal(controller.state().gliding, true);
assert.equal(controller.state().posePhase, phase, "gliding holds the wing phase");
for (let index = 0; index < 80; index++) controller.stepFixed({ glide: true, climb: -1, pitch: -1 });
assert.equal(controller.state().y, 1);
assert.equal(controller.state().altitudeBoundContact, "minimum");
});
it("allows only a crow to enter flight and lands exactly on the ground datum", () => {
const controller = new ActorController(groundOptions({ groundY: 12 }));
controller.stepFixed({ modeRequest: "flight" });
assert.equal(controller.state().mode, "ground");
controller.stepFixed({ kindRequest: "crow", modeRequest: "flight" });
assert.equal(controller.state().mode, "flight");
assert.ok(controller.state().y >= 12.75);
controller.stepFixed({ modeRequest: "ground" });
assert.equal(controller.state().mode, "ground");
assert.equal(controller.state().y, 12);
});
});
describe("actor identity, reset and replay", () => {
it("switches actor kind without losing a detached serializable identity", () => {
const controller = new ActorController(groundOptions());
const identityBefore = JSON.stringify(controller.snapshot().identity);
controller.setActorKind("dog");
controller.setActorKind("crow");
assert.equal(JSON.stringify(controller.snapshot().identity), identityBefore);
const detached = controller.snapshot();
detached.identity.profile.handle = "tampered";
assert.equal(controller.state().identity.profile.handle, "river");
assert.doesNotThrow(() => JSON.stringify(controller.snapshot()));
});
it("resets exactly and caps a resumed background tab", () => {
const controller = new ActorController(groundOptions());
const spawn = controller.snapshot();
controller.stepFixed({ kindRequest: "crow", modeRequest: "flight", climb: 1 });
controller.setIdentity({ id: "anon-9", displayName: "Guest", authenticated: false, profile: {} });
controller.tick(Number.NaN, { forward: 1 });
controller.tick(-4, { forward: 1 });
const steps = controller.tick(600, { forward: 1 });
assert.ok(steps <= 3);
controller.stepFixed({ reset: true });
assert.deepEqual(controller.snapshot(), spawn);
});
it("replays timed cross-kind input bit-for-bit", () => {
const frames = [
{ steps: 12, actions: { forward: 1, turn: 0.2 } },
{ steps: 1, actions: { kindRequest: "crow" as const, modeRequest: "flight" as const } },
{ steps: 30, actions: { forward: 0.7, turn: -0.4, climb: 0.5 } },
{ steps: 15, actions: { glide: true, pitch: -0.2 } },
{ steps: 1, actions: { kindRequest: "dog" as const } },
{ steps: 8, actions: { right: 1, sprint: true } },
];
const first = replayActorInputs(groundOptions(), frames);
const second = replayActorInputs(groundOptions(), frames);
assert.deepEqual(first, second);
assert.equal(first.trajectory.length, 68);
assert.equal(first.final.elapsedSteps, 67);
assert.equal(first.final.kind, "dog");
assert.equal(first.final.mode, "ground");
});
it("rejects invalid configuration and identity before creating state", () => {
assert.throws(() => new ActorController(groundOptions({ minFlightAltitude: 5, maxFlightAltitude: 2 })), RangeError);
assert.throws(() => new ActorController(groundOptions({ walkSpeedMps: 0 })), RangeError);
assert.throws(() => new ActorController(groundOptions({
horizontalBounds: { minX: 2, maxX: 1, minZ: 0, maxZ: 1 },
})), RangeError);
assert.throws(() => new ActorController(groundOptions({ identity: { ...IDENTITY, id: "" } })), RangeError);
});
});
+297
View File
@@ -0,0 +1,297 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
createJourney,
decodeJourneySnapshot,
encodeJourneySnapshot,
endpointCity,
clearJourneySession,
journeyReducer,
loadJourneySession,
officeCity,
saveJourneySession,
transitionJourney,
type JourneyActor,
type JourneyEvent,
type JourneyState,
type JourneyStorageAdapter,
} from "../journey/index.ts";
const CROW: JourneyActor = {
id: "anon-115",
kind: "crow",
signedIn: false,
profile: { displayName: "Visitor", color: "midnight" },
};
const KARTI: JourneyActor = {
id: "user-karti",
kind: "humanoid",
signedIn: true,
profile: { displayName: "Karti", face: "profile:user-karti", color: "black" },
};
function playFromLosAngeles(): JourneyState {
let state = createJourney({ actor: CROW, location: { scale: "socal" }, mode: "play" });
state = journeyReducer(state, {
type: "select-route",
routeId: "la-sf-i-5",
direction: 1,
});
state = journeyReducer(state, { type: "enter-vehicle", vehicleId: "model-x-hero" });
return state;
}
describe("journey state machine", () => {
it("navigates explicitly between California and either detailed city board", () => {
const california = createJourney({ actor: CROW, mode: "play" });
const actor = california.actor;
const bayArea = journeyReducer(california, {
type: "navigate-to-city",
city: "bay-area",
});
assert.deepEqual(bayArea.location, { scale: "bay-area" });
assert.equal(bayArea.actor, actor);
const returned = journeyReducer(bayArea, { type: "return-to-california" });
assert.deepEqual(returned.location, { scale: "california" });
assert.equal(returned.actor, actor);
const southern = journeyReducer(returned, { type: "navigate-to-city", city: "socal" });
assert.deepEqual(southern.location, { scale: "socal" });
});
it("requires leaving an office or vehicle before board navigation", () => {
let driving = playFromLosAngeles();
assert.deepEqual(
transitionJourney(driving, { type: "navigate-to-city", city: "bay-area" }),
{ accepted: false, state: driving, reason: "exit-vehicle-first" },
);
driving = journeyReducer(driving, { type: "exit-vehicle" });
const bayArea = journeyReducer(driving, { type: "navigate-to-city", city: "bay-area" });
let office = journeyReducer(bayArea, {
type: "enter-office",
officeId: "lumbridge-hq",
});
assert.deepEqual(
transitionJourney(office, { type: "return-to-california" }),
{ accepted: false, state: office, reason: "leave-office-first" },
);
office = journeyReducer(office, { type: "leave-office" });
assert.equal(journeyReducer(office, { type: "return-to-california" }).location.scale, "california");
});
it("maps route endpoints honestly onto the detailed city boards", () => {
assert.equal(endpointCity("los-angeles"), "socal");
assert.equal(endpointCity("san-francisco"), "bay-area");
let northbound = playFromLosAngeles();
northbound = journeyReducer(northbound, { type: "update-route-progress", progress: 0.74 });
northbound = journeyReducer(northbound, {
type: "reach-route-endpoint",
endpoint: "san-francisco",
});
assert.equal(northbound.location.scale, "bay-area");
assert.equal(northbound.route?.progress, 1);
assert.equal(northbound.vehicle?.vehicleId, "model-x-hero");
let southbound = journeyReducer(northbound, { type: "exit-vehicle" });
southbound = journeyReducer(southbound, {
type: "select-route",
routeId: "la-sf-us-101",
direction: -1,
});
southbound = journeyReducer(southbound, { type: "enter-vehicle", vehicleId: "model-x-hero" });
southbound = journeyReducer(southbound, {
type: "reach-route-endpoint",
endpoint: "los-angeles",
});
assert.equal(southbound.location.scale, "socal");
assert.equal(southbound.route?.progress, 0);
});
it("preserves identity and prior city across office doors", () => {
let state = createJourney({ actor: KARTI, location: { scale: "bay-area" }, mode: "play" });
const actor = state.actor;
state = journeyReducer(state, { type: "enter-office", officeId: "frontier-valley" });
assert.deepEqual(state.location, {
scale: "office",
officeId: "frontier-valley",
priorCity: "bay-area",
});
assert.equal(state.actor, actor);
state = journeyReducer(state, { type: "leave-office" });
assert.deepEqual(state.location, { scale: "bay-area" });
assert.equal(state.actor, actor);
assert.equal(officeCity("mateo-court"), "socal");
});
it("swaps a signed-in actor without disturbing their journey", () => {
const before = playFromLosAngeles();
const after = journeyReducer(before, { type: "sign-in-actor-swap", actor: KARTI });
assert.deepEqual(after.actor, KARTI);
assert.notEqual(after.actor, KARTI, "the reducer owns a defensive actor copy");
assert.deepEqual(after.location, before.location);
assert.deepEqual(after.vehicle, before.vehicle);
assert.deepEqual(after.route, before.route);
});
it("rejects invalid and out-of-order transitions as exact no-ops", () => {
const state = createJourney({ actor: CROW });
const badEvents: JourneyEvent[] = [
{ type: "enter-vehicle", vehicleId: "model-x-hero" },
{ type: "enter-office", officeId: "lumbridge-hq" },
{ type: "leave-office" },
{ type: "update-route-progress", progress: 0.5 },
{ type: "reach-route-endpoint", endpoint: "san-francisco" },
{ type: "sign-in-actor-swap", actor: CROW },
];
for (const event of badEvents) {
const result = transitionJourney(state, event);
assert.equal(result.accepted, false);
assert.equal(result.state, state);
assert.equal(journeyReducer(state, event), state);
}
});
it("requires the endpoint that agrees with the selected direction", () => {
const state = playFromLosAngeles();
const rejected = transitionJourney(state, {
type: "reach-route-endpoint",
endpoint: "los-angeles",
});
assert.deepEqual(rejected, { accepted: false, state, reason: "wrong-endpoint" });
});
it("requires play mode, a route, and exiting the car before an office", () => {
const observer = createJourney({ actor: CROW, location: { scale: "socal" } });
assert.equal(
transitionJourney(observer, { type: "enter-vehicle", vehicleId: "x" }).accepted,
false,
);
let driving = playFromLosAngeles();
driving = journeyReducer(driving, {
type: "reach-route-endpoint",
endpoint: "san-francisco",
});
const result = transitionJourney(driving, {
type: "enter-office",
officeId: "lumbridge-hq",
});
assert.deepEqual(result, { accepted: false, state: driving, reason: "exit-vehicle-first" });
});
it("encodes and decodes an isolated versioned reconnect snapshot", () => {
const state = playFromLosAngeles();
const encoded = encodeJourneySnapshot(state);
const decoded = decodeJourneySnapshot(encoded);
assert.equal(decoded.ok, true);
if (!decoded.ok) return;
assert.equal(decoded.snapshot.version, 1);
assert.deepEqual(decoded.state, state);
assert.notEqual(decoded.state, state);
decoded.state.actor.profile.displayName = "Changed offline";
assert.equal(state.actor.profile.displayName, "Visitor");
});
it("fails closed on corrupt, inconsistent, and future snapshots", () => {
assert.deepEqual(decodeJourneySnapshot("{"), {
ok: false,
error: "journey: snapshot is not valid JSON",
});
assert.equal(decodeJourneySnapshot({ version: 2, state: {} }).ok, false);
assert.equal(
decodeJourneySnapshot({
version: 1,
state: {
...createJourney({ actor: CROW }),
location: {
scale: "office",
officeId: "mateo-court",
priorCity: "bay-area",
},
},
}).ok,
false,
);
});
it("is deterministic for the same initial state and event log", () => {
const initial = createJourney({ actor: CROW, location: { scale: "socal" }, mode: "play" });
const events: JourneyEvent[] = [
{ type: "select-route", routeId: "la-sf-us-101", direction: 1 },
{ type: "enter-vehicle", vehicleId: "model-x-hero" },
{ type: "update-route-progress", progress: 0.25 },
{ type: "update-route-progress", progress: 0.75 },
{ type: "reach-route-endpoint", endpoint: "san-francisco" },
{ type: "exit-vehicle" },
{ type: "enter-office", officeId: "lumbridge-hq" },
{ type: "sign-in-actor-swap", actor: KARTI },
];
const run = (): JourneyState => events.reduce(journeyReducer, initial);
assert.deepEqual(run(), run());
});
});
class MemoryStorage implements JourneyStorageAdapter {
readonly values = new Map<string, string>();
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
removeItem(key: string): void {
this.values.delete(key);
}
}
describe("journey session persistence", () => {
it("round-trips through a caller-owned storage adapter and clears cleanly", () => {
const storage = new MemoryStorage();
const state = playFromLosAngeles();
assert.deepEqual(saveJourneySession(storage, "tera.journey", state), { ok: true });
const loaded = loadJourneySession(storage, "tera.journey");
assert.equal(loaded.status, "loaded");
if (loaded.status !== "loaded") return;
assert.deepEqual(loaded.state, state);
assert.notEqual(loaded.state, state);
assert.deepEqual(clearJourneySession(storage, "tera.journey"), { ok: true });
assert.deepEqual(loadJourneySession(storage, "tera.journey"), { status: "missing" });
});
it("rejects corrupt and future sessions without throwing or mutating storage", () => {
const storage = new MemoryStorage();
storage.values.set("corrupt", "{");
storage.values.set("future", JSON.stringify({ version: 2, state: {} }));
assert.equal(loadJourneySession(storage, "corrupt").status, "invalid");
assert.deepEqual(loadJourneySession(storage, "future"), {
status: "invalid",
error: "journey: unsupported snapshot version",
});
assert.equal(storage.values.has("future"), true);
});
it("contains adapter exceptions and invalid keys", () => {
const unavailable: JourneyStorageAdapter = {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("full");
},
removeItem: () => {
throw new Error("blocked");
},
};
const state = playFromLosAngeles();
assert.equal(loadJourneySession(unavailable, "tera.journey").status, "unavailable");
assert.equal(saveJourneySession(unavailable, "tera.journey", state).ok, false);
assert.equal(clearJourneySession(unavailable, "tera.journey").ok, false);
assert.equal(loadJourneySession(unavailable, " ").status, "invalid");
});
});
+99
View File
@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import { createOfficeWalker } from "../interiors/officeWalker.ts";
import { Plan } from "../interiors/plan.ts";
import type { Level, Office, Room, Wall } from "../interiors/types.ts";
const FLOOR: Room = {
id: "floor",
name: "Floor",
floor: "floor" as never,
outline: [{ x: 0, z: 0 }, { x: 10, z: 0 }, { x: 10, z: 8 }, { x: 0, z: 8 }],
};
function makePlan(walls: Wall[] = [], elevation = 2.5): Plan {
const level: Level = {
id: "ground",
name: "Ground",
elevation,
wallHeight: 3,
wallThickness: 0.1,
floorplan: { rooms: [FLOOR], walls },
};
const office: Office = { id: "actor-test", name: "Actor Test", levels: [level], viewpoints: [] };
return new Plan(office, { warn: false });
}
describe("office walker actor adapter", () => {
it("is inert by default and becomes a floor-aware humanoid when activated", () => {
const actor = createOfficeWalker(makePlan(), {
levelId: "ground",
position: { x: 2, z: 2 },
speed: 1,
fixedStep: 0.1,
});
assert.equal(actor.state().actor, "humanoid");
assert.equal(actor.root.position.y, 2.5);
actor.setAction({ x: 2, z: 0 });
actor.tick(0.2);
assert.deepEqual(actor.state().position, { x: 2, z: 2 });
actor.setActive(true);
assert.deepEqual(actor.action(), { x: 0, z: 0 }, "activation never replays stale input");
actor.setAction({ x: 2, z: 0 });
actor.tick(0.2);
assert.ok(actor.state().position.x > 2.19);
assert.equal(actor.view.position.y, 2.5);
assert.ok(Math.abs(actor.root.rotation.y + Math.PI / 2) < 1e-12);
actor.dispose();
});
it("builds an anonymous dog and publishes a defensive chase-camera contract", () => {
const actor = createOfficeWalker(makePlan(), {
levelId: "ground",
position: { x: 4, z: 4 },
actor: { kind: "anonymous-dog", coatColor: 0x222222 },
active: true,
});
assert.equal(actor.root.userData.actorType, "anonymous-dog");
const pose = actor.followPose();
assert.ok(pose.position.z > 4, "camera is behind a -Z-facing actor");
assert.ok(pose.target.z < 4, "camera aims ahead of the actor");
pose.position.x = 999;
assert.notEqual(actor.followPose().position.x, 999);
actor.dispose();
});
it("uses resolved door gaps while retaining wall collision and reset state", () => {
const plan = makePlan([{
id: "divider",
from: { x: 5, z: 0 },
to: { x: 5, z: 8 },
openings: [{ kind: "door", start: 3.4, width: 1.2, sill: 0, head: 2.1 }],
}]);
const actor = createOfficeWalker(plan, {
levelId: "ground",
position: { x: 4, z: 4 },
speed: 2,
fixedStep: 0.1,
active: true,
});
actor.setAction({ x: 1, z: 0 });
for (let index = 0; index < 10; index += 1) actor.tick(0.1);
assert.ok(actor.state().position.x > 5.5);
const reset = actor.reset({ levelId: "ground", position: { x: 2, z: 2 } });
assert.deepEqual(reset.position, { x: 2, z: 2 });
assert.deepEqual(reset.action, { x: 0, z: 0 });
assert.equal(reset.distance, 0);
actor.dispose();
});
it("removes and disposes its actor root idempotently", () => {
const actor = createOfficeWalker(makePlan(), { levelId: "ground", position: { x: 2, z: 2 } });
const parent = new THREE.Group();
parent.add(actor.root);
actor.dispose();
actor.dispose();
assert.equal(actor.root.parent, null);
});
});
+135
View File
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import {
createSceneActor,
type ActorIdentity,
type SceneActorOptions,
} from "../actors/index.ts";
const MEMBER: ActorIdentity = {
id: "member-7",
displayName: "Avery",
authenticated: true,
profile: {
handle: "avery",
appearance: { skinTone: "#9c6b50", primaryColor: "#183d57", accentColor: "#55b5c6" },
},
};
function options(over: Partial<SceneActorOptions> = {}): SceneActorOptions {
return {
kind: "humanoid",
identity: MEMBER,
fixedStepSeconds: 0.1,
position: { x: 2, z: 3 },
...over,
};
}
describe("playable city scene actor", () => {
it("is inert by default, exposes defensive actions, and animates when active", () => {
const actor = createSceneActor(options());
assert.equal(actor.root.name, "playable-scene-actor");
assert.ok(actor.root.getObjectByName("humanoid"));
actor.setActions({ forward: 1 });
const leaked = actor.actions();
leaked.forward = -1;
actor.tick(0.2);
assert.equal(actor.state().z, 3);
actor.setActive(true);
assert.equal(actor.actions().forward, 0, "activation clears stale movement");
actor.setActions({ forward: 1 });
actor.tick(0.2);
assert.ok(actor.state().z < 3);
assert.ok(actor.state().posePhase > 0);
assert.equal(actor.view.position.z, actor.root.position.z);
actor.dispose();
});
it("maps crow metre-space flight into a scaled California board and chase pose", () => {
const actor = createSceneActor(options({
kind: "crow",
mode: "flight",
position: { x: 10, y: 4, z: -20 },
sceneUnitsPerMetre: 0.02,
sceneOrigin: { x: 100, y: 3, z: -50 },
active: true,
minFlightAltitude: 1,
maxFlightAltitude: 6,
}));
assert.equal(actor.root.scale.x, 0.02);
assert.deepEqual(actor.root.position.toArray(), [100.2, 3.08, -50.4]);
actor.setActions({ forward: 1, climb: 1, pitch: 0.6, turn: 0.3 });
actor.tick(0.4);
assert.equal(actor.state().mode, "flight");
assert.ok(actor.state().y > 4 && actor.state().y <= 6);
assert.equal(actor.root.rotation.order, "YXZ");
assert.equal(actor.root.rotation.x, actor.state().pitch);
const pose = actor.followPose();
assert.ok(pose.position.toArray().every(Number.isFinite));
assert.ok(pose.target.toArray().every(Number.isFinite));
pose.position.x = 999;
assert.notEqual(actor.followPose().position.x, 999);
actor.dispose();
});
it("keeps its stable root and identity while replacing procedural rigs", () => {
const actor = createSceneActor(options({ active: true }));
const root = actor.root;
const firstRig = root.children[0];
actor.switchActor("crow", undefined, "flight");
assert.equal(actor.root, root);
assert.equal(firstRig?.parent, null);
assert.ok(root.getObjectByName("crow"));
assert.equal(actor.state().mode, "flight");
assert.deepEqual(actor.state().identity, MEMBER);
const guest: ActorIdentity = {
id: "anon-3",
displayName: "Guest Crow",
authenticated: false,
profile: { appearance: { primaryColor: "#20252b" } },
};
const crowRig = root.children[0];
actor.setIdentity(guest);
assert.equal(crowRig?.parent, null);
assert.deepEqual(actor.state().identity, guest);
actor.switchActor("dog");
assert.ok(root.getObjectByName("dog"));
assert.equal(actor.state().mode, "ground");
assert.deepEqual(actor.state().identity, guest);
actor.dispose();
});
it("applies kind requests from the normalized action stream exactly once", () => {
const actor = createSceneActor(options({ active: true }));
actor.setActions({ kindRequest: "crow", modeRequest: "flight", forward: 0.4 });
actor.tick(0.1);
assert.equal(actor.state().kind, "crow");
assert.equal(actor.state().mode, "flight");
assert.equal(actor.actions().kindRequest, "none");
assert.equal(actor.actions().modeRequest, "none");
assert.equal(actor.actions().forward, 0.4, "held axes survive edge clearing");
assert.ok(actor.root.getObjectByName("crow"));
actor.dispose();
});
it("removes and disposes the stable root idempotently", () => {
const actor = createSceneActor(options());
const parent = new THREE.Group();
parent.add(actor.root);
actor.dispose();
actor.dispose();
assert.equal(actor.root.parent, null);
const before = actor.state();
actor.tick(1);
assert.deepEqual(actor.state(), before);
});
it("rejects invalid scene scaling and camera configuration", () => {
assert.throws(() => createSceneActor(options({ sceneUnitsPerMetre: 0 })), RangeError);
assert.throws(() => createSceneActor(options({ sceneOrigin: { x: Infinity, y: 0, z: 0 } })), RangeError);
assert.throws(() => createSceneActor(options({ camera: { distance: -1 } })), RangeError);
});
});