California gets roads, traffic, and a car to follow
This commit is contained in:
+176
-35
@@ -32,8 +32,16 @@ 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,
|
||||
@@ -78,6 +86,7 @@ import type { Godmode, GodmodeHouseLights, GodmodePlace } from "./tools/index.ts
|
||||
import type { PoseEditor } from "./tools/poseEditor.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 },
|
||||
];
|
||||
@@ -131,7 +140,7 @@ const stage = createStage(canvas);
|
||||
const tera = createTeraClient({ fetch: authFetch });
|
||||
|
||||
let city: SceneHandle | null = null;
|
||||
let cityId = "sf";
|
||||
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.
|
||||
@@ -145,13 +154,13 @@ let cityId = "sf";
|
||||
* to be against the intention, and the intention is recorded synchronously in
|
||||
* the click handler.
|
||||
*/
|
||||
let wantedCity = "sf";
|
||||
let wantedCity = "california";
|
||||
let office: OfficeScene | null = null;
|
||||
let inside = false;
|
||||
let markers: Marker[] = SAMPLE_MARKERS;
|
||||
|
||||
/**
|
||||
* The buildings you can walk into, as pins on the city.
|
||||
* 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
|
||||
@@ -163,9 +172,10 @@ let markers: Marker[] = SAMPLE_MARKERS;
|
||||
* 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 — `SAMPLE_PALETTE`
|
||||
* `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.
|
||||
* 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}`,
|
||||
@@ -177,6 +187,7 @@ const OFFICE_MARKERS: Marker[] = OFFICE_SITES.map((entry) => ({
|
||||
// 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 } : {}),
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -677,8 +688,14 @@ async function mountCity(id: string) {
|
||||
// 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 =
|
||||
access.can.liveEnvironment && access.feeds?.flights ? tera.flights(region, routes) : null;
|
||||
id !== "california" && access.can.liveEnvironment && access.feeds?.flights
|
||||
? tera.flights(region, routes)
|
||||
: null;
|
||||
cityFlights = traffic;
|
||||
|
||||
/**
|
||||
@@ -717,9 +734,42 @@ async function mountCity(id: string) {
|
||||
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,
|
||||
markerPalette: palette,
|
||||
markers: initialMarkers,
|
||||
...(id === "california"
|
||||
? {
|
||||
roadTraffic: {
|
||||
pack: CALIFORNIA_TRANSPORT,
|
||||
routeId: "la-sf-us-101",
|
||||
count: 14,
|
||||
seed: 115,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
flights: dial.source,
|
||||
...(catalogue ? { satellites: catalogue } : {}),
|
||||
/**
|
||||
@@ -818,28 +868,6 @@ async function mountCity(id: string) {
|
||||
// not a decoration. LA gets its own weather, not San Francisco's fog.
|
||||
marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null,
|
||||
});
|
||||
/**
|
||||
* A door belongs to the board it stands on.
|
||||
*
|
||||
* The gate here used to be `id === "sf"`, which was correct for exactly as
|
||||
* long as every office was in the Bay Area. It stopped being correct the
|
||||
* moment one was not: a hard-coded city id would have kept the Los Angeles
|
||||
* building off the Los Angeles board and pinned it to San Francisco's.
|
||||
*
|
||||
* So the test is the board's own bounds, which is the same question asked
|
||||
* honestly — a pin for a building outside the rectangle being drawn is a pin
|
||||
* in the wrong place, whichever city that happens to be. The sample company
|
||||
* markers stay SF-only; they are sample data about one city and always were.
|
||||
*/
|
||||
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,
|
||||
);
|
||||
city.setMarkers(id === "sf" ? [...markers, ...doors] : doors);
|
||||
city.onChapterChange(() => renderLegend());
|
||||
|
||||
/**
|
||||
@@ -889,7 +917,7 @@ async function mountCity(id: string) {
|
||||
},
|
||||
});
|
||||
showPlan();
|
||||
minimap.setMarkers(id === "sf" ? [...markers, ...doors] : doors);
|
||||
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
|
||||
@@ -1304,6 +1332,7 @@ 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");
|
||||
|
||||
function showDetail(text: string | null) {
|
||||
const card = document.querySelector<HTMLElement>("#detail");
|
||||
@@ -1316,7 +1345,7 @@ function showDetail(text: string | null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The two-button strip above the legend: cities outside, buildings inside.
|
||||
* 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
|
||||
@@ -1332,8 +1361,8 @@ function renderCityPicker() {
|
||||
|
||||
for (const entry of entries) {
|
||||
const b = document.createElement("button");
|
||||
// `aria-pressed` rather than a class, because that is what these are: two
|
||||
// buttons of which exactly one is on. The stylesheet keys off the attribute
|
||||
// `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 = "city";
|
||||
b.type = "button";
|
||||
@@ -1449,6 +1478,7 @@ function renderLegend() {
|
||||
minimap?.setChapters(city.chapters, city.current());
|
||||
officePlan?.setActiveView(office?.current() ?? null);
|
||||
renderOfficeBadge();
|
||||
if (driveControls) driveControls.hidden = !routeDriveIsActive();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1814,8 +1844,6 @@ panelToggle?.addEventListener("click", () => {
|
||||
applyPanel();
|
||||
});
|
||||
|
||||
planToggle?.addEventListener("click", () => togglePlan());
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -1855,6 +1883,98 @@ shortcutsCard?.addEventListener("click", (event) => {
|
||||
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?.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 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);
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||
if (!heldDriveKeys.delete(key)) return;
|
||||
if (publishVehicleActions()) event.preventDefault();
|
||||
});
|
||||
|
||||
window.addEventListener("blur", () => {
|
||||
heldDriveKeys.clear();
|
||||
publishVehicleActions();
|
||||
});
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -1902,6 +2022,25 @@ window.addEventListener("keydown", (event) => {
|
||||
return;
|
||||
}
|
||||
const lower = event.key.toLowerCase();
|
||||
if (lower === "w" || lower === "a" || lower === "s" || lower === "d" || event.key === " ") {
|
||||
heldDriveKeys.add(event.key === " " ? " " : lower);
|
||||
if (publishVehicleActions()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (lower === "p" && publishVehicleActions({ modeRequest: "assisted" })) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (lower === "r" && publishVehicleActions({ reset: true })) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (lower === "c" && toggleVehicleCamera()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (lower === "m") {
|
||||
togglePlan();
|
||||
return;
|
||||
@@ -2369,7 +2508,9 @@ async function boot() {
|
||||
*/
|
||||
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 ?? "sf"));
|
||||
await building(`Building ${first?.label ?? "the city"}…`, () =>
|
||||
mountCity(first?.id ?? "california"),
|
||||
);
|
||||
|
||||
// The instruments, after the first board, because the panel reads a live
|
||||
// stage and there is not one before this line.
|
||||
|
||||
Reference in New Issue
Block a user