1
0

feat: add playable flight and office screen sharing

This commit is contained in:
2026-08-11 19:46:47 -07:00
parent c0c7fcf974
commit 16dc85a6f8
13 changed files with 1355 additions and 45 deletions
+14 -8
View File
@@ -941,6 +941,8 @@
<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> <button id="walk" class="walk" aria-pressed="false" hidden>Walk as your dog →</button>
<button id="fly" class="walk" aria-pressed="false" hidden>Fly the California route →</button>
<button id="screens" class="walk" hidden>Office screens →</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>
@@ -992,17 +994,21 @@
</div> </div>
<div id="walk-controls" class="walk-controls" aria-label="Walking controls" hidden> <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="a" data-walk-label="Left" data-aircraft-label="Roll left" 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="w" data-walk-label="Forward" data-aircraft-label="Pitch up" 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="s" data-walk-label="Back" data-aircraft-label="Pitch down" aria-pressed="false">Back</button>
<button class="walk-control" data-walk-key="d" aria-pressed="false">Right</button> <button class="walk-control" data-walk-key="d" data-walk-label="Right" data-aircraft-label="Roll right" aria-pressed="false">Right</button>
<button class="walk-control flight-only" data-walk-key="q" aria-pressed="false">Descend</button> <button class="walk-control flight-only" data-walk-key="q" data-walk-label="Descend" data-aircraft-label="Yaw left" aria-pressed="false">Descend</button>
<button class="walk-control flight-only" data-walk-key="e" aria-pressed="false">Climb</button> <button class="walk-control flight-only" data-walk-key="e" data-walk-label="Climb" data-aircraft-label="Yaw right" aria-pressed="false">Climb</button>
<button class="walk-control aircraft-only" data-walk-key=" " data-aircraft-label="Throttle" aria-pressed="false" hidden>Throttle</button>
<button class="walk-control aircraft-only" data-aircraft-action="assist" hidden>Assist</button>
<button class="walk-control aircraft-only" data-aircraft-action="reset" hidden>Reset</button>
</div> </div>
<p id="source" class="source"></p> <p id="source" class="source"></p>
<div id="profile-overlay" class="profile-overlay" hidden></div> <div id="profile-overlay" class="profile-overlay" hidden></div>
<div id="screens-overlay" class="profile-overlay" hidden></div>
<!-- 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
scrim leaves no way to dismiss it but the same button that opened it, scrim leaves no way to dismiss it but the same button that opened it,
@@ -1015,11 +1021,11 @@
<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 a route, or move while walking inside</dd> <dt><kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd></dt><dd>Drive, walk, or manually fly the California aircraft</dd>
<dt><kbd>V</kbd></dt><dd>Walk through an office / return to the dollhouse view</dd> <dt><kbd>V</kbd></dt><dd>Walk through an office / return to the dollhouse view</dd>
<dt><kbd>Q</kbd> / <kbd>E</kbd></dt><dd>Descend / climb while flying as a crow</dd> <dt><kbd>Q</kbd> / <kbd>E</kbd></dt><dd>Descend / climb while flying as a crow</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 or flight / reset the vehicle</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>
<dt><kbd>[</kbd> <kbd>]</kbd></dt><dd>Previous / next city</dd> <dt><kbd>[</kbd> <kbd>]</kbd></dt><dd>Previous / next city</dd>
<dt><kbd>O</kbd></dt><dd>Enter or leave the office</dd> <dt><kbd>O</kbd></dt><dd>Enter or leave the office</dd>
+42
View File
@@ -76,6 +76,10 @@ export interface SceneActor {
switchActor(kind: ActorKind, identity?: ActorIdentity, mode?: ActorMode): ActorControllerSnapshot; switchActor(kind: ActorKind, identity?: ActorIdentity, mode?: ActorMode): ActorControllerSnapshot;
/** Rebuild the visible skin for a new serializable identity, preserving motion and kind. */ /** Rebuild the visible skin for a new serializable identity, preserving motion and kind. */
setIdentity(identity: ActorIdentity): ActorControllerSnapshot; setIdentity(identity: ActorIdentity): ActorControllerSnapshot;
/** Attach a caller-owned live/static face texture. False when the current actor is not humanoid. */
attachFaceTexture(texture: THREE.Texture): boolean;
/** Detach and release the adapter's reference without disposing the caller texture. */
clearFaceTexture(): void;
dispose(): void; dispose(): void;
} }
@@ -137,6 +141,23 @@ function disposeRig(actor: RiggedActor): void {
else disposeCrow(actor.rig); else disposeCrow(actor.rig);
} }
const DEFAULT_FACE_COLOR = 0x18242b;
function faceMaterial(rig: HumanoidRig): THREE.MeshBasicMaterial {
const material = rig.face.material;
if (!(material instanceof THREE.MeshBasicMaterial)) {
throw new Error("scene actor: humanoid face must use one MeshBasicMaterial");
}
return material;
}
function applyFaceTexture(rig: HumanoidRig, texture: THREE.Texture | null): void {
const material = faceMaterial(rig);
material.map = texture;
material.color.set(texture ? 0xffffff : DEFAULT_FACE_COLOR);
material.needsUpdate = true;
}
function positive(value: number, name: string): number { function positive(value: number, name: string): number {
if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`); if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`);
return value; return value;
@@ -177,6 +198,7 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
root.add(actor.rig.root); root.add(actor.rig.root);
let enabled = options.active ?? false; let enabled = options.active ?? false;
let desired: ActorActionSnapshot = { ...NEUTRAL_ACTOR_ACTIONS }; let desired: ActorActionSnapshot = { ...NEUTRAL_ACTOR_ACTIONS };
let faceTexture: THREE.Texture | null = null;
let disposed = false; let disposed = false;
function sync(): void { function sync(): void {
@@ -218,6 +240,11 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
const previous = actor; const previous = actor;
actor = buildActor(kind, controller.state().identity); actor = buildActor(kind, controller.state().identity);
root.add(actor.rig.root); root.add(actor.rig.root);
if (actor.kind === "humanoid" && faceTexture) applyFaceTexture(actor.rig, faceTexture);
if (previous.kind === "humanoid") applyFaceTexture(previous.rig, null);
// A face is a humanoid-only, potentially sensitive live reference. Do not
// retain it invisibly while the player is represented as an animal.
if (actor.kind !== "humanoid") faceTexture = null;
disposeRig(previous); disposeRig(previous);
sync(); sync();
} }
@@ -299,10 +326,25 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
replaceActor(controller.state().kind); replaceActor(controller.state().kind);
return controller.snapshot(); return controller.snapshot();
}, },
attachFaceTexture(texture) {
if (disposed || actor.kind !== "humanoid") return false;
if (!texture || texture.isTexture !== true) {
throw new RangeError("scene actor: face texture must be a Three.js Texture");
}
faceTexture = texture;
applyFaceTexture(actor.rig, texture);
return true;
},
clearFaceTexture() {
faceTexture = null;
if (!disposed && actor.kind === "humanoid") applyFaceTexture(actor.rig, null);
},
dispose() { dispose() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
root.removeFromParent(); root.removeFromParent();
if (actor.kind === "humanoid") applyFaceTexture(actor.rig, null);
faceTexture = null;
disposeRig(actor); disposeRig(actor);
}, },
}; };
+9
View File
@@ -31,3 +31,12 @@ export {
type ElectricAircraftMaterials, type ElectricAircraftMaterials,
type ElectricAircraftRig, type ElectricAircraftRig,
} from "./asset.ts"; } from "./asset.ts";
export {
CALIFORNIA_AIR_ROUTE,
createSceneAircraft,
type AircraftProjection,
type SceneAircraft,
type SceneAircraftCameraOptions,
type SceneAircraftOptions,
type SceneAircraftView,
} from "./sceneAircraft.ts";
+213
View File
@@ -0,0 +1,213 @@
/** Three.js presentation adapter for the deterministic electric aircraft. */
import * as THREE from "three";
import type { Pose } from "../engine/scenekit.ts";
import {
advanceAircraftFans,
buildElectricAircraft,
disposeElectricAircraft,
setAircraftControlSurfaces,
setAircraftFanRotation,
type ElectricAircraftBuildOptions,
type ElectricAircraftRig,
} from "./asset.ts";
import {
AircraftController,
NEUTRAL_AIRCRAFT_ACTIONS,
normalizeAircraftActions,
type AircraftActionSnapshot,
type AircraftControllerOptions,
type AircraftControllerSnapshot,
type AircraftWaypoint,
} from "./controller.ts";
export const CALIFORNIA_AIR_ROUTE: readonly AircraftWaypoint[] = Object.freeze([
Object.freeze({ id: "los-angeles", lat: 34.0522, lng: -118.2437, altitudeM: 1_350 }),
Object.freeze({ id: "central-valley", lat: 36.45, lng: -120.45, altitudeM: 1_700 }),
Object.freeze({ id: "san-francisco", lat: 37.7749, lng: -122.4194, altitudeM: 1_250 }),
]);
export type AircraftProjection = (lat: number, lng: number) => readonly [x: number, z: number];
export interface SceneAircraftCameraOptions {
distance?: number;
height?: number;
lookAhead?: number;
}
export interface SceneAircraftOptions extends AircraftControllerOptions {
project: AircraftProjection;
groundAt?: (lat: number, lng: number) => number;
/** Aircraft altitude metres to vertical scene units. */
altitudeSceneUnitsPerMetre?: number;
/** Aircraft model metres to scene units; useful as a state-board glyph. */
visualSceneUnitsPerMetre?: number;
camera?: SceneAircraftCameraOptions;
asset?: ElectricAircraftBuildOptions;
/** False by default, so construction never steals input or camera ownership. */
active?: boolean;
}
export interface SceneAircraftView {
/** Stable live scene position suitable for lights, labels, and camera consumers. */
position: THREE.Vector3;
}
export interface SceneAircraft {
root: THREE.Group;
view: SceneAircraftView;
state(): AircraftControllerSnapshot;
actions(): AircraftActionSnapshot;
setActions(actions: Partial<AircraftActionSnapshot>): AircraftActionSnapshot;
tick(elapsedSeconds: number): AircraftControllerSnapshot;
active(): boolean;
setActive(active: boolean): void;
followPose(): Pose;
reset(): AircraftControllerSnapshot;
dispose(): void;
}
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 checkedPoint(value: readonly [number, number]): readonly [number, number] {
if (value.length < 2 || !Number.isFinite(value[0]) || !Number.isFinite(value[1])) {
throw new RangeError("aircraft projection must return finite x/z coordinates");
}
return value;
}
export function createSceneAircraft(options: SceneAircraftOptions): SceneAircraft {
if (typeof options.project !== "function") throw new RangeError("aircraft project callback is required");
if (options.groundAt !== undefined && typeof options.groundAt !== "function") {
throw new RangeError("aircraft groundAt must be a function");
}
const altitudeScale = positive(
options.altitudeSceneUnitsPerMetre ?? 0.004,
"altitudeSceneUnitsPerMetre",
);
const visualScale = positive(options.visualSceneUnitsPerMetre ?? 0.12, "visualSceneUnitsPerMetre");
const camera = {
distance: options.camera?.distance ?? 20,
height: options.camera?.height ?? 7,
lookAhead: options.camera?.lookAhead ?? 32,
};
positive(camera.distance, "camera.distance");
nonNegative(camera.height, "camera.height");
nonNegative(camera.lookAhead, "camera.lookAhead");
const controller = new AircraftController(options);
const rig: ElectricAircraftRig = buildElectricAircraft(options.asset);
const root = new THREE.Group();
root.name = "playable-scene-aircraft";
root.userData.kind = "playable-aircraft";
root.userData.forwardAxis = "-Z";
root.scale.setScalar(visualScale);
root.add(rig.root);
const view = { position: new THREE.Vector3() };
let desired: AircraftActionSnapshot = { ...NEUTRAL_AIRCRAFT_ACTIONS };
let enabled = options.active ?? false;
let disposed = false;
function groundAt(lat: number, lng: number): number {
const value = options.groundAt?.(lat, lng) ?? 0;
if (!Number.isFinite(value)) throw new RangeError("aircraft groundAt must return a finite height");
return value;
}
function sync(previousFanRadians?: number): void {
const state = controller.state();
const [x, z] = checkedPoint(options.project(state.lat, state.lng));
root.position.set(x, groundAt(state.lat, state.lng) + state.altitudeM * altitudeScale, z);
root.rotation.order = "YXZ";
root.rotation.set(
state.pitchDeg * Math.PI / 180,
-state.headingDeg * Math.PI / 180,
-state.rollDeg * Math.PI / 180,
);
view.position.copy(root.position);
setAircraftControlSurfaces(rig, {
roll: state.rollInput,
pitch: state.pitchInput,
yaw: state.yawInput,
});
if (previousFanRadians === undefined) setAircraftFanRotation(rig, state.fanRadians);
else {
let delta = state.fanRadians - previousFanRadians;
if (delta < -Math.PI) delta += Math.PI * 2;
if (delta > Math.PI) delta -= Math.PI * 2;
advanceAircraftFans(rig, delta);
}
}
function clearEdges(): void {
desired.modeRequest = "none";
desired.reset = false;
}
sync();
return {
root,
view,
state: () => controller.snapshot(),
actions: () => ({ ...desired }),
setActions(actions) {
desired = normalizeAircraftActions(actions);
return { ...desired };
},
tick(elapsedSeconds) {
if (!disposed && enabled) {
const previousFanRadians = controller.state().fanRadians;
controller.tick(elapsedSeconds, desired);
sync(previousFanRadians);
clearEdges();
}
return controller.snapshot();
},
active: () => enabled,
setActive(active) {
if (active !== enabled) desired = { ...NEUTRAL_AIRCRAFT_ACTIONS };
enabled = active;
},
followPose() {
const state = controller.state();
const heading = state.headingDeg * Math.PI / 180;
const pitch = state.pitchDeg * Math.PI / 180;
// Compass heading zero follows projected north at -Z, matching Tera's map convention.
const forward = new THREE.Vector3(
Math.sin(heading) * Math.cos(pitch),
Math.sin(pitch),
-Math.cos(heading) * Math.cos(pitch),
);
return {
position: root.position.clone()
.addScaledVector(forward, -camera.distance * visualScale)
.add(new THREE.Vector3(0, camera.height * visualScale, 0)),
target: root.position.clone().addScaledVector(forward, camera.lookAhead * visualScale),
};
},
reset() {
if (!disposed) {
controller.reset();
desired = { ...NEUTRAL_AIRCRAFT_ACTIONS };
sync();
}
return controller.snapshot();
},
dispose() {
if (disposed) return;
disposed = true;
root.removeFromParent();
root.remove(rig.root);
disposeElectricAircraft(rig);
},
};
}
+48 -2
View File
@@ -70,6 +70,14 @@ import type {
ActorControllerSnapshot, ActorControllerSnapshot,
ActorIdentity, ActorIdentity,
} from "../actors/controller.ts"; } from "../actors/controller.ts";
import {
createSceneAircraft,
type SceneAircraftOptions,
} from "../aircraft/sceneAircraft.ts";
import type {
AircraftActionSnapshot,
AircraftControllerSnapshot,
} from "../aircraft/controller.ts";
export interface SceneOptions { export interface SceneOptions {
city: City; city: City;
@@ -89,6 +97,8 @@ export interface SceneOptions {
actor?: SceneActorOptions; actor?: SceneActorOptions;
/** Geographic spawn anchor for that actor; defaults to the board centre. */ /** Geographic spawn anchor for that actor; defaults to the board centre. */
actorAnchor?: { lat: number; lng: number }; actorAnchor?: { lat: number; lng: number };
/** Optional possessed fixed-wing aircraft; the city supplies its projection and terrain. */
aircraft?: Omit<SceneAircraftOptions, "project" | "groundAt">;
flights?: FlightSource; flights?: FlightSource;
/** /**
* Element sets to propagate, if this deployment has any. * Element sets to propagate, if this deployment has any.
@@ -191,6 +201,10 @@ export interface SceneHandle {
setActorIdentity(identity: ActorIdentity): void; setActorIdentity(identity: ActorIdentity): void;
setActorActive(active: boolean): void; setActorActive(active: boolean): void;
actorActive(): boolean; actorActive(): boolean;
setAircraftActions(actions: Partial<AircraftActionSnapshot>): void;
aircraftState(): Readonly<AircraftControllerSnapshot> | null;
setAircraftActive(active: boolean): void;
aircraftActive(): 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;
@@ -377,6 +391,16 @@ export async function createScene(
}) })
: null; : null;
if (sceneActor) scene.add(sceneActor.root); if (sceneActor) scene.add(sceneActor.root);
const sceneAircraft = options.aircraft
? createSceneAircraft({
...options.aircraft,
project: (lat, lng) => world.project(lat, lng),
groundAt: (lat, lng) => world.groundAt(lat, lng),
altitudeSceneUnitsPerMetre: options.aircraft.altitudeSceneUnitsPerMetre ??
1 / world.metresPerUnit,
})
: null;
if (sceneAircraft) scene.add(sceneAircraft.root);
let flightLayer: FlightLayer | null = null; let flightLayer: FlightLayer | null = null;
let flightTimer = 0; let flightTimer = 0;
@@ -445,6 +469,7 @@ export async function createScene(
// is an explicit UI action, so a chapter selection always hands the camera // is an explicit UI action, so a chapter selection always hands the camera
// back before it moves anywhere else. // back before it moves anywhere else.
sceneActor?.setActive(false); sceneActor?.setActive(false);
sceneAircraft?.setActive(false);
kit.controls.minDistance = orbitMinDistance; kit.controls.minDistance = orbitMinDistance;
if (kit.camera.near !== 0.1) { if (kit.camera.near !== 0.1) {
kit.camera.near = 0.1; kit.camera.near = 0.1;
@@ -489,10 +514,13 @@ export async function createScene(
onExit: () => kit.resetPick(), onExit: () => kit.resetPick(),
tick(dt) { tick(dt) {
const actorPlaying = sceneActor?.active() ?? false; const actorPlaying = sceneActor?.active() ?? false;
kit.controls.enabled = !actorPlaying; const aircraftPlaying = sceneAircraft?.active() ?? false;
kit.controls.enabled = !actorPlaying && !aircraftPlaying;
kit.tick(dt); kit.tick(dt);
sceneActor?.tick(dt); sceneActor?.tick(dt);
if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose()); if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose());
sceneAircraft?.tick(dt);
if (aircraftPlaying && sceneAircraft) kit.setPose(sceneAircraft.followPose());
roadTraffic?.tick(dt); roadTraffic?.tick(dt);
clouds.tick(dt); clouds.tick(dt);
if (options.flights && flightLayer) { if (options.flights && flightLayer) {
@@ -543,6 +571,7 @@ export async function createScene(
nightLights.dispose(); nightLights.dispose();
markerLayer.dispose(); markerLayer.dispose();
roadTraffic?.dispose(); roadTraffic?.dispose();
sceneAircraft?.dispose();
kit.dispose(); kit.dispose();
scene.traverse((obj) => { scene.traverse((obj) => {
const mesh = obj as THREE.Mesh; const mesh = obj as THREE.Mesh;
@@ -589,7 +618,10 @@ export async function createScene(
setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); }, setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); },
setActorActive(active) { setActorActive(active) {
sceneActor?.setActive(active); sceneActor?.setActive(active);
if (active) roadTraffic?.setFollowing(false); if (active) {
sceneAircraft?.setActive(false);
roadTraffic?.setFollowing(false);
}
// State boards compress one real metre to a few hundredths of a scene // State boards compress one real metre to a few hundredths of a scene
// unit. Their possessed actor and chase camera are therefore closer than // 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 // the map camera's 0.1 near plane; lower it only for play mode so the
@@ -599,11 +631,25 @@ export async function createScene(
kit.camera.updateProjectionMatrix(); kit.camera.updateProjectionMatrix();
}, },
actorActive: () => sceneActor?.active() ?? false, actorActive: () => sceneActor?.active() ?? false,
setAircraftActions: (actions) => { sceneAircraft?.setActions(actions); },
aircraftState: () => sceneAircraft?.state() ?? null,
setAircraftActive(active) {
sceneAircraft?.setActive(active);
if (active) {
sceneActor?.setActive(false);
roadTraffic?.setFollowing(false);
}
kit.camera.near = active ? 0.01 : 0.1;
kit.controls.minDistance = active ? 0.01 : orbitMinDistance;
kit.camera.updateProjectionMatrix();
},
aircraftActive: () => sceneAircraft?.active() ?? false,
setMarkers(markers) { setMarkers(markers) {
markerLayer.setMarkers(markers); markerLayer.setMarkers(markers);
}, },
dispose() { dispose() {
sceneActor?.dispose(); sceneActor?.dispose();
sceneAircraft?.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.
* *
+68 -9
View File
@@ -2,9 +2,10 @@
* The office-facing half of walk mode: a numeric `WalkerController` wearing an * The office-facing half of walk mode: a numeric `WalkerController` wearing an
* original actor rig and publishing a third-person camera pose. * original actor rig and publishing a third-person camera pose.
* *
* There is deliberately no identity, webcam, keyboard or network code here. * There is deliberately no identity, webcam capture, keyboard or network code here.
* Callers choose humanoid or anonymous dog, translate their own input device to * Callers choose humanoid or anonymous dog, translate their own input device to
* a normalized planar action, and decide when walk mode is active. * a normalized planar action, and decide when walk mode is active. A caller may
* attach an already-created face texture; ownership stays with that caller.
*/ */
import * as THREE from "three"; import * as THREE from "three";
@@ -88,6 +89,12 @@ export interface OfficeWalker {
setAction(action: WalkerAction): WalkerAction; setAction(action: WalkerAction): WalkerAction;
tick(elapsedSeconds: number): OfficeWalkerState; tick(elapsedSeconds: number): OfficeWalkerState;
reset(spawn?: WalkerSpawn): OfficeWalkerState; reset(spawn?: WalkerSpawn): OfficeWalkerState;
/** Rebuild only the procedural skin, retaining this root and all walker/camera state. */
setAppearance(appearance: OfficeActorAppearance): OfficeWalkerState;
/** Attach a caller-owned texture. False for an anonymous dog. */
attachFaceTexture(texture: THREE.Texture): boolean;
/** Detach the current face without disposing the caller's texture. */
clearFaceTexture(): void;
/** A defensive scene-space chase-camera pose for the current actor state. */ /** A defensive scene-space chase-camera pose for the current actor state. */
followPose(): Pose; followPose(): Pose;
dispose(): void; dispose(): void;
@@ -98,8 +105,7 @@ type Actor =
| { kind: "anonymous-dog"; rig: DogRig }; | { kind: "anonymous-dog"; rig: DogRig };
export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): OfficeWalker { export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): OfficeWalker {
const appearance = options.actor ?? { kind: "humanoid" }; let actor = buildActor(options.actor ?? { kind: "humanoid" });
const actor = buildActor(appearance);
const controller: WalkerController = createWalker(plan, options); const controller: WalkerController = createWalker(plan, options);
const baseCamera = actor.kind === "humanoid" ? HUMANOID_CAMERA : DOG_CAMERA; const baseCamera = actor.kind === "humanoid" ? HUMANOID_CAMERA : DOG_CAMERA;
const camera = { const camera = {
@@ -112,15 +118,22 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
let enabled = options.active ?? false; let enabled = options.active ?? false;
let desired: WalkerAction = { x: 0, z: 0 }; let desired: WalkerAction = { x: 0, z: 0 };
let gait = 0; let gait = 0;
let faceTexture: THREE.Texture | null = null;
let disposed = false; let disposed = false;
const root = new THREE.Group();
root.name = "office-walker-actor";
root.userData.kind = "playable-actor";
root.userData.forwardAxis = "-Z";
root.userData.actorType = actor.rig.root.userData.actorType;
root.add(actor.rig.root);
const view = { position: new THREE.Vector3() }; const view = { position: new THREE.Vector3() };
function sync(state: WalkerState, elapsedSeconds = 0, travelled = 0): void { function sync(state: WalkerState, elapsedSeconds = 0, travelled = 0): void {
const level = plan.level(state.levelId); const level = plan.level(state.levelId);
if (!level) throw new Error(`office walker lost 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); root.position.set(state.position.x, level.floorY, state.position.z);
view.position.copy(actor.rig.root.position); view.position.copy(root.position);
actor.rig.root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z); root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z);
const speed = elapsedSeconds > 0 ? travelled / elapsedSeconds : 0; const speed = elapsedSeconds > 0 ? travelled / elapsedSeconds : 0;
const targetGait = enabled && speed > 1e-5 ? Math.min(1, speed / (options.speed ?? 1.6)) : 0; const targetGait = enabled && speed > 1e-5 ? Math.min(1, speed / (options.speed ?? 1.6)) : 0;
@@ -146,10 +159,24 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
}; };
} }
function replaceActor(appearance: OfficeActorAppearance): void {
const previous = actor;
actor = buildActor(appearance);
root.add(actor.rig.root);
if (actor.kind === "humanoid" && faceTexture) applyOfficeFaceTexture(actor.rig, faceTexture);
if (previous.kind === "humanoid") applyOfficeFaceTexture(previous.rig, null);
if (actor.kind !== "humanoid") faceTexture = null;
previous.rig.root.removeFromParent();
if (previous.kind === "humanoid") disposeHumanoid(previous.rig);
else disposeDog(previous.rig);
root.userData.actorType = actor.rig.root.userData.actorType;
sync(controller.state());
}
sync(controller.state()); sync(controller.state());
return { return {
root: actor.rig.root, root,
view, view,
state: snapshot, state: snapshot,
active: () => enabled, active: () => enabled,
@@ -178,6 +205,24 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
sync(state); sync(state);
return snapshot(); return snapshot();
}, },
setAppearance(appearance) {
if (disposed) return snapshot();
replaceActor(appearance);
return snapshot();
},
attachFaceTexture(texture) {
if (disposed || actor.kind !== "humanoid") return false;
if (!texture || texture.isTexture !== true) {
throw new RangeError("office walker: face texture must be a Three.js Texture");
}
faceTexture = texture;
applyOfficeFaceTexture(actor.rig, texture);
return true;
},
clearFaceTexture() {
faceTexture = null;
if (!disposed && actor.kind === "humanoid") applyOfficeFaceTexture(actor.rig, null);
},
followPose() { followPose() {
const state = controller.state(); const state = controller.state();
const level = plan.level(state.levelId); const level = plan.level(state.levelId);
@@ -198,7 +243,9 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
dispose() { dispose() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
actor.rig.root.removeFromParent(); root.removeFromParent();
if (actor.kind === "humanoid") applyOfficeFaceTexture(actor.rig, null);
faceTexture = null;
if (actor.kind === "humanoid") disposeHumanoid(actor.rig); if (actor.kind === "humanoid") disposeHumanoid(actor.rig);
else disposeDog(actor.rig); else disposeDog(actor.rig);
}, },
@@ -228,6 +275,18 @@ function buildActor(appearance: OfficeActorAppearance): Actor {
}; };
} }
const DEFAULT_OFFICE_FACE_COLOR = 0x18242b;
function applyOfficeFaceTexture(rig: HumanoidRig, texture: THREE.Texture | null): void {
const material = rig.face.material;
if (!(material instanceof THREE.MeshBasicMaterial)) {
throw new Error("office walker: humanoid face must use one MeshBasicMaterial");
}
material.map = texture;
material.color.set(texture ? 0xffffff : DEFAULT_OFFICE_FACE_COLOR);
material.needsUpdate = true;
}
function positive(value: number, name: string): number { function positive(value: number, name: string): number {
if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`); if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`);
return value; return value;
+275 -26
View File
@@ -80,6 +80,16 @@ import {
type ProfileEditor, type ProfileEditor,
} from "./profile/index.ts"; } from "./profile/index.ts";
import type { ActorIdentity } from "./actors/controller.ts"; import type { ActorIdentity } from "./actors/controller.ts";
import { SRGBColorSpace, VideoTexture } from "three";
import {
CALIFORNIA_AIR_ROUTE,
type AircraftActionSnapshot,
} from "./aircraft/index.ts";
import {
createOfficeScreenPanel,
type MediaSurfaceDescriptor,
type OfficeScreenPanel,
} from "./media/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.
@@ -422,6 +432,13 @@ let officeId = OFFICES[0]?.id ?? "lumbridge-hq";
* interior rig and is a supported state rather than a gap. * interior rig and is a supported state rather than a gap.
*/ */
let officeAtmosphere: Atmosphere | null = null; let officeAtmosphere: Atmosphere | null = null;
let officeScreenPanel: OfficeScreenPanel | null = null;
let sharedScreen: {
screenId: string;
stream: MediaStream;
video: HTMLVideoElement;
texture: VideoTexture;
} | null = null;
/** /**
* The plan panel's other occupant. * The plan panel's other occupant.
* *
@@ -728,6 +745,7 @@ async function mountCity(id: string) {
poseEditor?.destroy(); poseEditor?.destroy();
poseEditor = null; poseEditor = null;
stopWatchingOccupancy(); stopWatchingOccupancy();
disposeOfficeScreenUi();
officePlan?.dispose(); officePlan?.dispose();
officePlan = null; officePlan = null;
office?.dispose(); office?.dispose();
@@ -868,6 +886,18 @@ async function mountCity(id: string) {
count: 14, count: 14,
seed: 115, seed: 115,
}, },
aircraft: {
route: CALIFORNIA_AIR_ROUTE,
initialPosition: CALIFORNIA_AIR_ROUTE[0],
initialAltitudeM: CALIFORNIA_AIR_ROUTE[0]?.altitudeM ?? 1_350,
initialHeadingDeg: 320,
assistedAltitudeM: 1_500,
// Corridor altitude is an atlas glyph just like the black cars:
// literal metres would put the chase camera inside the coarse hills.
altitudeSceneUnitsPerMetre: 0.01,
visualSceneUnitsPerMetre: 0.22,
camera: { distance: 52, height: 18, lookAhead: 18 },
},
} }
: {}), : {}),
flights: dial.source, flights: dial.source,
@@ -1326,6 +1356,7 @@ 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();
disposeOfficeScreenUi();
office?.walker?.setActive(false); office?.walker?.setActive(false);
office?.walker?.setAction({ x: 0, z: 0 }); office?.walker?.setAction({ x: 0, z: 0 });
dispatchJourney({ type: "leave-office" }); dispatchJourney({ type: "leave-office" });
@@ -1491,9 +1522,12 @@ 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 walkButton = document.querySelector<HTMLButtonElement>("#walk");
const flyButton = document.querySelector<HTMLButtonElement>("#fly");
const screensButton = document.querySelector<HTMLButtonElement>("#screens");
const walkControls = document.querySelector<HTMLElement>("#walk-controls"); const walkControls = document.querySelector<HTMLElement>("#walk-controls");
const walkHint = document.querySelector<HTMLElement>("#walk-hint"); const walkHint = document.querySelector<HTMLElement>("#walk-hint");
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay"); const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
let profileEditor: ProfileEditor | null = null; let profileEditor: ProfileEditor | null = null;
function showDetail(text: string | null) { function showDetail(text: string | null) {
@@ -1559,6 +1593,7 @@ async function switchOffice(id: string) {
// The roster belongs to the building you have left. // The roster belongs to the building you have left.
stopWatchingOccupancy(); stopWatchingOccupancy();
disposeOfficeScreenUi();
officePlan?.dispose(); officePlan?.dispose();
officePlan = null; officePlan = null;
office?.dispose(); office?.dispose();
@@ -1626,6 +1661,7 @@ function renderLegend() {
} }
const walking = inside && (office?.walker?.active() ?? false); const walking = inside && (office?.walker?.active() ?? false);
const exploring = !inside && (city.actorActive() ?? false); const exploring = !inside && (city.actorActive() ?? false);
const flying = !inside && (city.aircraftActive() ?? false);
if (walkButton) { if (walkButton) {
walkButton.hidden = inside ? office?.walker === null : city.actorState() === null; walkButton.hidden = inside ? office?.walker === null : city.actorState() === null;
walkButton.setAttribute("aria-pressed", String(walking || exploring)); walkButton.setAttribute("aria-pressed", String(walking || exploring));
@@ -1637,6 +1673,15 @@ function renderLegend() {
walkButton.textContent = exploring ? "Return to flyover ↑" : `Explore as ${actor}`; walkButton.textContent = exploring ? "Return to flyover ↑" : `Explore as ${actor}`;
} }
} }
if (flyButton) {
flyButton.hidden = inside || cityId !== "california" || city.aircraftState() === null;
flyButton.setAttribute("aria-pressed", String(flying));
flyButton.textContent = flying ? "Return to flyover ↑" : "Fly the California route →";
}
if (screensButton) {
const canManage = inside && office?.depth === "full" && (office.listMediaSurfaces().length > 0);
screensButton.hidden = !canManage;
}
renderSource(); renderSource();
if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel; if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
if (canvas) { if (canvas) {
@@ -1646,6 +1691,8 @@ function renderLegend() {
? walking ? walking
? `${officeName()}, following your ${office?.walker?.state().actor === "anonymous-dog" ? "dog" : "humanoid"}. Use W A S D to move.` ? `${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.` : `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
: flying
? `${cityLabel}, following your electric aircraft. Use W A S D to fly or P to resume assisted flight.`
: exploring : exploring
? `${cityLabel}, following your ${city.actorState()?.kind ?? "actor"}. Use W A S D to move.` ? `${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.`, : `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
@@ -1658,12 +1705,27 @@ 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 (walkControls) walkControls.hidden = !(walking || exploring || flying);
for (const control of walkControls?.querySelectorAll<HTMLElement>(".flight-only") ?? []) { for (const control of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
control.hidden = inside; control.textContent = flying
? control.dataset.aircraftLabel ?? control.textContent
: control.dataset.walkLabel ?? control.textContent;
}
for (const control of walkControls?.querySelectorAll<HTMLElement>(".flight-only") ?? []) {
control.hidden = inside || (!flying && city.actorState()?.kind !== "crow");
}
for (const control of walkControls?.querySelectorAll<HTMLElement>(".aircraft-only") ?? []) {
control.hidden = !flying;
}
if (driveHint) driveHint.hidden = inside || cityId !== "california" || flying;
if (walkHint) {
walkHint.hidden = !(inside || city.actorState() || city.aircraftState());
walkHint.textContent = flying
? "WASD fly · P assisted · R reset"
: inside
? "V walk · WASD move"
: "V explore · WASD move";
} }
if (driveHint) driveHint.hidden = inside || cityId !== "california";
if (walkHint) walkHint.hidden = !(inside || city.actorState());
} }
/** /**
@@ -1800,7 +1862,10 @@ function renderOfficeBadge() {
if (!publicOffice) { if (!publicOffice) {
if (mediaSurfaces.length === 0) return; if (mediaSurfaces.length === 0) return;
const noun = mediaSurfaces.length === 1 ? "screen" : "screens"; const noun = mediaSurfaces.length === 1 ? "screen" : "screens";
officeBadge.textContent = `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`; const active = mediaSurfaces.filter((surface) => surface.bound).length;
officeBadge.textContent = active > 0
? `${active} of ${mediaSurfaces.length} ${noun} active · stop control in Office screens.`
: `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`;
return; return;
} }
officeBadge.replaceChildren( officeBadge.replaceChildren(
@@ -1863,23 +1928,20 @@ function renderTierBadge() {
} }
function applyProfilePreview(profile: LocalProfile): void { function applyProfilePreview(profile: LocalProfile): void {
if (access.subject === null || inside) return; if (access.subject === null) return;
city?.setActorIdentity(signedInActorIdentity(profile)); if (inside) {
} const appearance = resolveHumanoidAppearance(profile.appearance);
office?.walker?.setAppearance({
async function rebuildOfficeActor(): Promise<void> { kind: "humanoid",
if (!inside || !office) return; skinTone: appearance.skinTone,
const wasWalking = office.walker?.active() ?? false; outfitColor: appearance.outfitColor,
stopWatchingOccupancy(); accentColor: appearance.accentColor,
officePlan?.dispose(); hairColor: appearance.hairColor,
officePlan = null; bodyShape: appearance.bodyShape,
office.dispose(); });
office = null; } else {
officeAtmosphere = null; city?.setActorIdentity(signedInActorIdentity(profile));
await building("Updating your character…", () => enterOffice()); }
const rebuilt = office as OfficeScene | null;
if (wasWalking) rebuilt?.walker?.setActive(true);
renderLegend();
} }
function ensureProfileEditor(): ProfileEditor | null { function ensureProfileEditor(): ProfileEditor | null {
@@ -1903,9 +1965,9 @@ function ensureProfileEditor(): ProfileEditor | null {
}, },
}); });
city?.setActorIdentity(signedInActorIdentity(profile)); city?.setActorIdentity(signedInActorIdentity(profile));
applyProfilePreview(profile);
renderTierBadge(); renderTierBadge();
profileOverlay.hidden = true; profileOverlay.hidden = true;
if (inside) void rebuildOfficeActor();
}, },
onCancel() { onCancel() {
profileOverlay.hidden = true; profileOverlay.hidden = true;
@@ -1924,6 +1986,132 @@ function openProfileEditor(): void {
editor.open(); editor.open();
} }
function stopLocalScreenShare(): void {
const shared = sharedScreen;
if (!shared) return;
sharedScreen = null;
office?.clearMediaSurface(shared.screenId);
for (const track of shared.stream.getTracks()) track.stop();
shared.texture.dispose();
shared.video.pause();
shared.video.srcObject = null;
officeScreenPanel?.update(office?.listMediaSurfaces() ?? []);
renderOfficeBadge();
}
function disposeOfficeScreenUi(): void {
stopLocalScreenShare();
officeScreenPanel?.dispose();
officeScreenPanel = null;
if (screensOverlay) screensOverlay.hidden = true;
}
async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<void> {
if (!office || !inside || office.depth !== "full") return;
const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false;
if (!optedIn) {
showDetail("Opt in to view this screen before starting a local preview.");
return;
}
if (!navigator.mediaDevices?.getDisplayMedia) {
showDetail("This browser does not provide tab or window sharing.");
return;
}
let pendingStream: MediaStream | null = null;
let pendingVideo: HTMLVideoElement | null = null;
let pendingTexture: VideoTexture | null = null;
try {
showDetail("Choose a tab or window. Nothing is captured until you approve the browser prompt.");
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: "browser" },
audio: false,
// Chromium honours these as chooser preferences. Other browsers ignore
// unknown dictionary members and still require the same explicit prompt.
monitorTypeSurfaces: "exclude",
selfBrowserSurface: "exclude",
surfaceSwitching: "include",
} as DisplayMediaStreamOptions);
pendingStream = stream;
const video = document.createElement("video");
pendingVideo = video;
video.muted = true;
video.playsInline = true;
video.srcObject = stream;
await video.play();
const texture = new VideoTexture(video);
pendingTexture = texture;
texture.colorSpace = SRGBColorSpace;
texture.generateMipmaps = false;
stopLocalScreenShare();
if (!office?.bindMediaSurface(surface.screenId, { canView: true, optedIn: true }, texture)) {
for (const track of stream.getTracks()) track.stop();
texture.dispose();
video.pause();
video.srcObject = null;
showDetail("That screen is no longer available.");
return;
}
sharedScreen = { screenId: surface.screenId, stream, video, texture };
pendingStream = null;
pendingVideo = null;
pendingTexture = null;
stream.getVideoTracks()[0]?.addEventListener("ended", () => stopLocalScreenShare(), { once: true });
officeScreenPanel?.update(office.listMediaSurfaces());
renderOfficeBadge();
showDetail(`Sharing locally to ${surface.screenId}. Use Office screens to stop.`);
} catch (error) {
for (const track of pendingStream?.getTracks() ?? []) track.stop();
pendingTexture?.dispose();
if (pendingVideo) {
pendingVideo.pause();
pendingVideo.srcObject = null;
}
if ((error as DOMException)?.name !== "NotAllowedError") {
showDetail("The screen preview could not start. Try again from Office screens.");
}
}
}
function ensureOfficeScreenPanel(): OfficeScreenPanel | null {
if (!screensOverlay || !inside || !office || office.depth !== "full") return null;
if (officeScreenPanel) {
officeScreenPanel.update(office.listMediaSurfaces());
return officeScreenPanel;
}
officeScreenPanel = createOfficeScreenPanel({
container: screensOverlay,
surfaces: office.listMediaSurfaces(),
onRequestShare: (surface) => { void startLocalScreenShare(surface); },
onStopShare: () => stopLocalScreenShare(),
onViewerOptIn(surface, optedIn) {
if (!optedIn && sharedScreen?.screenId === surface.screenId) stopLocalScreenShare();
},
});
const syncOverlay = () => queueMicrotask(() => {
if (officeScreenPanel && !officeScreenPanel.state().open) screensOverlay.hidden = true;
});
officeScreenPanel.root.addEventListener("click", syncOverlay);
officeScreenPanel.root.addEventListener("keydown", (event) => {
event.stopPropagation();
syncOverlay();
});
return officeScreenPanel;
}
function openOfficeScreens(): void {
const panel = ensureOfficeScreenPanel();
if (!panel || !screensOverlay) return;
screensOverlay.hidden = false;
panel.open();
}
screensButton?.addEventListener("click", openOfficeScreens);
screensOverlay?.addEventListener("click", (event) => {
if (event.target !== screensOverlay) return;
officeScreenPanel?.close();
screensOverlay.hidden = true;
});
// ---- Navigation ------------------------------------------------------------- // ---- Navigation -------------------------------------------------------------
/** The views on offer right now — city chapters, or office viewpoints inside. */ /** The views on offer right now — city chapters, or office viewpoints inside. */
@@ -2064,6 +2252,10 @@ function toggleOfficeWalk(): boolean {
const active = !city.actorActive(); const active = !city.actorActive();
city.setActorActive(active); city.setActorActive(active);
if (!active) city.setActorActions({}); if (!active) city.setActorActions({});
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
}
renderLegend(); renderLegend();
return true; return true;
} }
@@ -2072,12 +2264,31 @@ function toggleOfficeWalk(): boolean {
const active = !walker.active(); const active = !walker.active();
walker.setActive(active); walker.setActive(active);
if (!active) walker.setAction({ x: 0, z: 0 }); if (!active) walker.setAction({ x: 0, z: 0 });
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
}
renderLegend(); renderLegend();
return true; return true;
} }
walkButton?.addEventListener("click", () => toggleOfficeWalk()); walkButton?.addEventListener("click", () => toggleOfficeWalk());
function toggleAircraft(): boolean {
if (inside || cityId !== "california" || !city?.aircraftState()) return false;
const active = !city.aircraftActive();
city.setAircraftActive(active);
if (!active) city.setAircraftActions({});
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
}
renderLegend();
return true;
}
flyButton?.addEventListener("click", () => toggleAircraft());
/** /**
* Clicking a building on the city walks into it. * Clicking a building on the city walks into it.
* *
@@ -2198,7 +2409,8 @@ 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?.actorActive() && city?.current() === state.routeId; return state !== null && state !== undefined && !city?.actorActive() &&
!city?.aircraftActive() && city?.current() === state.routeId;
} }
function publishVehicleActions( function publishVehicleActions(
@@ -2244,6 +2456,21 @@ function publishCityActorActions(): boolean {
return true; return true;
} }
function publishAircraftActions(
supplement: Partial<AircraftActionSnapshot> = {},
): boolean {
if (inside || !city?.aircraftActive()) return false;
city.setAircraftActions({
throttle: heldDriveKeys.has(" ") ? 1 : 0,
pitch: (heldDriveKeys.has("w") ? 1 : 0) - (heldDriveKeys.has("s") ? 1 : 0),
roll: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
yaw: (heldDriveKeys.has("e") ? 1 : 0) - (heldDriveKeys.has("q") ? 1 : 0),
modeRequest: supplement.modeRequest ?? "none",
reset: supplement.reset ?? false,
});
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");
@@ -2278,6 +2505,7 @@ for (const button of walkControls?.querySelectorAll<HTMLButtonElement>("[data-wa
heldDriveKeys.delete(key); heldDriveKeys.delete(key);
button.setAttribute("aria-pressed", "false"); button.setAttribute("aria-pressed", "false");
publishOfficeWalkActions(); publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions(); publishCityActorActions();
event.preventDefault(); event.preventDefault();
}; };
@@ -2286,6 +2514,7 @@ for (const button of walkControls?.querySelectorAll<HTMLButtonElement>("[data-wa
heldDriveKeys.add(key); heldDriveKeys.add(key);
button.setAttribute("aria-pressed", "true"); button.setAttribute("aria-pressed", "true");
publishOfficeWalkActions(); publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions(); publishCityActorActions();
event.preventDefault(); event.preventDefault();
}); });
@@ -2300,17 +2529,25 @@ driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
?.addEventListener("click", () => publishVehicleActions({ reset: true })); ?.addEventListener("click", () => publishVehicleActions({ reset: true }));
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']") driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
?.addEventListener("click", () => toggleVehicleCamera()); ?.addEventListener("click", () => toggleVehicleCamera());
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='assist']")
?.addEventListener("click", () => publishAircraftActions({ modeRequest: "assisted" }));
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='reset']")
?.addEventListener("click", () => publishAircraftActions({ reset: true }));
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() || publishOfficeWalkActions() || publishCityActorActions()) event.preventDefault(); if (
publishVehicleActions() || publishOfficeWalkActions() ||
publishAircraftActions() || publishCityActorActions()
) event.preventDefault();
}); });
window.addEventListener("blur", () => { window.addEventListener("blur", () => {
heldDriveKeys.clear(); heldDriveKeys.clear();
publishVehicleActions(); publishVehicleActions();
publishOfficeWalkActions(); publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions(); publishCityActorActions();
}); });
@@ -2394,6 +2631,10 @@ window.addEventListener("keydown", (event) => {
event.preventDefault(); event.preventDefault();
return; return;
} }
if (publishAircraftActions()) {
event.preventDefault();
return;
}
if (publishCityActorActions()) { if (publishCityActorActions()) {
event.preventDefault(); event.preventDefault();
return; return;
@@ -2403,10 +2644,18 @@ window.addEventListener("keydown", (event) => {
event.preventDefault(); event.preventDefault();
return; return;
} }
if (lower === "p" && publishAircraftActions({ modeRequest: "assisted" })) {
event.preventDefault();
return;
}
if (lower === "r" && publishVehicleActions({ reset: true })) { if (lower === "r" && publishVehicleActions({ reset: true })) {
event.preventDefault(); event.preventDefault();
return; return;
} }
if (lower === "r" && publishAircraftActions({ reset: true })) {
event.preventDefault();
return;
}
if (lower === "c" && toggleVehicleCamera()) { if (lower === "c" && toggleVehicleCamera()) {
event.preventDefault(); event.preventDefault();
return; return;
+6
View File
@@ -17,3 +17,9 @@ export {
type MediaSurfaceGrant, type MediaSurfaceGrant,
type OfficeMediaPresentation, type OfficeMediaPresentation,
} from "./presentation.ts"; } from "./presentation.ts";
export {
createOfficeScreenPanel,
type OfficeScreenPanel,
type OfficeScreenPanelOptions,
type OfficeScreenPanelState,
} from "./officeScreenPanel.ts";
+273
View File
@@ -0,0 +1,273 @@
/** Accessible, capture-free command panel for authored office screens. */
import type { MediaSurfaceDescriptor } from "./presentation.ts";
export interface OfficeScreenPanelOptions {
container: HTMLElement;
surfaces: readonly MediaSurfaceDescriptor[];
onSelect?: (surface: MediaSurfaceDescriptor) => void;
/** Intent only. The caller may prompt for capture after receiving it. */
onRequestShare?: (surface: MediaSurfaceDescriptor) => void;
/** Intent only. The caller owns presenter stop/revoke policy and media. */
onStopShare?: (surface: MediaSurfaceDescriptor) => void;
onViewerOptIn?: (surface: MediaSurfaceDescriptor, optedIn: boolean) => void;
}
export interface OfficeScreenPanelState {
open: boolean;
selectedId: string | null;
/** Explicit, local viewing choices. Empty initially and after disposal. */
optedInScreenIds: string[];
surfaces: MediaSurfaceDescriptor[];
}
export interface OfficeScreenPanel {
root: HTMLElement;
open(): OfficeScreenPanelState;
update(surfaces: readonly MediaSurfaceDescriptor[]): OfficeScreenPanelState;
close(): OfficeScreenPanelState;
state(): OfficeScreenPanelState;
dispose(): void;
}
const STYLES = `
.tera-screen-panel { width:min(520px,calc(100vw - 32px)); max-height:min(720px,calc(100dvh - 32px)); overflow:auto; padding:20px; color:#e8edf2; background:rgba(9,13,18,.94); border:1px solid rgba(255,255,255,.14); border-radius:8px; font:12px/1.5 ui-monospace,monospace; }
.tera-screen-panel__title { margin:0 0 4px; font-size:17px; }
.tera-screen-panel__intro,.tera-screen-panel__empty { color:rgba(255,255,255,.65); }
.tera-screen-panel__list { display:grid; gap:8px; margin:16px 0; }
.tera-screen-panel__screen { width:100%; padding:10px; color:inherit; text-align:left; background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.13); border-radius:5px; cursor:pointer; }
.tera-screen-panel__screen[aria-pressed="true"] { border-color:#f2b134; background:rgba(242,177,52,.1); }
.tera-screen-panel__meta { display:block; color:rgba(255,255,255,.58); }
.tera-screen-panel__actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:8px; }
.tera-screen-panel__button { min-height:40px; padding:8px 12px; color:inherit; background:rgba(255,255,255,.07); border:1px solid rgba(255,255,255,.15); border-radius:5px; cursor:pointer; }
.tera-screen-panel__button:disabled { opacity:.4; cursor:not-allowed; }
.tera-screen-panel :focus-visible { outline:2px solid #f2b134; outline-offset:2px; }
`;
let panelSequence = 0;
export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): OfficeScreenPanel {
if (!options.container || typeof options.container.append !== "function") {
throw new RangeError("office screen panel: container must be an HTMLElement");
}
let surfaces = validateSurfaces(options.surfaces);
let selectedId: string | null = surfaces[0]?.screenId ?? null;
const optedIn = new Set<string>();
let isOpen = false;
let disposed = false;
let invoker: HTMLElement | null = null;
const doc = options.container.ownerDocument;
const number = ++panelSequence;
const titleId = `tera-screen-panel-title-${number}`;
const introId = `tera-screen-panel-intro-${number}`;
const root = doc.createElement("section");
root.className = "tera-screen-panel";
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
root.setAttribute("aria-labelledby", titleId);
root.setAttribute("aria-describedby", introId);
root.setAttribute("aria-hidden", "true");
root.hidden = true;
const style = doc.createElement("style");
style.textContent = STYLES;
const title = doc.createElement("h2");
title.id = titleId;
title.className = "tera-screen-panel__title";
title.textContent = "Office screens";
const intro = doc.createElement("p");
intro.id = introId;
intro.className = "tera-screen-panel__intro";
intro.textContent = "Media is off by default. Choose a screen, then explicitly opt in to view or request sharing.";
const list = doc.createElement("div");
list.className = "tera-screen-panel__list";
list.setAttribute("role", "list");
const actions = doc.createElement("div");
actions.className = "tera-screen-panel__actions";
const optButton = button(doc, "Opt in to view", "opt-in");
const shareButton = button(doc, "Share screen", "share");
const stopButton = button(doc, "Stop / revoke", "stop");
const closeButton = button(doc, "Close", "close");
actions.append(optButton, shareButton, stopButton, closeButton);
root.append(style, title, intro, list, actions);
options.container.append(root);
function selected(): MediaSurfaceDescriptor | null {
return surfaces.find((surface) => surface.screenId === selectedId) ?? null;
}
function render(): void {
for (const child of [...list.children]) child.remove();
if (surfaces.length === 0) {
const empty = doc.createElement("p");
empty.className = "tera-screen-panel__empty";
empty.textContent = "No authored office screens are available.";
list.append(empty);
}
for (const surface of surfaces) {
const row = button(doc, "", "select");
row.className = "tera-screen-panel__screen";
row.setAttribute("role", "listitem");
row.setAttribute("data-screen-id", surface.screenId);
row.setAttribute("aria-pressed", String(surface.screenId === selectedId));
const name = doc.createElement("span");
name.textContent = surface.screenId;
const meta = doc.createElement("span");
meta.className = "tera-screen-panel__meta";
const room = surface.roomId ?? "Unassigned room";
const status = surface.bound ? "Media active" : "Media off";
meta.textContent = `${surface.levelId} · ${room} · ${status}`;
row.append(name, meta);
row.addEventListener("click", () => {
selectedId = surface.screenId;
render();
options.onSelect?.(copySurface(surface));
});
list.append(row);
}
const surface = selected();
const viewing = surface ? optedIn.has(surface.screenId) : false;
optButton.disabled = surface === null;
shareButton.disabled = surface === null || !viewing;
stopButton.disabled = surface === null || !surface.bound;
optButton.textContent = viewing ? "Stop viewing" : "Opt in to view";
optButton.setAttribute("aria-pressed", String(viewing));
}
optButton.addEventListener("click", () => {
const surface = selected();
if (!surface) return;
const next = !optedIn.has(surface.screenId);
if (next) optedIn.add(surface.screenId);
else optedIn.delete(surface.screenId);
render();
options.onViewerOptIn?.(copySurface(surface), next);
});
shareButton.addEventListener("click", () => {
const surface = selected();
if (surface) options.onRequestShare?.(copySurface(surface));
});
stopButton.addEventListener("click", () => {
const surface = selected();
if (surface?.bound) options.onStopShare?.(copySurface(surface));
});
closeButton.addEventListener("click", () => close());
function snapshot(): OfficeScreenPanelState {
return {
open: isOpen,
selectedId,
optedInScreenIds: [...optedIn],
surfaces: surfaces.map(copySurface),
};
}
function close(): OfficeScreenPanelState {
if (disposed || !isOpen) return snapshot();
isOpen = false;
root.hidden = true;
root.setAttribute("aria-hidden", "true");
invoker?.focus();
invoker = null;
return snapshot();
}
root.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key !== "Tab") return;
const focusable = controls();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (!event.shiftKey && doc.activeElement === last) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && doc.activeElement === first) {
event.preventDefault();
last.focus();
}
});
function controls(): HTMLElement[] {
const rows = [...list.children].filter((child): child is HTMLElement =>
(child as HTMLElement).getAttribute("data-screen-id") !== null,
);
return [...rows, optButton, shareButton, stopButton, closeButton].filter(
(control) => !(control as HTMLButtonElement).disabled,
);
}
render();
return {
root,
open() {
if (disposed) return snapshot();
const active = doc.activeElement as HTMLElement | null;
invoker = active && typeof active.focus === "function" ? active : null;
isOpen = true;
root.hidden = false;
root.setAttribute("aria-hidden", "false");
controls()[0]?.focus();
return snapshot();
},
update(next) {
if (disposed) return snapshot();
surfaces = validateSurfaces(next);
const ids = new Set(surfaces.map((surface) => surface.screenId));
for (const id of optedIn) if (!ids.has(id)) optedIn.delete(id);
if (selectedId === null || !ids.has(selectedId)) selectedId = surfaces[0]?.screenId ?? null;
render();
return snapshot();
},
close,
state: snapshot,
dispose() {
if (disposed) return;
close();
disposed = true;
optedIn.clear();
surfaces = [];
selectedId = null;
root.remove();
},
};
}
function validateSurfaces(input: readonly MediaSurfaceDescriptor[]): MediaSurfaceDescriptor[] {
if (!Array.isArray(input)) throw new TypeError("office screen panel: surfaces must be an array");
const seen = new Set<string>();
return input.map((surface) => {
if (!surface || typeof surface !== "object") throw new TypeError("office screen panel: invalid surface");
for (const field of ["screenId", "officeId", "levelId", "kind"] as const) {
if (typeof surface[field] !== "string" || surface[field].length === 0) {
throw new TypeError(`office screen panel: invalid ${field}`);
}
}
if (surface.roomId !== null && typeof surface.roomId !== "string") {
throw new TypeError("office screen panel: invalid roomId");
}
if (typeof surface.bound !== "boolean") throw new TypeError("office screen panel: invalid bound state");
if (seen.has(surface.screenId)) throw new TypeError("office screen panel: duplicate screenId");
seen.add(surface.screenId);
return copySurface(surface);
});
}
function copySurface(surface: MediaSurfaceDescriptor): MediaSurfaceDescriptor {
return { ...surface };
}
function button(doc: Document, label: string, action: string): HTMLButtonElement {
const value = doc.createElement("button");
value.type = "button";
value.className = "tera-screen-panel__button";
value.textContent = label;
value.setAttribute("data-action", action);
return value;
}
+160
View File
@@ -0,0 +1,160 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createOfficeScreenPanel, type MediaSurfaceDescriptor } from "../media/index.ts";
type Listener = (event: FakeEvent) => void;
class FakeEvent {
defaultPrevented = false;
readonly type: string;
readonly target: FakeElement;
readonly key: string;
readonly shiftKey: boolean;
constructor(
type: string,
target: FakeElement,
key = "",
shiftKey = false,
) {
this.type = type;
this.target = target;
this.key = key;
this.shiftKey = shiftKey;
}
preventDefault(): void { this.defaultPrevented = true; }
}
class FakeElement {
readonly children: FakeElement[] = [];
readonly attributes = new Map<string, string>();
readonly listeners = new Map<string, Listener[]>();
parentElement: FakeElement | null = null;
className = "";
textContent = "";
hidden = false;
disabled = false;
type = "";
id = "";
readonly ownerDocument: FakeDocument;
readonly tagName: string;
constructor(ownerDocument: FakeDocument, tagName: string) {
this.ownerDocument = ownerDocument;
this.tagName = tagName;
}
append(...nodes: FakeElement[]): void { for (const node of nodes) { node.parentElement = this; this.children.push(node); } }
setAttribute(name: string, value: string): void { this.attributes.set(name, value); }
getAttribute(name: string): string | null { return this.attributes.get(name) ?? null; }
addEventListener(type: string, listener: Listener): void {
const list = this.listeners.get(type); if (list) list.push(listener); else this.listeners.set(type, [listener]);
}
dispatch(type: string, init: { key?: string; shiftKey?: boolean } = {}): FakeEvent {
const event = new FakeEvent(type, this, init.key, init.shiftKey);
for (const listener of this.listeners.get(type) ?? []) listener(event);
return event;
}
focus(): void { this.ownerDocument.activeElement = this; }
remove(): void {
if (!this.parentElement) return;
const index = this.parentElement.children.indexOf(this); if (index >= 0) this.parentElement.children.splice(index, 1);
this.parentElement = null;
}
find(attribute: string, value: string): FakeElement {
if (this.attributes.get(attribute) === value) return this;
for (const child of this.children) { try { return child.find(attribute, value); } catch { /* next */ } }
throw new Error(`missing [${attribute}=${value}]`);
}
text(): string { return this.textContent + this.children.map((child) => child.text()).join(""); }
}
class FakeDocument {
activeElement: FakeElement | null = null;
createElement(tag: string): FakeElement { return new FakeElement(this, tag.toUpperCase()); }
}
const SURFACES: MediaSurfaceDescriptor[] = [
{ screenId: "lobby-monitor", officeId: "hq", levelId: "level-1", roomId: "Lobby", kind: "tera:screen.monitor", bound: false },
{ screenId: "commons-display", officeId: "hq", levelId: "level-2", roomId: "Commons", kind: "tera:screen.wall-display", bound: true },
];
function setup() {
const document = new FakeDocument();
const container = document.createElement("div");
const selected: string[] = [];
const shares: string[] = [];
const stops: string[] = [];
const opts: [string, boolean][] = [];
const panel = createOfficeScreenPanel({
container: container as unknown as HTMLElement,
surfaces: SURFACES,
onSelect: (surface) => selected.push(surface.screenId),
onRequestShare: (surface) => shares.push(surface.screenId),
onStopShare: (surface) => stops.push(surface.screenId),
onViewerOptIn: (surface, value) => opts.push([surface.screenId, value]),
});
return { document, container, panel, selected, shares, stops, opts };
}
describe("office screen manager panel", () => {
it("builds a closed labelled dialog with safe screen, room, and status text", () => {
const { container, panel } = setup();
const root = panel.root as unknown as FakeElement;
assert.equal(container.children.length, 1);
assert.equal(root.hidden, true);
assert.equal(root.getAttribute("role"), "dialog");
assert.equal(root.getAttribute("aria-modal"), "true");
assert.match(root.text(), /lobby-monitor/);
assert.match(root.text(), /Lobby/);
assert.match(root.text(), /Media off/);
assert.match(root.text(), /Media active/);
});
it("selects, explicitly opts in, and emits share/stop intent without capture", () => {
const { panel, selected, shares, stops, opts } = setup();
panel.open();
const root = panel.root as unknown as FakeElement;
root.find("data-screen-id", "commons-display").dispatch("click");
root.find("data-action", "opt-in").dispatch("click");
root.find("data-action", "share").dispatch("click");
root.find("data-action", "stop").dispatch("click");
assert.deepEqual(selected, ["commons-display"]);
assert.deepEqual(opts, [["commons-display", true]]);
assert.deepEqual(shares, ["commons-display"]);
assert.deepEqual(stops, ["commons-display"]);
assert.deepEqual(panel.state().optedInScreenIds, ["commons-display"]);
assert.equal("mediaDevices" in panel, false);
});
it("updates defensively, removes stale consent, and represents an empty office", () => {
const { panel } = setup();
const root = panel.root as unknown as FakeElement;
root.find("data-action", "opt-in").dispatch("click");
const next = [{ ...SURFACES[1]!, screenId: "safe-text-<script>" }];
panel.update(next);
next[0]!.screenId = "mutated";
assert.equal(panel.state().selectedId, "safe-text-<script>");
assert.deepEqual(panel.state().optedInScreenIds, []);
assert.match(root.text(), /safe-text-<script>/);
panel.update([]);
assert.equal(panel.state().selectedId, null);
assert.match(root.text(), /No authored office screens/);
});
it("traps tab focus, closes on Escape, restores focus, and disposes idempotently", () => {
const { document, container, panel } = setup();
const trigger = document.createElement("button");
trigger.focus();
panel.open();
const root = panel.root as unknown as FakeElement;
const first = root.find("data-screen-id", "lobby-monitor");
const last = root.find("data-action", "close");
last.focus();
assert.equal(root.dispatch("keydown", { key: "Tab" }).defaultPrevented, true);
assert.equal(document.activeElement, first);
first.focus();
assert.equal(root.dispatch("keydown", { key: "Tab", shiftKey: true }).defaultPrevented, true);
assert.equal(document.activeElement, last);
assert.equal(root.dispatch("keydown", { key: "Escape" }).defaultPrevented, true);
assert.equal(panel.state().open, false);
assert.equal(document.activeElement, trigger);
panel.dispose();
panel.dispose();
assert.equal(container.children.length, 0);
});
});
+66
View File
@@ -96,4 +96,70 @@ describe("office walker actor adapter", () => {
actor.dispose(); actor.dispose();
assert.equal(actor.root.parent, null); assert.equal(actor.root.parent, null);
}); });
it("refreshes appearance under a stable root without changing walker or camera state", () => {
const actor = createOfficeWalker(makePlan(), {
levelId: "ground",
position: { x: 2, z: 2 },
speed: 1,
fixedStep: 0.1,
active: true,
camera: { distance: 4, height: 2.8, targetHeight: 1.1, lookAhead: 0.9 },
});
const root = actor.root;
actor.setAction({ x: 1, z: 0 });
actor.tick(0.3);
const before = actor.state();
const cameraBefore = actor.followPose();
const oldRig = root.children[0];
const after = actor.setAppearance({ kind: "anonymous-dog", coatColor: 0x222222 });
assert.equal(actor.root, root);
assert.equal(oldRig?.parent, null);
assert.equal(after.actor, "anonymous-dog");
assert.deepEqual(after.position, before.position);
assert.deepEqual(after.facing, before.facing);
assert.equal(after.distance, before.distance);
assert.equal(after.active, before.active);
assert.deepEqual(after.action, before.action);
assert.deepEqual(actor.followPose().position.toArray(), cameraBefore.position.toArray());
assert.deepEqual(actor.followPose().target.toArray(), cameraBefore.target.toArray());
assert.equal(root.userData.actorType, "anonymous-dog");
actor.dispose();
});
it("keeps humanoid face texture caller-owned across a skin refresh and clear", () => {
const actor = createOfficeWalker(makePlan(), {
levelId: "ground",
position: { x: 2, z: 2 },
actor: { kind: "humanoid", outfitColor: 0x112233 },
});
const texture = new THREE.Texture();
let textureDisposals = 0;
texture.addEventListener("dispose", () => textureDisposals++);
assert.equal(actor.attachFaceTexture(texture), true);
const firstFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
const firstMaterial = firstFace.material as THREE.MeshBasicMaterial;
assert.equal(firstMaterial.map, texture);
actor.setAppearance({ kind: "humanoid", outfitColor: 0x334455, bodyShape: "broad" });
const secondFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
assert.notEqual(secondFace, firstFace);
assert.equal(firstMaterial.map, null);
assert.equal((secondFace.material as THREE.MeshBasicMaterial).map, texture);
actor.clearFaceTexture();
assert.equal((secondFace.material as THREE.MeshBasicMaterial).map, null);
actor.attachFaceTexture(texture);
actor.dispose();
assert.equal(textureDisposals, 0);
assert.equal((secondFace.material as THREE.MeshBasicMaterial).map, null);
});
it("does not accept a face texture while the office actor is anonymous", () => {
const actor = createOfficeWalker(makePlan(), {
levelId: "ground",
position: { x: 2, z: 2 },
actor: { kind: "anonymous-dog" },
});
assert.equal(actor.attachFaceTexture(new THREE.Texture()), false);
actor.dispose();
});
}); });
+51
View File
@@ -116,6 +116,57 @@ describe("playable city scene actor", () => {
actor.dispose(); actor.dispose();
}); });
it("refreshes humanoid identity around a caller-owned face texture", () => {
const actor = createSceneActor(options({ active: true }));
const texture = new THREE.Texture();
let textureDisposals = 0;
texture.addEventListener("dispose", () => textureDisposals++);
assert.equal(actor.attachFaceTexture(texture), true);
const firstFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
const firstMaterial = firstFace.material as THREE.MeshBasicMaterial;
assert.equal(firstMaterial.map, texture);
actor.setActions({ forward: 1 });
actor.tick(0.2);
const before = actor.state();
actor.setIdentity({
...MEMBER,
profile: { ...MEMBER.profile, appearance: { primaryColor: "#654321" } },
});
const nextFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
assert.notEqual(nextFace, firstFace);
assert.equal((nextFace.material as THREE.MeshBasicMaterial).map, texture);
assert.equal(firstMaterial.map, null, "released rig no longer retains caller texture");
assert.deepEqual(
{ x: actor.state().x, y: actor.state().y, z: actor.state().z, elapsedSteps: actor.state().elapsedSteps },
{ x: before.x, y: before.y, z: before.z, elapsedSteps: before.elapsedSteps },
);
actor.clearFaceTexture();
assert.equal((nextFace.material as THREE.MeshBasicMaterial).map, null);
actor.attachFaceTexture(texture);
actor.dispose();
assert.equal(textureDisposals, 0);
assert.equal((nextFace.material as THREE.MeshBasicMaterial).map, null);
});
it("refuses to attach a humanoid face to anonymous animal actors", () => {
const actor = createSceneActor(options());
const texture = new THREE.Texture();
actor.attachFaceTexture(texture);
const oldFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
const oldMaterial = oldFace.material as THREE.MeshBasicMaterial;
actor.switchActor("crow", undefined, "flight");
assert.equal(oldMaterial.map, null);
assert.equal(actor.attachFaceTexture(texture), false);
actor.switchActor("dog");
assert.equal(actor.attachFaceTexture(texture), false);
actor.switchActor("humanoid");
const newFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
assert.equal((newFace.material as THREE.MeshBasicMaterial).map, null, "animal role released the hidden face reference");
actor.dispose();
});
it("applies kind requests from the normalized action stream exactly once", () => { it("applies kind requests from the normalized action stream exactly once", () => {
const actor = createSceneActor(options({ active: true })); const actor = createSceneActor(options({ active: true }));
actor.setActions({ kindRequest: "crow", modeRequest: "flight", forward: 0.4 }); actor.setActions({ kindRequest: "crow", modeRequest: "flight", forward: 0.4 });
+130
View File
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import {
CALIFORNIA_AIR_ROUTE,
createSceneAircraft,
type SceneAircraftOptions,
} from "../aircraft/index.ts";
function options(overrides: Partial<SceneAircraftOptions> = {}): SceneAircraftOptions {
return {
project: (lat, lng) => [(lng + 121) * 20, -(lat - 36) * 20],
groundAt: () => 2,
route: CALIFORNIA_AIR_ROUTE,
initialPosition: { lat: 34.0522, lng: -118.2437 },
initialAltitudeM: 1_000,
initialHeadingDeg: 0,
fixedStepSeconds: 0.1,
altitudeSceneUnitsPerMetre: 0.005,
visualSceneUnitsPerMetre: 0.1,
...overrides,
};
}
describe("playable scene aircraft", () => {
it("projects a stable root and stays inert until activated", () => {
const aircraft = createSceneAircraft(options());
const root = aircraft.root;
assert.equal(root.name, "playable-scene-aircraft");
assert.ok(root.getObjectByName("electric-aircraft"));
assert.deepEqual(root.position.toArray(), [( -118.2437 + 121) * 20, 7, -(34.0522 - 36) * 20]);
assert.equal(root.scale.x, 0.1);
aircraft.setActions({ throttle: 1, roll: 0.8 });
aircraft.tick(0.2);
assert.equal(aircraft.state().elapsedSteps, 0);
assert.equal(aircraft.root, root);
aircraft.setActive(true);
assert.equal(aircraft.actions().throttle, 0, "activation clears stale input");
aircraft.setActions({ throttle: 1, roll: 0.8 });
aircraft.tick(0.2);
assert.equal(aircraft.state().elapsedSteps, 2);
assert.equal(aircraft.state().mode, "manual");
assert.equal(aircraft.view.position, aircraft.view.position);
assert.deepEqual(aircraft.view.position.toArray(), root.position.toArray());
aircraft.dispose();
});
it("maps heading, pitch, and right-bank roll under Tera's -Z convention", () => {
const aircraft = createSceneAircraft(options({ active: true }));
aircraft.setActions({ throttle: 0.7, pitch: 1, roll: 1, yaw: 0.2 });
aircraft.tick(1);
const state = aircraft.state();
assert.equal(aircraft.root.rotation.order, "YXZ");
assert.ok(Math.abs(aircraft.root.rotation.x - state.pitchDeg * Math.PI / 180) < 1e-12);
assert.ok(Math.abs(aircraft.root.rotation.y + state.headingDeg * Math.PI / 180) < 1e-12);
assert.ok(Math.abs(aircraft.root.rotation.z + state.rollDeg * Math.PI / 180) < 1e-12);
assert.ok(aircraft.root.rotation.z < 0, "positive controller roll presents right wing down");
aircraft.dispose();
});
it("animates articulated surfaces and fans from authoritative state", () => {
const aircraft = createSceneAircraft(options({ active: true }));
const leftAileron = aircraft.root.getObjectByName("electric-aircraft.aileron-left");
const rightAileron = aircraft.root.getObjectByName("electric-aircraft.aileron-right");
const leftFan = aircraft.root.getObjectByName("electric-aircraft.fan-left");
assert.ok(leftAileron && rightAileron && leftFan);
aircraft.setActions({ throttle: 1, roll: 0.8, pitch: 0.4, yaw: -0.3 });
aircraft.tick(0.5);
assert.ok(leftAileron.rotation.x > 0);
assert.ok(rightAileron.rotation.x < 0);
assert.notEqual(leftFan.rotation.z, 0);
aircraft.dispose();
});
it("publishes defensive finite chase poses for north and east headings", () => {
const north = createSceneAircraft(options({ initialHeadingDeg: 0 }));
const northPose = north.followPose();
assert.ok(northPose.position.z > north.root.position.z, "camera is south/behind northbound flight");
assert.ok(northPose.target.z < north.root.position.z);
northPose.position.x = 999;
assert.notEqual(north.followPose().position.x, 999);
north.dispose();
const east = createSceneAircraft(options({ initialHeadingDeg: 90 }));
const eastPose = east.followPose();
assert.ok(eastPose.position.x < east.root.position.x);
assert.ok(eastPose.target.x > east.root.position.x);
assert.ok(eastPose.position.toArray().every(Number.isFinite));
assert.ok(eastPose.target.toArray().every(Number.isFinite));
east.dispose();
});
it("clears edge actions, resumes assistance, and resets controller plus rig", () => {
const aircraft = createSceneAircraft(options({ active: true }));
const spawn = aircraft.state();
aircraft.setActions({ throttle: 1, roll: 1, modeRequest: "manual", reset: false });
aircraft.tick(0.2);
assert.equal(aircraft.actions().modeRequest, "none");
assert.equal(aircraft.actions().throttle, 1);
aircraft.setActions({ modeRequest: "assisted" });
aircraft.tick(0.1);
assert.equal(aircraft.state().mode, "assisted");
aircraft.reset();
assert.deepEqual(aircraft.state(), spawn);
assert.deepEqual(aircraft.actions(), {
throttle: 0,
yaw: 0,
pitch: 0,
roll: 0,
modeRequest: "none",
reset: false,
});
aircraft.dispose();
});
it("disposes idempotently and refuses invalid projection/scaling", () => {
const aircraft = createSceneAircraft(options());
const parent = new THREE.Group();
parent.add(aircraft.root);
aircraft.dispose();
aircraft.dispose();
assert.equal(aircraft.root.parent, null);
const before = aircraft.state();
aircraft.tick(1);
assert.deepEqual(aircraft.state(), before);
assert.throws(() => createSceneAircraft(options({ visualSceneUnitsPerMetre: 0 })), RangeError);
assert.throws(() => createSceneAircraft(options({ project: () => [Infinity, 0] })), RangeError);
assert.throws(() => createSceneAircraft(options({ groundAt: () => Number.NaN })), RangeError);
});
});