3904 lines
159 KiB
TypeScript
3904 lines
159 KiB
TypeScript
/**
|
|
* The demo: two cities under a real sun and moon, and one office 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. Clone it and it works.
|
|
*/
|
|
|
|
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 {
|
|
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 { 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 {
|
|
mergeVehicleActions,
|
|
sampleStandardGamepad,
|
|
type GamepadButtonState,
|
|
} from "./input/vehicle.ts";
|
|
import type { VehicleActionSnapshot } from "./transport/vehicleController.ts";
|
|
import {
|
|
createTeraClient,
|
|
describeLiveness,
|
|
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,
|
|
type AircraftActionSnapshot,
|
|
} from "./aircraft/index.ts";
|
|
import {
|
|
createOfficeScreenPanel,
|
|
type MediaSurfaceDescriptor,
|
|
type OfficeScreenPanel,
|
|
type OfficeScreenRemoteStatus,
|
|
} from "./media/index.ts";
|
|
import type { RemoteMediaState, RemoteOfficeMedia } from "./media/remoteMedia.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 ever sees it, so none of these three
|
|
* files is an edge in the module graph and none of them lands in the 780 kB
|
|
* everybody downloads. The office arrives through the `await import()` in
|
|
* `loadOffice()`, the tools through the one in `boot()`, and the rule that says
|
|
* so for `src/tools/` is written out at the top of `tools/index.ts`. Turning any
|
|
* of these into a value import silently undoes the split, and nothing fails —
|
|
* the bundle just gets big again.
|
|
*/
|
|
import type { OfficeScene } from "./interiors/officeScene.ts";
|
|
import type { Office, Presence } from "./interiors/types.ts";
|
|
import type { RobotOperationsDefinition } from "./interiors/robotOperations.ts";
|
|
import type { MaterialRegistry } from "./assets/materials.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<string, { cityId: string; officeId: string }>([
|
|
["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 of them, and the second one is why this is a table rather than the single
|
|
* hardcoded `import("./offices/lumbridge-hq.ts")` it replaces. They are
|
|
* deliberately unalike — a tower above Transbay, a hangar at Alameda Point,
|
|
* and an Arts District courtyard — because the
|
|
* thing worth showing is that one engine and one format render both, and that
|
|
* `OfficeSite` is what makes them feel like different places rather than the
|
|
* same room with different furniture.
|
|
*
|
|
* The loaders stay lazy. Every pack is a chunk this page does not fetch until
|
|
* somebody opens that door, which is the arithmetic `loadOffice` explains — and
|
|
* a second pack eagerly imported would put its furniture in the entry bundle
|
|
* for every visitor who never opens it.
|
|
*/
|
|
const OFFICE_LOADERS: Readonly<Record<ShippedOfficeId, () => 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<Partial<Record<
|
|
ShippedOfficeId,
|
|
() => 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<HTMLCanvasElement>("#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 between the Bay Area and SoCal abandoned a
|
|
* renderer on the one GL context this page has, and abandoned renderers do not
|
|
* give their textures back: `WebGLRenderer.dispose()` frees no texture at all,
|
|
* so ten switches measured 88 live GPU textures against zero `deleteTexture`
|
|
* calls, 16.8 MB of orphaned shadow map at a time. `stage.ts` has the numbers
|
|
* and the reading of three's source that they come from.
|
|
*
|
|
* Nothing disposes this. It outlives every city and every office on the page,
|
|
* and the page unload takes it — the same arrangement, and the same reasoning,
|
|
* as `officeMaterials` below.
|
|
*/
|
|
const stage = createStage(canvas);
|
|
|
|
// `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 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.
|
|
*
|
|
* This is the one thing that makes Tera and Spaces feel like one product rather
|
|
* than two views sharing a bundle. Both packs carry a real `site` — it is what
|
|
* puts the sun in the right place — and until now that coordinate was known to
|
|
* the lighting and to nothing else. A stranger looking at the board had no way
|
|
* to tell that two of those buildings are ones they can go inside.
|
|
*
|
|
* `OFFICE_SITES` rather than the packs themselves, deliberately: a pack is a
|
|
* lazy chunk worth tens of kilobytes and the city wants these the instant the
|
|
* board appears, long before anybody opens a door. See `offices/sites.ts`.
|
|
*
|
|
* `colorKey` is opaque to the engine, as every `Pin.colorKey` is — the palette
|
|
* resolves it, and giving these their own key is what lets a door look different
|
|
* from a company. `glyph` is equally literal: dimensions and a silhouette, with
|
|
* no office semantics in the renderer.
|
|
*/
|
|
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.
|
|
*
|
|
* `colorKey` is opaque to the engine and resolved by the consuming app, so the
|
|
* palette is this file's business. The office key is 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
|
|
* (`feed.palette`, further down) must still get doors it can see, and folding
|
|
* this into the sample set would lose it the moment real markers arrived.
|
|
*
|
|
* Amber, to sit with the chapter list and the "Enter the office" button rather
|
|
* than with the marker hues — a door is a piece of this application's
|
|
* navigation, and it should read as one.
|
|
*/
|
|
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<typeof createAtmosphere> | 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.
|
|
*
|
|
* Every other feed here is per-city and is torn down on a switch. These are not,
|
|
* and the asymmetry 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. The
|
|
* same element sets serve both cities and would serve a continent.
|
|
*
|
|
* **The elements are shared and the catalogue is not.** A `SatelliteCatalogue`
|
|
* is built around an observer, and the observer is the city centre: reusing one
|
|
* across a city switch would compute the Southland's sky from San Francisco and
|
|
* put every look angle several degrees out, with nothing on screen to say so.
|
|
* So the expensive, universal half is cached here and the cheap, local half is
|
|
* rebuilt per board.
|
|
*
|
|
* `null` until the first fetch is started, and on the overwhelming majority of
|
|
* deployments forever: `TERA_SATELLITES_SOURCE` is off by default. A promise
|
|
* rather than a value so that a second city mounted while the first fetch is
|
|
* still in the air waits for it instead of starting another.
|
|
*/
|
|
let satelliteElements: Promise<SatelliteElements[]> | 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.
|
|
*
|
|
* `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind
|
|
* two names, and until now leaving the office from the office door swapped the
|
|
* scene and left the address bar saying `office.` — so the site's own URL
|
|
* disagreed with the site. That is not a cosmetic complaint: it is the thing you
|
|
* copy, bookmark and send to someone, and it took them somewhere other than what
|
|
* you were looking at.
|
|
*
|
|
* So the city door is this hostname with its first label swapped, and the guess
|
|
* is exactly as safe as the one directly above it: it is only ever consulted
|
|
* when that label is literally `office`, which is the same string comparison
|
|
* that made this the office door in the first place. Same registrable domain,
|
|
* same origin policy, same static root, same API — a deployment that serves one
|
|
* of these names and not the other has half-configured itself, and the honest
|
|
* failure for that is a 404 on a name it chose not to serve rather than a silent
|
|
* lie in the address bar.
|
|
*
|
|
* Anything else — a bare domain, `spaces.example.com`, `?view=office` on the
|
|
* city door — derives nothing and keeps the in-page swap, which is what the
|
|
* whole scene-swap design was for.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
// ---- 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<Office["site"]>) {
|
|
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);
|
|
/**
|
|
* The sky's own cover, which is a different question from what it does to the
|
|
* light and is why the scene takes it separately.
|
|
*
|
|
* `atmosphere.cloudCover(env)` and **not** `currentWeather()?.cloudCover ?? 0`,
|
|
* and the difference is the whole point of the layer existing. `null` weather
|
|
* is "nobody was asked", which is not an edge case — it is the *default*
|
|
* deployment and the exact configuration this repo is held to: a stranger
|
|
* clones it, runs one command, and gets a city with no account and no key.
|
|
* Falling back to zero meant that stranger's sky was permanently, silently
|
|
* empty, and the cloud layer only ever appeared for somebody who had wired up
|
|
* NWS.
|
|
*
|
|
* `atmosphere` models a sky when nobody has observed one — it already does
|
|
* exactly that for the marine layer — and an observed cover still wins
|
|
* outright when there is one. See `cloudCover` in `atmosphere.ts`.
|
|
*/
|
|
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 clock = document.querySelector<HTMLElement>("#clock");
|
|
if (!clock) return;
|
|
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)}%` : "";
|
|
clock.textContent = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${daylightPhase(el)}${moon}`;
|
|
}
|
|
|
|
// ---- 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;
|
|
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.
|
|
*
|
|
* Still two gates, but only one of them is about the visitor now.
|
|
*
|
|
* `can.liveEnvironment` is true for everybody — the reasoning is in
|
|
* `access.ts`, and it comes down to the sky not being a thing an account can
|
|
* grant you. What remains load-bearing is `feeds`, the *deployment*: it is
|
|
* what stops the ordinary box, where every source is `none`, from polling two
|
|
* endpoints forever for a 404 on every tab that is open.
|
|
*
|
|
* The other half of the old comment is still worth keeping, because it was a
|
|
* real bug: the flights 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,
|
|
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);
|
|
},
|
|
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();
|
|
renderSource();
|
|
})
|
|
: 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(() => renderLegend());
|
|
|
|
/**
|
|
* 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) 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();
|
|
renderLegend();
|
|
}
|
|
|
|
/**
|
|
* The minimap's own frame pump.
|
|
*
|
|
* `Stage` owns the render loop and `SceneHandle` exposes no per-frame hook, so
|
|
* the alternative is adding an `onTick` to the scene handle for exactly one
|
|
* call site. 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 minimap at all.
|
|
*/
|
|
requestAnimationFrame(function pumpMinimap() {
|
|
requestAnimationFrame(pumpMinimap);
|
|
syncJourneyVehicle(performance.now());
|
|
// 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();
|
|
// The pose editor is on the same pump for the same reasons, and is `null` for
|
|
// everyone who is not god, so this is one property read per frame on a
|
|
// public page.
|
|
poseEditor?.tick();
|
|
publishRealtimePresence(performance.now());
|
|
pollLiveness();
|
|
});
|
|
|
|
/**
|
|
* 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;
|
|
renderSource();
|
|
}
|
|
|
|
/** 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,
|
|
// 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(() => renderLegend());
|
|
officePlan = buildOfficePlan(createOfficeMinimap, office);
|
|
}
|
|
city.stage.setScene(office);
|
|
inside = true;
|
|
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();
|
|
renderLegend();
|
|
// 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) {
|
|
/**
|
|
* 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();
|
|
office?.walker?.setActive(false);
|
|
office?.walker?.setAction({ x: 0, z: 0 });
|
|
dispatchJourney({ type: "leave-office" });
|
|
city.stage.setScene(city.stageScene);
|
|
inside = false;
|
|
attachCurrentWebcamFace();
|
|
moveRealtimePresence();
|
|
showPlan();
|
|
showDetail(null);
|
|
refreshGodmodePlace();
|
|
renderLegend();
|
|
}
|
|
|
|
/** Dispose everything whose coordinates or subscriptions belong to one office. */
|
|
function disposeLoadedOffice(): void {
|
|
stopWatchingOccupancy();
|
|
disposeOfficeScreenUi();
|
|
officePlan?.dispose();
|
|
officePlan = null;
|
|
office?.dispose();
|
|
office = null;
|
|
builtOfficeId = null;
|
|
officeAtmosphere = null;
|
|
}
|
|
|
|
/**
|
|
* Fetch Spaces.
|
|
*
|
|
* The office is the largest thing in this build that most visitors never open:
|
|
* the interior, the furniture catalogue, the material registry and the
|
|
* floorplan are 67 kB of chunk — 22 kB across the wire — and they used to be
|
|
* downloaded, parsed and executed on every load of a map page by people who
|
|
* came to look at a city. Behind these lazy imports Vite gives them
|
|
* chunks of their own and the door fetches them on the way through. Measured,
|
|
* entry chunk: 780.18 kB / 216.89 kB gzipped before, 720.89 / 198.33 after —
|
|
* the difference is smaller than the chunks because three.js is shared and
|
|
* stays where it was.
|
|
*
|
|
* All modules in one `Promise.all` because they are one arrival: the pack without
|
|
* the builder is a data file nobody can draw, so the fetches overlap rather
|
|
* than queue. Rollup emits chunks the browser asks for together; awaiting them
|
|
* in sequence would add avoidable round trips on a slow link.
|
|
*
|
|
* There is deliberately no retry and no cache-busting. 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 will,
|
|
* 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;
|
|
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] = await Promise.all([
|
|
import("./interiors/officeScene.ts"),
|
|
entry.load(),
|
|
import("./assets/materials.ts"),
|
|
import("./engine/officeMinimap.ts"),
|
|
entry.loadOperations?.() ?? Promise.resolve(null),
|
|
]);
|
|
// 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: "high" });
|
|
return {
|
|
createOfficeScene: interiors.createOfficeScene,
|
|
createOfficeMinimap: plan.createOfficeMinimap,
|
|
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;
|
|
}
|
|
}
|
|
|
|
/** 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** — not an empty one, not a
|
|
* hidden one — so there is nothing here to populate and the request would be one
|
|
* this visitor's session is going to be refused anyway. `presence.ts` explains
|
|
* at length why the layer is absent rather than emptied; this is the call site
|
|
* that would otherwise quietly reintroduce it.
|
|
*
|
|
* The fallback is `markers`' fallback, one room in: an API that does not answer
|
|
* gets the fabricated roster, because a clone with no server is the flagship
|
|
* case and a member shown the same empty room as a stranger has been told the
|
|
* tier means something when it does not. An API that *does* answer 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
|
|
* make the demo livelier 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();
|
|
renderOfficeBadge();
|
|
renderSource();
|
|
});
|
|
}
|
|
|
|
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 ---------------------------------------------------------------
|
|
|
|
const nav = document.querySelector<HTMLElement>("#chapters");
|
|
const blurb = document.querySelector<HTMLElement>("#blurb");
|
|
const title = document.querySelector<HTMLElement>("#title");
|
|
const subtitle = document.querySelector<HTMLElement>("#subtitle");
|
|
const enterButton = document.querySelector<HTMLButtonElement>("#enter");
|
|
const cityNav = document.querySelector<HTMLElement>("#cities");
|
|
const source = document.querySelector<HTMLElement>("#source");
|
|
const minimapFrame = document.querySelector<HTMLElement>("#minimap .minimap-frame");
|
|
const minimapReadout = document.querySelector<HTMLElement>("#minimap-readout");
|
|
const tierBadge = document.querySelector<HTMLElement>("#tier");
|
|
const presenceHost = document.querySelector<HTMLElement>("#presence-host");
|
|
const officeBadge = document.querySelector<HTMLElement>("#office-badge");
|
|
const panelToggle = document.querySelector<HTMLButtonElement>("#panel-toggle");
|
|
const panelToggleLabel = document.querySelector<HTMLElement>("#panel-toggle-label");
|
|
const shortcutsCard = document.querySelector<HTMLElement>("#shortcuts");
|
|
const helpButton = document.querySelector<HTMLButtonElement>("#help");
|
|
const planToggle = document.querySelector<HTMLButtonElement>("#plan-toggle");
|
|
const credits = document.querySelector<HTMLElement>("#credits");
|
|
const driveControls = document.querySelector<HTMLElement>("#drive-controls");
|
|
const driveHint = document.querySelector<HTMLElement>("#drive-hint");
|
|
const walkButton = document.querySelector<HTMLButtonElement>("#walk");
|
|
const flyButton = document.querySelector<HTMLButtonElement>("#fly");
|
|
const screensButton = document.querySelector<HTMLButtonElement>("#screens");
|
|
const walkControls = document.querySelector<HTMLElement>("#walk-controls");
|
|
const walkHint = document.querySelector<HTMLElement>("#walk-hint");
|
|
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
|
|
const webcamFaceIndicator = document.querySelector<HTMLElement>("#webcam-face-indicator");
|
|
const screensOverlay = document.querySelector<HTMLElement>("#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;
|
|
|
|
function showDetail(text: string | null) {
|
|
const card = document.querySelector<HTMLElement>("#detail");
|
|
const body = document.querySelector<HTMLElement>("#detail-text");
|
|
if (!card || !body) return;
|
|
card.hidden = text === null;
|
|
// The text, and not the card: the card also holds the dismiss button, which
|
|
// `textContent` on the card would delete the first time a marker was picked.
|
|
body.textContent = text ?? "";
|
|
}
|
|
|
|
/**
|
|
* The board strip above the legend: world scales outside, buildings inside.
|
|
*
|
|
* One control that answers "which of these am I in", pointed at whichever list
|
|
* is currently the answer. A second, separate office strip was the obvious
|
|
* alternative and is worse: it would sit dead and greyed out for the entire time
|
|
* anybody is looking at the city, which is most of the time.
|
|
*/
|
|
function renderCityPicker() {
|
|
if (!cityNav) return;
|
|
cityNav.replaceChildren();
|
|
const entries = inside
|
|
? OFFICES.map((o) => ({ id: o.id, label: o.label, active: o.id === officeId, status: o.status }))
|
|
: CITIES.map((c) => ({ id: c.id, label: c.label, active: c.id === cityId, status: null }));
|
|
|
|
for (const entry of entries) {
|
|
const b = document.createElement("button");
|
|
// `aria-pressed` rather than a class, because these buttons choose exactly
|
|
// one active board. The stylesheet keys off the attribute
|
|
// so the visual state and the announced state cannot drift apart.
|
|
b.className = entry.status ? "city office-choice" : "city";
|
|
b.type = "button";
|
|
b.setAttribute("aria-pressed", String(entry.active));
|
|
const name = document.createElement("span");
|
|
name.textContent = entry.label;
|
|
b.append(name);
|
|
if (entry.status) {
|
|
const status = document.createElement("span");
|
|
status.className = "office-choice__status";
|
|
status.textContent = entry.status;
|
|
b.append(status);
|
|
}
|
|
b.addEventListener("click", () => {
|
|
if (inside) void switchOffice(entry.id);
|
|
else switchCity(entry.id);
|
|
});
|
|
cityNav.append(b);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 and the light rig. 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, plan and scene all belong to the building 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();
|
|
renderLegend();
|
|
}
|
|
|
|
/** One legend for both places — a city chapter and an office viewpoint are both `View`s. */
|
|
function renderLegend() {
|
|
renderCityPicker();
|
|
if (!nav || !city) return;
|
|
const views: View[] = inside && office ? office.views : city.chapters;
|
|
const activeId = inside && office ? office.current() : city.current();
|
|
|
|
nav.replaceChildren();
|
|
views.forEach((view, i) => {
|
|
const button = document.createElement("button");
|
|
button.className = "chapter";
|
|
button.type = "button";
|
|
button.setAttribute("aria-pressed", String(view.id === activeId));
|
|
const number = view.number ?? String(i + 1).padStart(2, "0");
|
|
button.innerHTML = `<span class="num">${number}</span><span>${view.shortLabel}</span>`;
|
|
button.addEventListener("click", () => flyToIndex(i));
|
|
nav.append(button);
|
|
});
|
|
|
|
const active = views.find((v) => v.id === activeId);
|
|
if (blurb) {
|
|
blurb.textContent = active?.description ?? "";
|
|
blurb.hidden = !active?.description;
|
|
}
|
|
const cityLabel = CITIES.find((c) => c.id === cityId)?.city.name ?? "";
|
|
const selectedOffice = OFFICES.find((entry) => entry.id === officeId) ?? OFFICES[0];
|
|
if (title) title.textContent = inside ? officeName() : cityLabel;
|
|
if (subtitle) {
|
|
subtitle.textContent = inside
|
|
? `Tera · ${selectedOffice?.status ?? "building"} environment`
|
|
: "Tera · Lumbridge Simulate";
|
|
}
|
|
if (enterButton) {
|
|
enterButton.textContent = inside
|
|
? "← Back to the city"
|
|
: selectedOffice?.status === "active"
|
|
? `Open ${selectedOffice.label} →`
|
|
: `Preview ${selectedOffice?.label ?? "environment"} · building →`;
|
|
}
|
|
const walking = inside && (office?.walker?.active() ?? false);
|
|
const exploring = !inside && (city.actorActive() ?? false);
|
|
const flying = !inside && (city.aircraftActive() ?? false);
|
|
if (walkButton) {
|
|
walkButton.hidden = inside ? office?.walker === null : city.actorState() === null;
|
|
walkButton.setAttribute("aria-pressed", String(walking || exploring));
|
|
if (inside && office?.walker) {
|
|
const actor = office.walker.state().actor === "anonymous-dog" ? "your dog" : "your humanoid";
|
|
walkButton.textContent = walking ? "Return to overview ↑" : `Walk as ${actor} →`;
|
|
} else if (city.actorState()) {
|
|
const actor = city.actorState()?.kind === "crow" ? "your crow" : "your humanoid";
|
|
walkButton.textContent = exploring ? "Return to flyover ↑" : `Explore as ${actor} →`;
|
|
}
|
|
}
|
|
if (flyButton) {
|
|
flyButton.hidden = inside || cityId !== "california" || city.aircraftState() === null;
|
|
flyButton.setAttribute("aria-pressed", String(flying));
|
|
flyButton.textContent = flying ? "Return to flyover ↑" : "Fly the California route →";
|
|
}
|
|
if (screensButton) {
|
|
const canManage = inside && office?.depth === "full" && (office.listMediaSurfaces().length > 0);
|
|
screensButton.hidden = !canManage;
|
|
}
|
|
renderSource();
|
|
if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
|
|
if (canvas) {
|
|
canvas.setAttribute(
|
|
"aria-label",
|
|
inside
|
|
? walking
|
|
? `${officeName()}, following your ${office?.walker?.state().actor === "anonymous-dog" ? "dog" : "humanoid"}. Use W A S D to move.`
|
|
: `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
|
|
: flying
|
|
? `${cityLabel}, following your electric aircraft. Use W A S D to fly or P to resume assisted flight.`
|
|
: exploring
|
|
? `${cityLabel}, following your ${city.actorState()?.kind ?? "actor"}. Use W A S D to move, Q and E for altitude, and G to glide.`
|
|
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
|
|
);
|
|
}
|
|
// 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.
|
|
minimap?.setChapters(city.chapters, city.current());
|
|
officePlan?.setActiveView(office?.current() ?? null);
|
|
renderOfficeBadge();
|
|
if (driveControls) driveControls.hidden = !routeDriveIsActive();
|
|
if (walkControls) {
|
|
walkControls.hidden = !(walking || exploring || flying);
|
|
walkControls.setAttribute(
|
|
"aria-label",
|
|
flying
|
|
? "Aircraft flight controls"
|
|
: exploring && city.actorState()?.kind === "crow"
|
|
? "Crow flight controls"
|
|
: "Walking controls",
|
|
);
|
|
}
|
|
for (const control of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
|
|
control.textContent = flying
|
|
? control.dataset.aircraftLabel ?? control.textContent
|
|
: control.dataset.walkLabel ?? control.textContent;
|
|
}
|
|
for (const control of walkControls?.querySelectorAll<HTMLElement>(".flight-only") ?? []) {
|
|
control.hidden = inside || (!flying && city.actorState()?.kind !== "crow");
|
|
}
|
|
for (const control of walkControls?.querySelectorAll<HTMLElement>(".aircraft-only") ?? []) {
|
|
control.hidden = !flying;
|
|
}
|
|
for (const control of walkControls?.querySelectorAll<HTMLElement>(".crow-only") ?? []) {
|
|
control.hidden = inside || flying || city.actorState()?.kind !== "crow";
|
|
}
|
|
if (driveHint) driveHint.hidden = inside || cityId !== "california" || flying;
|
|
if (walkHint) {
|
|
walkHint.hidden = !(inside || city.actorState() || city.aircraftState());
|
|
walkHint.textContent = flying
|
|
? "WASD fly · P assisted · R reset"
|
|
: inside
|
|
? "V walk · WASD move"
|
|
: "V explore · WASD · Q/E altitude · G glide";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The corner label, which now names the parts rather than claiming the whole.
|
|
*
|
|
* Only the *positive* case gets a permanent label. This used to read "sample
|
|
* data · fabricated, not real companies" on every frame of every load, which is
|
|
* the overwhelmingly common case — no deployment has a markers source wired by
|
|
* default — so the disclosure was on screen approximately always and had become
|
|
* furniture. A caption nobody reads is not disclosure, it is a watermark. The
|
|
* fact still has to be somewhere on the same screen as the map, so it is stated
|
|
* on the boot card everyone passes through and again in the `?` card, one
|
|
* keypress away and permanently reachable.
|
|
*
|
|
* What is left is the informative signal, and it is three signals rather than
|
|
* one. The markers, the weather and the traffic arrive from three different
|
|
* places and every combination of them is a deployment that exists; a single
|
|
* flag has to pick one to be about and then lie about the other two. The
|
|
* particular lie this closes is "live data" printed over invented companies
|
|
* because a weather station answered — which is precisely the claim the `live`
|
|
* flag was introduced to prevent. `describeLiveness` in `adapters/http.ts` owns
|
|
* the wording; all three live is the only case that still says "live data".
|
|
*
|
|
* Called from `renderLegend` and once a second from the frame pump, because the
|
|
* feeds settle after the legend has been drawn.
|
|
*/
|
|
function renderSource() {
|
|
if (!source) return;
|
|
/**
|
|
* The office's provenance line, which outranks the city's.
|
|
*
|
|
* It used to live on `#office-badge` inside `#panel`, and on a phone `#panel`
|
|
* is a bottom sheet that starts closed — so the one sentence standing between
|
|
* twenty-five invented people at real desks and a screenshot presented as a
|
|
* staff list was behind a hamburger, on the device most likely to take the
|
|
* screenshot. This slot is the right home for it anyway and not merely a
|
|
* visible one: `#source` is where this page already says what on screen is and
|
|
* is not real, it is fixed and always drawn, and the phone stylesheet calls it
|
|
* "the one caption that is never allowed to be dropped for space".
|
|
*
|
|
* It replaces the city's label rather than joining it, because while you are
|
|
* standing in the office the markers, the sky and the traffic are facts about
|
|
* a board behind you — and "live data" printed over invented colleagues is the
|
|
* exact species of lie the liveness wording was rewritten to stop telling.
|
|
*/
|
|
const sample = inside && presenceIsSample;
|
|
// A class rather than an inline style, so the stylesheet keeps the decision
|
|
// about how a phone lays this out and this keeps the decision about what it
|
|
// says. The rule it turns on is the one that stops the sentence being
|
|
// ellipsised down to the half that reads as reassuring.
|
|
document.body.classList.toggle("sample-occupancy", sample);
|
|
if (sample) {
|
|
const caption = "sample occupancy · these people are invented";
|
|
if (source.textContent !== caption) source.textContent = caption;
|
|
source.hidden = false;
|
|
return;
|
|
}
|
|
const label = describeLiveness({
|
|
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,
|
|
});
|
|
if (source.textContent !== label) source.textContent = label;
|
|
source.hidden = label === "";
|
|
/**
|
|
* The green. `.source.live` in `index.html` is the whole visual difference
|
|
* between this line and the rest of the chrome, and the rewrite that replaced
|
|
* `source.className = "source live"` with a `textContent`/`hidden` pair
|
|
* dropped it — so every live label rendered at `--ink-3`, the same muted grey
|
|
* as a key hint, and the stylesheet rule could no longer match anything. This
|
|
* label is only ever on screen when it has something to say; the colour is
|
|
* how it says it is worth reading.
|
|
*/
|
|
source.classList.toggle("live", label !== "");
|
|
renderCredits();
|
|
}
|
|
|
|
/**
|
|
* Who to thank for what is on screen, in the `?` card.
|
|
*
|
|
* MET Norway and Open-Meteo publish under CC BY 4.0 and the server emits the
|
|
* credit line each of them asks for — `server/README.md` says in as many words
|
|
* that the consumer is expected to display it — and adsb.lol asks to be named
|
|
* for the positions. All of it arrived, was parsed into `WeatherFeed.attribution`
|
|
* and `FlightsBody.attribution`, and was then read by nobody: a licence
|
|
* obligation plumbed to within one line of being met.
|
|
*
|
|
* It goes in the `?` card rather than on the `#source` line, and that is a
|
|
* choice rather than convenience. The corner label is one short phrase and on a
|
|
* phone it is explicitly clamped to a single ellipsised line, so a licence
|
|
* sentence appended to it would be *truncated* — the one outcome worse than
|
|
* putting it a keypress away. The `?` card is reachable from every state the
|
|
* app can be in, on both layouts, and already carries the sentence about the
|
|
* markers being fabricated; the provenance of the map belongs in one place.
|
|
*
|
|
* Empty when nothing live is on screen, because a credit for data nobody is
|
|
* looking at is noise, and because the zero-config build owes nobody anything.
|
|
*/
|
|
function renderCredits() {
|
|
if (!credits) return;
|
|
const lines: string[] = [];
|
|
// The weather override is somebody's invention; it is not MET Norway's sky
|
|
// and must not be attributed to them.
|
|
if (weatherOverride === null) lines.push(...(weatherWatch?.current().attribution ?? []));
|
|
lines.push(...(cityFlights?.attribution() ?? []));
|
|
const unique = [...new Set(lines.filter((line) => line !== ""))];
|
|
credits.textContent = unique.join(" · ");
|
|
credits.hidden = unique.length === 0;
|
|
}
|
|
|
|
/**
|
|
* The one thing a public visitor is actually missing, said in the place where
|
|
* they would notice it missing.
|
|
*
|
|
* An empty office with no explanation reads as a bug — a floor that failed to
|
|
* load — and the fix for that is a sentence, not a disabled button. The
|
|
* sign-in link is offered *beside* the office rather than in front of it, so it
|
|
* is an upgrade and never a toll gate.
|
|
*/
|
|
function renderOfficeBadge() {
|
|
if (!officeBadge) return;
|
|
const publicOffice = inside && office !== null && office.depth === "public";
|
|
const mediaSurfaces = inside && office !== null && office.depth === "full"
|
|
? office.listMediaSurfaces()
|
|
: [];
|
|
const robotActivity = inside && office !== null ? office.robotActivityInfo() : null;
|
|
// The fabricated-occupancy caption used to be here too and is now on
|
|
// `#source` — see `renderSource`. This badge keeps the message that is a call
|
|
// to action rather than a disclosure, because that one belongs beside the
|
|
// office controls and survives being missed; the other one does not.
|
|
officeBadge.hidden = !publicOffice && mediaSurfaces.length === 0 && robotActivity === null;
|
|
if (!publicOffice) {
|
|
if (mediaSurfaces.length === 0) {
|
|
if (robotActivity) officeBadge.textContent = robotActivity.disclosure;
|
|
return;
|
|
}
|
|
const noun = mediaSurfaces.length === 1 ? "screen" : "screens";
|
|
const active = mediaSurfaces.filter((surface) => surface.bound).length;
|
|
const media = active > 0
|
|
? `${active} of ${mediaSurfaces.length} ${noun} active · stop control in Office screens.`
|
|
: `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`;
|
|
officeBadge.textContent = robotActivity ? `${media} ${robotActivity.disclosure}` : media;
|
|
return;
|
|
}
|
|
officeBadge.replaceChildren(
|
|
document.createTextNode("Public view — the building, not the people. "),
|
|
);
|
|
if (access.signInUrl !== null) {
|
|
const link = document.createElement("a");
|
|
link.href = access.signInUrl;
|
|
link.textContent = "Sign in for the live floor";
|
|
officeBadge.append(link, document.createTextNode("."));
|
|
} else {
|
|
officeBadge.append(document.createTextNode("Sign in to see who's in."));
|
|
}
|
|
if (robotActivity) officeBadge.append(document.createTextNode(` ${robotActivity.disclosure}`));
|
|
}
|
|
|
|
/**
|
|
* Who the site thinks you are, in the corner, always. Three words and a name.
|
|
*
|
|
* It is here rather than buried in a menu because every other difference on
|
|
* this page — an empty office, sample markers, no godmode tab — is a
|
|
* *silence*, and a silence you cannot attribute is indistinguishable from a
|
|
* fault. This is the line that tells you which of the two you are looking at.
|
|
*/
|
|
function renderTierBadge() {
|
|
if (!tierBadge) return;
|
|
tierBadge.className = `card tier ${access.tier}`;
|
|
const label = document.createElement("span");
|
|
/**
|
|
* The label names what you *get*, not who you are, and that is deliberate.
|
|
* "Signed in" was the first draft and it is a lie in the commonest case:
|
|
* a clean clone with no API at all resolves to `member`, and telling someone
|
|
* they are signed in to a server that does not exist is the sort of small
|
|
* dishonesty that makes the rest of the interface untrustworthy. "Full view"
|
|
* is true whether the tier came from a session or from there being nothing to
|
|
* have a session with; the subject, when there is one, says the rest.
|
|
*/
|
|
label.textContent =
|
|
access.tier === "god" ? "Godmode" : access.tier === "member" ? "Full view" : "Public view";
|
|
tierBadge.replaceChildren(label);
|
|
if (access.subject !== null) {
|
|
const who = document.createElement("span");
|
|
who.className = "who";
|
|
who.textContent = localProfile?.displayName ?? access.subject;
|
|
tierBadge.append(who);
|
|
if (localProfile) {
|
|
const customize = document.createElement("button");
|
|
customize.type = "button";
|
|
customize.className = "profile-trigger";
|
|
customize.textContent = "Character";
|
|
customize.addEventListener("click", openProfileEditor);
|
|
tierBadge.append(customize);
|
|
}
|
|
} else if (access.signInUrl !== null) {
|
|
const link = document.createElement("a");
|
|
link.href = access.signInUrl;
|
|
link.textContent = "Sign in";
|
|
tierBadge.append(link);
|
|
}
|
|
tierBadge.hidden = false;
|
|
}
|
|
|
|
// ---- 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<void> {
|
|
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<void> {
|
|
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;
|
|
document.body.classList.add("presence-on");
|
|
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;
|
|
});
|
|
|
|
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<void> {
|
|
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<ReturnType<WebcamCaptureController["start"]>>;
|
|
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);
|
|
renderTierBadge();
|
|
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);
|
|
renderOfficeBadge();
|
|
}
|
|
|
|
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<typeof remoteViewedScreen>,
|
|
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<void> {
|
|
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<typeof remoteViewedScreen>;
|
|
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<typeof sharedScreen>,
|
|
): Promise<void> {
|
|
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() ?? []);
|
|
renderOfficeBadge();
|
|
}
|
|
|
|
function disposeOfficeScreenUi(): void {
|
|
stopLocalScreenShare();
|
|
releaseRemoteViewer("off", "dispose");
|
|
officeScreenPanel?.dispose();
|
|
officeScreenPanel = null;
|
|
if (screensOverlay) screensOverlay.hidden = true;
|
|
}
|
|
|
|
async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<void> {
|
|
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());
|
|
renderOfficeBadge();
|
|
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();
|
|
}
|
|
|
|
screensButton?.addEventListener("click", openOfficeScreens);
|
|
screensOverlay?.addEventListener("click", (event) => {
|
|
if (event.target !== screensOverlay) return;
|
|
officeScreenPanel?.close();
|
|
screensOverlay.hidden = true;
|
|
});
|
|
|
|
// ---- Navigation -------------------------------------------------------------
|
|
|
|
/** The views on offer right now — city chapters, or office viewpoints inside. */
|
|
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) office.flyTo(view.id);
|
|
else {
|
|
const destination = cityId === "california" ? CALIFORNIA_DESTINATIONS.get(view.id) : undefined;
|
|
if (destination) {
|
|
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") {
|
|
dispatchJourney({ type: "set-mode", mode: "play" });
|
|
dispatchJourney({ type: "select-route", routeId: view.id, direction: 1 });
|
|
dispatchJourney({ type: "enter-vehicle", vehicleId: "model-x-black" });
|
|
}
|
|
city?.flyTo(view.id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
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;
|
|
/**
|
|
* Say so on the button before anything else happens.
|
|
*
|
|
* The boot card comes up too, but it comes up on the *next* frame at the
|
|
* earliest, and on a slow connection the chunk is the long pole rather than
|
|
* the build. A door that does nothing visible for half a second gets clicked
|
|
* again; a door that says "Opening…" gets waited for.
|
|
*/
|
|
if (enterButton) {
|
|
enterButton.textContent = "Opening the office…";
|
|
enterButton.setAttribute("aria-busy", "true");
|
|
}
|
|
try {
|
|
await building("Fetching the office…", () => enterOffice());
|
|
} finally {
|
|
entering = false;
|
|
enterButton?.removeAttribute("aria-busy");
|
|
// `renderLegend` writes the real label whichever way it went — "← Back to
|
|
// the city" if we are in, the door again if the fetch failed.
|
|
renderLegend();
|
|
}
|
|
}
|
|
|
|
enterButton?.addEventListener("click", () => void toggleOffice());
|
|
|
|
/** Switch between the authored dollhouse camera and the local possessed actor. */
|
|
function toggleOfficeWalk(): boolean {
|
|
if (!inside) {
|
|
if (!city?.actorState()) return false;
|
|
const active = !city.actorActive();
|
|
city.setActorActive(active);
|
|
if (!active) city.setActorActions({});
|
|
if (active && window.innerWidth <= 600) {
|
|
panelOpen = false;
|
|
applyPanel();
|
|
}
|
|
renderLegend();
|
|
return true;
|
|
}
|
|
const walker = office?.walker;
|
|
if (!walker) return false;
|
|
const active = !walker.active();
|
|
walker.setActive(active);
|
|
if (!active) walker.setAction({ x: 0, z: 0 });
|
|
if (active && window.innerWidth <= 600) {
|
|
panelOpen = false;
|
|
applyPanel();
|
|
}
|
|
renderLegend();
|
|
return true;
|
|
}
|
|
|
|
walkButton?.addEventListener("click", () => toggleOfficeWalk());
|
|
|
|
function toggleAircraft(): boolean {
|
|
if (inside || cityId !== "california" || !city?.aircraftState()) return false;
|
|
const active = !city.aircraftActive();
|
|
city.setAircraftActive(active);
|
|
if (!active) city.setAircraftActions({});
|
|
if (active && window.innerWidth <= 600) {
|
|
panelOpen = false;
|
|
applyPanel();
|
|
}
|
|
renderLegend();
|
|
return true;
|
|
}
|
|
|
|
flyButton?.addEventListener("click", () => toggleAircraft());
|
|
|
|
/**
|
|
* Clicking a building on the city walks into it.
|
|
*
|
|
* 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;
|
|
renderLegend();
|
|
});
|
|
});
|
|
|
|
// ---- Panels, plan and overlays ----------------------------------------------
|
|
|
|
/**
|
|
* 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 the viewport stops having an opinion.
|
|
*/
|
|
let panelOpen = window.innerWidth > 900;
|
|
let planOpen = window.innerWidth > 600;
|
|
let planChosen = false;
|
|
|
|
function applyPanel() {
|
|
document.body.classList.toggle("panel-closed", !panelOpen);
|
|
panelToggle?.setAttribute("aria-expanded", String(panelOpen));
|
|
}
|
|
|
|
function applyPlan() {
|
|
document.body.classList.toggle("minimap-off", !planOpen);
|
|
planToggle?.setAttribute("aria-pressed", String(planOpen));
|
|
}
|
|
|
|
/**
|
|
* 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**: `planOpen` is seeded `window.innerWidth > 600`, so a phone starts
|
|
* with it off, and a phone has no `M`. Every visible control at 390px was
|
|
* enumerated and none of them could turn it on. `index.html` has carried a
|
|
* designed phone layout for `.corner` — a bottom sheet above the rail, at
|
|
* `min(38dvh, 18rem)` — that no visitor to that layout could ever see, under a
|
|
* comment saying it "costs nothing until it is asked for". There was no way to
|
|
* ask. `#plan-toggle` is that way, shown wherever the pointer is coarse.
|
|
*
|
|
* So the key and the button call this, and `planChosen` is set by both for the
|
|
* same reason it always was: once somebody has an opinion, the viewport stops
|
|
* having one.
|
|
*/
|
|
function togglePlan() {
|
|
planOpen = !planOpen;
|
|
planChosen = true;
|
|
applyPlan();
|
|
}
|
|
|
|
planToggle?.addEventListener("click", () => togglePlan());
|
|
|
|
panelToggle?.addEventListener("click", () => {
|
|
panelOpen = !panelOpen;
|
|
applyPanel();
|
|
});
|
|
|
|
/**
|
|
* The scrim behind the phone's panel sheet. It is `display: none` above 600px,
|
|
* so this listener is only ever reachable where the sheet exists.
|
|
*/
|
|
document.querySelector<HTMLElement>("#scrim")?.addEventListener("click", () => {
|
|
panelOpen = false;
|
|
applyPanel();
|
|
});
|
|
|
|
document.querySelector<HTMLElement>("#detail-close")?.addEventListener("click", () => {
|
|
showDetail(null);
|
|
});
|
|
|
|
window.addEventListener("resize", () => {
|
|
if (!planChosen) {
|
|
planOpen = window.innerWidth > 600;
|
|
applyPlan();
|
|
}
|
|
});
|
|
|
|
function openShortcuts() {
|
|
if (!shortcutsCard || !shortcutsCard.hidden) return;
|
|
shortcutsCard.hidden = false;
|
|
document.querySelector<HTMLButtonElement>("#shortcuts-close")?.focus();
|
|
}
|
|
|
|
function closeShortcuts() {
|
|
if (!shortcutsCard || shortcutsCard.hidden) return;
|
|
shortcutsCard.hidden = true;
|
|
helpButton?.focus();
|
|
}
|
|
|
|
helpButton?.addEventListener("click", () => openShortcuts());
|
|
document.querySelector<HTMLElement>("#shortcuts-close")?.addEventListener("click", closeShortcuts);
|
|
shortcutsCard?.addEventListener("click", (event) => {
|
|
// The backdrop, not the sheet. Clicking the card itself must not close it.
|
|
if (event.target === shortcutsCard) closeShortcuts();
|
|
});
|
|
|
|
/** Held keyboard state translated into the same snapshot a gamepad/touch UI uses. */
|
|
const heldDriveKeys = new Set<string>();
|
|
|
|
function routeDriveIsActive(): boolean {
|
|
const state = !inside ? city?.vehicleState() : null;
|
|
return state !== null && state !== undefined && !city?.actorActive() &&
|
|
!city?.aircraftActive() && city?.current() === state.routeId;
|
|
}
|
|
|
|
function publishVehicleActions(
|
|
supplement: Partial<VehicleActionSnapshot> = {},
|
|
): boolean {
|
|
if (!routeDriveIsActive() || !city) return false;
|
|
const left = heldDriveKeys.has("a");
|
|
const right = heldDriveKeys.has("d");
|
|
city.setVehicleActions(
|
|
mergeVehicleActions(
|
|
{
|
|
throttle: heldDriveKeys.has("w") ? 1 : 0,
|
|
brake: heldDriveKeys.has("s") ? 1 : 0,
|
|
steering: (right ? 1 : 0) - (left ? 1 : 0),
|
|
handbrake: heldDriveKeys.has(" "),
|
|
},
|
|
supplement,
|
|
),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
function publishOfficeWalkActions(): boolean {
|
|
const walker = inside ? office?.walker : null;
|
|
if (!walker?.active()) return false;
|
|
walker.setAction({
|
|
x: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
|
|
z: (heldDriveKeys.has("s") ? 1 : 0) - (heldDriveKeys.has("w") ? 1 : 0),
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function publishCityActorActions(): boolean {
|
|
if (inside || !city?.actorActive()) return false;
|
|
city.setActorActions({
|
|
forward: (heldDriveKeys.has("w") ? 1 : 0) - (heldDriveKeys.has("s") ? 1 : 0),
|
|
right: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
|
|
turn: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
|
|
sprint: heldDriveKeys.has(" "),
|
|
climb: (heldDriveKeys.has("e") || heldDriveKeys.has(" ") ? 1 : 0) -
|
|
(heldDriveKeys.has("q") ? 1 : 0),
|
|
glide: heldDriveKeys.has("g"),
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function publishAircraftActions(
|
|
supplement: Partial<AircraftActionSnapshot> = {},
|
|
): boolean {
|
|
if (inside || !city?.aircraftActive()) return false;
|
|
city.setAircraftActions({
|
|
throttle: heldDriveKeys.has(" ") ? 1 : 0,
|
|
pitch: (heldDriveKeys.has("w") ? 1 : 0) - (heldDriveKeys.has("s") ? 1 : 0),
|
|
roll: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
|
|
yaw: (heldDriveKeys.has("e") ? 1 : 0) - (heldDriveKeys.has("q") ? 1 : 0),
|
|
modeRequest: supplement.modeRequest ?? "none",
|
|
reset: supplement.reset ?? false,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function toggleVehicleCamera(): boolean {
|
|
if (!routeDriveIsActive() || !city) return false;
|
|
city.setVehicleCamera(city.vehicleCamera() === "driver" ? "chase" : "driver");
|
|
return true;
|
|
}
|
|
|
|
for (const button of driveControls?.querySelectorAll<HTMLButtonElement>("[data-drive-key]") ?? []) {
|
|
const key = button.dataset.driveKey;
|
|
if (key === undefined) continue;
|
|
const release = (event: PointerEvent) => {
|
|
heldDriveKeys.delete(key);
|
|
button.setAttribute("aria-pressed", "false");
|
|
publishVehicleActions();
|
|
event.preventDefault();
|
|
};
|
|
button.addEventListener("pointerdown", (event) => {
|
|
button.setPointerCapture(event.pointerId);
|
|
heldDriveKeys.add(key);
|
|
button.setAttribute("aria-pressed", "true");
|
|
publishVehicleActions();
|
|
event.preventDefault();
|
|
});
|
|
button.addEventListener("pointerup", release);
|
|
button.addEventListener("pointercancel", release);
|
|
button.addEventListener("lostpointercapture", release);
|
|
}
|
|
|
|
for (const button of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
|
|
const key = button.dataset.walkKey;
|
|
if (key === undefined) continue;
|
|
const release = (event: PointerEvent) => {
|
|
heldDriveKeys.delete(key);
|
|
button.setAttribute("aria-pressed", "false");
|
|
publishOfficeWalkActions();
|
|
publishAircraftActions();
|
|
publishCityActorActions();
|
|
event.preventDefault();
|
|
};
|
|
button.addEventListener("pointerdown", (event) => {
|
|
button.setPointerCapture(event.pointerId);
|
|
heldDriveKeys.add(key);
|
|
button.setAttribute("aria-pressed", "true");
|
|
publishOfficeWalkActions();
|
|
publishAircraftActions();
|
|
publishCityActorActions();
|
|
event.preventDefault();
|
|
});
|
|
button.addEventListener("pointerup", release);
|
|
button.addEventListener("pointercancel", release);
|
|
button.addEventListener("lostpointercapture", release);
|
|
}
|
|
|
|
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='assist']")
|
|
?.addEventListener("click", () => publishVehicleActions({ modeRequest: "assisted" }));
|
|
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
|
|
?.addEventListener("click", () => publishVehicleActions({ reset: true }));
|
|
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
|
|
?.addEventListener("click", () => toggleVehicleCamera());
|
|
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='assist']")
|
|
?.addEventListener("click", () => publishAircraftActions({ modeRequest: "assisted" }));
|
|
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='reset']")
|
|
?.addEventListener("click", () => publishAircraftActions({ reset: true }));
|
|
|
|
window.addEventListener("keyup", (event) => {
|
|
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
|
if (!heldDriveKeys.delete(key)) return;
|
|
if (
|
|
publishVehicleActions() || publishOfficeWalkActions() ||
|
|
publishAircraftActions() || publishCityActorActions()
|
|
) event.preventDefault();
|
|
});
|
|
|
|
window.addEventListener("blur", () => {
|
|
heldDriveKeys.clear();
|
|
publishVehicleActions();
|
|
publishOfficeWalkActions();
|
|
publishAircraftActions();
|
|
publishCityActorActions();
|
|
});
|
|
|
|
let gamepadButtons: GamepadButtonState = { assist: false, reset: false };
|
|
function pollDriveGamepad() {
|
|
try {
|
|
const pad = navigator.getGamepads?.().find((candidate) => candidate !== null);
|
|
if (pad && routeDriveIsActive()) {
|
|
const sample = sampleStandardGamepad(pad, gamepadButtons);
|
|
gamepadButtons = sample.buttons;
|
|
publishVehicleActions(sample.actions);
|
|
} else {
|
|
gamepadButtons = { assist: false, reset: 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(pollDriveGamepad);
|
|
}
|
|
requestAnimationFrame(pollDriveGamepad);
|
|
|
|
/**
|
|
* Keyboard access to everything the mouse 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.
|
|
*/
|
|
window.addEventListener("keydown", (event) => {
|
|
if (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") {
|
|
if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts();
|
|
else if (inside) {
|
|
if (!leaveToCity()) leaveOffice();
|
|
}
|
|
else showDetail(null);
|
|
return;
|
|
}
|
|
if (event.key === "?") {
|
|
if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts();
|
|
else 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();
|
|
if (
|
|
lower === "w" || lower === "a" || lower === "s" || lower === "d" ||
|
|
lower === "q" || lower === "e" || lower === "g" || event.key === " "
|
|
) {
|
|
heldDriveKeys.add(event.key === " " ? " " : lower);
|
|
if (publishVehicleActions()) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (publishOfficeWalkActions()) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (publishAircraftActions()) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (publishCityActorActions()) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
if (lower === "p" && publishVehicleActions({ modeRequest: "assisted" })) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (lower === "p" && publishAircraftActions({ modeRequest: "assisted" })) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (lower === "r" && publishVehicleActions({ reset: true })) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (lower === "r" && publishAircraftActions({ reset: true })) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (lower === "c" && toggleVehicleCamera()) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (lower === "v" && toggleOfficeWalk()) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (lower === "m") {
|
|
togglePlan();
|
|
return;
|
|
}
|
|
if (lower === "o") void toggleOffice();
|
|
});
|
|
|
|
// ---- Time -------------------------------------------------------------------
|
|
|
|
/**
|
|
* The `#hour` slider and its `now` button are gone, replaced rather than kept.
|
|
*
|
|
* They were a second writer for one override, and the weaker of the two:
|
|
* `capabilitiesFor` hands `timeControl` and `debug` to exactly the same tier, so
|
|
* there was never an audience for the simple case — the only person who could
|
|
* see the scrubber was the same person who can open the godmode panel. Keeping
|
|
* both meant the slider wrote an hour onto *today* and silently discarded
|
|
* whatever date the panel had set, which is a bug with no upside.
|
|
*
|
|
* `#clock` stays exactly as it was, and stays visible to everyone: a map that
|
|
* will not say what time it is showing is worse than one you cannot scrub.
|
|
*
|
|
* What is left of the gate is one line, and it is belt and braces — the only
|
|
* writer of `instantOverride` is the panel, and the panel is not constructed
|
|
* unless `can.debug`. It stays because "no control" and "no override" are two
|
|
* different facts, and the second is the one the renderer depends on.
|
|
*/
|
|
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 a member or an anonymous visitor the result is
|
|
* not a panel with `display: none` on it — it is no element, no `<style>`, no
|
|
* key binding, and no bytes: `src/tools/` is reached only through the
|
|
* `await import()` below, so a non-god browser never fetches the chunk. A
|
|
* hidden instrument is still an instrument you shipped to a stranger, and the
|
|
* tier that gets these is the tier that can already read the source.
|
|
*
|
|
* That rule is also why the dock's geometry is set as element styles instead of
|
|
* a rule in `index.html`. Every visitor downloads that stylesheet; a
|
|
* `#pose-dock { … }` sitting in it that can never match is the one trace this
|
|
* arrangement would otherwise leave behind, in the bytes if not in the DOM.
|
|
*/
|
|
|
|
/** Cached across boards: the module is fetched once, the tool is rebuilt per city. */
|
|
let poseEditorFactory: typeof import("./tools/poseEditor.ts").createPoseEditor | null = null;
|
|
let poseDockBody: HTMLElement | null = null;
|
|
let poseDockOpen = true;
|
|
|
|
/**
|
|
* Where the panel thinks it is standing.
|
|
*
|
|
* `null` while there is no board — between a `dispose()` and the next
|
|
* `createScene`, which is now an `await` wide. The stage travels with the place
|
|
* even though there is only ever one of them, because what the panel is being
|
|
* told is *which board these counters are about*: `label`, the centre, the
|
|
* scale and the renderer's ledger are one reading, and handing the panel the
|
|
* stage separately would let it print SoCal's draw calls under the Bay Area's
|
|
* name for the length of a switch.
|
|
*
|
|
* `marineStrength` is deliberately not wired. It is private to `atmosphere.ts`
|
|
* today, and the panel prints "no hook wired" rather than a guess — see the
|
|
* handoff note; one `export` keyword upstream turns the readout on.
|
|
*/
|
|
function godmodePlace(): GodmodePlace | null {
|
|
if (!city) return null;
|
|
const entry = CITIES.find((c) => c.id === cityId);
|
|
const active = entry?.city ?? SAN_FRANCISCO;
|
|
const world = city.world;
|
|
const label = entry?.label ?? cityId;
|
|
return {
|
|
// The office is a different board for accounting: it is the ledger key, and
|
|
// its draw calls are not the city's.
|
|
label: inside ? `${label} · office` : label,
|
|
lat: active.center.lat,
|
|
lng: active.center.lng,
|
|
stage: city.stage,
|
|
metresPerUnit: world.metresPerUnit,
|
|
unproject: (x, z) => world.unproject(x, z),
|
|
};
|
|
}
|
|
|
|
/** `setPlace` when there is a place. Called on every board and room change. */
|
|
function refreshGodmodePlace() {
|
|
const place = godmodePlace();
|
|
if (place) godmode?.setPlace(place);
|
|
}
|
|
|
|
async function mountGodmode() {
|
|
if (!access.can.debug || godmode) return;
|
|
const place = godmodePlace();
|
|
if (!place) return;
|
|
|
|
const [tools, poses] = await Promise.all([
|
|
import("./tools/index.ts"),
|
|
import("./tools/poseEditor.ts"),
|
|
]);
|
|
poseEditorFactory = poses.createPoseEditor;
|
|
|
|
godmode = tools.createGodmode({
|
|
container: document.body,
|
|
initial: { instant: currentInstant(), place },
|
|
onTimeChange(instant) {
|
|
instantOverride = instant;
|
|
updateSun();
|
|
},
|
|
onWeatherOverride(w) {
|
|
weatherOverride = w;
|
|
updateSun();
|
|
// The corner label has to stop claiming live weather the moment the sky
|
|
// on screen is one somebody typed.
|
|
renderSource();
|
|
},
|
|
/**
|
|
* Both dials read through the module-level handles rather than closing over
|
|
* a board, because the panel outlives the city: it is mounted once and every
|
|
* later `mountCity` swaps `city` and `trafficDial` underneath it. A closure
|
|
* over the board that was current when the panel opened would go on driving
|
|
* a disposed scene after the first city switch.
|
|
*/
|
|
/**
|
|
* Read through the module-level `office`, never closed over — the panel is
|
|
* mounted once and every `switchOffice` replaces the scene underneath it.
|
|
*/
|
|
office: {
|
|
onHouseLights(mode) {
|
|
houseLights = mode;
|
|
// `officeLighting` is the only caller of `setSolarElevation` and
|
|
// `updateSun` is the only caller of that, so this is the whole apply.
|
|
updateSun();
|
|
},
|
|
onRobotsVisible(visible) {
|
|
office?.setRobotsVisible(visible);
|
|
},
|
|
onCeilingsVisible(visible) {
|
|
office?.setCeilingsVisible(visible);
|
|
},
|
|
read() {
|
|
// Only while a room is actually on the stage. Out in the city the office
|
|
// is paused and kept, and a dimmer aimed at a scene nobody is rendering
|
|
// is exactly the dead panel this section is arranged to avoid.
|
|
if (!inside || !office) return null;
|
|
return {
|
|
id: officeId,
|
|
depth: office.depth,
|
|
houseLevel: office.houseLevel(),
|
|
robots: office.robots().length,
|
|
};
|
|
},
|
|
},
|
|
sky: {
|
|
onExtraTraffic(count) {
|
|
trafficDial?.setExtra(count);
|
|
},
|
|
onSatellitesVisible(visible) {
|
|
satellitesVisible = visible;
|
|
city?.setSatellitesVisible(visible);
|
|
},
|
|
read() {
|
|
const counts = city?.satelliteCounts();
|
|
return {
|
|
extraTraffic: trafficDial?.extra() ?? 0,
|
|
trafficIsLive: cityFlights?.live() ?? false,
|
|
// `total: 0` is a board with no catalogue, which is not the same as a
|
|
// catalogue with nothing above the horizon — the panel says so.
|
|
satellites: counts && counts.total > 0 ? counts : null,
|
|
};
|
|
},
|
|
},
|
|
});
|
|
|
|
// The city was built before this chunk arrived, so its pose editor is built
|
|
// here; every later board gets one from `mountCity`.
|
|
if (city) mountPoseEditor(city);
|
|
addGodmodeShortcut();
|
|
}
|
|
|
|
/**
|
|
* The pose editor, rebuilt with the board.
|
|
*
|
|
* It holds a `World`, a camera and a controls, all three of which die with the
|
|
* city — the same reason the plan view is rebuilt rather than re-pointed.
|
|
*/
|
|
function mountPoseEditor(handle: SceneHandle) {
|
|
if (!access.can.debug || !poseEditorFactory) return;
|
|
const dock = instrumentDock();
|
|
poseEditor = poseEditorFactory({
|
|
container: dock,
|
|
allowed: access.can.debug,
|
|
world: handle.world,
|
|
camera: handle.stageScene.camera,
|
|
controls: handle.stageScene.controls,
|
|
/**
|
|
* A cut rather than a flight, for now.
|
|
*
|
|
* The tool is forbidden from writing the camera itself and asks for
|
|
* `SceneKit.flyTo`, which `SceneHandle` does not expose — it offers
|
|
* `flyTo(chapterId)` and nothing that takes a pose. Moving both ends of the
|
|
* orbit at once is exactly what the plan view's `onSeek` does a few hundred
|
|
* lines up and it lands on the right pose; what it does not do is ease, so
|
|
* re-flying a captured chapter jumps. `scene.ts` is another agent's file
|
|
* this week; the two-line addition that upgrades this is in the handoff.
|
|
*/
|
|
flyTo: (pose: Pose) => {
|
|
handle.stageScene.camera.position.copy(pose.position);
|
|
handle.stageScene.controls.target.copy(pose.target);
|
|
handle.stageScene.controls.update();
|
|
},
|
|
existingChapters: handle.chapters,
|
|
});
|
|
poseEditor.setVisible(poseDockOpen);
|
|
}
|
|
|
|
/**
|
|
* The dock, built once and reused across boards.
|
|
*
|
|
* Bottom right, above the key-hint rail and below the plan view, which is the
|
|
* one column of this layout with room in it: the left panel owns the left edge
|
|
* top to bottom, the godmode drawer opens bottom *centre*, and the plan view
|
|
* stops around 20rem down. The height is capped against both neighbours rather
|
|
* than at a flat `dvh`, so a short window shrinks the dock instead of sliding
|
|
* it under the plan view.
|
|
*/
|
|
function instrumentDock(): HTMLElement {
|
|
if (poseDockBody) return poseDockBody;
|
|
|
|
const dock = document.createElement("aside");
|
|
dock.id = "pose-dock";
|
|
dock.setAttribute("aria-label", "Pose editor");
|
|
Object.assign(dock.style, {
|
|
position: "fixed",
|
|
right: "var(--s4, 16px)",
|
|
bottom: "calc(var(--s4, 16px) + 5rem)",
|
|
zIndex: "5",
|
|
width: "min(20rem, calc(100vw - var(--s4, 16px) * 2))",
|
|
maxHeight: "min(52dvh, calc(100dvh - 26rem))",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "stretch",
|
|
gap: "var(--s1, 4px)",
|
|
});
|
|
|
|
const toggle = document.createElement("button");
|
|
toggle.type = "button";
|
|
toggle.className = "help";
|
|
toggle.setAttribute("aria-expanded", "true");
|
|
Object.assign(toggle.style, { alignSelf: "flex-end", fontFamily: "inherit" });
|
|
|
|
const body = document.createElement("div");
|
|
Object.assign(body.style, { flex: "1", minHeight: "0", display: "flex" });
|
|
|
|
function applyDock() {
|
|
toggle.textContent = poseDockOpen ? "poses ▾" : "poses ▴";
|
|
toggle.setAttribute("aria-expanded", String(poseDockOpen));
|
|
body.hidden = !poseDockOpen;
|
|
// Collapsed is genuinely idle: `setVisible(false)` is what stops the tool
|
|
// reading the camera on every frame of a map nobody is authoring against.
|
|
poseEditor?.setVisible(poseDockOpen);
|
|
}
|
|
|
|
toggle.addEventListener("click", () => {
|
|
poseDockOpen = !poseDockOpen;
|
|
applyDock();
|
|
});
|
|
|
|
dock.append(toggle, body);
|
|
document.body.append(dock);
|
|
poseDockBody = body;
|
|
applyDock();
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* `G` in the `?` card, added from here rather than typed into `index.html`.
|
|
*
|
|
* The card is the one place a visitor goes to find out what this page can do,
|
|
* so a key that exists must be in it — and a key that does not exist must not.
|
|
* Hard-coding the row would advertise an instrument nine visitors in ten have
|
|
* no way to open.
|
|
*/
|
|
function addGodmodeShortcut() {
|
|
const keys = document.querySelector<HTMLElement>("#shortcuts .keys");
|
|
const before = document.querySelector<HTMLElement>("#key-overlays");
|
|
if (!keys || document.querySelector("#key-godmode")) return;
|
|
const dt = document.createElement("dt");
|
|
dt.id = "key-godmode";
|
|
dt.innerHTML = "<kbd>G</kbd>";
|
|
const dd = document.createElement("dd");
|
|
dd.textContent = "Godmode: the clock, the weather and the counters";
|
|
keys.insertBefore(dt, before);
|
|
keys.insertBefore(dd, before);
|
|
}
|
|
|
|
// ---- The boot card ----------------------------------------------------------
|
|
|
|
const bootCard = document.querySelector<HTMLElement>("#boot");
|
|
const bootStep = document.querySelector<HTMLElement>("#boot-step");
|
|
|
|
/**
|
|
* Wait until the browser has actually put pixels on the glass.
|
|
*
|
|
* Writing to `textContent` and then immediately building a heightfield paints
|
|
* nothing: the style change and the two seconds of synchronous work are in the
|
|
* same task, so the frame the user sees is the one *after* the work. Two
|
|
* `requestAnimationFrame`s straddle a paint, which is the whole trick — a
|
|
* single one still runs before it.
|
|
*/
|
|
function painted(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wait until the renderer has actually drawn the thing that was being built.
|
|
*
|
|
* `painted()` is not enough any more and the difference is visible. The
|
|
* heightfield now builds in a Worker, so `createScene` resolves at a moment
|
|
* when the main thread is *idle* and two animation frames go by in 33 ms —
|
|
* long before the terrain mesh, the 140k building instances and the first
|
|
* WebGL draw have happened. The card faded on an empty canvas.
|
|
*
|
|
* `renderer.info.render.frame` is the renderer counting its own draws, which is
|
|
* the only witness that cannot be fooled by a fast frame: it advances exactly
|
|
* once per `render()`, so an increment means a frame of the new scene has been
|
|
* submitted, and the extra `painted()` after it puts that frame on the glass
|
|
* before the fade starts.
|
|
*
|
|
* The timeout is not a fallback to a timer, it is a bound on a promise that
|
|
* would otherwise never settle — an abandoned build never draws anything, and
|
|
* the boot card must not be the thing that outlives it.
|
|
*/
|
|
function drawn(timeoutMs = 4000): Promise<void> {
|
|
const from = stage.renderer.info.render.frame;
|
|
const deadline = performance.now() + timeoutMs;
|
|
return new Promise((resolve) => {
|
|
requestAnimationFrame(function wait() {
|
|
if (stage.renderer.info.render.frame > from || performance.now() > deadline) {
|
|
void painted().then(resolve);
|
|
return;
|
|
}
|
|
requestAnimationFrame(wait);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Which `building()` call the boot card belongs to.
|
|
*
|
|
* A generation counter rather than a boolean because two builds can overlap:
|
|
* clicking SoCal while the Bay Area is still building leaves the first
|
|
* `building()` running, and it must not fade a card the second one is using.
|
|
* Last one in owns it.
|
|
*/
|
|
let bootGeneration = 0;
|
|
|
|
/**
|
|
* Run something slow with the boot card up and a sentence saying what it is.
|
|
*
|
|
* Naming the work is most of the value: a blank page for two seconds reads as
|
|
* broken, and "Building the Bay Area…" for two seconds reads as busy. The
|
|
* percentage on top of it comes from `bootProgress`, which the heightfield
|
|
* drives directly from the Worker.
|
|
*
|
|
* `work` may be synchronous or not. It became "or not" when `createScene` did,
|
|
* and the flattening matters: without the `await` here the card faded about
|
|
* 800 ms in, while the city was still building, because a promise is a truthy
|
|
* value that returns instantly.
|
|
*/
|
|
async function building<T>(label: string, work: () => T | Promise<T>): Promise<T> {
|
|
const generation = ++bootGeneration;
|
|
if (bootStep) bootStep.textContent = label;
|
|
if (bootCard) {
|
|
bootCard.hidden = false;
|
|
bootCard.classList.remove("done");
|
|
}
|
|
await painted();
|
|
const result = await work();
|
|
await drawn();
|
|
// Superseded while we were building: the card belongs to a later call now and
|
|
// fading it would uncover a city that does not exist yet.
|
|
if (generation !== bootGeneration) return result;
|
|
bootCard?.classList.add("done");
|
|
window.setTimeout(() => {
|
|
if (bootCard?.classList.contains("done")) bootCard.hidden = true;
|
|
}, 300);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* The heightfield's progress, on the card that is already on screen.
|
|
*
|
|
* Nothing is written while the build is on the main thread, and that is not an
|
|
* oversight: `onMainThread` means the page is frozen for the duration, so every
|
|
* one of these writes would land in the same task and exactly one of them —
|
|
* the last — would ever be seen. The fallback build is the case where the
|
|
* static label is all the honesty available.
|
|
*
|
|
* The caller is responsible for not reporting an abandoned build; see the
|
|
* `onProgress` passed by `mountCity`.
|
|
*/
|
|
function bootProgress(label: string, fraction: number, onMainThread: boolean) {
|
|
if (!bootStep || onMainThread || bootCard?.hidden !== false) return;
|
|
bootStep.textContent = `Building ${label}… terrain ${Math.round(fraction * 100)}%`;
|
|
}
|
|
|
|
// ---- Boot -----------------------------------------------------------------
|
|
|
|
/**
|
|
* Access first, then data, then the board.
|
|
*
|
|
* The order is load-bearing in both directions and it used to be wrong. Markers
|
|
* were fetched before the tier was known, which is a request an anonymous
|
|
* visitor should not be making; and both decisions have to be settled before
|
|
* the *first* `mountCity`, because `markerPalette` is fixed at scene
|
|
* construction — the sample palette's keys are not the API's — and the flight
|
|
* source is chosen in the same call.
|
|
*
|
|
* Everything about the API remains optional. No server means the bundled sample
|
|
* set, the simulated traffic, and a label at the bottom of the screen saying
|
|
* which of the two you are looking at.
|
|
*/
|
|
async function boot() {
|
|
applyPanel();
|
|
applyPlan();
|
|
|
|
if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
|
|
access = await resolveAccess();
|
|
if (access.subject !== null) {
|
|
const loadedProfile = loadLocalProfile(sessionStorage, LOCAL_PROFILE_KEY);
|
|
localProfile = loadedProfile.status === "loaded"
|
|
? loadedProfile.profile
|
|
: createDefaultLocalProfile(access.subject, access.subject);
|
|
if (loadedProfile.status !== "loaded" || loadedProfile.migrated) {
|
|
saveLocalProfile(sessionStorage, LOCAL_PROFILE_KEY, localProfile);
|
|
}
|
|
} else {
|
|
localProfile = null;
|
|
}
|
|
dispatchJourney({
|
|
type: "sign-in-actor-swap",
|
|
actor: access.subject === null
|
|
? {
|
|
id: "anonymous",
|
|
kind: "crow",
|
|
signedIn: false,
|
|
profile: { displayName: "Guest" },
|
|
}
|
|
: {
|
|
id: access.subject,
|
|
kind: "humanoid",
|
|
signedIn: true,
|
|
profile: {
|
|
displayName: localProfile?.displayName ?? access.subject,
|
|
color: humanoidAppearance()?.accentColor,
|
|
},
|
|
},
|
|
});
|
|
applyTimeControl();
|
|
renderTierBadge();
|
|
|
|
// Both gates, for the reason `mountCity` gives at length: the tier says
|
|
// whether this visitor may ask, `feeds` says whether there is anything to
|
|
// ask. A box with `markers: "none"` serves the public empty body to everyone,
|
|
// so the request buys a round trip and lands on the same sample set.
|
|
if (access.can.liveMarkers && access.feeds?.markers) {
|
|
try {
|
|
const feed = await tera.markers();
|
|
markers = feed.value;
|
|
// The caller's palette, plus the door colour it cannot know about.
|
|
palette = { ...feed.palette, ...OFFICE_PALETTE };
|
|
liveData = feed.live;
|
|
} catch {
|
|
// A missing API is the self-host default, not an error.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Which metro to build, from the URL if it names one this build has.
|
|
*
|
|
* The parameter exists to make leaving the office lossless — `office.` sends
|
|
* you to `tera.?city=socal` rather than dropping you in the Bay Area — but it
|
|
* is read unconditionally, because a deep link to a city is a reasonable thing
|
|
* to want on its own and a parameter that only works when it arrives from one
|
|
* particular page is a trap. An unknown id falls back rather than failing:
|
|
* `?city=paris` on a build with two cities in it should show a city, not a
|
|
* black screen.
|
|
*/
|
|
const wanted = new URLSearchParams(location.search).get("city");
|
|
const first = CITIES.find((c) => c.id === wanted) ?? CITIES[0];
|
|
await building(`Building ${first?.label ?? "the city"}…`, () =>
|
|
mountCity(first?.id ?? "california"),
|
|
);
|
|
|
|
// There must be a mounted scene before remote snapshots have anywhere to go.
|
|
// Anonymous and zero-server builds retain their existing local-only path.
|
|
// Hosted presence is an enhancement: a failed lazy chunk or unavailable API
|
|
// must not stop profile/tools initialization after the world is already live.
|
|
await initializeRealtimePresence().catch(() => undefined);
|
|
|
|
// The instruments, after the first board, because the panel reads a live
|
|
// stage and there is not one before this line.
|
|
await mountGodmode();
|
|
|
|
// The `office.` front door. The city is already standing behind this, so the
|
|
// back button is a scene swap and not a rebuild.
|
|
if (OPENS_IN_OFFICE) await building("Fetching the office…", () => enterOffice());
|
|
|
|
/**
|
|
* The wall clock, once a minute.
|
|
*
|
|
* Skipped entirely while an override is up — the whole point of an override
|
|
* is that the map has stopped following the clock — and the panel is told the
|
|
* new instant only when it is not the one choosing it, so its own readouts
|
|
* stay pinned to what is being rendered rather than fighting it.
|
|
*/
|
|
window.setInterval(() => {
|
|
if (instantOverride !== null) return;
|
|
updateSun();
|
|
godmode?.setInstant(new Date());
|
|
}, 60_000);
|
|
}
|
|
|
|
void boot();
|