feat: add playable actors and seamless journey state
This commit is contained in:
+35
-6
@@ -280,6 +280,24 @@
|
||||
}
|
||||
@media (hover: hover) { .enter:hover { background: var(--amber-lit); } }
|
||||
|
||||
.walk {
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
padding: var(--s2) var(--s3);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(242, 177, 52, 0.48);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--amber-ink);
|
||||
background: rgba(242, 177, 52, 0.1);
|
||||
box-shadow: var(--shadow);
|
||||
transition: background var(--t), border-color var(--t);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.walk:hover { background: rgba(242, 177, 52, 0.2); border-color: var(--amber); }
|
||||
}
|
||||
.walk[aria-pressed="true"] { background: rgba(242, 177, 52, 0.25); }
|
||||
|
||||
/* The public-office note. Amber-edged rather than amber-filled: it is an
|
||||
explanation, not an action, and the one filled amber thing on screen
|
||||
should stay the door. */
|
||||
@@ -671,9 +689,9 @@
|
||||
only while a route chapter owns the follow camera. Full words rather
|
||||
than mystery glyphs: the controls disappear everywhere space is tight
|
||||
except on the devices that cannot drive without them. */
|
||||
.drive-controls { display: none; }
|
||||
.drive-controls, .walk-controls { display: none; }
|
||||
@media (pointer: coarse) {
|
||||
.drive-controls:not([hidden]) {
|
||||
.drive-controls:not([hidden]), .walk-controls:not([hidden]) {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(var(--s3) + env(safe-area-inset-bottom));
|
||||
@@ -691,7 +709,7 @@
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.drive-control {
|
||||
.drive-control, .walk-control {
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-sm);
|
||||
@@ -705,13 +723,14 @@
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.drive-control[aria-pressed="true"] {
|
||||
.drive-control[aria-pressed="true"], .walk-control[aria-pressed="true"] {
|
||||
border-color: rgba(242, 177, 52, 0.65);
|
||||
background: rgba(242, 177, 52, 0.28);
|
||||
color: var(--amber-ink);
|
||||
}
|
||||
/* The source line is useful but cannot sit underneath the controls. */
|
||||
body:has(.drive-controls:not([hidden])) .source { bottom: 7.5rem; }
|
||||
body:has(.drive-controls:not([hidden])) .source,
|
||||
body:has(.walk-controls:not([hidden])) .source { bottom: 7.5rem; }
|
||||
}
|
||||
|
||||
/* ---- Responsive -------------------------------------------------------
|
||||
@@ -897,6 +916,7 @@
|
||||
</section>
|
||||
<nav id="cities" class="cities" aria-label="City"></nav>
|
||||
<button id="enter" class="enter">Enter the office →</button>
|
||||
<button id="walk" class="walk" aria-pressed="false" hidden>Walk as your dog →</button>
|
||||
<p class="card badge" id="office-badge" hidden></p>
|
||||
<nav id="chapters" aria-label="Chapters"></nav>
|
||||
<p class="card" id="blurb"></p>
|
||||
@@ -919,6 +939,7 @@
|
||||
<div class="card hint" id="hint">
|
||||
<span><kbd>1</kbd>–<kbd>9</kbd> chapters</span>
|
||||
<span id="drive-hint">choose <kbd>2</kbd> or <kbd>3</kbd> to follow</span>
|
||||
<span id="walk-hint" hidden><kbd>V</kbd> walk · <kbd>WASD</kbd> move</span>
|
||||
<span><kbd>[</kbd> <kbd>]</kbd> city</span>
|
||||
<span><kbd>O</kbd> office</span>
|
||||
<span><kbd>M</kbd> plan</span>
|
||||
@@ -946,6 +967,13 @@
|
||||
<button class="drive-control" data-drive-action="camera">Camera</button>
|
||||
</div>
|
||||
|
||||
<div id="walk-controls" class="walk-controls" aria-label="Walking controls" hidden>
|
||||
<button class="walk-control" data-walk-key="a" aria-pressed="false">Left</button>
|
||||
<button class="walk-control" data-walk-key="w" aria-pressed="false">Forward</button>
|
||||
<button class="walk-control" data-walk-key="s" aria-pressed="false">Back</button>
|
||||
<button class="walk-control" data-walk-key="d" aria-pressed="false">Right</button>
|
||||
</div>
|
||||
|
||||
<p id="source" class="source"></p>
|
||||
|
||||
<!-- Behind the panel sheet on a phone, and nowhere else. A sheet with no
|
||||
@@ -959,7 +987,8 @@
|
||||
<h2 id="shortcuts-title">Keyboard</h2>
|
||||
<dl class="keys">
|
||||
<dt><kbd>1</kbd>–<kbd>9</kbd></dt><dd>Fly to a chapter, or an office viewpoint</dd>
|
||||
<dt><kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd></dt><dd>Drive after choosing the 101 or I-5 chapter</dd>
|
||||
<dt><kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd></dt><dd>Drive a route, or move while walking inside</dd>
|
||||
<dt><kbd>V</kbd></dt><dd>Walk through an office / return to the dollhouse view</dd>
|
||||
<dt><kbd>Space</kbd></dt><dd>Handbrake while driving</dd>
|
||||
<dt><kbd>P</kbd> / <kbd>R</kbd></dt><dd>Resume assisted drive / reset the car</dd>
|
||||
<dt><kbd>C</kbd></dt><dd>Switch chase / driver-height camera</dd>
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -29,13 +29,12 @@
|
||||
* station said so is a nice idea and a bad dependency for a room that has to
|
||||
* render with no network at all.
|
||||
*
|
||||
* ### Orbit dollhouse is the only navigation mode
|
||||
* ### Orbit dollhouse remains the default navigation mode
|
||||
*
|
||||
* Walk mode is not built here. `Plan` already produces the collision segments it
|
||||
* will need, which is the point of doing the wall split once, but v1 orbits: the
|
||||
* ceilings come off, the walls between you and what you are looking at go
|
||||
* translucent, and the existing camera, flight and picking machinery is reused
|
||||
* verbatim.
|
||||
* An optional `OfficeWalker` can temporarily possess a local actor and publish a
|
||||
* chase-camera pose. It is inactive by default; without one, or while inactive,
|
||||
* the ceilings, occlusion fading, named views and orbit controls behave exactly
|
||||
* as before.
|
||||
*
|
||||
* ### Two depths, and the public one is the architecture without the people
|
||||
*
|
||||
@@ -83,6 +82,11 @@ import { Plan, type Depth, type PlanOptions } from "./plan.ts";
|
||||
import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts";
|
||||
import { createShell, type Shell, type WallInfo } from "./shell.ts";
|
||||
import { createLuminaires, type Luminaires, type Walker } from "./luminaires.ts";
|
||||
import {
|
||||
createOfficeWalker,
|
||||
type OfficeWalker,
|
||||
type OfficeWalkerOptions,
|
||||
} from "./officeWalker.ts";
|
||||
import {
|
||||
createRobotLayer,
|
||||
type RobotLayer,
|
||||
@@ -210,6 +214,8 @@ export interface OfficeSceneOptions {
|
||||
* withhold, so a stranger gets them too.
|
||||
*/
|
||||
robots?: readonly RobotSpec[];
|
||||
/** Optional local walk actor. Constructed inactive unless `walker.active` says otherwise. */
|
||||
walker?: OfficeWalkerOptions;
|
||||
/** Defaults to false — the lid comes off, because that is the whole view. */
|
||||
showCeilings?: boolean;
|
||||
/** Fade the walls you are looking through. Defaults to true. */
|
||||
@@ -230,6 +236,8 @@ export interface OfficeScene extends StageScene {
|
||||
* print the "no presence" badge and whether to offer a sign-in.
|
||||
*/
|
||||
depth: Depth;
|
||||
/** Local walk-mode actor, or null when this scene was built as dollhouse-only. */
|
||||
walker: OfficeWalker | null;
|
||||
/** The pack's viewpoints, as the thing a legend prints and `flyTo` is keyed on. */
|
||||
views: View[];
|
||||
flyTo(viewId: string): void;
|
||||
@@ -486,6 +494,8 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
options.robots && options.robots.length > 0
|
||||
? createRobotLayer(plan, { materials, robots: options.robots })
|
||||
: null;
|
||||
const officeWalker = options.walker ? createOfficeWalker(plan, options.walker) : null;
|
||||
if (officeWalker) scene.add(officeWalker.root);
|
||||
if (robots) {
|
||||
scene.add(robots.group);
|
||||
/**
|
||||
@@ -496,7 +506,12 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
* through a reference taken at setup. Calling it every frame would allocate
|
||||
* nothing extra but would imply the array were a snapshot, which it is not.
|
||||
*/
|
||||
luminaires.setWalkers(robots.robots());
|
||||
}
|
||||
if (robots || officeWalker) {
|
||||
luminaires.setWalkers([
|
||||
...(robots?.robots() ?? NO_ROBOTS),
|
||||
...(officeWalker ? [officeWalker.view] : []),
|
||||
]);
|
||||
}
|
||||
// A public office has no presence layer, rather than an empty one. The
|
||||
// difference is not cosmetic: an empty `PresenceLayer` is a `THREE.Group`
|
||||
@@ -749,13 +764,18 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
controls: kit.controls,
|
||||
plan,
|
||||
depth,
|
||||
walker: officeWalker,
|
||||
views,
|
||||
// A public office anchors nothing, because it has nobody to anchor. The
|
||||
// empty map is this scene's own rather than a shared module-level one: an
|
||||
// HTML overlay that writes into what it was handed should not be able to
|
||||
// reach across into another office.
|
||||
anchors: presence?.anchors ?? new Map<string, THREE.Vector3>(),
|
||||
flyTo,
|
||||
flyTo(viewId) {
|
||||
officeWalker?.setActive(false);
|
||||
kit.controls.enabled = true;
|
||||
flyTo(viewId);
|
||||
},
|
||||
current: () => currentView,
|
||||
onViewChange(fn) {
|
||||
viewListeners.push(fn);
|
||||
@@ -799,7 +819,11 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
onExit: () => kit.resetPick(),
|
||||
tick(dt) {
|
||||
if (disposed) return;
|
||||
const walking = officeWalker?.active() ?? false;
|
||||
kit.controls.enabled = !walking;
|
||||
kit.tick(dt);
|
||||
officeWalker?.tick(dt);
|
||||
if (walking && officeWalker) kit.setPose(officeWalker.followPose());
|
||||
updateOcclusion();
|
||||
// Robots first: the lights above them should respond to where they are
|
||||
// *now*, not to where they were last frame.
|
||||
@@ -807,6 +831,7 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
luminaires.tick(dt);
|
||||
},
|
||||
dispose() {
|
||||
officeWalker?.dispose();
|
||||
robots?.dispose();
|
||||
luminaires.dispose();
|
||||
if (horizonPlane) {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* The office-facing half of walk mode: a numeric `WalkerController` wearing an
|
||||
* original actor rig and publishing a third-person camera pose.
|
||||
*
|
||||
* There is deliberately no identity, webcam, keyboard or network code here.
|
||||
* Callers choose humanoid or anonymous dog, translate their own input device to
|
||||
* a normalized planar action, and decide when walk mode is active.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
buildDog,
|
||||
disposeDog,
|
||||
poseDogAttention,
|
||||
poseDogWalk,
|
||||
type DogRig,
|
||||
} from "../assets/actors/dog.ts";
|
||||
import {
|
||||
buildHumanoid,
|
||||
disposeHumanoid,
|
||||
poseHumanoid,
|
||||
type HumanoidRig,
|
||||
} from "../assets/actors/humanoid.ts";
|
||||
import type { Pose } from "../engine/scenekit.ts";
|
||||
import type { Plan } from "./plan.ts";
|
||||
import {
|
||||
createWalker,
|
||||
normalizeWalkerAction,
|
||||
type WalkerAction,
|
||||
type WalkerController,
|
||||
type WalkerOptions,
|
||||
type WalkerSpawn,
|
||||
type WalkerState,
|
||||
} from "./walker.ts";
|
||||
|
||||
const HUMANOID_CAMERA = { distance: 3.2, height: 2.25, targetHeight: 1.25, lookAhead: 0.7 };
|
||||
const DOG_CAMERA = { distance: 2.6, height: 1.45, targetHeight: 0.48, lookAhead: 0.55 };
|
||||
const STRIDE_METRES = 0.72;
|
||||
const GAIT_EASE_SECONDS = 0.16;
|
||||
|
||||
export type OfficeActorKind = "humanoid" | "anonymous-dog";
|
||||
|
||||
export interface OfficeActorAppearance {
|
||||
kind: OfficeActorKind;
|
||||
skinTone?: THREE.ColorRepresentation;
|
||||
outfitColor?: THREE.ColorRepresentation;
|
||||
accentColor?: THREE.ColorRepresentation;
|
||||
hairColor?: THREE.ColorRepresentation;
|
||||
bodyShape?: "slim" | "average" | "broad";
|
||||
coatColor?: THREE.ColorRepresentation;
|
||||
markingsColor?: THREE.ColorRepresentation;
|
||||
collarColor?: THREE.ColorRepresentation;
|
||||
}
|
||||
|
||||
export interface FollowCameraOptions {
|
||||
/** Metres behind the actor. */
|
||||
distance?: number;
|
||||
/** Camera height above this level's floor, in metres. */
|
||||
height?: number;
|
||||
/** Look target height above this level's floor, in metres. */
|
||||
targetHeight?: number;
|
||||
/** Metres ahead of the actor to aim. */
|
||||
lookAhead?: number;
|
||||
}
|
||||
|
||||
export interface OfficeWalkerOptions extends WalkerOptions {
|
||||
actor?: OfficeActorAppearance;
|
||||
camera?: FollowCameraOptions;
|
||||
/** False by default: constructing an actor must not change dollhouse controls. */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface OfficeWalkerState extends WalkerState {
|
||||
active: boolean;
|
||||
actor: OfficeActorKind;
|
||||
action: WalkerAction;
|
||||
}
|
||||
|
||||
export interface OfficeWalker {
|
||||
/** Floor-centred, scene-ready actor root. */
|
||||
root: THREE.Group;
|
||||
/** Stable live position for occupancy-responsive office lighting. */
|
||||
view: { position: THREE.Vector3 };
|
||||
state(): OfficeWalkerState;
|
||||
active(): boolean;
|
||||
setActive(active: boolean): void;
|
||||
action(): WalkerAction;
|
||||
setAction(action: WalkerAction): WalkerAction;
|
||||
tick(elapsedSeconds: number): OfficeWalkerState;
|
||||
reset(spawn?: WalkerSpawn): OfficeWalkerState;
|
||||
/** A defensive scene-space chase-camera pose for the current actor state. */
|
||||
followPose(): Pose;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
type Actor =
|
||||
| { kind: "humanoid"; rig: HumanoidRig }
|
||||
| { kind: "anonymous-dog"; rig: DogRig };
|
||||
|
||||
export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): OfficeWalker {
|
||||
const appearance = options.actor ?? { kind: "humanoid" };
|
||||
const actor = buildActor(appearance);
|
||||
const controller: WalkerController = createWalker(plan, options);
|
||||
const baseCamera = actor.kind === "humanoid" ? HUMANOID_CAMERA : DOG_CAMERA;
|
||||
const camera = {
|
||||
distance: positive(options.camera?.distance ?? baseCamera.distance, "camera.distance"),
|
||||
height: positive(options.camera?.height ?? baseCamera.height, "camera.height"),
|
||||
targetHeight: nonNegative(options.camera?.targetHeight ?? baseCamera.targetHeight, "camera.targetHeight"),
|
||||
lookAhead: nonNegative(options.camera?.lookAhead ?? baseCamera.lookAhead, "camera.lookAhead"),
|
||||
};
|
||||
|
||||
let enabled = options.active ?? false;
|
||||
let desired: WalkerAction = { x: 0, z: 0 };
|
||||
let gait = 0;
|
||||
let disposed = false;
|
||||
const view = { position: new THREE.Vector3() };
|
||||
|
||||
function sync(state: WalkerState, elapsedSeconds = 0, travelled = 0): void {
|
||||
const level = plan.level(state.levelId);
|
||||
if (!level) throw new Error(`office walker lost level "${state.levelId}"`);
|
||||
actor.rig.root.position.set(state.position.x, level.floorY, state.position.z);
|
||||
view.position.copy(actor.rig.root.position);
|
||||
actor.rig.root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z);
|
||||
|
||||
const speed = elapsedSeconds > 0 ? travelled / elapsedSeconds : 0;
|
||||
const targetGait = enabled && speed > 1e-5 ? Math.min(1, speed / (options.speed ?? 1.6)) : 0;
|
||||
gait += (targetGait - gait) * Math.min(1, elapsedSeconds / GAIT_EASE_SECONDS);
|
||||
const phase = (state.distance / STRIDE_METRES) * Math.PI * 2;
|
||||
if (actor.kind === "humanoid") {
|
||||
poseHumanoid(actor.rig, { walkPhase: phase, stride: gait * 0.62 });
|
||||
} else {
|
||||
poseDogWalk(actor.rig, phase, gait * 0.62);
|
||||
poseDogAttention(actor.rig, 0, state.distance * 5);
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(): OfficeWalkerState {
|
||||
const state = controller.state();
|
||||
return {
|
||||
...state,
|
||||
position: { ...state.position },
|
||||
facing: { ...state.facing },
|
||||
active: enabled,
|
||||
actor: actor.kind,
|
||||
action: { ...desired },
|
||||
};
|
||||
}
|
||||
|
||||
sync(controller.state());
|
||||
|
||||
return {
|
||||
root: actor.rig.root,
|
||||
view,
|
||||
state: snapshot,
|
||||
active: () => enabled,
|
||||
setActive(active) {
|
||||
if (active !== enabled) desired = { x: 0, z: 0 };
|
||||
enabled = active;
|
||||
},
|
||||
action: () => ({ ...desired }),
|
||||
setAction(action) {
|
||||
desired = normalizeWalkerAction(action);
|
||||
return { ...desired };
|
||||
},
|
||||
tick(elapsedSeconds) {
|
||||
if (disposed) return snapshot();
|
||||
const before = controller.state();
|
||||
const next = enabled
|
||||
? controller.tick(elapsedSeconds, desired)
|
||||
: controller.tick(elapsedSeconds, { x: 0, z: 0 });
|
||||
sync(next, Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0, next.distance - before.distance);
|
||||
return snapshot();
|
||||
},
|
||||
reset(spawn) {
|
||||
const state = controller.reset(spawn);
|
||||
desired = { x: 0, z: 0 };
|
||||
gait = 0;
|
||||
sync(state);
|
||||
return snapshot();
|
||||
},
|
||||
followPose() {
|
||||
const state = controller.state();
|
||||
const level = plan.level(state.levelId);
|
||||
if (!level) throw new Error(`office walker lost level "${state.levelId}"`);
|
||||
return {
|
||||
position: new THREE.Vector3(
|
||||
state.position.x - state.facing.x * camera.distance,
|
||||
level.floorY + camera.height,
|
||||
state.position.z - state.facing.z * camera.distance,
|
||||
),
|
||||
target: new THREE.Vector3(
|
||||
state.position.x + state.facing.x * camera.lookAhead,
|
||||
level.floorY + camera.targetHeight,
|
||||
state.position.z + state.facing.z * camera.lookAhead,
|
||||
),
|
||||
};
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
actor.rig.root.removeFromParent();
|
||||
if (actor.kind === "humanoid") disposeHumanoid(actor.rig);
|
||||
else disposeDog(actor.rig);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildActor(appearance: OfficeActorAppearance): Actor {
|
||||
if (appearance.kind === "anonymous-dog") {
|
||||
return {
|
||||
kind: "anonymous-dog",
|
||||
rig: buildDog({
|
||||
...(appearance.coatColor !== undefined ? { coatColor: appearance.coatColor } : {}),
|
||||
...(appearance.markingsColor !== undefined ? { markingsColor: appearance.markingsColor } : {}),
|
||||
...(appearance.collarColor !== undefined ? { collarColor: appearance.collarColor } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "humanoid",
|
||||
rig: buildHumanoid({
|
||||
...(appearance.skinTone !== undefined ? { skinTone: appearance.skinTone } : {}),
|
||||
...(appearance.outfitColor !== undefined ? { outfitColor: appearance.outfitColor } : {}),
|
||||
...(appearance.accentColor !== undefined ? { accentColor: appearance.accentColor } : {}),
|
||||
...(appearance.hairColor !== undefined ? { hairColor: appearance.hairColor } : {}),
|
||||
...(appearance.bodyShape !== undefined ? { bodyShape: appearance.bodyShape } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function positive(value: number, name: string): number {
|
||||
if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegative(value: number, name: string): number {
|
||||
if (value < 0 || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and non-negative`);
|
||||
return value;
|
||||
}
|
||||
+11
-2
@@ -33,6 +33,8 @@ export interface WalkerSpawn {
|
||||
}
|
||||
|
||||
export interface WalkerOptions extends WalkerSpawn {
|
||||
/** Initial unit direction; defaults to north / local -Z. */
|
||||
facing?: Point2;
|
||||
/** Circular footprint radius, in metres. */
|
||||
radius?: number;
|
||||
/** Metres per second at full input. */
|
||||
@@ -86,7 +88,8 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
|
||||
|
||||
let spawn = checkedSpawn(plan, options, radius);
|
||||
let position = copy(spawn.position);
|
||||
let facing: Point2 = { x: 0, z: -1 };
|
||||
const initialFacing = normalizedFacing(options.facing);
|
||||
let facing: Point2 = copy(initialFacing);
|
||||
let distance = 0;
|
||||
let accumulator = 0;
|
||||
|
||||
@@ -102,7 +105,7 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
|
||||
function reset(next = spawn): WalkerState {
|
||||
spawn = checkedSpawn(plan, next, radius);
|
||||
position = copy(spawn.position);
|
||||
facing = { x: 0, z: -1 };
|
||||
facing = copy(initialFacing);
|
||||
distance = 0;
|
||||
accumulator = 0;
|
||||
return snapshot();
|
||||
@@ -138,6 +141,12 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
|
||||
return { state: snapshot, tick, reset };
|
||||
}
|
||||
|
||||
function normalizedFacing(value: Point2 | undefined): Point2 {
|
||||
if (!value || !finitePoint(value)) return { x: 0, z: -1 };
|
||||
const length = Math.hypot(value.x, value.z);
|
||||
return length > EPSILON ? { x: value.x / length, z: value.z / length } : { x: 0, z: -1 };
|
||||
}
|
||||
|
||||
function moveWithSliding(
|
||||
plan: WalkerPlan,
|
||||
levelId: string,
|
||||
|
||||
@@ -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";
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 };
|
||||
+236
-4
@@ -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,13 +1558,30 @@ 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.`
|
||||
? 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.`,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user