1
0

feat: unify life-sim play controls

This commit is contained in:
2026-08-19 03:04:53 -07:00
parent 120eac878a
commit 264daaab61
14 changed files with 1697 additions and 234 deletions
+560 -178
View File
@@ -37,11 +37,17 @@ 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";
aircraftActionsFromPlay,
cameraRelativePlanar,
crowActionsFromPlay,
groundActorActionsFromPlay,
PlayInputRouter,
sampleStandardPlayGamepad,
vehicleActionsFromPlay,
type PlayDigitalControl,
type StandardPlayGamepadButtons,
} from "./input/play.ts";
import { PointerStick } from "./input/pointerStick.ts";
import {
createTeraClient,
describeLiveness,
@@ -91,8 +97,13 @@ import { SRGBColorSpace, VideoTexture } from "three";
import {
CALIFORNIA_AIR_ROUTE,
createAircraftPoseSnapshot,
type AircraftActionSnapshot,
} from "./aircraft/index.ts";
import {
cityControlMode,
createControlModeState,
transitionControlMode,
type ControlMode,
} from "./play/controlMode.ts";
import {
createOfficeScreenPanel,
type MediaSurfaceDescriptor,
@@ -285,6 +296,8 @@ let office: OfficeScene | null = null;
/** The pack used by `office`; kept separate from the currently selected door. */
let builtOfficeId: string | null = null;
let inside = false;
let controlModeState = createControlModeState();
const playInput = new PlayInputRouter();
let markers: Marker[] = SAMPLE_MARKERS;
let realtimeClient: RealtimeClient | null = null;
let presenceIndicator: PresenceIndicator | null = null;
@@ -804,6 +817,8 @@ async function mountCity(id: string) {
weatherWatch = null;
poseEditor?.destroy();
poseEditor = null;
clearPublishedPlayInput();
controlModeState = createControlModeState();
disposeLoadedOffice();
inside = false;
minimap?.dispose();
@@ -1062,6 +1077,7 @@ async function mountCity(id: string) {
marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null,
});
city.onChapterChange(() => renderLegend());
city.onControlModeChange((mode) => adoptCityControlMode(mode));
/**
* The plan view, built last, because it reads the finished `World` — the
@@ -1081,7 +1097,7 @@ async function mountCity(id: string) {
// definition of "phone", in `stage.ts`, read by both.
maxPixelRatio: deviceProfile().maxPixelRatio,
onSeek(lat, lng) {
if (!city) return;
if (!city || city.controlMode() !== "overview") return;
/**
* Slide the orbit target and carry the camera with it, keeping the offset
* between them. A seek is "look over there", not "go to chapter three":
@@ -1135,6 +1151,8 @@ async function mountCity(id: string) {
requestAnimationFrame(function pumpMinimap() {
requestAnimationFrame(pumpMinimap);
syncJourneyVehicle(performance.now());
updateLocalPlayerMaps();
renderPlayHud();
// 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
@@ -1149,6 +1167,61 @@ requestAnimationFrame(function pumpMinimap() {
pollLiveness();
});
function updateLocalPlayerMaps(): void {
if (!city) return;
if (inside) {
minimap?.setPlayer(null);
const walker = controlModeState.mode === "office-walk" ? office?.walker?.state() : null;
officePlan?.setPlayer(walker
? {
levelId: walker.levelId,
x: walker.position.x,
z: walker.position.z,
headingRad: Math.atan2(-walker.facing.x, -walker.facing.z),
kind: walker.actor,
}
: null);
return;
}
officePlan?.setPlayer(null);
if (city.controlMode() === "drive") {
const state = city.vehicleState();
minimap?.setPlayer(state
? { lat: state.lat, lng: state.lng, headingDeg: state.headingDeg, kind: "vehicle" }
: null);
return;
}
if (city.controlMode() === "aircraft") {
const state = city.aircraftState();
minimap?.setPlayer(state
? { lat: state.lat, lng: state.lng, headingDeg: state.headingDeg, kind: "aircraft" }
: null);
return;
}
if (city.controlMode() === "actor") {
const state = city.actorState();
const pack = CITIES.find((candidate) => candidate.id === cityId)?.city;
if (!state || !pack) {
minimap?.setPlayer(null);
return;
}
const anchor = cityId === "california" ? { lat: 35.5, lng: -119.5 } : pack.center;
const [originX, originZ] = city.world.project(anchor.lat, anchor.lng);
const [lat, lng] = city.world.unproject(
originX + state.x / city.world.metresPerUnit,
originZ + state.z / city.world.metresPerUnit,
);
minimap?.setPlayer({
lat,
lng,
headingDeg: ((-state.yaw * 180 / Math.PI) % 360 + 360) % 360,
kind: "actor",
});
return;
}
minimap?.setPlayer(null);
}
/**
* Whether the corner label is still telling the truth, once a second.
*
@@ -1302,8 +1375,13 @@ async function enterOffice() {
office.onViewChange(() => renderLegend());
officePlan = buildOfficePlan(createOfficeMinimap, office);
}
requestControlMode("overview");
city.stage.setScene(office);
inside = true;
controlModeState = {
mode: "office-overview",
revision: controlModeState.revision + 1,
};
attachCurrentWebcamFace();
moveRealtimePresence();
const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area";
@@ -1344,6 +1422,7 @@ function buildOfficePlan(
// exact drift `deviceProfile` exists in one file to prevent.
maxPixelRatio: deviceProfile().maxPixelRatio,
onSeek(x, z) {
if (controlModeState.mode !== "office-overview") return;
/**
* The same move the city plan makes, and for the same reason: slide the
* orbit target and carry the camera with it, keeping the offset between
@@ -1426,11 +1505,16 @@ function leaveOffice() {
// rather than publish into a room the user has already left.
stopWatchingOccupancy();
disposeOfficeScreenUi();
clearPublishedPlayInput();
office?.walker?.setActive(false);
office?.walker?.setAction({ x: 0, z: 0 });
dispatchJourney({ type: "leave-office" });
city.stage.setScene(city.stageScene);
inside = false;
controlModeState = {
mode: "overview",
revision: controlModeState.revision + 1,
};
city.setControlMode("overview");
attachCurrentWebcamFace();
moveRealtimePresence();
showPlan();
@@ -1614,6 +1698,21 @@ 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 touchPlayControls = document.querySelector<HTMLElement>("#touch-play-controls");
const playStick = document.querySelector<HTMLElement>("#play-stick");
const touchPrimary = document.querySelector<HTMLButtonElement>("#touch-primary");
const touchSecondary = document.querySelector<HTMLButtonElement>("#touch-secondary");
const touchPitchUp = document.querySelector<HTMLButtonElement>("#touch-pitch-up");
const touchPitchDown = document.querySelector<HTMLButtonElement>("#touch-pitch-down");
const touchAssist = document.querySelector<HTMLButtonElement>("#touch-assist");
const touchReset = document.querySelector<HTMLButtonElement>("#touch-reset");
const touchCamera = document.querySelector<HTMLButtonElement>("#touch-camera");
const touchMap = document.querySelector<HTMLButtonElement>("#touch-map");
const modeDock = document.querySelector<HTMLElement>("#mode-dock");
const playHud = document.querySelector<HTMLElement>("#play-hud");
const playHudMode = document.querySelector<HTMLElement>("#play-hud-mode");
const playHudPrimary = document.querySelector<HTMLElement>("#play-hud-primary");
const playHudStatus = document.querySelector<HTMLElement>("#play-hud-status");
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
const webcamFaceIndicator = document.querySelector<HTMLElement>("#webcam-face-indicator");
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
@@ -1623,6 +1722,100 @@ let webcamFaceTexture: WebcamFaceTextureAdapter | null = null;
let webcamFaceConsent = createWebcamFaceConsent();
let webcamCapture: WebcamCaptureController | null = null;
function availableControlModes() {
const route = city?.current();
return {
insideOffice: inside,
drive: !inside && city?.vehicleState() !== null &&
(route === "la-sf-us-101" || route === "la-sf-i-5"),
actor: !inside && city?.actorState() !== null,
aircraft: !inside && city?.aircraftState() !== null,
officeWalk: inside && office?.walker !== null && office?.walker !== undefined,
};
}
function clearPublishedPlayInput(): void {
playInput.clearAll();
for (const button of document.querySelectorAll<HTMLElement>(
"[data-drive-key][aria-pressed], [data-walk-key][aria-pressed], [data-play-control][aria-pressed]",
)) button.setAttribute("aria-pressed", "false");
resetPlayStick();
city?.setVehicleActions({});
city?.setActorActions({});
city?.setAircraftActions({});
office?.walker?.setAction({ x: 0, z: 0 });
}
function adoptCityControlMode(mode: ReturnType<typeof cityControlMode>): void {
if (inside) return;
const previous = controlModeState.mode;
if (controlModeState.mode !== mode) {
clearPublishedPlayInput();
controlModeState = { mode, revision: controlModeState.revision + 1 };
}
if (
previous === "overview" && mode !== "overview" &&
(document.body.classList.contains("touch-capable") ||
window.matchMedia?.("(pointer: coarse)").matches === true)
) {
planOpen = false;
planChosen = true;
applyPlan();
}
renderLegend();
}
/** One transaction updates Journey, simulation ownership, input and chrome. */
function requestControlMode(requested: ControlMode): boolean {
const transition = transitionControlMode(controlModeState, requested, availableControlModes());
if (transition.changed) clearPublishedPlayInput();
controlModeState = transition.state;
const next = transition.state.mode;
if (inside) {
city?.setControlMode("overview");
const walking = next === "office-walk";
office?.walker?.setActive(walking);
if (!walking) office?.walker?.setAction({ x: 0, z: 0 });
} else {
office?.walker?.setActive(false);
city?.setControlMode(cityControlMode(next));
}
if (next === "drive") {
const routeId = city?.current();
if (routeId === "la-sf-us-101" || routeId === "la-sf-i-5") {
dispatchJourney({ type: "set-mode", mode: "play" });
if (journey.route?.routeId !== routeId) {
dispatchJourney({ type: "select-route", routeId, direction: 1 });
}
if (!journey.vehicle) dispatchJourney({ type: "enter-vehicle", vehicleId: "model-x-black" });
}
} else if (journey.vehicle) {
dispatchJourney({ type: "exit-vehicle" });
}
if (next !== "overview" && next !== "office-overview") {
dispatchJourney({ type: "set-mode", mode: "play" });
} else if (!journey.vehicle) {
dispatchJourney({ type: "set-mode", mode: "observe" });
}
if (
transition.changed &&
(transition.previous === "overview" || transition.previous === "office-overview") &&
next !== "overview" && next !== "office-overview" &&
(document.body.classList.contains("touch-capable") ||
window.matchMedia?.("(pointer: coarse)").matches === true)
) {
planOpen = false;
planChosen = true;
applyPlan();
}
renderLegend();
publishPlayActions();
return transition.accepted;
}
function showDetail(text: string | null) {
const card = document.querySelector<HTMLElement>("#detail");
const body = document.querySelector<HTMLElement>("#detail-text");
@@ -1762,6 +1955,25 @@ function renderLegend() {
const walking = inside && (office?.walker?.active() ?? false);
const exploring = !inside && (city.actorActive() ?? false);
const flying = !inside && (city.aircraftActive() ?? false);
const actualMode: ControlMode = inside
? walking ? "office-walk" : "office-overview"
: city.controlMode();
if (controlModeState.mode !== actualMode) {
controlModeState = { mode: actualMode, revision: controlModeState.revision + 1 };
}
const availability = availableControlModes();
for (const button of modeDock?.querySelectorAll<HTMLButtonElement>("[data-control-mode]") ?? []) {
const mode = button.dataset.controlMode as ControlMode;
button.hidden = mode === "drive" ? !availability.drive
: mode === "actor" ? !availability.actor
: mode === "aircraft" ? !availability.aircraft
: mode === "office-walk" ? !availability.officeWalk
: false;
const pressed = mode === "overview"
? actualMode === "overview" || actualMode === "office-overview"
: mode === actualMode;
button.setAttribute("aria-pressed", String(pressed));
}
if (walkButton) {
walkButton.hidden = inside ? office?.walker === null : city.actorState() === null;
walkButton.setAttribute("aria-pressed", String(walking || exploring));
@@ -1791,6 +2003,8 @@ function renderLegend() {
? walking
? `${officeName()}, following your ${office?.walker?.state().actor === "anonymous-dog" ? "dog" : "humanoid"}. Use W A S D to move.`
: `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
: routeDriveIsActive()
? `${cityLabel}, following your car on ${city.vehicleState()?.roadName ?? "the selected route"}. Use W A S D to drive.`
: flying
? `${cityLabel}, following your electric aircraft. Use W A S D to fly or P to resume assisted flight.`
: exploring
@@ -1816,6 +2030,32 @@ function renderLegend() {
: "Walking controls",
);
}
const touchModeActive = routeDriveIsActive() || walking || exploring || flying;
if (touchPlayControls) {
touchPlayControls.hidden = !touchModeActive;
touchPlayControls.setAttribute("aria-label", `${actualMode.replace("office-", "")} touch controls`);
}
const crowPlaying = exploring && city.actorState()?.kind === "crow" &&
city.actorState()?.mode === "flight";
if (touchPrimary) {
touchPrimary.hidden = walking;
touchPrimary.dataset.playControl = crowPlaying ? "ascend" : "primary";
touchPrimary.textContent = routeDriveIsActive() ? "Handbrake"
: flying ? "Throttle"
: crowPlaying ? "Climb"
: "Sprint";
}
if (touchSecondary) {
touchSecondary.hidden = !crowPlaying;
touchSecondary.dataset.playControl = "secondary";
touchSecondary.textContent = "Glide";
}
if (touchPitchUp) touchPitchUp.hidden = !crowPlaying;
if (touchPitchDown) touchPitchDown.hidden = !crowPlaying;
if (touchAssist) touchAssist.hidden = !(routeDriveIsActive() || flying);
if (touchReset) touchReset.hidden = !(routeDriveIsActive() || flying);
if (touchCamera) touchCamera.hidden = !routeDriveIsActive();
if (touchMap) touchMap.setAttribute("aria-pressed", String(planOpen));
for (const control of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
control.textContent = flying
? control.dataset.aircraftLabel ?? control.textContent
@@ -1837,7 +2077,46 @@ function renderLegend() {
? "WASD fly · P assisted · R reset"
: inside
? "V walk · WASD move"
: "V explore · WASD · Q/E altitude · G glide";
: "V explore · WASD · Q/E altitude · I/K pitch · G glide";
}
}
function renderPlayHud(): void {
if (!playHud || !playHudMode || !playHudPrimary || !playHudStatus || !city) return;
const mode = controlModeState.mode;
playHud.hidden = mode === "overview" || mode === "office-overview";
playHudStatus.classList.remove("warning");
if (mode === "drive") {
const state = city.vehicleState();
if (!state) return;
playHudMode.textContent = "Drive";
playHudPrimary.textContent = `${Math.round(state.speedMps * 2.23694)} mph · ${state.roadName}`;
playHudStatus.textContent = `${state.mode} · ${Math.round(state.progress * 100)}% · ${city.vehicleCamera() ?? "chase"}`;
playHudStatus.classList.toggle("warning", state.guardrailContact || state.collisionRisk > 0.55);
} else if (mode === "actor") {
const state = city.actorState();
if (!state) return;
playHudMode.textContent = state.kind === "crow" ? "Crow" : "Explore";
playHudPrimary.textContent = state.kind === "crow"
? `${state.speedMps.toFixed(1)} m/s · ${Math.max(0, state.y).toFixed(0)} m alt`
: `${state.speedMps.toFixed(1)} m/s · ${state.distanceM.toFixed(0)} m travelled`;
playHudStatus.textContent = state.kind === "crow"
? `${state.crowPose} · ${Math.round(state.flightEnergy * 100)}% energy`
: `${state.mode} · ${state.identity.displayName}`;
playHudStatus.classList.toggle("warning", state.altitudeBoundContact !== "none");
} else if (mode === "aircraft") {
const state = city.aircraftState();
if (!state) return;
playHudMode.textContent = "Flight";
playHudPrimary.textContent = `${Math.round(state.speedMps * 1.94384)} kt · ${Math.round(state.altitudeM).toLocaleString()} m`;
playHudStatus.textContent = `${state.mode} · ${Math.round(state.batteryWh)} Wh${state.stalled ? " · STALL" : ""}`;
playHudStatus.classList.toggle("warning", state.stalled || state.hardLanding || state.envelopeContact);
} else if (mode === "office-walk") {
const state = office?.walker?.state();
if (!state) return;
playHudMode.textContent = "Office";
playHudPrimary.textContent = `${officeName()} · ${state.distance.toFixed(0)} m walked`;
playHudStatus.textContent = `${state.position.x.toFixed(1)}, ${state.position.z.toFixed(1)} m`;
}
}
@@ -2824,21 +3103,33 @@ function currentViews(): View[] {
function flyToIndex(index: number) {
const view = currentViews()[index];
if (!view) return;
if (inside && office) office.flyTo(view.id);
if (inside && office) {
requestControlMode("office-overview");
office.flyTo(view.id);
renderLegend();
}
else {
const destination = cityId === "california" ? CALIFORNIA_DESTINATIONS.get(view.id) : undefined;
if (destination) {
requestControlMode("overview");
officeId = destination.officeId;
journeyToCity(destination.cityId === "socal" ? "socal" : "bay-area");
switchCity(destination.cityId);
return;
}
if (view.id === "la-sf-us-101" || view.id === "la-sf-i-5") {
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);
requestControlMode("drive");
if (window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
}
renderLegend();
return;
}
requestControlMode("overview");
city?.flyTo(view.id);
renderLegend();
}
}
@@ -2870,6 +3161,7 @@ function leaveToCity(cityWanted?: string): boolean {
}
function switchCity(id: string) {
requestControlMode(inside ? "office-overview" : "overview");
if (inside && leaveToCity(id)) return;
if (inside) leaveOffice();
if (id === wantedCity) return;
@@ -2950,9 +3242,8 @@ enterButton?.addEventListener("click", () => void toggleOffice());
function toggleOfficeWalk(): boolean {
if (!inside) {
if (!city?.actorState()) return false;
const active = !city.actorActive();
city.setActorActive(active);
if (!active) city.setActorActions({});
const active = city.controlMode() !== "actor";
requestControlMode(active ? "actor" : "overview");
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
@@ -2963,8 +3254,7 @@ function toggleOfficeWalk(): boolean {
const walker = office?.walker;
if (!walker) return false;
const active = !walker.active();
walker.setActive(active);
if (!active) walker.setAction({ x: 0, z: 0 });
requestControlMode(active ? "office-walk" : "office-overview");
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
@@ -2977,9 +3267,8 @@ 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({});
const active = city.controlMode() !== "aircraft";
requestControlMode(active ? "aircraft" : "overview");
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
@@ -2990,6 +3279,17 @@ function toggleAircraft(): boolean {
flyButton?.addEventListener("click", () => toggleAircraft());
for (const button of modeDock?.querySelectorAll<HTMLButtonElement>("[data-control-mode]") ?? []) {
button.addEventListener("click", () => {
const mode = button.dataset.controlMode as ControlMode;
requestControlMode(mode);
if (mode !== "overview" && mode !== "office-overview" && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
}
});
}
/**
* Clicking a building on the city walks into it.
*
@@ -3038,6 +3338,7 @@ function applyPanel() {
function applyPlan() {
document.body.classList.toggle("minimap-off", !planOpen);
planToggle?.setAttribute("aria-pressed", String(planOpen));
touchMap?.setAttribute("aria-pressed", String(planOpen));
}
/**
@@ -3109,72 +3410,10 @@ 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?.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;
return state !== null && state !== undefined && city?.controlMode() === "drive" &&
city.current() === state.routeId;
}
function toggleVehicleCamera(): boolean {
@@ -3183,98 +3422,257 @@ function toggleVehicleCamera(): boolean {
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();
function cameraForward(
camera: { position: { x: number; z: number } },
controls: { target: { x: number; z: number } },
) {
return { x: controls.target.x - camera.position.x, z: controls.target.z - camera.position.z };
}
function publishPlayActions(): boolean {
const input = playInput.snapshot();
const requests = playInput.consumeRequests();
if (routeDriveIsActive() && city) {
city.setVehicleActions({
...vehicleActionsFromPlay(input),
modeRequest: requests.has("assist") ? "assisted" : "none",
reset: requests.has("reset"),
});
if (requests.has("camera")) toggleVehicleCamera();
return true;
}
const walker = inside && controlModeState.mode === "office-walk" ? office?.walker : null;
if (walker?.active() && office) {
walker.setAction(cameraRelativePlanar(input, cameraForward(office.camera, office.controls)));
return true;
}
if (!inside && city?.controlMode() === "actor") {
const state = city.actorState();
if (!state) return false;
if (state.kind === "crow" && state.mode === "flight") {
city.setActorActions({
...crowActionsFromPlay(input), modeRequest: "none", kindRequest: "none", reset: false,
});
} else {
city.setActorActions({
...groundActorActionsFromPlay(
input,
state.yaw,
cameraForward(city.stageScene.camera, city.stageScene.controls),
),
pitch: 0, climb: 0, glide: false, modeRequest: "none", kindRequest: "none", reset: false,
});
}
return true;
}
if (!inside && city?.controlMode() === "aircraft") {
city.setAircraftActions({
...aircraftActionsFromPlay(input),
modeRequest: requests.has("assist") ? "assisted" : "none",
reset: requests.has("reset"),
});
return true;
}
return false;
}
function controlForKey(key: string): PlayDigitalControl | null {
switch (key === " " ? key : key.toLowerCase()) {
case "w": return "forward";
case "s": return "backward";
case "a": return "left";
case "d": return "right";
case "q": return "descend";
case "e": return "ascend";
case "i": return "pitch-up";
case "k": return "pitch-down";
case " ": return "primary";
case "g": return "secondary";
default: return null;
}
}
function bindPointerControls(
container: HTMLElement | null,
selector: "[data-drive-key]" | "[data-walk-key]",
attribute: "driveKey" | "walkKey",
): void {
for (const button of container?.querySelectorAll<HTMLButtonElement>(selector) ?? []) {
const key = button.dataset[attribute];
const control = key === undefined ? null : controlForKey(key);
if (!control) continue;
const release = (event: PointerEvent) => {
playInput.clearSource(`pointer:${event.pointerId}`);
button.setAttribute("aria-pressed", "false");
publishPlayActions();
event.preventDefault();
};
button.addEventListener("pointerdown", (event) => {
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
button.setPointerCapture(event.pointerId);
playInput.setDigital(`pointer:${event.pointerId}`, control, true);
button.setAttribute("aria-pressed", "true");
publishPlayActions();
event.preventDefault();
});
button.addEventListener("pointerup", release);
button.addEventListener("pointercancel", release);
button.addEventListener("lostpointercapture", release);
}
}
bindPointerControls(driveControls, "[data-drive-key]", "driveKey");
bindPointerControls(walkControls, "[data-walk-key]", "walkKey");
const pointerStick = new PointerStick();
function paintPlayStick(axes = { moveX: 0, moveY: 0 }): void {
if (!playStick) return;
const travel = Math.max(0, (playStick.getBoundingClientRect().width - 52) / 2);
playStick.style.setProperty("--stick-x", `${axes.moveX * travel}px`);
playStick.style.setProperty("--stick-y", `${-axes.moveY * travel}px`);
}
function resetPlayStick(): void {
const pointerId = pointerStick.activePointer();
if (pointerId !== null) {
pointerStick.end(pointerId);
playInput.clearSource(`stick:${pointerId}`);
}
playStick?.classList.remove("active");
paintPlayStick();
}
if (playStick) {
playStick.addEventListener("pointerdown", (event) => {
const axes = pointerStick.begin(event.pointerId, event.clientX, event.clientY, playStick.getBoundingClientRect());
if (!axes) return;
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
playStick.setPointerCapture(event.pointerId);
playStick.classList.add("active");
playInput.setAxes(`stick:${event.pointerId}`, axes);
paintPlayStick(axes);
publishPlayActions();
event.preventDefault();
});
playStick.addEventListener("pointermove", (event) => {
const axes = pointerStick.move(event.pointerId, event.clientX, event.clientY);
if (!axes) return;
playInput.setAxes(`stick:${event.pointerId}`, axes);
paintPlayStick(axes);
publishPlayActions();
event.preventDefault();
});
const finishStick = (event: PointerEvent, cancelled: boolean) => {
const finished = cancelled ? pointerStick.cancel(event.pointerId) : pointerStick.end(event.pointerId);
if (!finished) return;
playInput.clearSource(`stick:${event.pointerId}`);
playStick.classList.remove("active");
paintPlayStick();
publishPlayActions();
event.preventDefault();
};
playStick.addEventListener("pointerup", (event) => finishStick(event, false));
playStick.addEventListener("pointercancel", (event) => finishStick(event, true));
playStick.addEventListener("lostpointercapture", (event) => finishStick(event, true));
}
function digitalControlFromData(raw: string | undefined): PlayDigitalControl | null {
if (
raw === "forward" || raw === "backward" || raw === "left" || raw === "right" ||
raw === "ascend" || raw === "descend" || raw === "pitch-up" || raw === "pitch-down" ||
raw === "primary" || raw === "secondary"
) return raw;
return null;
}
for (const button of touchPlayControls?.querySelectorAll<HTMLButtonElement>("[data-play-control]") ?? []) {
const held = new Map<number, PlayDigitalControl>();
button.addEventListener("pointerdown", (event) => {
const control = digitalControlFromData(button.dataset.playControl);
if (!control) return;
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
button.setPointerCapture(event.pointerId);
held.set(event.pointerId, control);
playInput.setDigital(`action:${event.pointerId}`, control, true);
button.setAttribute("aria-pressed", "true");
publishPlayActions();
event.preventDefault();
});
const release = (event: PointerEvent) => {
if (!held.delete(event.pointerId)) return;
playInput.clearSource(`action:${event.pointerId}`);
button.setAttribute("aria-pressed", String(held.size > 0));
publishPlayActions();
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);
}
touchAssist?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); });
touchReset?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); });
touchCamera?.addEventListener("click", () => { playInput.request("camera"); publishPlayActions(); });
touchMap?.addEventListener("click", () => {
togglePlan();
touchMap.setAttribute("aria-pressed", String(planOpen));
});
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='assist']")
?.addEventListener("click", () => publishVehicleActions({ modeRequest: "assisted" }));
?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); });
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
?.addEventListener("click", () => publishVehicleActions({ reset: true }));
?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); });
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
?.addEventListener("click", () => toggleVehicleCamera());
?.addEventListener("click", () => { playInput.request("camera"); publishPlayActions(); });
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='assist']")
?.addEventListener("click", () => publishAircraftActions({ modeRequest: "assisted" }));
?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); });
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='reset']")
?.addEventListener("click", () => publishAircraftActions({ reset: true }));
?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); });
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();
const control = controlForKey(event.key);
if (!control) return;
playInput.setDigital("keyboard", control, false);
if (publishPlayActions()) event.preventDefault();
});
window.addEventListener("blur", () => {
heldDriveKeys.clear();
publishVehicleActions();
publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions();
function releaseAllPlayInput(): void {
clearPublishedPlayInput();
publishPlayActions();
}
window.addEventListener("blur", releaseAllPlayInput);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") releaseAllPlayInput();
});
let gamepadButtons: GamepadButtonState = { assist: false, reset: false };
function pollDriveGamepad() {
let gamepadButtons: StandardPlayGamepadButtons = { assist: false, reset: false, camera: false };
function pollPlayGamepad() {
try {
const pad = navigator.getGamepads?.().find((candidate) => candidate !== null);
if (pad && routeDriveIsActive()) {
const sample = sampleStandardGamepad(pad, gamepadButtons);
if (pad) {
const sample = sampleStandardPlayGamepad(pad, gamepadButtons);
gamepadButtons = sample.buttons;
publishVehicleActions(sample.actions);
playInput.clearSource("gamepad");
playInput.setAxes("gamepad", sample.axes);
for (const control of sample.digital) playInput.setDigital("gamepad", control, true);
for (const request of sample.requests) playInput.request(request);
publishPlayActions();
} else {
gamepadButtons = { assist: false, reset: false };
playInput.clearSource("gamepad");
gamepadButtons = { assist: false, reset: false, camera: false };
}
} catch {
// Some privacy-hardened browsers expose the method but throw until a pad
// has produced a trusted event. Keyboard/touch remain fully functional.
}
requestAnimationFrame(pollDriveGamepad);
requestAnimationFrame(pollPlayGamepad);
}
requestAnimationFrame(pollDriveGamepad);
requestAnimationFrame(pollPlayGamepad);
window.addEventListener("pointerdown", (event) => {
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
}, { capture: true });
/**
* Keyboard access to everything the mouse can reach.
@@ -3286,7 +3684,7 @@ requestAnimationFrame(pollDriveGamepad);
* belongs to that control, not to this.
*/
window.addEventListener("keydown", (event) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
@@ -3324,45 +3722,29 @@ window.addEventListener("keydown", (event) => {
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()) {
const control = controlForKey(event.key);
if (control) {
playInput.setDigital("keyboard", control, true);
if (publishPlayActions()) {
event.preventDefault();
return;
}
}
if (lower === "p" && publishVehicleActions({ modeRequest: "assisted" })) {
if (lower === "p" && (routeDriveIsActive() || city?.controlMode() === "aircraft")) {
playInput.request("assist");
publishPlayActions();
event.preventDefault();
return;
}
if (lower === "p" && publishAircraftActions({ modeRequest: "assisted" })) {
if (lower === "r" && (routeDriveIsActive() || city?.controlMode() === "aircraft")) {
playInput.request("reset");
publishPlayActions();
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()) {
if (lower === "c" && routeDriveIsActive()) {
playInput.request("camera");
publishPlayActions();
event.preventDefault();
return;
}