feat: unify life-sim play controls
This commit is contained in:
+560
-178
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user