feat: add playable flight and office screen sharing
This commit is contained in:
+14
-8
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+273
-24
@@ -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;
|
||||||
|
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));
|
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 {
|
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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user