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>
<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="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>
<nav id="chapters" aria-label="Chapters"></nav>
<p class="card" id="blurb"></p>
@@ -992,17 +994,21 @@
</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>
<button class="walk-control flight-only" data-walk-key="q" aria-pressed="false">Descend</button>
<button class="walk-control flight-only" data-walk-key="e" aria-pressed="false">Climb</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" data-walk-label="Forward" data-aircraft-label="Pitch up" aria-pressed="false">Forward</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" data-walk-label="Right" data-aircraft-label="Roll right" aria-pressed="false">Right</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" 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>
<p id="source" class="source"></p>
<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
scrim leaves no way to dismiss it but the same button that opened it,
@@ -1015,11 +1021,11 @@
<h2 id="shortcuts-title">Keyboard</h2>
<dl class="keys">
<dt><kbd>1</kbd><kbd>9</kbd></dt><dd>Fly to a chapter, or an office viewpoint</dd>
<dt><kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd></dt><dd>Drive 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>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>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>[</kbd> <kbd>]</kbd></dt><dd>Previous / next city</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;
/** Rebuild the visible skin for a new serializable identity, preserving motion and kind. */
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;
}
@@ -137,6 +141,23 @@ function disposeRig(actor: RiggedActor): void {
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 {
if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`);
return value;
@@ -177,6 +198,7 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
root.add(actor.rig.root);
let enabled = options.active ?? false;
let desired: ActorActionSnapshot = { ...NEUTRAL_ACTOR_ACTIONS };
let faceTexture: THREE.Texture | null = null;
let disposed = false;
function sync(): void {
@@ -218,6 +240,11 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
const previous = actor;
actor = buildActor(kind, controller.state().identity);
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);
sync();
}
@@ -299,10 +326,25 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
replaceActor(controller.state().kind);
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() {
if (disposed) return;
disposed = true;
root.removeFromParent();
if (actor.kind === "humanoid") applyFaceTexture(actor.rig, null);
faceTexture = null;
disposeRig(actor);
},
};
+9
View File
@@ -31,3 +31,12 @@ export {
type ElectricAircraftMaterials,
type ElectricAircraftRig,
} 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,
ActorIdentity,
} from "../actors/controller.ts";
import {
createSceneAircraft,
type SceneAircraftOptions,
} from "../aircraft/sceneAircraft.ts";
import type {
AircraftActionSnapshot,
AircraftControllerSnapshot,
} from "../aircraft/controller.ts";
export interface SceneOptions {
city: City;
@@ -89,6 +97,8 @@ export interface SceneOptions {
actor?: SceneActorOptions;
/** Geographic spawn anchor for that actor; defaults to the board centre. */
actorAnchor?: { lat: number; lng: number };
/** Optional possessed fixed-wing aircraft; the city supplies its projection and terrain. */
aircraft?: Omit<SceneAircraftOptions, "project" | "groundAt">;
flights?: FlightSource;
/**
* Element sets to propagate, if this deployment has any.
@@ -191,6 +201,10 @@ export interface SceneHandle {
setActorIdentity(identity: ActorIdentity): void;
setActorActive(active: boolean): void;
actorActive(): boolean;
setAircraftActions(actions: Partial<AircraftActionSnapshot>): void;
aircraftState(): Readonly<AircraftControllerSnapshot> | null;
setAircraftActive(active: boolean): void;
aircraftActive(): boolean;
setMarkers(markers: Marker[]): void;
/** Take this city off the stage and release everything it built. */
dispose(): void;
@@ -377,6 +391,16 @@ export async function createScene(
})
: null;
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 flightTimer = 0;
@@ -445,6 +469,7 @@ export async function createScene(
// is an explicit UI action, so a chapter selection always hands the camera
// back before it moves anywhere else.
sceneActor?.setActive(false);
sceneAircraft?.setActive(false);
kit.controls.minDistance = orbitMinDistance;
if (kit.camera.near !== 0.1) {
kit.camera.near = 0.1;
@@ -489,10 +514,13 @@ export async function createScene(
onExit: () => kit.resetPick(),
tick(dt) {
const actorPlaying = sceneActor?.active() ?? false;
kit.controls.enabled = !actorPlaying;
const aircraftPlaying = sceneAircraft?.active() ?? false;
kit.controls.enabled = !actorPlaying && !aircraftPlaying;
kit.tick(dt);
sceneActor?.tick(dt);
if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose());
sceneAircraft?.tick(dt);
if (aircraftPlaying && sceneAircraft) kit.setPose(sceneAircraft.followPose());
roadTraffic?.tick(dt);
clouds.tick(dt);
if (options.flights && flightLayer) {
@@ -543,6 +571,7 @@ export async function createScene(
nightLights.dispose();
markerLayer.dispose();
roadTraffic?.dispose();
sceneAircraft?.dispose();
kit.dispose();
scene.traverse((obj) => {
const mesh = obj as THREE.Mesh;
@@ -589,7 +618,10 @@ export async function createScene(
setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); },
setActorActive(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
// 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
@@ -599,11 +631,25 @@ export async function createScene(
kit.camera.updateProjectionMatrix();
},
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) {
markerLayer.setMarkers(markers);
},
dispose() {
sceneActor?.dispose();
sceneAircraft?.dispose();
/**
* 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
* 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
* 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";
@@ -88,6 +89,12 @@ export interface OfficeWalker {
setAction(action: WalkerAction): WalkerAction;
tick(elapsedSeconds: number): OfficeWalkerState;
reset(spawn?: WalkerSpawn): OfficeWalkerState;
/** Rebuild only the procedural skin, retaining this root and all walker/camera state. */
setAppearance(appearance: OfficeActorAppearance): OfficeWalkerState;
/** Attach a caller-owned texture. False for an anonymous dog. */
attachFaceTexture(texture: THREE.Texture): boolean;
/** Detach the current face without disposing the caller's texture. */
clearFaceTexture(): void;
/** A defensive scene-space chase-camera pose for the current actor state. */
followPose(): Pose;
dispose(): void;
@@ -98,8 +105,7 @@ type Actor =
| { kind: "anonymous-dog"; rig: DogRig };
export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): OfficeWalker {
const appearance = options.actor ?? { kind: "humanoid" };
const actor = buildActor(appearance);
let actor = buildActor(options.actor ?? { kind: "humanoid" });
const controller: WalkerController = createWalker(plan, options);
const baseCamera = actor.kind === "humanoid" ? HUMANOID_CAMERA : DOG_CAMERA;
const camera = {
@@ -112,15 +118,22 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
let enabled = options.active ?? false;
let desired: WalkerAction = { x: 0, z: 0 };
let gait = 0;
let faceTexture: THREE.Texture | null = null;
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() };
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);
root.position.set(state.position.x, level.floorY, state.position.z);
view.position.copy(root.position);
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;
@@ -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());
return {
root: actor.rig.root,
root,
view,
state: snapshot,
active: () => enabled,
@@ -178,6 +205,24 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
sync(state);
return snapshot();
},
setAppearance(appearance) {
if (disposed) return snapshot();
replaceActor(appearance);
return snapshot();
},
attachFaceTexture(texture) {
if (disposed || actor.kind !== "humanoid") return false;
if (!texture || texture.isTexture !== true) {
throw new RangeError("office walker: face texture must be a Three.js Texture");
}
faceTexture = texture;
applyOfficeFaceTexture(actor.rig, texture);
return true;
},
clearFaceTexture() {
faceTexture = null;
if (!disposed && actor.kind === "humanoid") applyOfficeFaceTexture(actor.rig, null);
},
followPose() {
const state = controller.state();
const level = plan.level(state.levelId);
@@ -198,7 +243,9 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
dispose() {
if (disposed) return;
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);
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 {
if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`);
return value;
+274 -25
View File
@@ -80,6 +80,16 @@ import {
type ProfileEditor,
} from "./profile/index.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
* 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.
*/
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.
*
@@ -728,6 +745,7 @@ async function mountCity(id: string) {
poseEditor?.destroy();
poseEditor = null;
stopWatchingOccupancy();
disposeOfficeScreenUi();
officePlan?.dispose();
officePlan = null;
office?.dispose();
@@ -868,6 +886,18 @@ async function mountCity(id: string) {
count: 14,
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,
@@ -1326,6 +1356,7 @@ function leaveOffice() {
// Before the scene swap, so the last thing the watch can do is abort a request
// rather than publish into a room the user has already left.
stopWatchingOccupancy();
disposeOfficeScreenUi();
office?.walker?.setActive(false);
office?.walker?.setAction({ x: 0, z: 0 });
dispatchJourney({ type: "leave-office" });
@@ -1491,9 +1522,12 @@ const credits = document.querySelector<HTMLElement>("#credits");
const driveControls = document.querySelector<HTMLElement>("#drive-controls");
const driveHint = document.querySelector<HTMLElement>("#drive-hint");
const walkButton = document.querySelector<HTMLButtonElement>("#walk");
const flyButton = document.querySelector<HTMLButtonElement>("#fly");
const screensButton = document.querySelector<HTMLButtonElement>("#screens");
const walkControls = document.querySelector<HTMLElement>("#walk-controls");
const walkHint = document.querySelector<HTMLElement>("#walk-hint");
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
let profileEditor: ProfileEditor | null = null;
function showDetail(text: string | null) {
@@ -1559,6 +1593,7 @@ async function switchOffice(id: string) {
// The roster belongs to the building you have left.
stopWatchingOccupancy();
disposeOfficeScreenUi();
officePlan?.dispose();
officePlan = null;
office?.dispose();
@@ -1626,6 +1661,7 @@ function renderLegend() {
}
const walking = inside && (office?.walker?.active() ?? false);
const exploring = !inside && (city.actorActive() ?? false);
const flying = !inside && (city.aircraftActive() ?? false);
if (walkButton) {
walkButton.hidden = inside ? office?.walker === null : city.actorState() === null;
walkButton.setAttribute("aria-pressed", String(walking || exploring));
@@ -1637,6 +1673,15 @@ function renderLegend() {
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();
if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
if (canvas) {
@@ -1646,6 +1691,8 @@ function renderLegend() {
? walking
? `${officeName()}, following your ${office?.walker?.state().actor === "anonymous-dog" ? "dog" : "humanoid"}. Use W A S D to move.`
: `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
: flying
? `${cityLabel}, following your electric aircraft. Use W A S D to fly or P to resume assisted flight.`
: 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.`,
@@ -1658,12 +1705,27 @@ function renderLegend() {
officePlan?.setActiveView(office?.current() ?? null);
renderOfficeBadge();
if (driveControls) driveControls.hidden = !routeDriveIsActive();
if (walkControls) walkControls.hidden = !(walking || exploring);
for (const control of walkControls?.querySelectorAll<HTMLElement>(".flight-only") ?? []) {
control.hidden = inside;
if (walkControls) walkControls.hidden = !(walking || exploring || flying);
for (const control of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
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 (mediaSurfaces.length === 0) return;
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;
}
officeBadge.replaceChildren(
@@ -1863,23 +1928,20 @@ function renderTierBadge() {
}
function applyProfilePreview(profile: LocalProfile): void {
if (access.subject === null || inside) return;
if (access.subject === null) return;
if (inside) {
const appearance = resolveHumanoidAppearance(profile.appearance);
office?.walker?.setAppearance({
kind: "humanoid",
skinTone: appearance.skinTone,
outfitColor: appearance.outfitColor,
accentColor: appearance.accentColor,
hairColor: appearance.hairColor,
bodyShape: appearance.bodyShape,
});
} else {
city?.setActorIdentity(signedInActorIdentity(profile));
}
async function rebuildOfficeActor(): Promise<void> {
if (!inside || !office) return;
const wasWalking = office.walker?.active() ?? false;
stopWatchingOccupancy();
officePlan?.dispose();
officePlan = null;
office.dispose();
office = null;
officeAtmosphere = null;
await building("Updating your character…", () => enterOffice());
const rebuilt = office as OfficeScene | null;
if (wasWalking) rebuilt?.walker?.setActive(true);
renderLegend();
}
}
function ensureProfileEditor(): ProfileEditor | null {
@@ -1903,9 +1965,9 @@ function ensureProfileEditor(): ProfileEditor | null {
},
});
city?.setActorIdentity(signedInActorIdentity(profile));
applyProfilePreview(profile);
renderTierBadge();
profileOverlay.hidden = true;
if (inside) void rebuildOfficeActor();
},
onCancel() {
profileOverlay.hidden = true;
@@ -1924,6 +1986,132 @@ function openProfileEditor(): void {
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 -------------------------------------------------------------
/** The views on offer right now — city chapters, or office viewpoints inside. */
@@ -2064,6 +2252,10 @@ function toggleOfficeWalk(): boolean {
const active = !city.actorActive();
city.setActorActive(active);
if (!active) city.setActorActions({});
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
}
renderLegend();
return true;
}
@@ -2072,12 +2264,31 @@ function toggleOfficeWalk(): boolean {
const active = !walker.active();
walker.setActive(active);
if (!active) walker.setAction({ x: 0, z: 0 });
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
}
renderLegend();
return true;
}
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.
*
@@ -2198,7 +2409,8 @@ const heldDriveKeys = new Set<string>();
function routeDriveIsActive(): boolean {
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(
@@ -2244,6 +2456,21 @@ function publishCityActorActions(): boolean {
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 {
if (!routeDriveIsActive() || !city) return false;
city.setVehicleCamera(city.vehicleCamera() === "driver" ? "chase" : "driver");
@@ -2278,6 +2505,7 @@ for (const button of walkControls?.querySelectorAll<HTMLButtonElement>("[data-wa
heldDriveKeys.delete(key);
button.setAttribute("aria-pressed", "false");
publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions();
event.preventDefault();
};
@@ -2286,6 +2514,7 @@ for (const button of walkControls?.querySelectorAll<HTMLButtonElement>("[data-wa
heldDriveKeys.add(key);
button.setAttribute("aria-pressed", "true");
publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions();
event.preventDefault();
});
@@ -2300,17 +2529,25 @@ driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
?.addEventListener("click", () => publishVehicleActions({ reset: true }));
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
?.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) => {
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (!heldDriveKeys.delete(key)) return;
if (publishVehicleActions() || publishOfficeWalkActions() || publishCityActorActions()) event.preventDefault();
if (
publishVehicleActions() || publishOfficeWalkActions() ||
publishAircraftActions() || publishCityActorActions()
) event.preventDefault();
});
window.addEventListener("blur", () => {
heldDriveKeys.clear();
publishVehicleActions();
publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions();
});
@@ -2394,6 +2631,10 @@ window.addEventListener("keydown", (event) => {
event.preventDefault();
return;
}
if (publishAircraftActions()) {
event.preventDefault();
return;
}
if (publishCityActorActions()) {
event.preventDefault();
return;
@@ -2403,10 +2644,18 @@ window.addEventListener("keydown", (event) => {
event.preventDefault();
return;
}
if (lower === "p" && publishAircraftActions({ modeRequest: "assisted" })) {
event.preventDefault();
return;
}
if (lower === "r" && publishVehicleActions({ reset: true })) {
event.preventDefault();
return;
}
if (lower === "r" && publishAircraftActions({ reset: true })) {
event.preventDefault();
return;
}
if (lower === "c" && toggleVehicleCamera()) {
event.preventDefault();
return;
+6
View File
@@ -17,3 +17,9 @@ export {
type MediaSurfaceGrant,
type OfficeMediaPresentation,
} 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();
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();
});
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", () => {
const actor = createSceneActor(options({ active: true }));
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);
});
});