/** * The demo: two cities under a real sun and moon, and two studios you can step * into. * * It ships **no real company data**. The markers are fabricated — see * `src/adapters/sample.ts`, which says so loudly — because real positions are * geocoded and real pipeline status is private, and neither belongs in this * repo. When a Tera API is present the same markers arrive from it instead, and * the UI says which of the two it is showing. * * It also runs with no server at all: the sun and moon are computed locally, * the traffic is simulated, the office is a data file, the studio hardware is a * fixed-step simulator running in this tab. Clone it and it works. * * ### What this file is, after the interface moved out * * This file used to hold about forty imperative `element.hidden = condition` * decisions with the condition and the write on the same line, spread through a * 655-line "Chrome" section, and none of it was testable without a WebGL * context. That is now two modules: `ui/chromeState.ts` decides and `ui/mount.ts` * writes, and what is left here is the one call between them — * `chrome.apply(chromeState(chromeInputs()))` — plus the thing this file is * actually for, which is **assembly**: it owns the clock, the access tier, which * board is mounted, which building is open, and which of the delivered modules * is handed to which scene. * * The rule that keeps it that way: nothing below this line may write to a DOM * node that `mount.ts` owns. Where you want a piece of chrome to change, add a * field to `ChromeInputs` and call `renderChrome()`. */ import { createAtmosphere, observe, PACIFIC_MARINE_LAYER, type Atmosphere, type WeatherObservation, } from "./engine/atmosphere.ts"; import { officeDaylight, withHouseLights } from "./interiors/daylight.ts"; import { createScene, type SceneHandle } from "./engine/scene.ts"; import { createEnvironmentRig } from "./engine/environmentRig.ts"; import { aircraftDetail, regionOf, SimulatedFlights, withTrafficDial, type TrafficDial, } from "./engine/flights.ts"; import { SatelliteCatalogue, type SatelliteElements } from "./engine/satellites.ts"; import type { Pose } from "./engine/scenekit.ts"; import { createStage, deviceProfile } from "./engine/stage.ts"; import { daylightPhase } from "./engine/solar.ts"; import type { Aircraft, City, Marker, MarkerPalette, View } from "./engine/types.ts"; import CALIFORNIA from "./cities/california.ts"; import SAN_FRANCISCO from "./cities/sf.ts"; import SOCAL from "./cities/socal.ts"; import CALIFORNIA_TRANSPORT from "./transport/california.ts"; import { aircraftActionsFromPlay, cameraRelativePlanar, crowActionsFromPlay, groundActorActionsFromPlay, PlayInputRouter, sampleStandardPlayGamepad, vehicleActionsFromPlay, type StandardPlayGamepadButtons, } from "./input/play.ts"; import { createTeraClient, type PresenceWatch, type TrafficSource, type WeatherWatch, } from "./adapters/http.ts"; import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_PRESENCE_PALETTE, samplePresenceForOfficeAt, sampleRoutesFor, } from "./adapters/sample.ts"; import { OFFICE_SITES, type ShippedOfficeId } from "./offices/sites.ts"; import { authFetch } from "./session.ts"; import { capabilitiesFor, resolveAccess, type Access } from "./access.ts"; import { createMinimap, type Minimap } from "./engine/minimap.ts"; import { createJourney, journeyReducer, loadJourneySession, saveJourneySession, type JourneyCity, type JourneyEvent, type JourneyState, } from "./journey/index.ts"; import { actorKindForPresence, createProfileEditor, createDefaultLocalProfile, createWebcamCapture, createWebcamFaceConsent, createWebcamFacePanel, createWebcamFaceTexture, loadLocalProfile, resolveHumanoidAppearance, saveLocalProfile, type LocalProfile, type ProfileEditor, type WebcamCaptureController, type WebcamFacePanel, type WebcamFaceTextureAdapter, } from "./profile/index.ts"; import type { ActorIdentity } from "./actors/controller.ts"; import { SRGBColorSpace, VideoTexture } from "three"; import { CALIFORNIA_AIR_ROUTE, createAircraftPoseSnapshot, } from "./aircraft/index.ts"; import { cityControlMode, createControlModeState, transitionControlMode, type ControlMode, } from "./play/controlMode.ts"; import { createOfficeScreenPanel, type MediaSurfaceDescriptor, type OfficeScreenPanel, type OfficeScreenRemoteStatus, } from "./media/index.ts"; import type { RemoteMediaState, RemoteOfficeMedia } from "./media/remoteMedia.ts"; /** * The interface, as two modules and one call. * * `chromeState` is pure — no DOM, no THREE, no clock — and `mountChrome` is the * only thing in the product that writes to the page. Both are value imports and * both belong in the entry chunk: the chrome is on screen before anything else * is, including the boot card's own successor. */ import { chromeState, seedPanelOpen, seedPlanOpen, type ChromeDetail, type ChromeInputs, type ChromeLayout, } from "./ui/chromeState.ts"; import { mountChrome, type ChromeHandle } from "./ui/mount.ts"; import { controlForKey, edgeForKey } from "./ui/shortcuts.ts"; import { browserStorage, hasSeenOnboarding } from "./ui/onboarding.ts"; import { playHudKindFor, type AircraftDetailInput, type PlayTelemetry } from "./ui/hud.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. * * `import type` is erased before Rollup sees it, so none of these files is an * edge in the module graph. The office arrives through `loadOffice()`, the tools * through the `await import()` in `boot()`. Turning any of them into a value * import silently undoes the split and nothing fails — the bundle just gets big * again. The device and vehicle simulators travel with the office for the same * reason: renderer-free, but with nothing to say until you are in a studio. */ import type { OfficeScene } from "./interiors/officeScene.ts"; import type { Office, Presence } from "./interiors/types.ts"; import type { RobotOperationsDefinition } from "./interiors/robotOperations.ts"; import type { MaterialQuality, MaterialRegistry } from "./assets/materials.ts"; import type { DeviceSource } from "./devices/adapter.ts"; import type { DeviceCommand, DeviceDeclaration, DeviceState } from "./devices/types.ts"; import type { SimulatedVehicleTelemetry } from "./transport/vehicleTelemetry.ts"; // Type-only for the reason above, and it matters more here than it looks: // `officeMinimap.ts` imports the asset registry as a *value*, to read prop // footprints, so a value import of it here would drag the furniture catalogue // back into the entry chunk and quietly undo the split this block exists to // protect. It arrives with the office, in `loadOffice()`, because it is only // ever drawn once you are standing in one. import type { OfficeMinimap } from "./engine/officeMinimap.ts"; import type { Godmode, GodmodeHouseLights, GodmodePlace } from "./tools/index.ts"; import type { PoseEditor } from "./tools/poseEditor.ts"; import type { EntityPoseSnapshot, InterestCell, RealtimeClient, ServerRealtimeMessage, } from "./realtime/index.ts"; import type { PresenceIndicator } from "./realtime/presenceIndicator.ts"; const CITIES: { id: string; label: string; city: City }[] = [ { id: "california", label: "California", city: CALIFORNIA }, { id: "sf", label: "Bay Area", city: SAN_FRANCISCO }, { id: "socal", label: "SoCal", city: SOCAL }, ]; /** The corridor's scale doors, and the office each detailed board arrives near. */ const CALIFORNIA_DESTINATIONS = new Map([ ["los-angeles", { cityId: "socal", officeId: "mateo-court" }], ["san-francisco", { cityId: "sf", officeId: "lumbridge-hq" }], ]); const JOURNEY_SESSION_KEY = "tera:journey:v1"; const restoredJourney = loadJourneySession(sessionStorage, JOURNEY_SESSION_KEY); let journey: JourneyState = restoredJourney.status === "loaded" ? restoredJourney.state : createJourney({ actor: { id: "anonymous", kind: "crow", signedIn: false, profile: { displayName: "Guest" }, }, }); const LOCAL_PROFILE_KEY = "tera:profile:v2"; let localProfile: LocalProfile | null = null; function humanoidAppearance() { return localProfile ? resolveHumanoidAppearance(localProfile.appearance) : null; } function signedInActorIdentity(profile = localProfile): ActorIdentity { const appearance = profile ? resolveHumanoidAppearance(profile.appearance) : null; return { id: access.subject ?? "anonymous", displayName: profile?.displayName ?? access.subject ?? "Guest", authenticated: access.subject !== null, profile: { appearance: appearance ? { skinTone: appearance.skinTone, primaryColor: appearance.outfitColor, accentColor: appearance.accentColor, hairColor: appearance.hairColor, bodyShape: appearance.bodyShape, } : { primaryColor: "#151a20", accentColor: "#f2b134" }, }, }; } function dispatchJourney(event: JourneyEvent): boolean { const next = journeyReducer(journey, event); if (next === journey) return false; journey = next; saveJourneySession(sessionStorage, JOURNEY_SESSION_KEY, journey); return true; } function journeyToCity(city: JourneyCity): void { if (journey.location.scale === "office") dispatchJourney({ type: "leave-office" }); if (journey.location.scale !== "california") dispatchJourney({ type: "return-to-california" }); dispatchJourney({ type: "navigate-to-city", city }); } /** * The buildings this page can walk into. * * Three, deliberately unalike — a tower above Transbay, a hangar at Alameda * Point, an Arts District courtyard — because the thing worth showing is that * one engine and one format render all three, and that `OfficeSite` is what * makes them feel like different places rather than the same room with * different furniture. * * The loaders stay lazy: a pack eagerly imported would put its furniture in the * entry bundle for every visitor who never opens that door. */ const OFFICE_LOADERS: Readonly Promise<{ default: Office }>>> = { "lumbridge-hq": () => import("./offices/lumbridge-hq.ts"), "frontier-valley": () => import("./offices/frontier-valley.ts"), "mateo-court": () => import("./offices/mateo-court.ts"), }; const OFFICE_OPERATION_LOADERS: Readonly Promise<{ default: RobotOperationsDefinition }> >>> = { "lumbridge-hq": async () => ({ default: (await import("./offices/operations/lumbridge-hq.ts")).LUMBRIDGE_HQ_ROBOT_OPERATIONS, }), "mateo-court": async () => ({ default: (await import("./offices/operations/mateo-court.ts")).MATEO_COURT_ROBOT_OPERATIONS, }), }; const OFFICES = OFFICE_SITES.map((entry) => ({ ...entry, label: entry.name, load: OFFICE_LOADERS[entry.id], loadOperations: OFFICE_OPERATION_LOADERS[entry.id], })); const canvas = document.querySelector("#scene"); if (!canvas) throw new Error("#scene canvas missing"); /** * One renderer, one loop, for as long as this page is open. * * Built here rather than inside `createScene` because a `WebGLRenderer` is a * property of the *canvas* and not of the city drawn on it. When each city built * its own, every switch abandoned a renderer on the one GL context this page * has — and `WebGLRenderer.dispose()` frees no texture at all. `stage.ts` has * the measurement and the reading of three's source it comes from. * * Nothing disposes this: it outlives every city and every office on the page, * the same arrangement as `officeMaterials` below. */ const stage = createStage(canvas); /** * One environment map for the page, beside the one renderer, for the same * reason there is one renderer. * * A `PMREMGenerator` compiles three shader programs and each cached environment * is a half-float render target; both are properties of the GL context, not of * the board drawn on it. Building a rig inside `createScene` would allocate a * fresh chain per city and orphan the previous one on every switch, which is * precisely the arithmetic `stage.ts` records for the renderer itself. So it is * built here, handed to both scenes, and each scene calls `release()` on its way * out so the rig's ledger does not retain a floor plate nobody can see. * * Nothing about it is eager: it allocates on the first `apply()`, and the two * kinds — a sky for the city, a room for an office — are cached separately, so a * page that never opens a door never builds the office blur chain. */ const environment = createEnvironmentRig(stage.renderer); // `authFetch` so the private-office pack (`/api/v1/offices/:id`, which answers // 404 rather than 403 to anyone who may not see it) is requested as the signed-in // viewer. On a `password`-mode or open deployment it is an ordinary fetch. const tera = createTeraClient({ fetch: authFetch }); let city: SceneHandle | null = null; let cityId = "california"; /** * The city the user last *asked* for, which is not the same as the one that is * mounted or even the one that is being built. * * `building()` defers its work by two animation frames so the boot card can * paint, and a frame under load is not 16 ms — measured at 250 ms on a software * rasteriser. Clicking SoCal and then changing your mind inside that window * used to hit `if (id === cityId) return` against a `cityId` the deferred * `mountCity` had not written yet, so the second click was discarded as * redundant and you arrived at the city you had just cancelled. The guard has * to be against the intention, and the intention is recorded synchronously in * the click handler. */ let wantedCity = "california"; let office: OfficeScene | null = null; /** The pack used by `office`; kept separate from the currently selected door. */ let builtOfficeId: string | null = null; let inside = false; let controlModeState = createControlModeState(); const playInput = new PlayInputRouter(); let markers: Marker[] = SAMPLE_MARKERS; let realtimeClient: RealtimeClient | null = null; let presenceIndicator: PresenceIndicator | null = null; let stopRealtimeSubscription: (() => void) | null = null; let realtimeOperation = 0; let lastRealtimePublishAt = 0; let realtimePageActive = true; /** Page-scoped wire identities; auth subject never enters the spatial protocol. */ const realtimeActorId = crypto.randomUUID(); const realtimeVehicleId = crypto.randomUUID(); const realtimeAircraftId = crypto.randomUUID(); /** * The buildings you can walk into, as procedural glyphs on the city. * * The one thing that makes Tera and Spaces read as one product rather than two * views sharing a bundle: both packs carry a real `site`, and until these * existed that coordinate was known to the lighting and to nothing else. * * `OFFICE_SITES` rather than the packs, deliberately — a pack is a lazy chunk * and the city wants these the instant the board appears. `colorKey` and `glyph` * stay opaque to the engine, so a door looks different from a company without * the renderer knowing what either means. */ const OFFICE_MARKERS: Marker[] = OFFICE_SITES.map((entry) => ({ id: `office:${entry.id}`, label: entry.name, colorKey: "office", blurb: `${entry.entryCopy} · ${entry.status}`, lat: entry.site.lat, lng: entry.site.lng, // Hand-typed from the street grid, like every other coordinate here. Not a // placeholder, so it is drawn as a real address. located: true, ...(entry.site.exterior ? { glyph: entry.site.exterior } : {}), })); /** * Whether a marker is a door rather than a company. * * The id prefix is the whole test, and it is deliberately something no marker * off the wire can collide with: `markers/gate.ts` serves rows from a synced * database and none of them are namespaced this way. */ function officeIdOf(marker: Marker): string | null { return marker.id.startsWith("office:") ? marker.id.slice("office:".length) : null; } /** * Whatever the pointer is over, so a click knows what it clicked. * * The engine reports picks by hover rather than by click — that is what drives * the detail card — so the click handler has no argument of its own and reads * this instead. `null` whenever the pointer is over open ground, which is what * makes a click on the terrain do nothing. */ let hoveredMarker: Marker | null = null; /** * The colour a door is drawn in, which no marker feed knows about. * * Merged in **here** rather than added to `SAMPLE_PALETTE`, because it is not a * sample of anything: a deployment that replaces the whole marker feed with its * own palette must still get doors it can see. Amber, so a door reads as this * application's navigation rather than as another marker hue. */ const OFFICE_PALETTE: MarkerPalette = { office: 0xf5b53f }; let palette: MarkerPalette = { ...SAMPLE_PALETTE, ...OFFICE_PALETTE }; let liveData = false; /** * What this visitor may do. Resolved once in `boot()`; every gate below reads * `access.can.*` and nothing else. * * The pre-boot value is the **closed** one, deliberately. A handler that * somehow fires before `resolveAccess()` has settled — a keystroke on a slow * connection, a click on a control that is in the document from first paint — * should offer a visitor less than they are entitled to and never more. The * rule that produces this value, and the SSO bug that once produced it wrongly, * are written out in `src/access.ts`. */ let access: Access = { tier: "anon", subject: null, signInUrl: null, can: capabilitiesFor("anon"), feeds: null, }; let atmosphere: ReturnType | null = null; let minimap: Minimap | null = null; /** * The sky over the city currently on screen, polled while it is on screen. * * One watch at a time and it belongs to the board, not to the page. Stopping it * at the top of `mountCity` is the whole of the cancel-on-switch rule: a * `/weather` request for San Francisco that lands after the user has moved to * SoCal would otherwise put the marine layer over Long Beach, and it is a * request in flight for most of the second in which somebody clicks. */ let weatherWatch: WeatherWatch | null = null; /** * The live traffic source, when there is one, kept so the corner label can ask * it whether the aircraft on screen were observed. `null` means the simulator, * which is never live and does not need asking. */ let cityFlights: TrafficSource | null = null; /** * The satellite element sets, fetched once for the page rather than once per city. * * The asymmetry with every other feed here is the physics: an aircraft at * 10,000 m is visible for tens of kilometres and the two boards are six hundred * apart, but a satellite at 550 km is above the horizon for a circle two * thousand kilometres across. So the elements are shared — and the *catalogue* * is not, because a `SatelliteCatalogue` is built around an observer, and * reusing one would compute the Southland's sky from San Francisco with nothing * on screen to say so. * * A promise rather than a value, so a second city mounted while the first fetch * is in the air waits for it instead of starting another. `null` forever on the * majority of deployments: `TERA_SATELLITES_SOURCE` is off by default. */ let satelliteElements: Promise | null = null; /** * The fabricated-traffic dial for the board on screen, rebuilt with every city. * * Held here rather than inside the godmode closure because the panel is mounted * once and the board is not: a dial captured when the panel opened would keep * pushing aircraft at a scene that had been disposed two city switches ago. */ let trafficDial: TrafficDial | null = null; /** * Whether the satellite layer is drawn, remembered across city switches. * * A new board builds a new layer, which starts visible, so a god who turned the * sky off and then changed city would have it come back on — a setting that * quietly undoes itself is worse than one that is not there. */ let satellitesVisible = true; /** * The build in progress. Aborting it is what makes a second click on the other * city cheap: `createScene` drops the heightfield, resolves `null`, and has * allocated no WebGL context for the abandoned board. */ let mounting: AbortController | null = null; let godmode: Godmode | null = null; let poseEditor: PoseEditor | null = null; /** * The office, once somebody has asked for it. Both are `null` until the first * `loadOffice()` because both live in a chunk this page does not fetch until * then — see `loadOffice` for the arithmetic, and the import block above for * what keeps them out of the entry chunk. * * The registry is one texture set for every office this page ever builds. * Signing in while standing in the public office is a `dispose()` and a second * `createOfficeScene` at `"full"` — cheap only if both are handed the same * registry, because drawing the textures is the expensive part and a registry * draws them once. Owned here and disposed nowhere: it outlives every scene * that borrows it, and the page teardown takes the process with it. */ let officePack: Office | null = null; let officeMaterials: MaterialRegistry | null = null; /** * Which building the door leads to. Changed by the picker while you are inside. * * `officePack` is the pack for *this* id and is rebuilt on a switch rather than * memoised forever, which is the one-line difference from the arrangement that * could only ever hold one office. */ let officeId: string = OFFICES[0]?.id ?? "lumbridge-hq"; /** * The office's own sky, when its pack says where it stands. * * A second `Atmosphere` rather than the city's, because the two are at different * scales and in different places: the city's is built with `metresPerUnit` near * 94 and a fog measured in board spans, and an office runs at 1 m per unit * fifty metres across. `null` for a pack with no `site`, which keeps the fixed * 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; remote: RemoteOfficeMedia | null; } | null = null; let remoteViewedScreen: { screenId: string; officeId: string; video: HTMLVideoElement; texture: VideoTexture; remote: RemoteOfficeMedia; bound: boolean; } | null = null; let remoteMediaOperation = 0; let remoteViewerRequestScreenId: string | null = null; /** * The plan panel's other occupant. * * There are two of these because there are two places, and the panel shows the * one you are standing in. Holding the city plan up while the scene in front of * it is a floor plate was the bug this pair exists to fix: the single piece of * chrome whose whole job is "where am I" was answering about another county. * * It is built with the office and dies with it, so the office's `Plan`, camera * and controls — all three of which it holds by reference — cannot outlive the * scene they came from. Which of the two is *mounted* is a separate question * from which exist, and `showPlan()` is the only thing that answers it. */ let officePlan: OfficeMinimap | null = null; /** * Whether the people currently on the floor came from the deployment or from * `sample.ts`. * * Kept because the difference has to be *said*. Every other gap on this page is * a silence — an empty office, a missing scrubber — and a silence you cannot * attribute is indistinguishable from a fault; here the failure is the opposite * and worse, because fabricated people are not silent. Twenty-five invented * names at real desks is a screenshot somebody will take, and it must not be * possible to take it without the caption. */ let presenceIsSample = false; /** * The deployment's own roster, or `null` while the sample one stands in for it. * * Held separately from what is on screen because the two are refreshed by * different things: a real roster arrives from the API on its own timer, and the * sample one is a function of the clock and has to be recomputed whenever the * clock moves. Collapsing them would mean either re-rendering a live roster on * every scrub or freezing the sample one. */ let livePresence: Presence[] | null = null; /** * The running poll of who is in, or `null` when nobody is standing in the room. * * It belongs to the visit and not to the page, which is the same rule * `weatherWatch` follows one line up and for a sharper reason: a roster is * requested with a credential and describes people, so a watch left running * after somebody stepped back out to the city is a page quietly asking about a * room nobody is looking at. Started by `enterOffice`, stopped by `leaveOffice` * and by `mountCity`, and there is never more than one. */ let presenceWatch: PresenceWatch | null = null; /** * `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind * two names, and which name you arrived at is the whole difference: one is a * map with an office in it, the other is an office with a map behind it. The * city is still built underneath either way — that is what makes "← Back to the * city" work from the office front door — so this is a policy about where boot * *stops*, not about what boot builds. */ const OFFICE_HOST = location.hostname.split(".")[0] === "office"; const OPENS_IN_OFFICE = OFFICE_HOST || new URLSearchParams(location.search).get("view") === "office"; /** * Where the city is, when this page is the office front door. * * Leaving the office used to swap the scene and leave the address bar saying * `office.`, so the URL you would copy and send disagreed with what you were * looking at. The city door is therefore this hostname with its first label * swapped — a guess only ever made when that label is literally `office`, which * is the same comparison that made this the office door. Same registrable * domain, same static root, same API; a deployment serving one name and not the * other has half-configured itself, and a 404 is the honest failure for that. * * Anything else — a bare domain, `?view=office` on the city door — derives * nothing and keeps the in-page swap. */ function cityDoorUrl(cityWanted?: string): string | null { if (!OFFICE_HOST) return null; const url = new URL(location.href); const labels = url.hostname.split("."); labels[0] = "tera"; url.hostname = labels.join("."); url.search = ""; url.hash = ""; // Carried so that stepping out of the office into SoCal lands in SoCal. The // page load is the cost of telling the truth about where you are; arriving in // the wrong metro on top of it would not be. if (cityWanted !== undefined && cityWanted !== CITIES[0]?.id) { url.searchParams.set("city", cityWanted); } return url.href; } // ---- The interface's own state -------------------------------------------- /** * The applier, once the page has one. * * `null` for the handful of statements between this module's first line and * `boot()`, which is why every call site is optional-chained rather than * asserted: a keystroke that lands in that window should do nothing, not throw. */ let chrome: ChromeHandle | null = null; /** * Two pieces of chrome are a *user* decision rather than a media query, and the * distinction matters: a media query that hides the plan below 600px also makes * `M` do nothing there, which is the width where a plan view is most useful and * least affordable. So the width only seeds the initial state, and the moment * someone presses the key or the button the viewport stops having an opinion. * * The seeds themselves live in `ui/chromeState.ts` next to the breakpoints they * read, because that is where the reasoning about what a phone can afford is — * and because the old inline `window.innerWidth > 600` for the plan was the * whole of live defect 10, "the minimap disappears entirely" on a phone. */ let panelOpen = seedPanelOpen(window.innerWidth); let planOpen = seedPlanOpen(window.innerWidth); let panelChosen = false; let planChosen = false; /** * Whether this browser has been offered the first-run coach. * * Read **once**, here, before anything mounts: `mountOnboarding` marks the flag * as it appears rather than as it completes, so asking `localStorage` a second * time after the coach is up would answer "seen" and take it straight back off * the screen. The write belongs to the coach; this is the read, and it is the * only one. */ let firstVisit = !hasSeenOnboarding(browserStorage()); /** * What the detail card is showing: an authored sentence, an observed aircraft, * or nothing. * * A union rather than a string, because the two are not the same kind of thing * and flattening the aircraft to a sentence is exactly what used to happen — * `showDetail(\`${m.label} — ${m.blurb}\`)` for everything, including a track * with five fields and a licence credit on it. */ let detail: ChromeDetail | null = null; // ---- Studio hardware ------------------------------------------------------- /** * Where the readings for the room you are standing in come from. * * One at a time and it belongs to the visit, like `presenceWatch` above it. The * choice between the deployment's own device route and the fixed-step simulator * in this tab is made inside `devices/adapter.ts` and deliberately not here — * that is the one place the anon-first fallback decision lives, and a second * copy of it in the app is how the two drift apart. */ let deviceSource: DeviceSource | null = null; /** The latest readings, so the panel and the hardware in the room agree. */ let deviceStates: readonly DeviceState[] = []; /** * The car outside, as a state machine. * * Seeded from the office id, so a studio's Model X has the same charge, the same * cabin temperature and the same parking jitter on every machine and on every * reload — a car whose battery was different every time you opened the door * would read as noise rather than as a vehicle. */ let vehicleTelemetry: SimulatedVehicleTelemetry | null = null; /** * The clock line, already formatted, because this file owns the clock. * * `instantOverride` is the one thing on the page that can make the rendered * instant disagree with the wall clock, and it lives here — so a formatter * downstream would have to be handed the override as well as the time, which is * two facts to keep in step for one string. Written by `updateSun`, which is * also the only thing that reads the sun. */ let clockLabel = ""; // ---- Time ----------------------------------------------------------------- /** * `null` follows the wall clock. The override exists because the honest answer * at 2 a.m. is a very dark city — correct, and not what you want to be looking * at while judging whether the sun is in the right place. * * A whole `Date` and not an hour, which is the change the godmode panel forced * and the right shape anyway. The old scrubber wrote an hour onto *today*, so * there was no way to ask for the December solstice, and any control that could * set a date would have had it silently discarded on the next scrub. One * override, one writer, one type that can carry everything the sun depends on. */ let instantOverride: Date | null = null; /** * Where the office's house lights take their level from. Godmode's switch, and * it survives the office being rebuilt because the panel re-asserts it. */ let houseLights: GodmodeHouseLights = "sun"; /** * The sun's height as the *fittings* are told it, which is the real one unless * somebody has a hand on the switch. * * Forcing the level by lying about the elevation rather than opening a second * path into `luminaires.ts`, and that is the design rather than a shortcut: * `OfficeScene.setSolarElevation` feeds the fittings and nothing else, while * the rig outside is computed from the real `env` a line later. So this moves * the *interior* and leaves the sky, the key light and the fog exactly where the * clock put them — which is the whole point of a control that lets you look at * the night office in daylight. ±90° is far outside the 0°–6° ramp in * `luminaires.ts`, and stays outside it if that band is ever widened. */ function houseElevation(actual: number): number { return houseLights === "sun" ? actual : houseLights === "on" ? -90 : 90; } /** * A fabricated sky, or `null` for whatever the deployment reports. * * Kept next to the instant because it is the same kind of thing — a god-only * lie about the inputs, told to see what the renderer does with it — and it * takes precedence over the live observation for exactly as long as it is set. */ let weatherOverride: WeatherObservation | null = null; function currentInstant(): Date { return instantOverride ?? new Date(); } /** * What the sky is doing, in the order the answers are trusted. * * The override wins because somebody typed it. Otherwise the live observation, * and `null` — nobody was asked — when there is no watch or it has not landed * yet. `null` is not "clear": `atmosphere.ts` treats a *reported* clear sky as * authority that suppresses the modelled marine layer, so handing it an * invented clear day on every failed poll would permanently kill San * Francisco's fog on the zero-config box where the local model is all there is. * See `WeatherFeed` in `adapters/http.ts`, which is careful about the same * distinction from the other side. */ function currentWeather(): WeatherObservation | null { return weatherOverride ?? weatherWatch?.current().value ?? null; } /** * The office's rig for the instant being rendered, in the building's own frame. * * Same clock, same weather and same `Atmosphere` machinery the city runs on — * which is the entire point. An office that dimmed on a schedule of its own * would be a second sun, and CONTRACT.md §4 exists to say there is one. * * `officeDaylight` does the two things a room needs and a map does not: it turns * the sun into the building's frame using `site.heading`, so the light comes * through the windows the pack actually has, and it moves the fog outdoors. */ function officeLighting(site: NonNullable) { const env = observe(site.lat, site.lng, currentInstant(), currentWeather()); // `officeAtmosphere` is built alongside the pack; falling back to the fixed // rig here would be a flicker rather than a fix, so this is only ever called // where one exists. const state = officeAtmosphere?.apply(env); if (!state) return undefined; /** * The building's own lights, on top of whatever is left of the sun. * * The order matters and is the only subtle thing here: the daylight * adaptation runs first, because it is about the *sun* — which way the * building faces and where the weather starts — and the house lights are * added to the result, because they are about the building. Doing it the * other way round would rotate the interior lighting by the building's * heading, which is meaningless: a ceiling does not face a compass point. * * `office` may not exist yet — this is called once at construction, before * there is a scene to ask — in which case the elevation is fed straight to the * ramp so the first frame is already correct rather than a lit room fading * down or a dark one fading up. */ office?.setSolarElevation(houseElevation(env.sun.elevation)); const house = office?.houseLevel() ?? houseLevelFor(houseElevation(env.sun.elevation)); return withHouseLights(officeDaylight(state, site), house); } /** * The lights-on ramp, for the one moment there is no office to ask. * * Duplicating the two bounds from `luminaires.ts` is a smell and is the lesser * of the two available ones: the alternative is building the office scene with * a rig computed from a light level it cannot report yet, which means the room * is visibly wrong for exactly one frame at every entry. Kept in step by being * four lines long and named after the thing it mirrors. */ function houseLevelFor(solarElevationDeg: number): number { return 1 - Math.min(1, Math.max(0, solarElevationDeg / 6)); } function updateSun() { const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO; // The office follows the same clock, and follows it whether or not you are // standing in it — walking back in to a room lit for an hour ago is the // failure this avoids. const site = officePack?.site; if (office && site && officeAtmosphere) { const state = officeLighting(site); if (state) office.setLighting(state); } // The roster follows the same clock the sun does. Never over a live answer. if (livePresence === null) applyPresence(); if (!city || !atmosphere) return; const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather()); city.setLighting(atmosphere.apply(env)); city.setSolarElevation(env.sun.elevation); /** * `atmosphere.cloudCover(env)` and **not** `currentWeather()?.cloudCover ?? 0`. * * `null` weather is "nobody was asked", which is not an edge case — it is the * default deployment and the configuration this repo is held to. Falling back * to zero meant a clean clone's sky was permanently, silently empty. * `atmosphere` models a sky when nobody has observed one, and an observed * cover still wins outright when there is one. */ city.setCloudCover(atmosphere.cloudCover(env)); city.setWind(currentWeather()?.windKph ?? null, currentWeather()?.windDirDeg ?? null); // The override itself, not `currentInstant()`. Handing over a resolved date // would peg the sky to whatever second this ran in, and this runs about once a // second — so an unscrubbed sky would advance in visible steps while the // aircraft beside it moved smoothly. `null` means "read the clock yourself". city.setSkyInstant(instantOverride); // The plan view follows the same day the map does. It computes its own // palette from this one number rather than reading the rig, because a rig is // a set of three.js lights and the minimap has none. minimap?.setSolarElevation(env.sun.elevation); const el = env.sun.elevation; const time = env.time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); const moon = env.moon ? ` · moon ${Math.round(env.moon.illuminated * 100)}%` : ""; // Formatted here rather than in the chrome, because the clock is this file's // — `instantOverride` is the one thing on the page that can make it disagree // with the wall clock, and a formatter downstream would have to be told. clockLabel = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${daylightPhase(el)}${moon}`; renderChrome(); } // ---- Cities --------------------------------------------------------------- /** * Switching city tears the old one down completely. * * Unlike the city↔office move — where the city is paused and kept, because you * are coming straight back — nobody flips between metros often enough to * justify holding two heightfields and 140k building instances at once. */ async function mountCity(id: string) { const entry = CITIES.find((c) => c.id === id); if (!entry || !canvas) return; /** * Abandon whatever is still building before touching anything else. * * Two clicks on the city buttons a second apart used to run two heightfields * to completion and race to assign `city`; now the first `createScene` sees * its signal go and resolves `null` without ever allocating a renderer. The * controller is replaced rather than reused because an aborted signal stays * aborted, and the new build must not be born cancelled. */ mounting?.abort(); const mount = new AbortController(); mounting = mount; wantedCity = id; weatherWatch?.stop(); weatherWatch = null; poseEditor?.destroy(); poseEditor = null; clearPublishedPlayInput(); controlModeState = createControlModeState(); disposeLoadedOffice(); inside = false; minimap?.dispose(); minimap = null; city?.dispose(); // Not merely tidy. `city` is read by the frame pump, by `updateSun` and by // every render function, and the gap between the dispose above and the // assignment below is now an `await` wide rather than a statement — long // enough for all three to run against a torn-down scene. city = null; cityId = id; /** * The sky and the traffic are per-city and are chosen here, before the build, * because `flights` is fixed at scene construction. * * Two gates and only one of them is about the visitor. `can.liveEnvironment` * is true for everybody — the sky is not a thing an account can grant. What is * load-bearing is `feeds`, the *deployment*, which stops the ordinary box * where every source is `none` polling two endpoints forever for a 404. * * These used to be gated on the *markers* flag, which is a different feed * entirely, so a deployment with a real ADS-B receiver and no marker file flew * the simulator. */ const region = regionOf(entry.city); // The hand-authored corridors for *this* city. `SAMPLE_ROUTES` was passed // unconditionally and all of it is over San Francisco, so the SoCal board's // entire sky projected ~590 km off the world and rendered as nothing at all. const routes = sampleRoutesFor(entry.city); // The public traffic API is region-oriented and intentionally capped around // one metro. A state-wide request would either be rejected or become a data // vacuum, so California keeps the honest deterministic sky while its two // detailed boards continue to use live ADS-B when available. const traffic = id !== "california" && access.can.liveEnvironment && access.feeds?.flights ? tera.flights(region, routes) : null; cityFlights = traffic; /** * Started here and awaited below, so the fetch overlaps the heightfield build * rather than following it. Gated on the deployment for the same reason the * traffic is: a box with no satellite source answers with an empty catalogue, * and asking it once per page load for that is a request nobody needs. * * Not gated on the visitor. There is no `can.` check because there is nothing * to grant — the objects in this catalogue broadcast their positions to * anybody with a radio, and every element set in it is a US Government work. */ if (satelliteElements === null && access.feeds?.satellites) { satelliteElements = tera.satellites(); } const elements = (await satelliteElements) ?? []; // Rebuilt per board: the observer is this city's centre. See the note on // `satelliteElements` for why only the elements are shared. const catalogue = elements.length === 0 ? undefined : new SatelliteCatalogue(elements, entry.city.center); // The build may have been abandoned while that was in the air. if (mount.signal.aborted) { traffic?.dispose(); if (cityFlights === traffic) cityFlights = null; return; } // Wrapped, not replaced: the dial passes the real sky through untouched and // concatenates fabricated aircraft after it, so it composes with a live ADS-B // feed as readily as with the simulator. `cityFlights` stays the unwrapped // source — the corner label asks it whether what is on screen was observed, // and the answer is about the feed rather than about the dial. const dial = withTrafficDial(traffic ?? new SimulatedFlights(routes), region); // Carried across the switch, so a dial somebody set on the last board is still // set on this one. dial.setExtra(trafficDial?.extra() ?? 0); trafficDial = dial; /** * Stable destinations are handed to the scene at construction time, not a * frame later. A building glyph needs that head start so `blocks.ts` can * reserve its footprint before the anonymous one-draw-call skyline is * emitted; otherwise both buildings occupy the same address and the useful * one is usually hidden inside the random one. * * A door belongs to the board it stands on. This used to be gated on a * hard-coded `id === "sf"`; using the board's own bounds is what keeps Mateo * Court on the Southland board and off the Bay Area one. Sample companies * stay SF-only because that sample feed is about one city and always was. */ const bounds = entry.city.bounds; const doors = OFFICE_MARKERS.filter( (m) => m.lat >= bounds.minLat && m.lat <= bounds.maxLat && m.lng >= bounds.minLng && m.lng <= bounds.maxLng, ); const initialMarkers = id === "sf" ? [...markers, ...doors] : doors; const handle = await createScene(stage, { city: entry.city, // The page's one rig, shared with the office. Handed in rather than built // per board; see the note where it is constructed. environment, actor: { kind: actorKindForPresence(access.subject !== null, "outdoors"), identity: access.subject === null ? { id: "anonymous", displayName: "Guest", authenticated: false, profile: { appearance: { primaryColor: "#11151a", accentColor: "#f2b134" } }, } : signedInActorIdentity(), mode: access.subject === null ? "flight" : "ground", position: access.subject === null ? { y: 1_200 } : { y: 0 }, minFlightAltitude: 20, maxFlightAltitude: 1_500, ...(access.subject === null ? { camera: { distance: 2.55, height: 1.2, targetHeight: 0.1, lookAhead: 1.3 } } : {}), }, ...(id === "california" ? { actorAnchor: { lat: 35.5, lng: -119.5 } } : {}), markerPalette: palette, markers: initialMarkers, ...(id === "california" ? { roadTraffic: { pack: CALIFORNIA_TRANSPORT, routeId: "la-sf-us-101", 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, ...(catalogue ? { satellites: catalogue } : {}), /** * A pin is a hover *and* a click, and an office pin is a door. * * `onMarkerPick` fires for both — `scenekit`'s picking calls it on hover * with the marker and on leave with `null` — so this cannot simply open a * building on every call or the office would fly open the moment the pointer * crossed a tower. The hover shows the card; the click is a separate * listener below, which reads whatever the hover last resolved. */ onMarkerPick: (m) => { hoveredMarker = m; showDetail(m ? `${m.label}${m.blurb ? ` — ${m.blurb}` : ""}` : null); }, /** * The other thing worth pointing at, and the one the owner asked for by * name: a card per aeroplane, for anybody who lands on this page. * * Hover opens it and hover away closes it, which is the same contract the * marker card has had since picking existed — there is no click handler * here because there is nothing extra a click could mean. See * `showAircraftDetail` for where the provenance comes from. */ onAircraftPick: (a) => showAircraftDetail(a), signal: mount.signal, // An abandoned build keeps its worker running for a tick or two after the // abort; its percentages must not land on the card the new city is using. onProgress: (p) => { if (!mount.signal.aborted) bootProgress(entry.label, p.fraction, p.onMainThread); }, ...(access.subject !== null ? { realtimePeers: { localSceneUnitsPerMetre: entry.city.latScale / 111_320 } } : {}), }); if (!handle) { /** * Superseded. `scene.dispose()` is what normally cancels the traffic * source, and there is no scene — so the polling this call started would * otherwise outlive the board it was started for, and keep a request in * the air for a city nobody is looking at. */ traffic?.dispose(); if (cityFlights === traffic) cityFlights = null; return; } city = handle; attachCurrentWebcamFace(); moveRealtimePresence(); // A new board builds a new layer, and a new layer starts visible. Reapply // whatever the panel last said, or the setting silently undoes itself on the // first city switch. handle.setSatellitesVisible(satellitesVisible); /** * The weather, started only now that the board exists. * * Deliberately after the build rather than beside it: a poll issued at the * top of a two-second heightfield is a request for a city the user may * already have left, and the first thing `stop()` would do is throw the * answer away. Nothing on screen is waiting for it — `updateSun` runs * immediately with `null`, which is climatology, and the observation * replaces it when it lands. */ // Weather observations are metro-scoped for the same reason live aircraft // are: one honest station cannot describe a 600 km corridor. California uses // the local climatology model; the detailed SF and SoCal boards keep their // live observations. weatherWatch = id !== "california" && access.can.liveEnvironment && access.feeds?.weather ? tera.watchWeather(entry.city.center, () => { updateSun(); renderChrome(); }) : null; // Fog distances are scene units, so they have to follow the board — 210/460 // was tuned for a 230-unit San Francisco and fogs out most of a 1000-unit // Bay Area. They also have to clear the CAMERA, which sits about 0.6 spans // out on the whole-board view: a fog starting nearer than that is behind the // viewer's own shoulder, and every pixel in frame is then at full fog. // // That last failure used to be catastrophic and is now only bad, and the // difference is worth recording because the comment used to claim the worse // version. The night fog colour is derived from the sky, the night sky was // nearly black, and so a fog plane behind the camera turned the entire map // off. `atmosphere.ts` now floors the night ground rig and stops the // obscuration convergence subtracting it again, and the night fog here lands // around #16203a — aerial perspective that lifts distance rather than a // blackout. The clearance is still required: a board flattened to one uniform // value is unreadable at any brightness. It is no longer the difference // between a map and a black rectangle. const [wx, nz] = city.world.project(entry.city.bounds.maxLat, entry.city.bounds.minLng); const [ex, sz] = city.world.project(entry.city.bounds.minLat, entry.city.bounds.maxLng); const span = Math.max(Math.abs(ex - wx), Math.abs(sz - nz)); atmosphere = createAtmosphere({ lng: entry.city.center.lng, metresPerUnit: city.world.metresPerUnit, // Pushed out with the camera. `scene.ts` now lets the orbit reach 2.0 spans // so the viewer can get above the satellite dome, and at the old far of 2.8 // the board sat at 51% fog from that pose — the whole city washing out at // exactly the moment the shot is meant to be the city under the // constellation. 3.9 keeps it near a fifth, which is the haze it had at the // old limit. clearFog: { near: span * 1.15, far: span * 3.9 }, // The floor on how far you can see, and it has to know how big the board // is. `minVisibilityM` defaults to 4.5 km, which is honest weather and // completely wrong here: this board is ninety-four kilometres across, so // real visibility correctly hides three quarters of it and the night view // renders as a black rectangle. A map is looked at from outside the // atmosphere it is depicting. minVisibilityM: span * city.world.metresPerUnit * 1.6, // The marine layer is a fact about the eastern Pacific at this latitude, // not a decoration. LA gets its own weather, not San Francisco's fog. marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null, }); city.onChapterChange(() => renderChrome()); city.onControlModeChange((mode) => adoptCityControlMode(mode)); /** * The plan view, built last, because it reads the finished `World` — the * heightfield the terrain has already paid for — and the live camera and * controls the scene has just made. It is torn down and rebuilt with the * city for the same reason the city is: nothing in it survives a change of * board, and it holds a `World` that would otherwise leak. */ minimap = createMinimap({ world: city.world, city: entry.city, camera: city.stageScene.camera, controls: city.stageScene.controls, markerPalette: palette, // The plan view is a 2D canvas the same size as a phone's thumb, and on a // handheld it is drawn at the same ceiling the WebGL renderer uses. One // definition of "phone", in `stage.ts`, read by both. maxPixelRatio: deviceProfile().maxPixelRatio, onSeek(lat, lng) { if (!city || city.controlMode() !== "overview") return; /** * Slide the orbit target and carry the camera with it, keeping the offset * between them. A seek is "look over there", not "go to chapter three": * snapping to a chapter pose throws away the angle and the distance the * user spent the last minute choosing, and doing it from a click on a map * is the kind of surprise that stops people clicking on the map. * * No easing, deliberately. `flyTo` would need a pose, which is the thing * being avoided, and an instant move is also the correct answer under * `prefers-reduced-motion`. */ const { camera, controls } = city.stageScene; const [x, z] = city.world.project(lat, lng); const y = city.world.groundAt(lat, lng); const dx = camera.position.x - controls.target.x; const dy = camera.position.y - controls.target.y; const dz = camera.position.z - controls.target.z; controls.target.set(x, y, z); camera.position.set(x + dx, y + dy, z + dz); }, onHover(info) { if (!minimapReadout) return; minimapReadout.textContent = info ? `${info.lat.toFixed(4)}, ${info.lng.toFixed(4)}${info.district ? ` · ${info.district}` : ""}` : ""; }, }); showPlan(); minimap.setMarkers(initialMarkers); // The instruments, for the one visitor in a deployment who has them. The pose // editor holds a `World`, a camera and a controls, so it belongs to the board // and dies with it — the same reason the plan view does. mountPoseEditor(city); refreshGodmodePlace(); updateSun(); renderChrome(); } /** * The app's own frame pump, beside the renderer's. * * `Stage` owns the render loop and neither scene handle exposes a per-frame * hook, so the alternative is adding an `onTick` to both for a handful of call * sites. This is the smaller change and it costs nothing measurable: an idle * `tick()` is a timestamp comparison and a dirty flag, 0.0002 ms, and the loop * keeps running unchanged across a city swap, across the office swap, and * during the window where there is no scene at all. * * What lives on it, and why each is here rather than in a scene: * * - the two plan widgets, which are 2-D canvases the engine draws; * - the pose editor, which is `null` for everyone who is not god; * - the studio hardware and the car outside, which are **simulations** rather * than layers — both are fixed-step state machines that the arena also drives, * and putting their clocks in a scene would mean the arena and the renderer * stepping two different copies; * - the chrome, but only while a body is under control. See below. */ let lastPumpAt = performance.now(); requestAnimationFrame(function pump(now) { requestAnimationFrame(pump); /** * Clamped, because a backgrounded tab comes back with a `dt` of minutes and * neither simulator should be asked to catch up on a room nobody was in. Both * of them drop the excess internally as well; this is the cheaper half of the * same decision, made before the call rather than inside it. */ const dt = Math.min(0.1, Math.max(0, (now - lastPumpAt) / 1000)); lastPumpAt = now; syncJourneyVehicle(now); updateLocalPlayerMaps(); // Only the mounted one. The other is still constructed and still holds a live // camera, but its canvas is out of the document, so `clientWidth` is 0, every // `resize()` puts it back to `ready = false`, and ticking it would be a // function call that exists to do nothing sixty times a second. if (inside) officePlan?.tick(); else minimap?.tick(); if (inside) { // A no-op on the API strategy, which is driven by its own poll — see // `devices/adapter.ts`, which owns that choice and nothing above it knows // which of the two it got. deviceSource?.tick(dt); stepVehicleTelemetry(dt); } poseEditor?.tick(); publishRealtimePresence(now); pollLiveness(); /** * The chrome, on the frames where something in it actually moves. * * `chromeState` is pure and `mount.apply` compares before it writes, so * calling this unconditionally would be correct — and it would also rebuild * nothing, sixty times a second, for the ninety-odd percent of a session * spent in an orbit camera. The one part of the interface that changes per * frame is the play HUD's speed and altitude, and `playHudKindFor` is exactly * the test for "is a controller reporting numbers right now". Everything else * calls `renderChrome()` at the moment it changes, which is what makes this * an optimisation rather than a second source of truth. */ if (playHudKindFor(controlModeState.mode) !== null) renderChrome(); }); function updateLocalPlayerMaps(): void { if (!city) return; if (inside) { minimap?.setPlayer(null); const walker = controlModeState.mode === "office-walk" ? office?.walker?.state() : null; officePlan?.setPlayer(walker ? { levelId: walker.levelId, x: walker.position.x, z: walker.position.z, headingRad: Math.atan2(-walker.facing.x, -walker.facing.z), kind: walker.actor, } : null); return; } officePlan?.setPlayer(null); if (city.controlMode() === "drive") { const state = city.vehicleState(); minimap?.setPlayer(state ? { lat: state.lat, lng: state.lng, headingDeg: state.headingDeg, kind: "vehicle" } : null); return; } if (city.controlMode() === "aircraft") { const state = city.aircraftState(); minimap?.setPlayer(state ? { lat: state.lat, lng: state.lng, headingDeg: state.headingDeg, kind: "aircraft" } : null); return; } if (city.controlMode() === "actor") { const state = city.actorState(); const pack = CITIES.find((candidate) => candidate.id === cityId)?.city; if (!state || !pack) { minimap?.setPlayer(null); return; } const anchor = cityId === "california" ? { lat: 35.5, lng: -119.5 } : pack.center; const [originX, originZ] = city.world.project(anchor.lat, anchor.lng); const [lat, lng] = city.world.unproject( originX + state.x / city.world.metresPerUnit, originZ + state.z / city.world.metresPerUnit, ); minimap?.setPlayer({ lat, lng, headingDeg: ((-state.yaw * 180 / Math.PI) % 360 + 360) % 360, kind: "actor", }); return; } minimap?.setPlayer(null); } /** * Whether the corner label is still telling the truth, once a second. * * The two live feeds settle on their own schedule and neither has an event to * subscribe to: `TrafficSource.live()` flips when a `/flights` body lands * inside the region, which is somewhere in the first fifteen seconds, and the * weather watch fires its own callback but only when a *poll* settles. A * one-second sample is late by nothing anybody can perceive and costs a * subtraction on the frames it skips. */ let livenessCheckedAt = 0; let journeySyncedAt = 0; let previousVehicleProgress: number | null = null; function pollLiveness() { const now = performance.now(); if (now - livenessCheckedAt < 1000) return; livenessCheckedAt = now; renderChrome(); } /** Persist route progress cheaply and turn the hero's wrap into a real scale door. */ function syncJourneyVehicle(now: number): void { if (!routeDriveIsActive() || !city || journey.route === null || journey.vehicle === null) { previousVehicleProgress = null; return; } const vehicle = city.vehicleState(); if (!vehicle) return; const progress = vehicle.progress; const arrived = journey.route.direction === 1 ? previousVehicleProgress !== null && previousVehicleProgress > 0.94 && progress < 0.06 : previousVehicleProgress !== null && previousVehicleProgress < 0.06 && progress > 0.94; previousVehicleProgress = progress; if (arrived) { const endpoint = journey.route.direction === 1 ? "san-francisco" : "los-angeles"; dispatchJourney({ type: "reach-route-endpoint", endpoint }); dispatchJourney({ type: "exit-vehicle" }); switchCity(endpoint === "san-francisco" ? "sf" : "socal"); return; } if (now - journeySyncedAt < 500) return; journeySyncedAt = now; dispatchJourney({ type: "update-route-progress", progress }); } // ---- Office --------------------------------------------------------------- /** * Everyone gets in. The tier picks which building they get, not whether the * door opens. * * The office used to be members-only, and the anonymous view of this site was a * map with a greyed-out button on it — the single most interesting thing the * project does, visible only as something you cannot have. At `"public"` depth * the same shell, the same furniture and the same named viewpoints are built, * and the only thing missing is the people. That is withheld because the API * refuses occupancy to an anonymous caller, not because this function declined * to draw it. */ async function enterOffice() { if (!city) return; // A city can have more than one authored door. Leaving one office parks its // scene for a cheap re-entry, but clicking a different door must not reopen // that cached building under the new office id. Tear down every resource // owned by the old room before loading the requested one; the parked city // actor, journey identity and local profile deliberately live elsewhere. if (office && builtOfficeId !== officeId) disposeLoadedOffice(); if (!office) { const built = await loadOffice(); // The chunk arrived after the user had already left for the other city, or // it did not arrive at all. Either way there is no room to walk into and // `loadOffice` has already said so on the button. if (!built || !city) return; const { createOfficeScene, createOfficeMinimap, pack, materials, robotOperations } = built; const depth = access.can.officeDepth; // Keep the remote-avatar renderer behind the authenticated boundary. The // public Office door can build its full local scene without downloading it. const createOfficePeers = access.subject !== null ? (await import("./realtime/scenePeers.ts")).createScenePeers : null; /** * The building's own sky, built before the scene because the scene wants the * opening rig and the horizon drop at construction. * * `metresPerUnit: 1` — an office is authored in metres, and this is the only * thing `Atmosphere` needs to know about scale. The fog it computes from * that is then thrown away and replaced by `officeDaylight`, because a fog * sized for a fifty-metre room is a fog inside the room. */ officeAtmosphere = pack.site ? createAtmosphere({ lng: pack.site.lng, metresPerUnit: 1, /** * No marine layer, deliberately — unlike the city, which gets one. * * The model is a fact about sea level on this coast, and both of these * buildings look *out over* it: one from 188 m up a tower, one across * an estuary. Handing it to the office put the observer inside the fog * it describes, which greyed the sky to near-white at one in the * afternoon and took the view with it. The city keeps its fog; the * rooms that look at the city do not stand in it. */ marineLayer: null, }) : null; office = createOfficeScene(pack, { dom: city.stage.renderer.domElement, // The page's one environment map, the same object the city holds. Indoors // it is the difference between `deviceMesh`, `chairBase` and the Model X's // clearcoat reading as metal and reading as grey plastic. environment, /** * The same aeroplanes the board outside is drawing. * * `dial.source` rather than the raw feed, so a sky somebody turned up in * godmode is the sky in both places; and the *city's* source rather than a * second subscription, so the office is a second reader of one feed rather * than a second poller of a volunteer-funded API. The office never * disposes it — see the note on `OfficeSceneOptions.flights`. */ ...(trafficDial ? { flights: trafficDial.source } : {}), /** * The car on the apron. `corridor` detail because it is parked and the * arrival viewpoint looks at it from across a courtyard: the 22 extra draw * calls `follow` buys are mirrors, glass frames and brake calipers, none * of which resolve at that range. * * The seed is the office id rather than a constant, so the two studios do * not park identical cars at identical angles — which is the single * clearest tell that a scene was generated. */ exteriorVehicle: { detail: "corridor", seed: seedForOffice(officeId) }, // A visitor owns one local actor. Anonymous visitors are the promised // office dog; a signed-in visitor gets the procedural humanoid. The // arrival viewpoint is already a pack-authored clear point on a floor, // which makes it the honest spawn and keeps coordinates out of the app. walker: { levelId: pack.viewpoints[0]?.levelId ?? pack.levels[0]?.id ?? "level-1", position: pack.viewpoints[0]?.focus.at ?? { x: 0, z: 0 }, facing: pack.viewpoints[0] ? { x: -Math.sin(pack.viewpoints[0].focus.rotation), z: -Math.cos(pack.viewpoints[0].focus.rotation), } : { x: 0, z: -1 }, actor: actorKindForPresence(access.subject !== null, "office") === "dog" ? { kind: "anonymous-dog", coatColor: 0x17191c, collarColor: 0xf2b134 } : { kind: "humanoid", ...(humanoidAppearance() ?? { outfitColor: 0x151a20, accentColor: 0xf2b134 }) }, }, // Only when there is no sky to put behind it. A sited office computes a // gradient and a horizon; painting the old flat colour over that is the // bug that looks exactly like the sky not working. ...(pack.site ? {} : { background: 0x11161c }), ...(pack.site ? { lighting: officeLighting(pack.site) } : {}), ...(pack.site ? { horizon: { drop: pack.site.elevation } } : {}), // Only offices with explicit, validated simulated operations get robots. // No geometry-derived random errands and no implication of live work. ...(robotOperations ? { robotOperations } : {}), depth, materials, // Ignored entirely at `"public"` depth, where no layer is built to colour. presencePalette: SAMPLE_PRESENCE_PALETTE, // Two different questions, so two different callbacks. `onPresencePick` // answers "who is at this desk"; `onPlacePick` answers only "this is a // desk, and it is the fourteenth one" — which is all a stranger is told. ...(depth === "full" ? { onPresencePick: (p) => showDetail(p ? p.label : null) } : { onPlacePick: (place) => showDetail(place ? place.label : null) }), ...(createOfficePeers ? { realtimePeers: { create: createOfficePeers } } : {}), }); builtOfficeId = officeId; office.onViewChange(() => renderChrome()); officePlan = buildOfficePlan(createOfficeMinimap, office); startDeviceFeed(built.createDeviceSource, office.devices); startVehicleTelemetry(built.createSimulatedVehicleTelemetry); } requestControlMode("overview"); city.stage.setScene(office); inside = true; controlModeState = { mode: "office-overview", revision: controlModeState.revision + 1, }; attachCurrentWebcamFace(); moveRealtimePresence(); const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area"; journeyToCity(desiredCity); dispatchJourney({ type: "enter-office", officeId: officeId as "lumbridge-hq" | "frontier-valley" | "mateo-court" }); showPlan(); showDetail(null); refreshGodmodePlace(); renderChrome(); // After the room is on screen, not before. The first ask is a second request // and the building is worth looking at while it is in flight; awaiting it here // would hold the door shut on a network round trip to draw people into a scene // the user cannot see yet. watchOccupancy(); } /** * The plan panel, for the office. * * Built once alongside the scene rather than on every entry, because it reads * that scene's camera and controls by reference — a second one built against a * disposed office would draw a footprint from a camera nobody is moving. * * The constructor is passed in rather than imported, for the reason written at * the import block: naming the module here as a value would put the furniture * catalogue back in the entry chunk. */ function buildOfficePlan( create: typeof import("./engine/officeMinimap.ts").createOfficeMinimap, scene: OfficeScene, ): OfficeMinimap { const panel = create({ plan: scene.plan, camera: scene.camera, controls: scene.controls, // The same ceiling the city plan takes, from the same place. Two 2D canvases // in one corner of one page disagreeing about what a phone can afford is the // exact drift `deviceProfile` exists in one file to prevent. maxPixelRatio: deviceProfile().maxPixelRatio, onSeek(x, z) { if (controlModeState.mode !== "office-overview") return; /** * The same move the city plan makes, and for the same reason: slide the * orbit target and carry the camera with it, keeping the offset between * them. A seek is "look over there", not "go to viewpoint three". * * The height is deliberately untouched. Indoors that matters more than it * does outdoors — the difference between a viewpoint two metres off the * floor and one fourteen metres up is the difference between standing in * the room and looking down into it, and clicking the plan should not * silently move you between those two. */ const { camera, controls } = scene; const dx = camera.position.x - controls.target.x; const dy = camera.position.y - controls.target.y; const dz = camera.position.z - controls.target.z; controls.target.set(x, controls.target.y, z); camera.position.set(x + dx, controls.target.y + dy, z + dz); }, onHover(info) { if (!minimapReadout) return; if (!info) { minimapReadout.textContent = ""; return; } // Metres, not degrees. The city widget prints a latitude and a longitude // because that is the address of a place on a map; the address of a place // in a building is where it is on the floor, in the units the pack was // authored in, so a self-hoster can read a number off this and type it // into their own office file. const where = `${info.x.toFixed(1)}, ${info.z.toFixed(1)} m`; // The storey is named only when there is more than one to be on. On a // single-level pack — which is the reference office and will be most // packs — "Level 1" on every hover is a word that never changes and // therefore never informs. const level = scene.plan.levels.length > 1 && info.level ? ` · ${info.level}` : ""; // The person last, and after the room, because the readout is read left to // right as an address getting more specific: the floor, then the room, // then who is in it. const room = info.room ? ` · ${info.room}` : ""; const person = info.person ? ` · ${info.person}` : ""; minimapReadout.textContent = `${where}${level}${room}${person}`; }, }); /** * Once, not per frame. * * `scene.robots()` hands back a stable array of vectors the robot layer * mutates in place, so the plan reads this frame's positions through a * reference taken here — the same handshake `officeScene` already makes with * `luminaires.setWalkers`. Polling it every frame would allocate nothing and * would imply the array were a snapshot, which it is not. */ panel.setRobots(scene.robots()); return panel; } /** * Put the plan for wherever you are into the panel. * * One function rather than a `replaceChildren` at each of the call sites, * because the failure it prevents is silent: the panel keeps showing a widget * that is still ticking and still correct *about somewhere else*, which looks * exactly like a working map. */ function showPlan() { const widget = inside ? officePlan : minimap; if (!minimapFrame || !widget) return; minimapFrame.replaceChildren(widget.canvas); // The canvas has just re-entered the document with a real box for the first // time, or the first time since it left it. Nothing else tells it that: the // `ResizeObserver` fires on a change of size, and going from detached to // attached at the same size is not one. widget.resize(); } function leaveOffice() { if (!city) return; // 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(); clearPublishedPlayInput(); office?.walker?.setActive(false); dispatchJourney({ type: "leave-office" }); city.stage.setScene(city.stageScene); inside = false; controlModeState = { mode: "overview", revision: controlModeState.revision + 1, }; city.setControlMode("overview"); attachCurrentWebcamFace(); moveRealtimePresence(); showPlan(); showDetail(null); refreshGodmodePlace(); renderChrome(); } /** Dispose everything whose coordinates or subscriptions belong to one office. */ function disposeLoadedOffice(): void { stopWatchingOccupancy(); disposeOfficeScreenUi(); // Before the scene, so a reading in flight cannot land on a disposed layer. // `stop()` is idempotent and is the only thing that owns a timer down there. deviceSource?.stop(); deviceSource = null; deviceStates = []; vehicleTelemetry = null; officePlan?.dispose(); officePlan = null; office?.dispose(); office = null; builtOfficeId = null; officeAtmosphere = null; } /** * A stable integer per building, for the things that must look the same twice. * * FNV-1a over the id, which is the same hash the arena's checksums use and is * chosen for the same property: it is short, it has no dependencies, and it * gives two ids that differ by one character completely different seeds — so * `lumbridge-hq` and `mateo-court` genuinely park different cars rather than * two that differ in the third decimal. */ function seedForOffice(id: string): number { let hash = 0x811c9dc5; for (let i = 0; i < id.length; i += 1) { hash ^= id.charCodeAt(i); hash = Math.imul(hash, 0x01000193); } return hash >>> 0; } /** * Point the hardware in this room at whatever will answer for it. * * The choice — the deployment's device route, or the fixed-step simulator in * this tab — is made inside `createDeviceSource` and deliberately not here. * That is the one place the anon-first fallback lives, and the reason it is not * a branch in this file is that the two halves would drift: the route refuses an * anonymous read, which is correct, and an anonymous visitor is the audience * this product is designed for, so the refusal must produce a working * instrument rather than a dead one. * * `serverHasDevices` is the same short-circuit `access.feeds` performs for * weather and markers: a box with no device source answers with an empty body, * and asking it once per TTL per tab forever to be told nothing is a request * nobody needs. */ function startDeviceFeed( create: typeof import("./devices/adapter.ts").createDeviceSource, declarations: readonly DeviceDeclaration[], ): void { deviceSource?.stop(); deviceStates = []; if (declarations.length === 0) { deviceSource = null; return; } deviceSource = create({ declarations, client: tera, officeId: officePack?.id ?? officeId, serverHasDevices: access.feeds?.devices !== false, seed: seedForOffice(officeId), onReading: (reading) => { deviceStates = reading.states; // The hardware in the room and the panel beside it, from one reading, so // a lit indicator and a lit control can never disagree about a mic. office?.setDeviceStates(reading.states); chrome?.applyDeviceStates(reading.states); }, }); const opening = deviceSource.current(); deviceStates = opening.states; office?.setDeviceStates(opening.states); chrome?.applyDeviceStates(opening.states); } /** * Send one command, and say nothing if it is refused. * * `normalizeDeviceCommand` has already run inside the panel, so anything that * reaches here is well formed for a device this office declared. A `null` back * is a *deployment* refusal — an anonymous caller writing to a real room — and * the panel is left showing the last reading rather than a control that appears * to have worked. The refusal is not narrated because the sign-in offer is * already on the same screen. */ function sendDeviceCommand(command: DeviceCommand): void { void deviceSource?.command(command).then((state) => { if (state === null) return; deviceStates = deviceStates.map((current) => (current.id === state.id ? state : current)); office?.setDeviceStates(deviceStates); chrome?.applyDeviceStates(deviceStates); }); } /** Bring the studio hardware panel into view, opening the column if it is shut. */ function openDevicePanel(): void { panelOpen = true; panelChosen = true; renderChrome(); document.querySelector("#device-section")?.scrollIntoView({ block: "nearest", // A jump rather than a glide, and not only for `prefers-reduced-motion`: the // panel it is scrolling inside has just been opened in the same frame. behavior: "auto", }); } /** * The car outside, as its own fixed-step simulation. * * Seeded per office and stepped from the frame pump rather than from the office * scene, because this is the *same* module the arena drives — a state machine * with a snapshot and a restore, not a render layer. Keeping its clock here is * what lets the renderer and the arena hold two independent instances of one * implementation instead of two implementations. * * The ambient temperature is a constant and that is a gap rather than a choice: * `WeatherObservation` carries cloud, precipitation, visibility and wind, and no * temperature at all, so there is nothing live to couple to. Inventing one from * the sun's elevation would be publishing a reading nobody took, which is the * one thing the whole telemetry layer is arranged not to do. */ function startVehicleTelemetry( create: typeof import("./transport/vehicleTelemetry.ts").createSimulatedVehicleTelemetry, ): void { vehicleTelemetry = create({ seed: seedForOffice(officeId), fixedStepSeconds: VEHICLE_STEP_SECONDS, ambientC: DEFAULT_AMBIENT_C, }); office?.setVehicleTelemetry(vehicleTelemetry.current()); } /** Seconds of simulated vehicle time per fixed step. */ const VEHICLE_STEP_SECONDS = 0.5; /** * The ambient temperature the parked car sits in, in Celsius. * * A mild Californian afternoon, which is what both studios stand in. See * `startVehicleTelemetry` for why it is not read off the weather feed. */ const DEFAULT_AMBIENT_C = 19; let vehicleStepDebt = 0; /** * Advance the car by whole fixed steps, and only ever by whole ones. * * A fixed-step simulator that is handed a variable `dt` stops being * reproducible, and reproducibility is the entire reason this module is shared * with the arena. So the frame's elapsed time is accumulated and spent in whole * steps, with the remainder carried; `apply` is signature-guarded downstream, so * the frames that spend no step cost a comparison. */ function stepVehicleTelemetry(dt: number): void { const source = vehicleTelemetry; if (!source || !office) return; vehicleStepDebt = Math.min(vehicleStepDebt + dt, VEHICLE_STEP_SECONDS * 4); while (vehicleStepDebt >= VEHICLE_STEP_SECONDS) { vehicleStepDebt -= VEHICLE_STEP_SECONDS; source.stepFixed(); } office.setVehicleTelemetry(source.current()); } /** * Fetch Spaces. * * The office is the largest thing in this build that most visitors never open, * so it is a chunk the door fetches on the way through rather than bytes every * map visitor downloads. Measured entry chunk: 780.18 kB before the split, * 720.89 after — smaller than the chunks themselves because three.js is shared * and stays where it was. * * One `Promise.all` because they are one arrival: a pack without its builder is * a data file nobody can draw, so the fetches overlap rather than queue. * * No retry and no cache-busting, deliberately. A failed chunk fetch is a deploy * that moved the file under an open tab; the honest answer is to say the door * did not open and let the next click try again, which it can, because a * rejected dynamic import is not memoised by the browser. */ async function loadOffice(): Promise<{ createOfficeScene: typeof import("./interiors/officeScene.ts").createOfficeScene; createOfficeMinimap: typeof import("./engine/officeMinimap.ts").createOfficeMinimap; createDeviceSource: typeof import("./devices/adapter.ts").createDeviceSource; createSimulatedVehicleTelemetry: typeof import("./transport/vehicleTelemetry.ts").createSimulatedVehicleTelemetry; pack: Office; materials: MaterialRegistry; robotOperations: RobotOperationsDefinition | null; } | null> { try { // More modules and still one arrival. The plan renderer reads prop // footprints off the asset registry, so it is already downstream of the // furniture catalogue this chunk exists to hold back — asking for it here // costs nothing beyond the module itself, and asking for it anywhere else // would cost the whole catalogue in the entry chunk. const entry = OFFICES.find((o) => o.id === officeId) ?? OFFICES[0]; if (!entry) return null; const [interiors, pack, assets, plan, operations, devices, telemetry] = await Promise.all([ import("./interiors/officeScene.ts"), entry.load(), import("./assets/materials.ts"), import("./engine/officeMinimap.ts"), entry.loadOperations?.() ?? Promise.resolve(null), // Neither of these imports THREE, the DOM or the network, and neither has // anything to say until somebody is standing in a studio — so both travel // with the furniture rather than in the bundle every map visitor fetches. import("./devices/adapter.ts"), import("./transport/vehicleTelemetry.ts"), ]); // Assigned rather than memoised with `??=`: the memo was what made this // single-office forever, quietly serving the first pack fetched for every // later request whatever id was asked for. officePack = pack.default; officeMaterials ??= new assets.MaterialRegistry({ quality: officeMaterialQuality() }); return { createOfficeScene: interiors.createOfficeScene, createOfficeMinimap: plan.createOfficeMinimap, createDeviceSource: devices.createDeviceSource, createSimulatedVehicleTelemetry: telemetry.createSimulatedVehicleTelemetry, pack: officePack, materials: officeMaterials, robotOperations: operations?.default ?? null, }; } catch { showDetail("The office did not load. Check the connection and try the door again."); return null; } } /** * How much texture a studio may draw, decided by the device rather than by a * constant. * * This said `"high"` for every visitor, which meant `materials.ts` had a fully * implemented `low` and `medium` path — different roughness handling, a cheaper * glazing fallback, and 256px textures instead of 512 — that **no caller could * ever reach**. The documented mobile escape hatch had never actually existed, * and a phone was being handed desktop-grade texture memory for a canvas at * 1.5× pixel ratio. * * `medium` rather than `low` on a handheld: `low` drops to flat Lambert with no * maps at all, which is the setting that makes an office open on an integrated * GPU and is far more than a modern phone needs. `medium` is the same physically * shaded materials at half the texture resolution, which is the actual * difference a phone can feel. * * `deviceProfile()` is the one definition of "phone" on this page — the pixel * ratio cap, the shadow map size and now the texture budget all read it, so they * cannot disagree about what a phone is. */ function officeMaterialQuality(): MaterialQuality { return deviceProfile().handheld ? "medium" : "high"; } /** The office's name, for the two bits of chrome that say where you are. */ function officeName(): string { return officePack?.name ?? "Spaces"; } /** * Who is in the building, asked for once per entry. * * Only at full depth, and that is not an optimisation: `createOfficeScene` at * `"public"` builds no presence layer at all, so there is nothing to populate * and the request would be one the session is going to be refused anyway. * * The fallback is `markers`' fallback, one room in. An API that answers is * believed, including when it answers with nobody — an office where everyone has * gone home is a real fact about an office, and overwriting it with invented * people to liven up the demo is the one thing this file must never do. */ function watchOccupancy() { stopWatchingOccupancy(); const scene = office; if (!scene || scene.depth !== "full") return; presenceWatch = tera.watchPresence(officePack?.id ?? "lumbridge-hq", (body) => { livePresence = body?.people ?? null; presenceIsSample = false; // The scene may have been torn down between a request going out and coming // back — a city switch disposes the office — and writing people into a // disposed layer is a use-after-free with a friendly name. The watch's own // `stop()` already drops late answers; this is the belt to that brace, // because the office can also be *replaced* (signing in rebuilds it at full // depth) without the watch having been stopped in between. if (office !== scene) return; applyPresence(); renderChrome(); }); } function stopWatchingOccupancy() { presenceWatch?.stop(); presenceWatch = null; // Or the next building — or the next entry into this one — opens wearing the // previous deployment's roster while its own request is still in the air. livePresence = null; } /** * Put whoever is in the building on screen. * * The live roster if there is one, and otherwise the sample one **as it would be * at the instant being rendered**. That second half is what stops the office * showing a full complement of seated people at one in the morning, under house * lights that came on because the sun is down — which was the least believable * thing left in the room once the clock became real. * * A live answer always wins. An API that says the building is empty is telling * the truth about the building, and dressing it with invented people would be * the one lie this whole layer is arranged to avoid. */ function applyPresence() { if (!office || office.depth !== "full") return; const fallback = samplePresenceForOfficeAt(officePack?.id ?? officeId, currentInstant()); const people = livePresence ?? fallback; presenceIsSample = livePresence === null && fallback.length > 0; office.setPresence(people); officePlan?.setPresence(people); } // ---- Chrome --------------------------------------------------------------- // // Six DOM handles and no more. Everything else on this page belongs to // `ui/mount.ts`, which is the only module in the product that writes to it — // these six are the hosts of things `mount` deliberately does not own: two // widgets built by the engine, one overlay built by `profile/`, one by // `media/`, and one indicator built by `realtime/`. const minimapFrame = document.querySelector("#minimap .minimap-frame"); const minimapReadout = document.querySelector("#minimap-readout"); const presenceHost = document.querySelector("#presence-host"); const webcamFaceIndicator = document.querySelector("#webcam-face-indicator"); const profileOverlay = document.querySelector("#profile-overlay"); const screensOverlay = document.querySelector("#screens-overlay"); let profileEditor: ProfileEditor | null = null; let webcamFacePanel: WebcamFacePanel | null = null; let webcamFaceTexture: WebcamFaceTextureAdapter | null = null; let webcamFaceConsent = createWebcamFaceConsent(); let webcamCapture: WebcamCaptureController | null = null; /** Whether hosted presence has a widget on screen; the chrome makes room for it. */ let presenceMounted = false; function availableControlModes() { const route = city?.current(); return { insideOffice: inside, drive: !inside && city?.vehicleState() !== null && (route === "la-sf-us-101" || route === "la-sf-i-5"), actor: !inside && city?.actorState() !== null, aircraft: !inside && city?.aircraftState() !== null, officeWalk: inside && office?.walker !== null && office?.walker !== undefined, }; } /** * The same availability, as the list the chrome takes. * * Two shapes for one fact is a smell and is the lesser one here: * `transitionControlMode` was written against the record and is tested against * it, and `chromeState` takes a list because a list is what a dock renders. This * is the one conversion, in one place, rather than each caller deriving its own. */ function availableModes(): ControlMode[] { const can = availableControlModes(); const modes: ControlMode[] = [inside ? "office-overview" : "overview"]; if (can.drive) modes.push("drive"); if (can.actor) modes.push("actor"); if (can.aircraft) modes.push("aircraft"); if (can.officeWalk) modes.push("office-walk"); return modes; } function clearPublishedPlayInput(): void { playInput.clearAll(); city?.setVehicleActions({}); city?.setActorActions({}); city?.setAircraftActions({}); office?.walker?.setAction({ x: 0, z: 0 }); } function adoptCityControlMode(mode: ReturnType): void { if (inside) return; const previous = controlModeState.mode; if (controlModeState.mode !== mode) { clearPublishedPlayInput(); controlModeState = { mode, revision: controlModeState.revision + 1 }; } if (previous === "overview" && mode !== "overview") closePanelForPlay(); renderChrome(); } /** One transaction updates Journey, simulation ownership, input and chrome. */ function requestControlMode(requested: ControlMode): boolean { const transition = transitionControlMode(controlModeState, requested, availableControlModes()); if (transition.changed) clearPublishedPlayInput(); controlModeState = transition.state; const next = transition.state.mode; if (inside) { city?.setControlMode("overview"); const walking = next === "office-walk"; office?.walker?.setActive(walking); if (!walking) office?.walker?.setAction({ x: 0, z: 0 }); } else { office?.walker?.setActive(false); city?.setControlMode(cityControlMode(next)); } if (next === "drive") { const routeId = city?.current(); if (routeId === "la-sf-us-101" || routeId === "la-sf-i-5") { dispatchJourney({ type: "set-mode", mode: "play" }); if (journey.route?.routeId !== routeId) { dispatchJourney({ type: "select-route", routeId, direction: 1 }); } if (!journey.vehicle) dispatchJourney({ type: "enter-vehicle", vehicleId: "model-x-black" }); } } else if (journey.vehicle) { dispatchJourney({ type: "exit-vehicle" }); } if (next !== "overview" && next !== "office-overview") { dispatchJourney({ type: "set-mode", mode: "play" }); } else if (!journey.vehicle) { dispatchJourney({ type: "set-mode", mode: "observe" }); } if ( transition.changed && (transition.previous === "overview" || transition.previous === "office-overview") && next !== "overview" && next !== "office-overview" ) { closePanelForPlay(); } renderChrome(); publishPlayActions(); return transition.accepted; } /** One authored sentence in the detail card, or nothing. */ function showDetail(text: string | null) { detail = text === null ? null : { kind: "text", text }; renderChrome(); } /** * One aeroplane in the detail card, for anybody at all. * * Owner decision 2: the flight card is part of the demo an anonymous visitor * gets, and there is nothing here an account could grant — an ADS-B position is * broadcast unencrypted by the aircraft to anybody with a receiver. What the * *deployment* knows and the engine does not is provenance, which is why the * live source is asked first: `TrafficSource.detail` carries the transponder * address off the wire body and the credit line the feed asks to be shown * beside its data, and neither is recoverable from the position alone. * * The fallback is not a degraded case. It is the zero-config clone, the * California corridor board and every aircraft the godmode dial invented, and * for all three the honest answer is a card that says "simulated track" rather * than a hex address nobody transmitted. `aircraftDetail` defaults `observed` to * false precisely so that forgetting to say so cannot produce the other claim. */ function showAircraftDetail(aircraft: Aircraft | null): void { if (aircraft === null) { if (detail?.kind === "aircraft") showDetail(null); return; } const centre = CITIES.find((c) => c.id === cityId)?.city.center; const resolved = cityFlights?.detail(aircraft.id) ?? aircraftDetail(aircraft, { observed: false, ...(centre ? { from: centre } : {}) }); const card: AircraftDetailInput = { // The hex when the feed gave one; the source's own id otherwise, which the // card only ever shows for a track it has already labelled as simulated. id: resolved.icao24 ?? resolved.id, callsign: resolved.callsign, lat: resolved.lat, lng: resolved.lng, altitude: resolved.altitudeM, heading: resolved.headingDeg, synthetic: !resolved.observed, attribution: resolved.attribution.length > 0 ? resolved.attribution.join(" · ") : null, }; detail = { kind: "aircraft", aircraft: card }; renderChrome(); } /** The board strip: worlds outside a building, buildings inside one. */ function boardTabs() { return inside ? OFFICES.map((o) => ({ id: o.id, label: o.label, status: o.status })) : CITIES.map((c) => ({ id: c.id, label: c.label, status: null })); } /** * Whatever the mode's controller is reporting, in the shape the HUD formats. * * Keyed off `playHudKindFor` rather than off a second `switch` on the mode, so * the two orbit states are excluded once, in the module that also decides the * card's visibility. `null` whenever the controller has nothing to say — a * scene that has not built an aircraft cannot report on one — and the HUD is * hidden rather than blank in that case. */ function playTelemetry(): PlayTelemetry | null { switch (playHudKindFor(controlModeState.mode)) { case "drive": { const state = city?.vehicleState(); if (!state) return null; return { kind: "drive", speedMps: state.speedMps, roadName: state.roadName, driveMode: state.mode, progress: state.progress, camera: city?.vehicleCamera() ?? "chase", guardrailContact: state.guardrailContact, collisionRisk: state.collisionRisk, }; } case "actor": { const state = city?.actorState(); if (!state) return null; return { kind: "actor", actor: state.kind, speedMps: state.speedMps, altitudeM: Math.max(0, state.y), distanceM: state.distanceM, pose: state.kind === "crow" ? state.crowPose : state.mode, flightEnergy: state.flightEnergy, displayName: state.identity.displayName, atAltitudeBound: state.altitudeBoundContact !== "none", }; } case "aircraft": { const state = city?.aircraftState(); if (!state) return null; return { kind: "aircraft", speedMps: state.speedMps, altitudeM: state.altitudeM, flightMode: state.mode, batteryWh: state.batteryWh, stalled: state.stalled, hardLanding: state.hardLanding, envelopeContact: state.envelopeContact, }; } case "office-walk": { const state = office?.walker?.state(); if (!state) return null; return { kind: "office-walk", officeLabel: officeName(), distanceM: state.distance, x: state.position.x, z: state.position.z, }; } default: return null; } } /** The body under control, for the labels that name it. */ function chromeActor() { if (inside) { const walker = office?.walker?.state(); if (!walker) return {}; return { kind: walker.actor === "anonymous-dog" ? ("dog" as const) : ("humanoid" as const) }; } if (controlModeState.mode === "aircraft") return { flying: true }; const state = city?.actorState(); if (!state) return {}; return { kind: state.kind, flying: state.mode === "flight" }; } /** * Everybody who is owed a credit for what is currently on screen. * * MET Norway and Open-Meteo publish under CC BY 4.0 and the ADS-B feeds ask to * be named for the positions; all of it arrived, and until the `?` sheet started * printing it, all of it was read by nobody — a licence obligation plumbed to * within one line of being met. The weather override is deliberately excluded: * a sky somebody typed is not MET Norway's sky and must not be attributed to * them. */ function creditLines(): string[] { const lines: string[] = []; if (weatherOverride === null) lines.push(...(weatherWatch?.current().attribution ?? [])); lines.push(...(cityFlights?.attribution() ?? [])); return lines.filter((line) => line.trim() !== ""); } /** * Everything the interface needs to know, as one plain object. * * This function is the whole of the coupling between the application and its * chrome. It reads module state and returns data; it writes nothing and it * decides nothing — every "should this be visible" question in the product is * answered by `chromeState`, and every answer is applied by `mount`. Adding a * piece of chrome is a field here, a decision there and a write in the applier, * in that order, and never a fortieth `element.hidden =` in this file. */ function chromeInputs(): ChromeInputs { const entry = CITIES.find((c) => c.id === cityId); const selectedOffice = OFFICES.find((o) => o.id === officeId) ?? OFFICES[0]; const mediaSurfaces = inside && office?.depth === "full" ? office.listMediaSurfaces() : []; const robotActivity = inside ? office?.robotActivityInfo() ?? null : null; const views: View[] = inside && office ? office.views : city?.chapters ?? []; const activeViewId = inside && office ? office.current() : city?.current() ?? null; return { mode: controlModeState.mode, available: availableModes(), access: { tier: access.tier, signInUrl: access.signInUrl, subject: access.subject, displayName: localProfile?.displayName ?? null, hasProfile: localProfile !== null, }, inside, officeDepth: inside ? office?.depth ?? null : null, viewport: { width: window.innerWidth, height: window.innerHeight, // The class is set by the first touch this page ever sees and never // removed, because a laptop with a touchscreen has both and the one the // person actually used is the one worth believing. coarsePointer: document.body.classList.contains("touch-capable") || window.matchMedia?.("(pointer: coarse)").matches === true, }, feeds: { markers: liveData, // An override is a sky somebody invented, so it retires the claim for as // long as it is up: the label is about what is on screen, not about what // the deployment could have shown. weather: weatherOverride === null && (weatherWatch?.current().live ?? false), flights: cityFlights?.live() ?? false, weatherOverridden: weatherOverride !== null, sampleOccupancy: presenceIsSample, credits: creditLines(), }, degraded: access.degraded ?? [], devices: inside ? office?.devices ?? [] : [], firstVisit, panelOpen, planOpen, board: { cityId, cityLabel: entry?.city.name ?? "", officeId, officeLabel: inside ? officeName() : selectedOffice?.label ?? "the studio", officeStatus: selectedOffice?.status ?? "building", isCalifornia: cityId === "california", }, actor: chromeActor(), office: { mediaSurfaceCount: mediaSurfaces.length, mediaSurfaceActiveCount: mediaSurfaces.filter((surface) => surface.bound).length, robotDisclosure: robotActivity?.disclosure ?? null, walkable: office?.walker != null, }, cameraLive: webcamCapture?.status() === "active", presenceVisible: presenceMounted, boards: boardTabs(), views: views.map((view) => ({ id: view.id, number: view.number ?? null, shortLabel: view.shortLabel, ...(view.description === undefined ? {} : { description: view.description }), })), activeViewId, telemetry: playTelemetry(), detail, clockLabel, }; } /** * Draw the interface. One call, and it replaces forty. * * Cheap enough to run from the frame pump — `chromeState` is pure arithmetic and * string building, and the applier compares before it writes and rebuilds a * subtree only when that subtree's signature moves — but it is still called on * *changes* rather than unconditionally, and the pump only joins in while a body * is under control. See `pumpChrome`. * * The two plan widgets are updated here rather than inside the applier because * neither is chrome: they are canvases the engine draws, holding a live camera, * and `mount.ts` is deliberately ignorant of THREE. */ function renderChrome(): void { // The one place the mode the app *thinks* it is in is reconciled with the mode // the scene is actually in. A scene can change it on its own — a chapter that // is a driving route puts the city into `drive` — and a dock that disagreed // with the camera was the oldest bug in this file. if (city) { const walking = inside && (office?.walker?.active() ?? false); const actual: ControlMode = inside ? walking ? "office-walk" : "office-overview" : city.controlMode(); if (controlModeState.mode !== actual) { controlModeState = { mode: actual, revision: controlModeState.revision + 1 }; } } chrome?.apply(chromeState(chromeInputs())); // Each plan rings the entry its own legend is showing as current. Both are // updated whichever one is mounted, so the hidden one is already right when it // comes back rather than correcting itself a frame after it appears. if (city) minimap?.setChapters(city.chapters, city.current()); officePlan?.setActiveView(office?.current() ?? null); } /** * Walk out of one building and into another. * * A full teardown and rebuild rather than a swap, because everything an office * scene holds is derived from its pack: the shell, the plan panel, the camera * limits, the horizon drop, the light rig, the hardware on the desks and the car * on the apron. The expensive part — the texture registry — is deliberately * *not* rebuilt, which is the same trick that makes signing in cheap: * `officeMaterials` outlives every scene that borrows it, so a switch costs * geometry and not the thing that draws the wood grain. */ async function switchOffice(id: string) { // `entering` is the same guard `toggleOffice` uses, and this has to share it. // Without it, two clicks inside the loading window build two office scenes and // the first is parked on the stage with nothing holding a reference to it — // a whole `OfficeScene`, its geometry and its plan panel, leaked per click. if (entering || id === officeId || !OFFICES.some((o) => o.id === id)) return; const previous = officeId; officeId = id; // The roster, screen session, hardware feed, plan and scene all belong to the // building being left. disposeLoadedOffice(); entering = true; try { await building("Opening the door…", () => enterOffice()); } finally { entering = false; } /** * The old room is already gone by the time we find out whether the new one * arrived, and a failed chunk fetch is a real case — `loadOffice` says so on * the card and returns null. * * So there is no room on the stage and `inside` still claims there is. Put the * viewer back in the city rather than in an empty scene, and put the door back * on the building they came from, or the picker is wedged pointing at an * office that will not open. */ if (office || !city) return; officeId = previous; inside = false; city.stage.setScene(city.stageScene); showPlan(); renderChrome(); } // ---- Hosted realtime presence -------------------------------------------- function realtimeInterest(): InterestCell { if (inside) return { kind: "office", officeId }; if (cityId === "california") return { kind: "california-tile", x: 0, y: 0, level: 0 }; return { kind: "city", cityId: cityId === "socal" ? "socal" : "bay-area" }; } function degrees(radians: number): number { return ((radians * 180 / Math.PI + 540) % 360) - 180; } function realtimePose(): EntityPoseSnapshot | null { if (access.subject === null) return null; const timestampMs = Date.now(); if (inside) { const walker = office?.walker; if (!walker) return null; const state = walker.state(); const headingDeg = -degrees(Math.atan2(-state.facing.x, -state.facing.z)); return { entity: "actor", actorId: realtimeActorId, kind: state.actor === "anonymous-dog" ? "dog" : "humanoid", sequence: 0, timestampMs, pose: { space: "local", cell: { kind: "office", officeId }, xM: state.position.x, yM: office?.plan.level(state.levelId)?.floorY ?? 0, zM: state.position.z, headingDeg, pitchDeg: 0, }, velocity: { xMps: state.action.x * 1.6, yMps: 0, zMps: state.action.z * 1.6, yawDegPerSec: 0, }, }; } const aircraft = city?.aircraftActive() ? city.aircraftState() : null; if (aircraft) { return createAircraftPoseSnapshot( { aircraftId: realtimeAircraftId, pilotActorId: realtimeActorId }, aircraft, 0, timestampMs, ); } const vehicle = journey.vehicle !== null ? city?.vehicleState() : null; if (vehicle) { const heading = vehicle.headingDeg * Math.PI / 180; return { entity: "vehicle", vehicleId: realtimeVehicleId, kind: "model-x", driverActorId: realtimeActorId, sequence: 0, timestampMs, pose: { space: "geographic", lat: vehicle.lat, lng: vehicle.lng, altitudeM: 0, headingDeg: vehicle.headingDeg, pitchDeg: 0, }, velocity: { xMps: Math.sin(heading) * vehicle.speedMps, yMps: 0, zMps: Math.cos(heading) * vehicle.speedMps, yawDegPerSec: 0, }, steering: vehicle.steering, wheelRadians: vehicle.wheelRadians, }; } const actor = city?.actorState(); if (!actor || !city) return null; const headingDeg = -degrees(actor.yaw); const velocity = { xMps: -Math.sin(actor.yaw) * actor.speedMps, yMps: actor.verticalSpeedMps, // Geographic velocity is east/up/north; scene +Z is south. zMps: Math.cos(actor.yaw) * actor.speedMps, yawDegPerSec: 0, }; const origin = cityId === "california" ? { lat: 35.5, lng: -119.5 } : city.world.city.center; const [originX, originZ] = city.world.project(origin.lat, origin.lng); const [lat, lng] = city.world.unproject( originX + actor.x / city.world.metresPerUnit, originZ + actor.z / city.world.metresPerUnit, ); return { entity: "actor", actorId: realtimeActorId, kind: actor.kind, sequence: 0, timestampMs, pose: { space: "geographic", lat, lng, altitudeM: actor.y, headingDeg, pitchDeg: degrees(actor.pitch), }, velocity, }; } function ownRealtimeEntity(snapshot: EntityPoseSnapshot): boolean { if (snapshot.entity === "actor") return snapshot.actorId === realtimeClient?.state().actorId; if (snapshot.entity === "vehicle") { return snapshot.driverActorId === realtimeClient?.state().actorId || snapshot.vehicleId === realtimeVehicleId; } return snapshot.pilotActorId === realtimeClient?.state().actorId || snapshot.aircraftId === realtimeAircraftId; } function clearRealtimePeers(): void { city?.clearRemoteEntities(); office?.clearRemoteEntities(); updatePresenceIndicator(); } function applyRealtimeMessage(message: ServerRealtimeMessage): void { if (message.type === "membership-revoked") { stopWebcamFace(true); clearRealtimePeers(); return; } const updates = message.type === "pose-delta" ? message.updates : message.type === "join-grant" ? message.initial : message.type === "resume-grant" ? message.snapshot : []; const target = inside ? office : city; for (const snapshot of updates) { if (!ownRealtimeEntity(snapshot)) target?.upsertRemoteSnapshot(snapshot); } if (message.type === "pose-delta") { for (const id of message.removedEntityIds) { if ( id !== `actor:${realtimeClient?.state().actorId}` && id !== `vehicle:${realtimeVehicleId}` && id !== `aircraft:${realtimeAircraftId}` ) { target?.removeRemoteEntity(id); } } } updatePresenceIndicator(); } function updatePresenceIndicator(state = realtimeClient?.state()): void { if (!presenceIndicator || access.subject === null) return; const connection: "connecting" | "live" | "reconnecting" | "offline" = state?.status === "streaming" ? "live" : state?.status === "reconnecting" ? "reconnecting" : state?.status === "left" || state?.status === "disposed" ? "offline" : "connecting"; const nearbyPeerCount = connection === "live" ? (inside ? office?.remoteEntityCount() : city?.remoteEntityCount()) ?? 0 : 0; presenceIndicator.update({ signedIn: true, connection, nearbyPeerCount }); } async function joinRealtimePresence(operation: number): Promise { const client = realtimeClient; const pose = realtimePose(); if (!client || !pose) return; try { const grant = await client.join(realtimeInterest(), pose); if (operation !== realtimeOperation) return; applyRealtimeMessage(grant); } catch { if (operation === realtimeOperation) { presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 }); } } } function moveRealtimePresence(): void { clearRealtimePeers(); const client = realtimeClient; if (!client || access.subject === null) return; const operation = ++realtimeOperation; if (client.state().sessionId === null) { void joinRealtimePresence(operation); return; } updatePresenceIndicator({ ...client.state(), status: "reconnecting" }); void client.moveInterest(realtimeInterest()) .then((grant) => { if (operation === realtimeOperation) applyRealtimeMessage(grant); }) .catch(() => { if (operation === realtimeOperation) { presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 }); } }); } function publishRealtimePresence(now: number): void { if (!realtimeClient || now - lastRealtimePublishAt < 100) return; const pose = realtimePose(); if (!pose || realtimeClient.state().sessionId === null) return; lastRealtimePublishAt = now; try { realtimeClient.publishPose(pose); } catch { presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 }); } } async function initializeRealtimePresence(): Promise { if (!realtimePageActive || access.subject === null || !presenceHost || realtimeClient) return; const [{ createRealtimeClient }, { createPresenceIndicator }] = await Promise.all([ import("./realtime/client.ts"), import("./realtime/presenceIndicator.ts"), ]); // `pagehide` can overtake the lazy chunks. Never construct a fresh client // after the page has already run its terminal cleanup. if (!realtimePageActive || access.subject === null || realtimeClient) return; presenceHost.hidden = false; // The class that makes room for it is `chromeState`'s to add, from // `presenceVisible` — one place decides what the top-right column contains. presenceMounted = true; presenceIndicator = createPresenceIndicator({ container: presenceHost, onRetry: () => moveRealtimePresence(), }); presenceIndicator.update({ signedIn: true, connection: "connecting", nearbyPeerCount: 0 }); realtimeClient = createRealtimeClient({ actorId: realtimeActorId, authenticatedFetch: authFetch, sendIntervalMs: 100, }); stopRealtimeSubscription = realtimeClient.subscribe({ onMessage: applyRealtimeMessage, onStateChange: updatePresenceIndicator, onError: () => presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 }), }); moveRealtimePresence(); } window.addEventListener("pagehide", () => { realtimePageActive = false; stopWebcamFace(true); disposeOfficeScreenUi(); realtimeOperation += 1; stopRealtimeSubscription?.(); stopRealtimeSubscription = null; clearRealtimePeers(); // Page teardown cannot await the leave round trip. Contain a rotated-token // or offline failure so navigation never produces an unhandled rejection; // the server's disconnect grace still removes the ephemeral session. void realtimeClient?.dispose().catch(() => undefined); realtimeClient = null; presenceIndicator?.dispose(); presenceIndicator = null; // The one thing on this page that owns GPU memory neither scene created. It // is disposed by whoever owns the `Stage`, which is this file, and never by a // scene — the two kinds of environment are shared between every city and every // office that ever existed on this page. environment.dispose(); }); function applyProfilePreview(profile: LocalProfile): void { 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 cameraSupported(): boolean { return typeof navigator.mediaDevices?.getUserMedia === "function"; } /** Attach the one ephemeral texture to whichever signed-in humanoid is current. */ function attachCurrentWebcamFace(): void { const texture = webcamFaceTexture?.texture() ?? null; if (!texture || access.subject === null) return; city?.clearActorFaceTexture(); office?.walker?.clearFaceTexture(); if (inside) office?.walker?.attachFaceTexture(texture); else city?.attachActorFaceTexture(texture); } /** * Stop app-owned capture. The texture adapter deliberately never owns tracks, * so this boundary must stop every one before dropping the stream reference. */ function stopWebcamFace(revoke = false): void { city?.clearActorFaceTexture(); office?.walker?.clearFaceTexture(); webcamFaceTexture?.clear(); webcamCapture?.stop(); if (revoke) webcamFaceConsent.revoke(); else webcamFaceConsent.stop(); webcamFaceTexture?.sync(); webcamFacePanel?.update(cameraSupported() ? "off" : "unsupported"); } async function startWebcamFace(): Promise { if (access.subject === null || !localProfile || !cameraSupported() || webcamCapture?.status() === "active" || webcamCapture?.status() === "requesting") return; webcamFacePanel?.update("requesting"); webcamFaceConsent.requestStart(); webcamCapture ??= createWebcamCapture({ acquire: () => navigator.mediaDevices.getUserMedia({ audio: false, video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 640 } }, }), createVideo: () => { const video = document.createElement("video"); video.muted = true; video.playsInline = true; video.autoplay = true; video.hidden = true; document.body.append(video); return video; }, }); let binding: Awaited>; try { binding = await webcamCapture.start(); } catch (error) { webcamFaceConsent.stop(); const denied = error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "SecurityError"); webcamFacePanel?.update( "error", denied ? "Camera permission was denied. Nothing was captured." : "Camera could not start. Check the device and try again.", ); return; } // Null means Stop/pagehide overtook an asynchronous permission or play step. if (!binding) return; if (access.subject === null) { stopWebcamFace(true); return; } try { const consent = webcamFaceConsent.start(true); if (!consent.accepted) throw new Error("camera consent was no longer active"); webcamFaceTexture ??= createWebcamFaceTexture({ consent: webcamFaceConsent }); webcamFaceTexture.bind(binding); for (const track of binding.stream.getVideoTracks()) { track.addEventListener("ended", () => { if (webcamCapture?.binding()?.stream === binding.stream) stopWebcamFace(); }, { once: true }); } attachCurrentWebcamFace(); webcamFacePanel?.update("active"); // Native permission UI can restore focus to the page after the promise // resolves. Put it back inside the still-open modal on the next frame. requestAnimationFrame(() => { if (profileEditor?.state().open) webcamFacePanel?.focusStop(); }); } catch { stopWebcamFace(); webcamFacePanel?.update("error", "Camera preview could not start. Nothing was retained."); } } function ensureProfileEditor(): ProfileEditor | null { if (profileEditor) return profileEditor; if (!profileOverlay || !localProfile || access.subject === null) return null; profileEditor = createProfileEditor({ container: profileOverlay, profile: localProfile, identityId: access.subject, onPreview: applyProfilePreview, onSave(profile) { localProfile = profile; saveLocalProfile(sessionStorage, LOCAL_PROFILE_KEY, profile); dispatchJourney({ type: "sign-in-actor-swap", actor: { id: access.subject ?? "anonymous", kind: "humanoid", signedIn: true, profile: { displayName: profile.displayName, color: humanoidAppearance()?.accentColor }, }, }); city?.setActorIdentity(signedInActorIdentity(profile)); applyProfilePreview(profile); renderChrome(); profileOverlay.hidden = true; }, onCancel() { profileOverlay.hidden = true; }, }); profileEditor.root.addEventListener("keydown", (event) => event.stopPropagation()); if (webcamFaceIndicator) { const host = document.createElement("div"); profileEditor.root.insertBefore(host, profileEditor.root.querySelector("form")?.nextSibling ?? null); webcamFacePanel = createWebcamFacePanel({ container: host, indicatorContainer: webcamFaceIndicator, supported: cameraSupported(), onStart: startWebcamFace, onStop: () => stopWebcamFace(), }); if (webcamCapture?.status() === "active") webcamFacePanel.update("active"); profileEditor.registerFocusables(webcamFacePanel.focusables); } return profileEditor; } function openProfileEditor(): void { if (!localProfile || !profileOverlay) return; const editor = ensureProfileEditor(); if (!editor) return; editor.update(localProfile); profileOverlay.hidden = false; editor.open(); } function screenBinding(surface: MediaSurfaceDescriptor) { return { officeId: surface.officeId, levelId: surface.levelId, roomId: surface.roomId, screenId: surface.screenId, }; } function remotePanelStatus(screenId: string, status: OfficeScreenRemoteStatus): void { officeScreenPanel?.setRemoteStatus(screenId, status); renderChrome(); } function isRemoteAuthorizationError(error: unknown): boolean { return error instanceof Error && /\((?:401|403)\)/.test(error.message); } function releaseRemoteViewer( status: OfficeScreenRemoteStatus = "off", action: "revoke" | "dispose" = "revoke", ): void { const viewing = remoteViewedScreen; const pendingScreenId = remoteViewerRequestScreenId; remoteMediaOperation += 1; remoteViewerRequestScreenId = null; if (pendingScreenId && pendingScreenId !== viewing?.screenId) { remotePanelStatus(pendingScreenId, status); } if (!viewing) return; remoteViewedScreen = null; office?.clearMediaSurface(viewing.screenId); // `OfficeScreenPanel` owns defensive descriptor snapshots rather than a // live view of OfficeScene. Refresh after clear just as the bind path does, // otherwise the panel can keep saying "Media active" after remote teardown. officeScreenPanel?.update(office?.listMediaSurfaces() ?? []); viewing.texture.dispose(); viewing.video.pause(); viewing.video.srcObject = null; remotePanelStatus(viewing.screenId, status); const finish = action === "revoke" ? viewing.remote.revoke() : viewing.remote.dispose(); void finish.catch(() => undefined); } function syncRemoteViewerState( viewing: NonNullable, state: RemoteMediaState, ): void { if (remoteViewedScreen !== viewing) return; if (state.status === "reconnecting") remotePanelStatus(viewing.screenId, "reconnecting"); else if (state.status === "connecting") remotePanelStatus(viewing.screenId, "connecting"); else if (state.status === "live") remotePanelStatus(viewing.screenId, "live"); if (state.hasRemoteVideo && !viewing.bound) { // Playback is a consequence of the user's screen-specific opt-in click; // the transport itself deliberately never calls `play()`. void viewing.video.play().then(() => { if (remoteViewedScreen !== viewing || !office || !inside) return; viewing.bound = office.bindMediaSurface( viewing.screenId, { canView: true, optedIn: true }, viewing.texture, ); officeScreenPanel?.update(office.listMediaSurfaces()); remotePanelStatus(viewing.screenId, viewing.bound ? "live" : "error"); }).catch(() => { if (remoteViewedScreen === viewing) { showDetail("The remote screen arrived, but this browser could not start video playback."); releaseRemoteViewer("error", "dispose"); } }); } if (state.status === "stopped" || state.status === "revoked") { showDetail(state.status === "revoked" ? "Remote screen access was revoked." : "The remote screen share stopped."); releaseRemoteViewer("stopped", "dispose"); } } async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise { if (access.subject === null || !inside || !office || office.depth !== "full") return; releaseRemoteViewer("off"); const operation = ++remoteMediaOperation; remoteViewerRequestScreenId = surface.screenId; remotePanelStatus(surface.screenId, "connecting"); showDetail(`Connecting to ${surface.screenId}…`); try { const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts"); const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false; if (operation !== remoteMediaOperation || access.subject === null || !inside || !office || office.depth !== "full" || officeId !== surface.officeId || !optedIn) return; const video = document.createElement("video"); video.muted = true; video.playsInline = true; video.autoplay = false; const texture = new VideoTexture(video); texture.colorSpace = SRGBColorSpace; texture.generateMipmaps = false; let viewing: NonNullable; const remote = createRemoteOfficeMedia({ role: "viewer", binding: screenBinding(surface), authenticatedFetch: authFetch, onStateChange: (state) => { if (viewing) syncRemoteViewerState(viewing, state); }, onError: (error) => { if (remoteViewedScreen !== viewing) return; if (isRemoteAuthorizationError(error)) { showDetail("Remote screen authorization ended."); releaseRemoteViewer("unavailable", "dispose"); } else { remotePanelStatus(surface.screenId, "reconnecting"); } }, }); viewing = { screenId: surface.screenId, officeId: surface.officeId, video, texture, remote, bound: false }; remoteViewerRequestScreenId = null; remoteViewedScreen = viewing; await remote.startViewer({ viewerOptIn: true, video }); if (remoteViewedScreen === viewing) showDetail(`Waiting for ${surface.screenId} remote video…`); } catch (error) { if (operation !== remoteMediaOperation) return; remoteViewerRequestScreenId = null; releaseRemoteViewer("unavailable", "dispose"); remotePanelStatus(surface.screenId, "unavailable"); showDetail(isRemoteAuthorizationError(error) ? "Remote screen authorization ended. Sign in again to reconnect." : "No authorized remote share is available for that screen."); } } async function startRemotePresenter( surface: MediaSurfaceDescriptor, shared: NonNullable, ): Promise { if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return; remotePanelStatus(surface.screenId, "connecting"); try { const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts"); if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return; const remote = createRemoteOfficeMedia({ role: "presenter", binding: screenBinding(surface), authenticatedFetch: authFetch, onStateChange(state) { if (sharedScreen !== shared) return; if (state.status === "live") remotePanelStatus(surface.screenId, "live"); else if (state.status === "reconnecting") remotePanelStatus(surface.screenId, "reconnecting"); else if (state.status === "connecting") remotePanelStatus(surface.screenId, "connecting"); else if (state.status === "stopped" || state.status === "revoked") { showDetail("The hosted screen share ended."); queueMicrotask(() => { if (sharedScreen === shared) stopLocalScreenShare(); }); } }, onError: (error) => { if (sharedScreen !== shared) return; if (isRemoteAuthorizationError(error)) { showDetail("Screen sharing stopped because authorization ended."); queueMicrotask(() => { if (sharedScreen === shared) stopLocalScreenShare(); }); } else { remotePanelStatus(surface.screenId, "reconnecting"); } }, }); shared.remote = remote; await remote.startPresenter({ consent: { authorized: true, optedIn: true }, stream: shared.stream, video: shared.video }); if (sharedScreen === shared) showDetail(`Sharing ${surface.screenId} locally and to authorized remote viewers.`); } catch (error) { if (sharedScreen !== shared) return; if (isRemoteAuthorizationError(error)) { showDetail("Screen sharing stopped because authorization ended. Sign in again to share."); stopLocalScreenShare(); return; } void shared.remote?.dispose().catch(() => undefined); shared.remote = null; remotePanelStatus(surface.screenId, "unavailable"); showDetail(`Sharing locally to ${surface.screenId}; hosted sharing is unavailable.`); } } function stopLocalScreenShare(): void { const shared = sharedScreen; if (!shared) return; sharedScreen = null; remoteMediaOperation += 1; office?.clearMediaSurface(shared.screenId); remotePanelStatus(shared.screenId, "stopped"); void shared.remote?.stop().catch(() => undefined); for (const track of shared.stream.getTracks()) track.stop(); shared.texture.dispose(); shared.video.pause(); shared.video.srcObject = null; officeScreenPanel?.update(office?.listMediaSurfaces() ?? []); renderChrome(); } function disposeOfficeScreenUi(): void { stopLocalScreenShare(); releaseRemoteViewer("off", "dispose"); officeScreenPanel?.dispose(); officeScreenPanel = null; if (screensOverlay) screensOverlay.hidden = true; } async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise { if (!office || !inside || office.depth !== "full") return; // One authored surface role at a time. A viewer texture must not survive // behind a new presenter preview or be cleared later over the presenter. releaseRemoteViewer("off"); 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", width: { ideal: 1_280, max: 1_280 }, height: { ideal: 720, max: 720 }, frameRate: { ideal: 15, max: 15 }, }, 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; } const shared = { screenId: surface.screenId, stream, video, texture, remote: null }; sharedScreen = shared; pendingStream = null; pendingVideo = null; pendingTexture = null; stream.getVideoTracks()[0]?.addEventListener("ended", () => stopLocalScreenShare(), { once: true }); officeScreenPanel?.update(office.listMediaSurfaces()); renderChrome(); showDetail(`Sharing locally to ${surface.screenId}. Use Office screens to stop.`); // Anonymous/self-host-only behavior ends here exactly as before. The // transport chunk is fetched only for a signed-in explicit share. if (access.subject !== null) void startRemotePresenter(surface, shared); } 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); }, onSelect(surface) { if ((remoteViewedScreen && remoteViewedScreen.screenId !== surface.screenId) || (remoteViewerRequestScreenId !== null && remoteViewerRequestScreenId !== surface.screenId)) { releaseRemoteViewer("off"); } }, onStopShare: (surface) => { if (sharedScreen?.screenId === surface.screenId) stopLocalScreenShare(); if (remoteViewedScreen?.screenId === surface.screenId || remoteViewerRequestScreenId === surface.screenId) { releaseRemoteViewer("stopped"); } }, onViewerOptIn(surface, optedIn) { if (!optedIn) { if (remoteViewedScreen?.screenId === surface.screenId || remoteViewerRequestScreenId === surface.screenId) { releaseRemoteViewer("off"); } else { remotePanelStatus(surface.screenId, "off"); } } else if (access.subject !== null) { void startRemoteViewer(surface); } }, }); 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(); } // The `Office screens →` button is bound by `mount`; this is the overlay's own // backdrop, which is not chrome and stays here with the panel it dismisses. 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. */ function currentViews(): View[] { if (inside && office) return office.views; return city?.chapters ?? []; } function flyToIndex(index: number) { const view = currentViews()[index]; if (!view) return; if (inside && office) { requestControlMode("office-overview"); office.flyTo(view.id); renderChrome(); } else { const destination = cityId === "california" ? CALIFORNIA_DESTINATIONS.get(view.id) : undefined; if (destination) { requestControlMode("overview"); officeId = destination.officeId; journeyToCity(destination.cityId === "socal" ? "socal" : "bay-area"); switchCity(destination.cityId); return; } if (view.id === "la-sf-us-101" || view.id === "la-sf-i-5") { city?.flyTo(view.id); requestControlMode("drive"); closePanelForPlay(); renderChrome(); return; } requestControlMode("overview"); city?.flyTo(view.id); renderChrome(); } } /** * Step out of the office to the city, by whichever route keeps the URL honest. * * Returns true when it has started a navigation and the caller should stop — the * page is on its way out, and anything after this runs against a document that * is about to be replaced. * * The `?view=office` case is the cheap half of the same fix. There is no other * host to go to, so the scene swap stands, but the query that put us in the * office is dropped from the address bar on the way out. `replaceState` rather * than `pushState`: the office and the city are one page, and a back button that * walks a history of scene swaps is a back button that does not leave. */ function leaveToCity(cityWanted?: string): boolean { const url = cityDoorUrl(cityWanted); if (url !== null) { location.assign(url); return true; } const here = new URL(location.href); if (here.searchParams.get("view") === "office") { here.searchParams.delete("view"); history.replaceState(null, "", `${here.pathname}${here.search}${here.hash}`); } return false; } function switchCity(id: string) { requestControlMode(inside ? "office-overview" : "overview"); if (inside && leaveToCity(id)) return; if (inside) leaveOffice(); if (id === wantedCity) return; // Arriving at a detailed board should put its local front door under the // existing Office button. The California overview keeps whichever building // the traveller last visited; it is a scale, not a fourth office location. if (id === "socal") officeId = "mateo-court"; else if (id === "sf" && officeId === "mateo-court") officeId = "lumbridge-hq"; if (id === "california") { if (journey.vehicle) dispatchJourney({ type: "exit-vehicle" }); if (journey.location.scale === "office") dispatchJourney({ type: "leave-office" }); if (journey.location.scale !== "california") dispatchJourney({ type: "return-to-california" }); } else { journeyToCity(id === "socal" ? "socal" : "bay-area"); } wantedCity = id; const label = CITIES.find((c) => c.id === id)?.label ?? id; void building(`Building ${label}…`, () => mountCity(id)); } function stepCity(delta: number) { const at = CITIES.findIndex((c) => c.id === wantedCity); const next = CITIES[(at + delta + CITIES.length) % CITIES.length]; if (next) switchCity(next.id); } /** * Guards the door against the second click. * * Entering now begins with a network fetch for the Spaces chunk, so the window * between the click and the room is wide enough to click in again — and two * `enterOffice()` calls in that window build two office scenes, park the second * on the stage and leak the first, textures and all. One flag, cleared in a * `finally` so a failed fetch does not wedge the door shut. */ let entering = false; async function toggleOffice() { if (inside) { if (leaveToCity()) return; leaveOffice(); return; } if (entering) return; // Only the first entry fetches or builds anything; after that the office is // parked in memory next to the paused city and the swap is a pointer. if (office) { void enterOffice(); return; } entering = true; try { /* * The boot card is the whole of the "something is happening" story now, and * it is enough: `building()` puts it up before the fetch and takes it down * only once a frame of the new scene is on the glass. The old arrangement * also wrote "Opening the office…" onto the door itself, which meant this * file reaching into a label `mount.ts` now owns to say something the card * in front of it was already saying. */ await building("Fetching the office…", () => enterOffice()); } finally { entering = false; // Writes the real label whichever way it went — "← Back to the city" if we // are in, the door again if the chunk fetch failed. renderChrome(); } } /** Switch between the authored dollhouse camera and the local possessed actor. */ function toggleOfficeWalk(): boolean { if (!inside) { if (!city?.actorState()) return false; const active = city.controlMode() !== "actor"; requestControlMode(active ? "actor" : "overview"); if (active) closePanelForPlay(); renderChrome(); return true; } const walker = office?.walker; if (!walker) return false; const active = !walker.active(); requestControlMode(active ? "office-walk" : "office-overview"); if (active) closePanelForPlay(); renderChrome(); return true; } function toggleAircraft(): boolean { if (inside || cityId !== "california" || !city?.aircraftState()) return false; const active = city.controlMode() !== "aircraft"; requestControlMode(active ? "aircraft" : "overview"); if (active) closePanelForPlay(); renderChrome(); return true; } /** * Clicking a building on the city walks into it. * * On the canvas rather than on anything the engine owns, because the engine * reports picks by *hover* — `onMarkerPick` fires as the pointer crosses a pin * and again with `null` as it leaves — so there is no click event to hang this * on down there. `hoveredMarker` is whatever that hover last resolved, which is * exactly what a click on the same pixel means. * * Guarded on not already being inside: the city's canvas is the office's canvas * too, they share one renderer, and a stray click on the floor of a room should * not re-enter the building you are standing in. */ canvas.addEventListener("click", () => { if (inside) return; const marker = hoveredMarker; if (!marker) return; const id = officeIdOf(marker); if (id === null || entering) return; entering = true; officeId = id; void building(`Opening ${marker.label}…`, () => enterOffice()).finally(() => { entering = false; renderChrome(); }); }); // ---- Panels, plan and overlays ---------------------------------------------- /** * Get the left column out of the way when a body takes over. * * Only on a phone, where `#panel` is a bottom sheet covering a third of the * screen: possessing an actor and then being unable to see it is not a trade * anybody would make, and the sheet has a toggle to bring it back. Above that * width the column is furniture beside the map rather than on top of it, and * closing it would be taking away a thing that costs nothing. * * `panelChosen` is set for the same reason a keypress sets it: once anything has * had an opinion about this panel, the viewport stops having one. * * The plan view is deliberately **not** closed here any more. It used to be, and * the reason was that on a phone it was a bottom sheet in exactly the place the * joystick now occupies; `ui/chromeState.ts` moved it to a glanceable corner and * closing it is now taking the map away at the moment somebody is navigating. */ function closePanelForPlay(): void { if (window.innerWidth > 600) return; panelOpen = false; panelChosen = true; } /** * One body, two ways in, and the second one is the point. * * This lived inline in the `M` branch of the keydown handler and was reachable * from nowhere else, which made the plan view unreachable on any touch device: * a phone starts without `M` and had no button. `#plan-toggle` and the touch * pad's Map button are that way in, and both arrive here through `mount`'s * `onTogglePlan`, so the key and the buttons cannot disagree about the state. */ function togglePlan(): void { setPlanOpen(!planOpen); } function setPlanOpen(open: boolean): void { planOpen = open; planChosen = true; renderChrome(); } function setPanelOpen(open: boolean): void { panelOpen = open; panelChosen = true; renderChrome(); } /** * The viewport crossed a published breakpoint — a rotation, a window drag, a * tablet turning over. * * Both seeds are re-run, and that "both" is the fix: `resize` used to re-seed * the plan and, for no stated reason, not the panel, so rotating a tablet from * portrait to landscape left the sheet closed over a layout wide enough that the * toggle which would reopen it is not drawn. Either is skipped once its own * control has been touched. */ function adoptLayout(_layout: ChromeLayout): void { if (!panelChosen) panelOpen = seedPanelOpen(window.innerWidth); if (!planChosen) planOpen = seedPlanOpen(window.innerWidth); renderChrome(); } function routeDriveIsActive(): boolean { const state = !inside ? city?.vehicleState() : null; return state !== null && state !== undefined && city?.controlMode() === "drive" && city.current() === state.routeId; } function toggleVehicleCamera(): boolean { if (!routeDriveIsActive() || !city) return false; city.setVehicleCamera(city.vehicleCamera() === "driver" ? "chase" : "driver"); return true; } function cameraForward( camera: { position: { x: number; z: number } }, controls: { target: { x: number; z: number } }, ) { return { x: controls.target.x - camera.position.x, z: controls.target.z - camera.position.z }; } function publishPlayActions(): boolean { const input = playInput.snapshot(); const requests = playInput.consumeRequests(); if (routeDriveIsActive() && city) { city.setVehicleActions({ ...vehicleActionsFromPlay(input), modeRequest: requests.has("assist") ? "assisted" : "none", reset: requests.has("reset"), }); if (requests.has("camera")) toggleVehicleCamera(); return true; } const walker = inside && controlModeState.mode === "office-walk" ? office?.walker : null; if (walker?.active() && office) { walker.setAction(cameraRelativePlanar(input, cameraForward(office.camera, office.controls))); return true; } if (!inside && city?.controlMode() === "actor") { const state = city.actorState(); if (!state) return false; if (state.kind === "crow" && state.mode === "flight") { city.setActorActions({ ...crowActionsFromPlay(input), modeRequest: "none", kindRequest: "none", reset: false, }); } else { city.setActorActions({ ...groundActorActionsFromPlay( input, state.yaw, cameraForward(city.stageScene.camera, city.stageScene.controls), ), pitch: 0, climb: 0, glide: false, modeRequest: "none", kindRequest: "none", reset: false, }); } return true; } if (!inside && city?.controlMode() === "aircraft") { city.setAircraftActions({ ...aircraftActionsFromPlay(input), modeRequest: requests.has("assist") ? "assisted" : "none", reset: requests.has("reset"), }); return true; } return false; } window.addEventListener("keyup", (event) => { const control = controlForKey(event.key); if (!control) return; playInput.setDigital("keyboard", control, false); if (publishPlayActions()) event.preventDefault(); }); function releaseAllPlayInput(): void { clearPublishedPlayInput(); publishPlayActions(); } window.addEventListener("blur", releaseAllPlayInput); document.addEventListener("visibilitychange", () => { if (document.visibilityState !== "visible") releaseAllPlayInput(); }); let gamepadButtons: StandardPlayGamepadButtons = { assist: false, reset: false, camera: false }; function pollPlayGamepad() { try { const pad = navigator.getGamepads?.().find((candidate) => candidate !== null); if (pad) { const sample = sampleStandardPlayGamepad(pad, gamepadButtons); gamepadButtons = sample.buttons; playInput.clearSource("gamepad"); playInput.setAxes("gamepad", sample.axes); for (const control of sample.digital) playInput.setDigital("gamepad", control, true); for (const request of sample.requests) playInput.request(request); publishPlayActions(); } else { playInput.clearSource("gamepad"); gamepadButtons = { assist: false, reset: false, camera: false }; } } catch { // Some privacy-hardened browsers expose the method but throw until a pad // has produced a trusted event. Keyboard/touch remain fully functional. } requestAnimationFrame(pollPlayGamepad); } requestAnimationFrame(pollPlayGamepad); window.addEventListener("pointerdown", (event) => { if (event.pointerType === "touch") document.body.classList.add("touch-capable"); }, { capture: true }); /** * Keyboard access to everything the pointer can reach. * * Bound to `window` rather than to the canvas, because the canvas is only * focusable by accident and a shortcut that stops working when you tab to the * legend is worse than no shortcut. The guard is the usual one: a keystroke that * lands in a text field or on the plan view's own arrow-key handler belongs to * that control, not to this. * * Every binding below is resolved through `ui/shortcuts.ts`, which is also what * renders the `?` sheet — so a key that works and a key that is documented are * now the same list. They were not: the sheet had no row for `G` while `g` was * bound to glide *and* inserted at runtime meaning godmode, and Space was * documented as "Handbrake while driving" while being the generic primary in * every mode. */ window.addEventListener("keydown", (event) => { if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; if ( target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable) ) { return; } if (event.key === "Escape") { // Through the applier rather than at `#shortcuts.hidden`, so the focus // return and the `onShortcuts` callback stay correct whichever way the // sheet was opened. if (chrome?.shortcutsOpen() === true) chrome.closeShortcuts(); else if (inside) { if (!leaveToCity()) leaveOffice(); } else showDetail(null); return; } if (event.key === "?") { if (chrome?.shortcutsOpen() === true) chrome.closeShortcuts(); else chrome?.openShortcuts(); event.preventDefault(); return; } if (event.key >= "1" && event.key <= "9") { flyToIndex(Number(event.key) - 1); return; } if (event.key === "[") { stepCity(-1); return; } if (event.key === "]") { stepCity(1); return; } const lower = event.key.toLowerCase(); const control = controlForKey(event.key); if (control) { playInput.setDigital("keyboard", control, true); if (publishPlayActions()) { event.preventDefault(); return; } } /** * The three one-shot requests — assist, reset, camera — from the same table * the held controls come from. * * This was three hand-written `lower === "p"`-shaped branches, each carrying * its own copy of "which modes is this meaningful in". The mode test is still * here, because it is a fact about the simulation rather than about the * keyboard: only a drive has a camera to swap and only a drive or a flight has * an assist to resume, and a `P` that silently queued a request in overview * would be a key that does nothing while claiming to do something. */ const edge = edgeForKey(event.key); if (edge !== null) { const driving = routeDriveIsActive(); const flying = city?.controlMode() === "aircraft"; const meaningful = edge === "camera" ? driving : driving || flying; if (meaningful) { playInput.request(edge); publishPlayActions(); event.preventDefault(); return; } } if (lower === "v" && toggleOfficeWalk()) { event.preventDefault(); return; } if (lower === "m") { togglePlan(); return; } if (lower === "o") void toggleOffice(); }); // ---- Time ------------------------------------------------------------------- /** * One writer for one override. * * The `#hour` slider is gone: `capabilitiesFor` hands `timeControl` and `debug` * to the same tier, so the only person who could see the scrubber was the person * who can open the godmode panel — and it wrote an hour onto *today*, silently * discarding whatever date the panel had set. * * The clock line stays visible to everyone; a map that will not say what time it * is showing is worse than one you cannot scrub. This gate is belt and braces: * "no control" and "no override" are two different facts, and the renderer * depends on the second. */ function applyTimeControl() { if (!access.can.timeControl) instantOverride = null; } // ---- Instruments ------------------------------------------------------------ /** * The godmode panel and the pose editor, for the one visitor in a deployment * who has them. * * **Constructed, not hidden.** Everything in this section is behind * `access.can.debug`, and for anyone else the result is no element, no * `