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); } }
|
@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
|
/* 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
|
explanation, not an action, and the one filled amber thing on screen
|
||||||
should stay the door. */
|
should stay the door. */
|
||||||
@@ -671,9 +689,9 @@
|
|||||||
only while a route chapter owns the follow camera. Full words rather
|
only while a route chapter owns the follow camera. Full words rather
|
||||||
than mystery glyphs: the controls disappear everywhere space is tight
|
than mystery glyphs: the controls disappear everywhere space is tight
|
||||||
except on the devices that cannot drive without them. */
|
except on the devices that cannot drive without them. */
|
||||||
.drive-controls { display: none; }
|
.drive-controls, .walk-controls { display: none; }
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
.drive-controls:not([hidden]) {
|
.drive-controls:not([hidden]), .walk-controls:not([hidden]) {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: calc(var(--s3) + env(safe-area-inset-bottom));
|
bottom: calc(var(--s3) + env(safe-area-inset-bottom));
|
||||||
@@ -691,7 +709,7 @@
|
|||||||
-webkit-backdrop-filter: var(--blur);
|
-webkit-backdrop-filter: var(--blur);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
.drive-control {
|
.drive-control, .walk-control {
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
border: 1px solid var(--hairline);
|
border: 1px solid var(--hairline);
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
@@ -705,13 +723,14 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-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);
|
border-color: rgba(242, 177, 52, 0.65);
|
||||||
background: rgba(242, 177, 52, 0.28);
|
background: rgba(242, 177, 52, 0.28);
|
||||||
color: var(--amber-ink);
|
color: var(--amber-ink);
|
||||||
}
|
}
|
||||||
/* The source line is useful but cannot sit underneath the controls. */
|
/* 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 -------------------------------------------------------
|
/* ---- Responsive -------------------------------------------------------
|
||||||
@@ -897,6 +916,7 @@
|
|||||||
</section>
|
</section>
|
||||||
<nav id="cities" class="cities" aria-label="City"></nav>
|
<nav id="cities" class="cities" aria-label="City"></nav>
|
||||||
<button id="enter" class="enter">Enter the office →</button>
|
<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>
|
<p class="card badge" id="office-badge" hidden></p>
|
||||||
<nav id="chapters" aria-label="Chapters"></nav>
|
<nav id="chapters" aria-label="Chapters"></nav>
|
||||||
<p class="card" id="blurb"></p>
|
<p class="card" id="blurb"></p>
|
||||||
@@ -919,6 +939,7 @@
|
|||||||
<div class="card hint" id="hint">
|
<div class="card hint" id="hint">
|
||||||
<span><kbd>1</kbd>–<kbd>9</kbd> chapters</span>
|
<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="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>[</kbd> <kbd>]</kbd> city</span>
|
||||||
<span><kbd>O</kbd> office</span>
|
<span><kbd>O</kbd> office</span>
|
||||||
<span><kbd>M</kbd> plan</span>
|
<span><kbd>M</kbd> plan</span>
|
||||||
@@ -946,6 +967,13 @@
|
|||||||
<button class="drive-control" data-drive-action="camera">Camera</button>
|
<button class="drive-control" data-drive-action="camera">Camera</button>
|
||||||
</div>
|
</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>
|
<p id="source" class="source"></p>
|
||||||
|
|
||||||
<!-- Behind the panel sheet on a phone, and nowhere else. A sheet with no
|
<!-- Behind the panel sheet on a phone, and nowhere else. A sheet with no
|
||||||
@@ -959,7 +987,8 @@
|
|||||||
<h2 id="shortcuts-title">Keyboard</h2>
|
<h2 id="shortcuts-title">Keyboard</h2>
|
||||||
<dl class="keys">
|
<dl class="keys">
|
||||||
<dt><kbd>1</kbd>–<kbd>9</kbd></dt><dd>Fly to a chapter, or an office viewpoint</dd>
|
<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>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>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>
|
<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 {
|
export function createCrowMaterials(options: CrowBuildOptions = {}): CrowMaterials {
|
||||||
return {
|
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 }),
|
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 }),
|
beak: new THREE.MeshStandardMaterial({ name: "crow.beak", color: 0x202327, roughness: 0.78 }),
|
||||||
eye: new THREE.MeshBasicMaterial({ name: "crow.eye", color: 0xd4b168, toneMapped: false }),
|
eye: new THREE.MeshBasicMaterial({ name: "crow.eye", color: 0xd4b168, toneMapped: false }),
|
||||||
|
|||||||
@@ -333,7 +333,11 @@ export class TextureBin {
|
|||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = size;
|
canvas.width = size;
|
||||||
canvas.height = 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;
|
if (!ctx) return null;
|
||||||
|
|
||||||
DRAW[kind](ctx, size);
|
DRAW[kind](ctx, size);
|
||||||
|
|||||||
+56
-1
@@ -64,6 +64,8 @@ import type {
|
|||||||
ScenePalette,
|
ScenePalette,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
import { World, type FieldProgress } from "./world.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 {
|
export interface SceneOptions {
|
||||||
city: City;
|
city: City;
|
||||||
@@ -79,6 +81,10 @@ export interface SceneOptions {
|
|||||||
markers?: Marker[];
|
markers?: Marker[];
|
||||||
/** Optional deterministic road traffic for a state/corridor-scale board. */
|
/** Optional deterministic road traffic for a state/corridor-scale board. */
|
||||||
roadTraffic?: RoadTrafficOptions;
|
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;
|
flights?: FlightSource;
|
||||||
/**
|
/**
|
||||||
* Element sets to propagate, if this deployment has any.
|
* Element sets to propagate, if this deployment has any.
|
||||||
@@ -175,6 +181,10 @@ export interface SceneHandle {
|
|||||||
vehicleState(): Readonly<VehicleControllerState> | null;
|
vehicleState(): Readonly<VehicleControllerState> | null;
|
||||||
setVehicleCamera(mode: VehicleCameraMode): void;
|
setVehicleCamera(mode: VehicleCameraMode): void;
|
||||||
vehicleCamera(): VehicleCameraMode | null;
|
vehicleCamera(): VehicleCameraMode | null;
|
||||||
|
setActorActions(actions: Partial<ActorActionSnapshot>): void;
|
||||||
|
actorState(): Readonly<ActorControllerSnapshot> | null;
|
||||||
|
setActorActive(active: boolean): void;
|
||||||
|
actorActive(): boolean;
|
||||||
setMarkers(markers: Marker[]): void;
|
setMarkers(markers: Marker[]): void;
|
||||||
/** Take this city off the stage and release everything it built. */
|
/** Take this city off the stage and release everything it built. */
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
@@ -254,6 +264,7 @@ export async function createScene(
|
|||||||
Math.hypot(eastX, southZ),
|
Math.hypot(eastX, southZ),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const orbitMinDistance = Math.max(4, boardSpan * 0.02);
|
||||||
const kit = createSceneKit({
|
const kit = createSceneKit({
|
||||||
scene,
|
scene,
|
||||||
dom: stage.renderer.domElement,
|
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.
|
* the invariant: nothing should be cut off before it has fully faded out.
|
||||||
*/
|
*/
|
||||||
far: boardSpan * 4,
|
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
|
* Far enough out to be **above the satellites**, which is what the extra
|
||||||
* half-span buys.
|
* half-span buys.
|
||||||
@@ -344,6 +355,22 @@ export async function createScene(
|
|||||||
? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic)
|
? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic)
|
||||||
: null;
|
: null;
|
||||||
if (roadTraffic) scene.add(roadTraffic.group);
|
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 flightLayer: FlightLayer | null = null;
|
||||||
let flightTimer = 0;
|
let flightTimer = 0;
|
||||||
@@ -408,6 +435,15 @@ export async function createScene(
|
|||||||
function flyTo(chapterId: string) {
|
function flyTo(chapterId: string) {
|
||||||
const ch = chapterById[chapterId];
|
const ch = chapterById[chapterId];
|
||||||
if (!ch) return;
|
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);
|
const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
|
||||||
if (route && roadTraffic) {
|
if (route && roadTraffic) {
|
||||||
roadTraffic.setRoute(route.id);
|
roadTraffic.setRoute(route.id);
|
||||||
@@ -446,7 +482,11 @@ export async function createScene(
|
|||||||
// bug.
|
// bug.
|
||||||
onExit: () => kit.resetPick(),
|
onExit: () => kit.resetPick(),
|
||||||
tick(dt) {
|
tick(dt) {
|
||||||
|
const actorPlaying = sceneActor?.active() ?? false;
|
||||||
|
kit.controls.enabled = !actorPlaying;
|
||||||
kit.tick(dt);
|
kit.tick(dt);
|
||||||
|
sceneActor?.tick(dt);
|
||||||
|
if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose());
|
||||||
roadTraffic?.tick(dt);
|
roadTraffic?.tick(dt);
|
||||||
clouds.tick(dt);
|
clouds.tick(dt);
|
||||||
if (options.flights && flightLayer) {
|
if (options.flights && flightLayer) {
|
||||||
@@ -538,10 +578,25 @@ export async function createScene(
|
|||||||
vehicleState: () => roadTraffic?.hero() ?? null,
|
vehicleState: () => roadTraffic?.hero() ?? null,
|
||||||
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
|
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
|
||||||
vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
|
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) {
|
setMarkers(markers) {
|
||||||
markerLayer.setMarkers(markers);
|
markerLayer.setMarkers(markers);
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
|
sceneActor?.dispose();
|
||||||
/**
|
/**
|
||||||
* Off the stage, then released — and the stage itself is left running.
|
* 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
|
* station said so is a nice idea and a bad dependency for a room that has to
|
||||||
* render with no network at all.
|
* 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
|
* An optional `OfficeWalker` can temporarily possess a local actor and publish a
|
||||||
* will need, which is the point of doing the wall split once, but v1 orbits: the
|
* chase-camera pose. It is inactive by default; without one, or while inactive,
|
||||||
* ceilings come off, the walls between you and what you are looking at go
|
* the ceilings, occlusion fading, named views and orbit controls behave exactly
|
||||||
* translucent, and the existing camera, flight and picking machinery is reused
|
* as before.
|
||||||
* verbatim.
|
|
||||||
*
|
*
|
||||||
* ### Two depths, and the public one is the architecture without the people
|
* ### 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 { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts";
|
||||||
import { createShell, type Shell, type WallInfo } from "./shell.ts";
|
import { createShell, type Shell, type WallInfo } from "./shell.ts";
|
||||||
import { createLuminaires, type Luminaires, type Walker } from "./luminaires.ts";
|
import { createLuminaires, type Luminaires, type Walker } from "./luminaires.ts";
|
||||||
|
import {
|
||||||
|
createOfficeWalker,
|
||||||
|
type OfficeWalker,
|
||||||
|
type OfficeWalkerOptions,
|
||||||
|
} from "./officeWalker.ts";
|
||||||
import {
|
import {
|
||||||
createRobotLayer,
|
createRobotLayer,
|
||||||
type RobotLayer,
|
type RobotLayer,
|
||||||
@@ -210,6 +214,8 @@ export interface OfficeSceneOptions {
|
|||||||
* withhold, so a stranger gets them too.
|
* withhold, so a stranger gets them too.
|
||||||
*/
|
*/
|
||||||
robots?: readonly RobotSpec[];
|
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. */
|
/** Defaults to false — the lid comes off, because that is the whole view. */
|
||||||
showCeilings?: boolean;
|
showCeilings?: boolean;
|
||||||
/** Fade the walls you are looking through. Defaults to true. */
|
/** 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.
|
* print the "no presence" badge and whether to offer a sign-in.
|
||||||
*/
|
*/
|
||||||
depth: Depth;
|
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. */
|
/** The pack's viewpoints, as the thing a legend prints and `flyTo` is keyed on. */
|
||||||
views: View[];
|
views: View[];
|
||||||
flyTo(viewId: string): void;
|
flyTo(viewId: string): void;
|
||||||
@@ -486,6 +494,8 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
|||||||
options.robots && options.robots.length > 0
|
options.robots && options.robots.length > 0
|
||||||
? createRobotLayer(plan, { materials, robots: options.robots })
|
? createRobotLayer(plan, { materials, robots: options.robots })
|
||||||
: null;
|
: null;
|
||||||
|
const officeWalker = options.walker ? createOfficeWalker(plan, options.walker) : null;
|
||||||
|
if (officeWalker) scene.add(officeWalker.root);
|
||||||
if (robots) {
|
if (robots) {
|
||||||
scene.add(robots.group);
|
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
|
* 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.
|
* 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
|
// A public office has no presence layer, rather than an empty one. The
|
||||||
// difference is not cosmetic: an empty `PresenceLayer` is a `THREE.Group`
|
// 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,
|
controls: kit.controls,
|
||||||
plan,
|
plan,
|
||||||
depth,
|
depth,
|
||||||
|
walker: officeWalker,
|
||||||
views,
|
views,
|
||||||
// A public office anchors nothing, because it has nobody to anchor. The
|
// 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
|
// 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
|
// HTML overlay that writes into what it was handed should not be able to
|
||||||
// reach across into another office.
|
// reach across into another office.
|
||||||
anchors: presence?.anchors ?? new Map<string, THREE.Vector3>(),
|
anchors: presence?.anchors ?? new Map<string, THREE.Vector3>(),
|
||||||
flyTo,
|
flyTo(viewId) {
|
||||||
|
officeWalker?.setActive(false);
|
||||||
|
kit.controls.enabled = true;
|
||||||
|
flyTo(viewId);
|
||||||
|
},
|
||||||
current: () => currentView,
|
current: () => currentView,
|
||||||
onViewChange(fn) {
|
onViewChange(fn) {
|
||||||
viewListeners.push(fn);
|
viewListeners.push(fn);
|
||||||
@@ -799,7 +819,11 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
|||||||
onExit: () => kit.resetPick(),
|
onExit: () => kit.resetPick(),
|
||||||
tick(dt) {
|
tick(dt) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
const walking = officeWalker?.active() ?? false;
|
||||||
|
kit.controls.enabled = !walking;
|
||||||
kit.tick(dt);
|
kit.tick(dt);
|
||||||
|
officeWalker?.tick(dt);
|
||||||
|
if (walking && officeWalker) kit.setPose(officeWalker.followPose());
|
||||||
updateOcclusion();
|
updateOcclusion();
|
||||||
// Robots first: the lights above them should respond to where they are
|
// Robots first: the lights above them should respond to where they are
|
||||||
// *now*, not to where they were last frame.
|
// *now*, not to where they were last frame.
|
||||||
@@ -807,6 +831,7 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
|||||||
luminaires.tick(dt);
|
luminaires.tick(dt);
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
|
officeWalker?.dispose();
|
||||||
robots?.dispose();
|
robots?.dispose();
|
||||||
luminaires.dispose();
|
luminaires.dispose();
|
||||||
if (horizonPlane) {
|
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 {
|
export interface WalkerOptions extends WalkerSpawn {
|
||||||
|
/** Initial unit direction; defaults to north / local -Z. */
|
||||||
|
facing?: Point2;
|
||||||
/** Circular footprint radius, in metres. */
|
/** Circular footprint radius, in metres. */
|
||||||
radius?: number;
|
radius?: number;
|
||||||
/** Metres per second at full input. */
|
/** 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 spawn = checkedSpawn(plan, options, radius);
|
||||||
let position = copy(spawn.position);
|
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 distance = 0;
|
||||||
let accumulator = 0;
|
let accumulator = 0;
|
||||||
|
|
||||||
@@ -102,7 +105,7 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
|
|||||||
function reset(next = spawn): WalkerState {
|
function reset(next = spawn): WalkerState {
|
||||||
spawn = checkedSpawn(plan, next, radius);
|
spawn = checkedSpawn(plan, next, radius);
|
||||||
position = copy(spawn.position);
|
position = copy(spawn.position);
|
||||||
facing = { x: 0, z: -1 };
|
facing = copy(initialFacing);
|
||||||
distance = 0;
|
distance = 0;
|
||||||
accumulator = 0;
|
accumulator = 0;
|
||||||
return snapshot();
|
return snapshot();
|
||||||
@@ -138,6 +141,12 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
|
|||||||
return { state: snapshot, tick, reset };
|
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(
|
function moveWithSliding(
|
||||||
plan: WalkerPlan,
|
plan: WalkerPlan,
|
||||||
levelId: string,
|
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 };
|
||||||
+237
-5
@@ -60,6 +60,15 @@ import { OFFICE_SITES } from "./offices/sites.ts";
|
|||||||
import { authFetch } from "./session.ts";
|
import { authFetch } from "./session.ts";
|
||||||
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
|
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
|
||||||
import { createMinimap, type Minimap } from "./engine/minimap.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
|
* Three type-only imports and not one value among them, which is what keeps the
|
||||||
* office and the instruments out of the entry chunk.
|
* 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 },
|
{ 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.
|
* The buildings this page can walk into.
|
||||||
*
|
*
|
||||||
@@ -758,6 +800,29 @@ async function mountCity(id: string) {
|
|||||||
|
|
||||||
const handle = await createScene(stage, {
|
const handle = await createScene(stage, {
|
||||||
city: entry.city,
|
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,
|
markerPalette: palette,
|
||||||
markers: initialMarkers,
|
markers: initialMarkers,
|
||||||
...(id === "california"
|
...(id === "california"
|
||||||
@@ -1030,6 +1095,23 @@ async function enterOffice() {
|
|||||||
|
|
||||||
office = createOfficeScene(pack, {
|
office = createOfficeScene(pack, {
|
||||||
dom: city.stage.renderer.domElement,
|
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
|
// 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
|
// gradient and a horizon; painting the old flat colour over that is the
|
||||||
// bug that looks exactly like the sky not working.
|
// bug that looks exactly like the sky not working.
|
||||||
@@ -1061,6 +1143,9 @@ async function enterOffice() {
|
|||||||
}
|
}
|
||||||
city.stage.setScene(office);
|
city.stage.setScene(office);
|
||||||
inside = true;
|
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();
|
showPlan();
|
||||||
showDetail(null);
|
showDetail(null);
|
||||||
refreshGodmodePlace();
|
refreshGodmodePlace();
|
||||||
@@ -1177,6 +1262,9 @@ function leaveOffice() {
|
|||||||
// Before the scene swap, so the last thing the watch can do is abort a request
|
// 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.
|
// rather than publish into a room the user has already left.
|
||||||
stopWatchingOccupancy();
|
stopWatchingOccupancy();
|
||||||
|
office?.walker?.setActive(false);
|
||||||
|
office?.walker?.setAction({ x: 0, z: 0 });
|
||||||
|
dispatchJourney({ type: "leave-office" });
|
||||||
city.stage.setScene(city.stageScene);
|
city.stage.setScene(city.stageScene);
|
||||||
inside = false;
|
inside = false;
|
||||||
showPlan();
|
showPlan();
|
||||||
@@ -1338,6 +1426,9 @@ const planToggle = document.querySelector<HTMLButtonElement>("#plan-toggle");
|
|||||||
const credits = document.querySelector<HTMLElement>("#credits");
|
const credits = document.querySelector<HTMLElement>("#credits");
|
||||||
const driveControls = document.querySelector<HTMLElement>("#drive-controls");
|
const driveControls = document.querySelector<HTMLElement>("#drive-controls");
|
||||||
const driveHint = document.querySelector<HTMLElement>("#drive-hint");
|
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) {
|
function showDetail(text: string | null) {
|
||||||
const card = document.querySelector<HTMLElement>("#detail");
|
const card = document.querySelector<HTMLElement>("#detail");
|
||||||
@@ -1467,14 +1558,31 @@ function renderLegend() {
|
|||||||
// what is behind it, and that is the badge's job to say, not the button's.
|
// 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 →";
|
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();
|
renderSource();
|
||||||
if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
|
if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
canvas.setAttribute(
|
canvas.setAttribute(
|
||||||
"aria-label",
|
"aria-label",
|
||||||
inside
|
inside
|
||||||
? `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
|
? walking
|
||||||
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
|
? `${officeName()}, following your ${office?.walker?.state().actor === "anonymous-dog" ? "dog" : "humanoid"}. Use W A S D to move.`
|
||||||
|
: `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
|
||||||
|
: exploring
|
||||||
|
? `${cityLabel}, following your ${city.actorState()?.kind ?? "actor"}. Use W A S D to move.`
|
||||||
|
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Each plan rings the entry its own legend is showing as current. Both are
|
// Each plan rings the entry its own legend is showing as current. Both are
|
||||||
@@ -1484,7 +1592,9 @@ function renderLegend() {
|
|||||||
officePlan?.setActiveView(office?.current() ?? null);
|
officePlan?.setActiveView(office?.current() ?? null);
|
||||||
renderOfficeBadge();
|
renderOfficeBadge();
|
||||||
if (driveControls) driveControls.hidden = !routeDriveIsActive();
|
if (driveControls) driveControls.hidden = !routeDriveIsActive();
|
||||||
|
if (walkControls) walkControls.hidden = !(walking || exploring);
|
||||||
if (driveHint) driveHint.hidden = inside || cityId !== "california";
|
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];
|
const view = currentViews()[index];
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
if (inside && office) office.flyTo(view.id);
|
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 && leaveToCity(id)) return;
|
||||||
if (inside) leaveOffice();
|
if (inside) leaveOffice();
|
||||||
if (id === wantedCity) return;
|
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;
|
wantedCity = id;
|
||||||
const label = CITIES.find((c) => c.id === id)?.label ?? id;
|
const label = CITIES.find((c) => c.id === id)?.label ?? id;
|
||||||
void building(`Building ${label}…`, () => mountCity(id));
|
void building(`Building ${label}…`, () => mountCity(id));
|
||||||
@@ -1774,6 +1910,27 @@ async function toggleOffice() {
|
|||||||
|
|
||||||
enterButton?.addEventListener("click", () => void 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.
|
* Clicking a building on the city walks into it.
|
||||||
*
|
*
|
||||||
@@ -1894,7 +2051,7 @@ const heldDriveKeys = new Set<string>();
|
|||||||
|
|
||||||
function routeDriveIsActive(): boolean {
|
function routeDriveIsActive(): boolean {
|
||||||
const state = !inside ? city?.vehicleState() : null;
|
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(
|
function publishVehicleActions(
|
||||||
@@ -1917,6 +2074,28 @@ function publishVehicleActions(
|
|||||||
return true;
|
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 {
|
function toggleVehicleCamera(): boolean {
|
||||||
if (!routeDriveIsActive() || !city) return false;
|
if (!routeDriveIsActive() || !city) return false;
|
||||||
city.setVehicleCamera(city.vehicleCamera() === "driver" ? "chase" : "driver");
|
city.setVehicleCamera(city.vehicleCamera() === "driver" ? "chase" : "driver");
|
||||||
@@ -1944,6 +2123,29 @@ for (const button of driveControls?.querySelectorAll<HTMLButtonElement>("[data-d
|
|||||||
button.addEventListener("lostpointercapture", release);
|
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']")
|
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='assist']")
|
||||||
?.addEventListener("click", () => publishVehicleActions({ modeRequest: "assisted" }));
|
?.addEventListener("click", () => publishVehicleActions({ modeRequest: "assisted" }));
|
||||||
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
|
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
|
||||||
@@ -1954,12 +2156,14 @@ driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
|
|||||||
window.addEventListener("keyup", (event) => {
|
window.addEventListener("keyup", (event) => {
|
||||||
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||||
if (!heldDriveKeys.delete(key)) return;
|
if (!heldDriveKeys.delete(key)) return;
|
||||||
if (publishVehicleActions()) event.preventDefault();
|
if (publishVehicleActions() || publishOfficeWalkActions() || publishCityActorActions()) event.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("blur", () => {
|
window.addEventListener("blur", () => {
|
||||||
heldDriveKeys.clear();
|
heldDriveKeys.clear();
|
||||||
publishVehicleActions();
|
publishVehicleActions();
|
||||||
|
publishOfficeWalkActions();
|
||||||
|
publishCityActorActions();
|
||||||
});
|
});
|
||||||
|
|
||||||
let gamepadButtons: GamepadButtonState = { assist: false, reset: false };
|
let gamepadButtons: GamepadButtonState = { assist: false, reset: false };
|
||||||
@@ -2034,6 +2238,14 @@ window.addEventListener("keydown", (event) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (publishOfficeWalkActions()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (publishCityActorActions()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (lower === "p" && publishVehicleActions({ modeRequest: "assisted" })) {
|
if (lower === "p" && publishVehicleActions({ modeRequest: "assisted" })) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -2047,6 +2259,10 @@ window.addEventListener("keydown", (event) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (lower === "v" && toggleOfficeWalk()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (lower === "m") {
|
if (lower === "m") {
|
||||||
togglePlan();
|
togglePlan();
|
||||||
return;
|
return;
|
||||||
@@ -2482,6 +2698,22 @@ async function boot() {
|
|||||||
|
|
||||||
if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
|
if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
|
||||||
access = await resolveAccess();
|
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();
|
applyTimeControl();
|
||||||
renderTierBadge();
|
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