1
0

feat: add playable flight and office screen sharing

This commit is contained in:
2026-08-11 19:46:47 -07:00
parent c0c7fcf974
commit 16dc85a6f8
13 changed files with 1355 additions and 45 deletions
+275 -26
View File
@@ -80,6 +80,16 @@ import {
type ProfileEditor,
} from "./profile/index.ts";
import type { ActorIdentity } from "./actors/controller.ts";
import { SRGBColorSpace, VideoTexture } from "three";
import {
CALIFORNIA_AIR_ROUTE,
type AircraftActionSnapshot,
} from "./aircraft/index.ts";
import {
createOfficeScreenPanel,
type MediaSurfaceDescriptor,
type OfficeScreenPanel,
} from "./media/index.ts";
/**
* Three type-only imports and not one value among them, which is what keeps the
* office and the instruments out of the entry chunk.
@@ -422,6 +432,13 @@ let officeId = OFFICES[0]?.id ?? "lumbridge-hq";
* interior rig and is a supported state rather than a gap.
*/
let officeAtmosphere: Atmosphere | null = null;
let officeScreenPanel: OfficeScreenPanel | null = null;
let sharedScreen: {
screenId: string;
stream: MediaStream;
video: HTMLVideoElement;
texture: VideoTexture;
} | null = null;
/**
* The plan panel's other occupant.
*
@@ -728,6 +745,7 @@ async function mountCity(id: string) {
poseEditor?.destroy();
poseEditor = null;
stopWatchingOccupancy();
disposeOfficeScreenUi();
officePlan?.dispose();
officePlan = null;
office?.dispose();
@@ -868,6 +886,18 @@ async function mountCity(id: string) {
count: 14,
seed: 115,
},
aircraft: {
route: CALIFORNIA_AIR_ROUTE,
initialPosition: CALIFORNIA_AIR_ROUTE[0],
initialAltitudeM: CALIFORNIA_AIR_ROUTE[0]?.altitudeM ?? 1_350,
initialHeadingDeg: 320,
assistedAltitudeM: 1_500,
// Corridor altitude is an atlas glyph just like the black cars:
// literal metres would put the chase camera inside the coarse hills.
altitudeSceneUnitsPerMetre: 0.01,
visualSceneUnitsPerMetre: 0.22,
camera: { distance: 52, height: 18, lookAhead: 18 },
},
}
: {}),
flights: dial.source,
@@ -1326,6 +1356,7 @@ function leaveOffice() {
// Before the scene swap, so the last thing the watch can do is abort a request
// rather than publish into a room the user has already left.
stopWatchingOccupancy();
disposeOfficeScreenUi();
office?.walker?.setActive(false);
office?.walker?.setAction({ x: 0, z: 0 });
dispatchJourney({ type: "leave-office" });
@@ -1491,9 +1522,12 @@ const credits = document.querySelector<HTMLElement>("#credits");
const driveControls = document.querySelector<HTMLElement>("#drive-controls");
const driveHint = document.querySelector<HTMLElement>("#drive-hint");
const walkButton = document.querySelector<HTMLButtonElement>("#walk");
const flyButton = document.querySelector<HTMLButtonElement>("#fly");
const screensButton = document.querySelector<HTMLButtonElement>("#screens");
const walkControls = document.querySelector<HTMLElement>("#walk-controls");
const walkHint = document.querySelector<HTMLElement>("#walk-hint");
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
let profileEditor: ProfileEditor | null = null;
function showDetail(text: string | null) {
@@ -1559,6 +1593,7 @@ async function switchOffice(id: string) {
// The roster belongs to the building you have left.
stopWatchingOccupancy();
disposeOfficeScreenUi();
officePlan?.dispose();
officePlan = null;
office?.dispose();
@@ -1626,6 +1661,7 @@ function renderLegend() {
}
const walking = inside && (office?.walker?.active() ?? false);
const exploring = !inside && (city.actorActive() ?? false);
const flying = !inside && (city.aircraftActive() ?? false);
if (walkButton) {
walkButton.hidden = inside ? office?.walker === null : city.actorState() === null;
walkButton.setAttribute("aria-pressed", String(walking || exploring));
@@ -1637,6 +1673,15 @@ function renderLegend() {
walkButton.textContent = exploring ? "Return to flyover ↑" : `Explore as ${actor}`;
}
}
if (flyButton) {
flyButton.hidden = inside || cityId !== "california" || city.aircraftState() === null;
flyButton.setAttribute("aria-pressed", String(flying));
flyButton.textContent = flying ? "Return to flyover ↑" : "Fly the California route →";
}
if (screensButton) {
const canManage = inside && office?.depth === "full" && (office.listMediaSurfaces().length > 0);
screensButton.hidden = !canManage;
}
renderSource();
if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
if (canvas) {
@@ -1646,6 +1691,8 @@ function renderLegend() {
? walking
? `${officeName()}, following your ${office?.walker?.state().actor === "anonymous-dog" ? "dog" : "humanoid"}. Use W A S D to move.`
: `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
: flying
? `${cityLabel}, following your electric aircraft. Use W A S D to fly or P to resume assisted flight.`
: exploring
? `${cityLabel}, following your ${city.actorState()?.kind ?? "actor"}. Use W A S D to move.`
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
@@ -1658,12 +1705,27 @@ function renderLegend() {
officePlan?.setActiveView(office?.current() ?? null);
renderOfficeBadge();
if (driveControls) driveControls.hidden = !routeDriveIsActive();
if (walkControls) walkControls.hidden = !(walking || exploring);
for (const control of walkControls?.querySelectorAll<HTMLElement>(".flight-only") ?? []) {
control.hidden = inside;
if (walkControls) walkControls.hidden = !(walking || exploring || flying);
for (const control of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
control.textContent = flying
? control.dataset.aircraftLabel ?? control.textContent
: control.dataset.walkLabel ?? control.textContent;
}
for (const control of walkControls?.querySelectorAll<HTMLElement>(".flight-only") ?? []) {
control.hidden = inside || (!flying && city.actorState()?.kind !== "crow");
}
for (const control of walkControls?.querySelectorAll<HTMLElement>(".aircraft-only") ?? []) {
control.hidden = !flying;
}
if (driveHint) driveHint.hidden = inside || cityId !== "california" || flying;
if (walkHint) {
walkHint.hidden = !(inside || city.actorState() || city.aircraftState());
walkHint.textContent = flying
? "WASD fly · P assisted · R reset"
: inside
? "V walk · WASD move"
: "V explore · WASD move";
}
if (driveHint) driveHint.hidden = inside || cityId !== "california";
if (walkHint) walkHint.hidden = !(inside || city.actorState());
}
/**
@@ -1800,7 +1862,10 @@ function renderOfficeBadge() {
if (!publicOffice) {
if (mediaSurfaces.length === 0) return;
const noun = mediaSurfaces.length === 1 ? "screen" : "screens";
officeBadge.textContent = `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`;
const active = mediaSurfaces.filter((surface) => surface.bound).length;
officeBadge.textContent = active > 0
? `${active} of ${mediaSurfaces.length} ${noun} active · stop control in Office screens.`
: `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`;
return;
}
officeBadge.replaceChildren(
@@ -1863,23 +1928,20 @@ function renderTierBadge() {
}
function applyProfilePreview(profile: LocalProfile): void {
if (access.subject === null || inside) return;
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();
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));
}
}
function ensureProfileEditor(): ProfileEditor | null {
@@ -1903,9 +1965,9 @@ function ensureProfileEditor(): ProfileEditor | null {
},
});
city?.setActorIdentity(signedInActorIdentity(profile));
applyProfilePreview(profile);
renderTierBadge();
profileOverlay.hidden = true;
if (inside) void rebuildOfficeActor();
},
onCancel() {
profileOverlay.hidden = true;
@@ -1924,6 +1986,132 @@ function openProfileEditor(): void {
editor.open();
}
function stopLocalScreenShare(): void {
const shared = sharedScreen;
if (!shared) return;
sharedScreen = null;
office?.clearMediaSurface(shared.screenId);
for (const track of shared.stream.getTracks()) track.stop();
shared.texture.dispose();
shared.video.pause();
shared.video.srcObject = null;
officeScreenPanel?.update(office?.listMediaSurfaces() ?? []);
renderOfficeBadge();
}
function disposeOfficeScreenUi(): void {
stopLocalScreenShare();
officeScreenPanel?.dispose();
officeScreenPanel = null;
if (screensOverlay) screensOverlay.hidden = true;
}
async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<void> {
if (!office || !inside || office.depth !== "full") return;
const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false;
if (!optedIn) {
showDetail("Opt in to view this screen before starting a local preview.");
return;
}
if (!navigator.mediaDevices?.getDisplayMedia) {
showDetail("This browser does not provide tab or window sharing.");
return;
}
let pendingStream: MediaStream | null = null;
let pendingVideo: HTMLVideoElement | null = null;
let pendingTexture: VideoTexture | null = null;
try {
showDetail("Choose a tab or window. Nothing is captured until you approve the browser prompt.");
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: "browser" },
audio: false,
// Chromium honours these as chooser preferences. Other browsers ignore
// unknown dictionary members and still require the same explicit prompt.
monitorTypeSurfaces: "exclude",
selfBrowserSurface: "exclude",
surfaceSwitching: "include",
} as DisplayMediaStreamOptions);
pendingStream = stream;
const video = document.createElement("video");
pendingVideo = video;
video.muted = true;
video.playsInline = true;
video.srcObject = stream;
await video.play();
const texture = new VideoTexture(video);
pendingTexture = texture;
texture.colorSpace = SRGBColorSpace;
texture.generateMipmaps = false;
stopLocalScreenShare();
if (!office?.bindMediaSurface(surface.screenId, { canView: true, optedIn: true }, texture)) {
for (const track of stream.getTracks()) track.stop();
texture.dispose();
video.pause();
video.srcObject = null;
showDetail("That screen is no longer available.");
return;
}
sharedScreen = { screenId: surface.screenId, stream, video, texture };
pendingStream = null;
pendingVideo = null;
pendingTexture = null;
stream.getVideoTracks()[0]?.addEventListener("ended", () => stopLocalScreenShare(), { once: true });
officeScreenPanel?.update(office.listMediaSurfaces());
renderOfficeBadge();
showDetail(`Sharing locally to ${surface.screenId}. Use Office screens to stop.`);
} catch (error) {
for (const track of pendingStream?.getTracks() ?? []) track.stop();
pendingTexture?.dispose();
if (pendingVideo) {
pendingVideo.pause();
pendingVideo.srcObject = null;
}
if ((error as DOMException)?.name !== "NotAllowedError") {
showDetail("The screen preview could not start. Try again from Office screens.");
}
}
}
function ensureOfficeScreenPanel(): OfficeScreenPanel | null {
if (!screensOverlay || !inside || !office || office.depth !== "full") return null;
if (officeScreenPanel) {
officeScreenPanel.update(office.listMediaSurfaces());
return officeScreenPanel;
}
officeScreenPanel = createOfficeScreenPanel({
container: screensOverlay,
surfaces: office.listMediaSurfaces(),
onRequestShare: (surface) => { void startLocalScreenShare(surface); },
onStopShare: () => stopLocalScreenShare(),
onViewerOptIn(surface, optedIn) {
if (!optedIn && sharedScreen?.screenId === surface.screenId) stopLocalScreenShare();
},
});
const syncOverlay = () => queueMicrotask(() => {
if (officeScreenPanel && !officeScreenPanel.state().open) screensOverlay.hidden = true;
});
officeScreenPanel.root.addEventListener("click", syncOverlay);
officeScreenPanel.root.addEventListener("keydown", (event) => {
event.stopPropagation();
syncOverlay();
});
return officeScreenPanel;
}
function openOfficeScreens(): void {
const panel = ensureOfficeScreenPanel();
if (!panel || !screensOverlay) return;
screensOverlay.hidden = false;
panel.open();
}
screensButton?.addEventListener("click", openOfficeScreens);
screensOverlay?.addEventListener("click", (event) => {
if (event.target !== screensOverlay) return;
officeScreenPanel?.close();
screensOverlay.hidden = true;
});
// ---- Navigation -------------------------------------------------------------
/** The views on offer right now — city chapters, or office viewpoints inside. */
@@ -2064,6 +2252,10 @@ function toggleOfficeWalk(): boolean {
const active = !city.actorActive();
city.setActorActive(active);
if (!active) city.setActorActions({});
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
}
renderLegend();
return true;
}
@@ -2072,12 +2264,31 @@ function toggleOfficeWalk(): boolean {
const active = !walker.active();
walker.setActive(active);
if (!active) walker.setAction({ x: 0, z: 0 });
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
}
renderLegend();
return true;
}
walkButton?.addEventListener("click", () => toggleOfficeWalk());
function toggleAircraft(): boolean {
if (inside || cityId !== "california" || !city?.aircraftState()) return false;
const active = !city.aircraftActive();
city.setAircraftActive(active);
if (!active) city.setAircraftActions({});
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
}
renderLegend();
return true;
}
flyButton?.addEventListener("click", () => toggleAircraft());
/**
* Clicking a building on the city walks into it.
*
@@ -2198,7 +2409,8 @@ const heldDriveKeys = new Set<string>();
function routeDriveIsActive(): boolean {
const state = !inside ? city?.vehicleState() : null;
return state !== null && state !== undefined && !city?.actorActive() && city?.current() === state.routeId;
return state !== null && state !== undefined && !city?.actorActive() &&
!city?.aircraftActive() && city?.current() === state.routeId;
}
function publishVehicleActions(
@@ -2244,6 +2456,21 @@ function publishCityActorActions(): boolean {
return true;
}
function publishAircraftActions(
supplement: Partial<AircraftActionSnapshot> = {},
): boolean {
if (inside || !city?.aircraftActive()) return false;
city.setAircraftActions({
throttle: heldDriveKeys.has(" ") ? 1 : 0,
pitch: (heldDriveKeys.has("w") ? 1 : 0) - (heldDriveKeys.has("s") ? 1 : 0),
roll: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
yaw: (heldDriveKeys.has("e") ? 1 : 0) - (heldDriveKeys.has("q") ? 1 : 0),
modeRequest: supplement.modeRequest ?? "none",
reset: supplement.reset ?? false,
});
return true;
}
function toggleVehicleCamera(): boolean {
if (!routeDriveIsActive() || !city) return false;
city.setVehicleCamera(city.vehicleCamera() === "driver" ? "chase" : "driver");
@@ -2278,6 +2505,7 @@ for (const button of walkControls?.querySelectorAll<HTMLButtonElement>("[data-wa
heldDriveKeys.delete(key);
button.setAttribute("aria-pressed", "false");
publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions();
event.preventDefault();
};
@@ -2286,6 +2514,7 @@ for (const button of walkControls?.querySelectorAll<HTMLButtonElement>("[data-wa
heldDriveKeys.add(key);
button.setAttribute("aria-pressed", "true");
publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions();
event.preventDefault();
});
@@ -2300,17 +2529,25 @@ driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
?.addEventListener("click", () => publishVehicleActions({ reset: true }));
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
?.addEventListener("click", () => toggleVehicleCamera());
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='assist']")
?.addEventListener("click", () => publishAircraftActions({ modeRequest: "assisted" }));
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='reset']")
?.addEventListener("click", () => publishAircraftActions({ reset: true }));
window.addEventListener("keyup", (event) => {
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (!heldDriveKeys.delete(key)) return;
if (publishVehicleActions() || publishOfficeWalkActions() || publishCityActorActions()) event.preventDefault();
if (
publishVehicleActions() || publishOfficeWalkActions() ||
publishAircraftActions() || publishCityActorActions()
) event.preventDefault();
});
window.addEventListener("blur", () => {
heldDriveKeys.clear();
publishVehicleActions();
publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions();
});
@@ -2394,6 +2631,10 @@ window.addEventListener("keydown", (event) => {
event.preventDefault();
return;
}
if (publishAircraftActions()) {
event.preventDefault();
return;
}
if (publishCityActorActions()) {
event.preventDefault();
return;
@@ -2403,10 +2644,18 @@ window.addEventListener("keydown", (event) => {
event.preventDefault();
return;
}
if (lower === "p" && publishAircraftActions({ modeRequest: "assisted" })) {
event.preventDefault();
return;
}
if (lower === "r" && publishVehicleActions({ reset: true })) {
event.preventDefault();
return;
}
if (lower === "r" && publishAircraftActions({ reset: true })) {
event.preventDefault();
return;
}
if (lower === "c" && toggleVehicleCamera()) {
event.preventDefault();
return;