diff --git a/README.md b/README.md
index d1aa060..f0ab73d 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,22 @@ npm install
npm run dev
```
+## Play controls
+
+The bottom mode dock is the local-player source of truth: View, Drive, Explore,
+Fly, or office Walk. A transition clears stale held input and atomically hands
+the follow camera to one subsystem. `WASD` is movement; `Q/E` is vertical or
+yaw, `I/K` pitches the crow, Space is the primary action, `G` glides, `P`
+resumes assistance, `R` resets, and `C` switches the driving camera. A standard
+gamepad maps both sticks, triggers, shoulders, and rising-edge action buttons.
+
+Touch play uses a pointer-ID analogue stick at lower left and only the actions
+that apply to the current mode at lower right. The Map button remains available
+during possession. Touch, keyboard, and gamepad state are independent, so a
+released or cancelled finger cannot clear another source that is still held.
+The UI and follow camera are presentation adapters only; they never enter Arena
+observations, rewards, snapshots, traces, or simulator hashes.
+
## Headless RL environments
Tera also exports a versioned, renderer-independent Arena contract with five
diff --git a/index.html b/index.html
index 6464f5d..94c2897 100644
--- a/index.html
+++ b/index.html
@@ -765,6 +765,72 @@
than mystery glyphs: the controls disappear everywhere space is tight
except on the devices that cannot drive without them. */
.drive-controls, .walk-controls { display: none; }
+ .play-hud {
+ position: fixed;
+ top: calc(var(--s4) + env(safe-area-inset-top));
+ left: 50%;
+ z-index: 4;
+ min-width: min(27rem, calc(100vw - 12rem));
+ max-width: calc(100vw - var(--s4) * 2);
+ transform: translateX(-50%);
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: baseline;
+ gap: var(--s2) var(--s3);
+ padding: var(--s2) var(--s3);
+ pointer-events: none;
+ color: var(--ink-2);
+ background: rgba(8, 12, 17, 0.78);
+ border: 1px solid var(--hairline);
+ border-radius: var(--r);
+ box-shadow: var(--shadow);
+ backdrop-filter: var(--blur);
+ -webkit-backdrop-filter: var(--blur);
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.05em;
+ }
+ .play-hud[hidden] { display: none; }
+ .play-hud-mode { color: var(--amber-ink); text-transform: uppercase; letter-spacing: 0.11em; }
+ .play-hud-primary { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .play-hud-status { text-align: right; color: var(--ink-3); }
+ .play-hud-status.warning { color: #ff9d72; }
+ .mode-dock {
+ position: fixed;
+ left: 50%;
+ bottom: calc(var(--s4) + env(safe-area-inset-bottom));
+ z-index: 4;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ padding: 4px;
+ border: 1px solid var(--hairline);
+ border-radius: 999px;
+ background: rgba(8, 12, 17, 0.82);
+ box-shadow: var(--shadow);
+ backdrop-filter: var(--blur);
+ -webkit-backdrop-filter: var(--blur);
+ }
+ .mode-dock button {
+ min-width: 44px;
+ min-height: 38px;
+ padding: 5px 10px;
+ border: 0;
+ border-radius: 999px;
+ color: var(--ink-3);
+ background: transparent;
+ font: inherit;
+ font-size: 9px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ cursor: pointer;
+ }
+ .mode-dock button[aria-pressed="true"] {
+ color: var(--amber-ink);
+ background: rgba(242, 177, 52, 0.2);
+ }
+ .mode-dock button[hidden] { display: none; }
@media (pointer: coarse) {
.drive-controls:not([hidden]), .walk-controls:not([hidden]) {
position: fixed;
@@ -807,6 +873,142 @@
body:has(.drive-controls:not([hidden])) .source,
body:has(.walk-controls:not([hidden])) .source { bottom: 7.5rem; }
}
+ body.touch-capable .drive-controls:not([hidden]),
+ body.touch-capable .walk-controls:not([hidden]) {
+ position: fixed;
+ left: 50%;
+ bottom: calc(var(--s3) + env(safe-area-inset-bottom));
+ transform: translateX(-50%);
+ z-index: 5;
+ width: min(23rem, calc(100vw - var(--s4) * 2));
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: var(--s1);
+ padding: var(--s1);
+ border: 1px solid var(--hairline);
+ border-radius: var(--r);
+ background: var(--glass-strong);
+ backdrop-filter: var(--blur);
+ -webkit-backdrop-filter: var(--blur);
+ box-shadow: var(--shadow);
+ }
+ body.touch-capable .drive-control,
+ body.touch-capable .walk-control {
+ min-height: 48px;
+ border: 1px solid var(--hairline);
+ border-radius: var(--r-sm);
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--ink);
+ font: inherit;
+ font-size: 10px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
+ }
+ body.touch-capable .drive-control[aria-pressed="true"],
+ body.touch-capable .walk-control[aria-pressed="true"] {
+ border-color: rgba(242, 177, 52, 0.65);
+ background: rgba(242, 177, 52, 0.28);
+ color: var(--amber-ink);
+ }
+ body:has(.drive-controls:not([hidden])) .mode-dock,
+ body:has(.drive-controls:not([hidden])) .rail { bottom: calc(8rem + env(safe-area-inset-bottom)); }
+ body:has(.walk-controls:not([hidden])) .mode-dock,
+ body:has(.walk-controls:not([hidden])) .rail { bottom: calc(11.5rem + env(safe-area-inset-bottom)); }
+
+ /* GTA-like touch surface: analogue movement on the left, only the
+ actions meaningful to the current vehicle/actor on the right. */
+ .touch-play-controls { display: none; }
+ @media (pointer: coarse) {
+ .touch-play-controls:not([hidden]) { display: block; }
+ }
+ body.touch-capable .touch-play-controls:not([hidden]) { display: block; }
+ .touch-play-controls:not([hidden]) {
+ position: fixed;
+ inset: 0;
+ z-index: 5;
+ pointer-events: none;
+ }
+ .play-stick {
+ --stick-x: 0px;
+ --stick-y: 0px;
+ position: absolute;
+ left: max(var(--s3), env(safe-area-inset-left));
+ bottom: calc(var(--s3) + env(safe-area-inset-bottom));
+ width: clamp(7.5rem, 31vw, 9rem);
+ aspect-ratio: 1;
+ border-radius: 50%;
+ pointer-events: auto;
+ touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
+ background: radial-gradient(circle, rgba(255,255,255,.08) 0 38%, rgba(8,12,17,.78) 40% 100%);
+ border: 1px solid rgba(255,255,255,.18);
+ box-shadow: inset 0 0 24px rgba(0,0,0,.35), var(--shadow);
+ backdrop-filter: var(--blur);
+ -webkit-backdrop-filter: var(--blur);
+ }
+ .play-stick-ring {
+ position: absolute;
+ inset: 21%;
+ border: 1px solid rgba(242,177,52,.32);
+ border-radius: 50%;
+ }
+ .play-stick-knob {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 3.25rem;
+ height: 3.25rem;
+ transform: translate(calc(-50% + var(--stick-x)), calc(-50% + var(--stick-y)));
+ border-radius: 50%;
+ background: rgba(242,177,52,.28);
+ border: 1px solid rgba(242,177,52,.72);
+ box-shadow: 0 4px 14px rgba(0,0,0,.45);
+ transition: transform 80ms ease-out;
+ }
+ .play-stick.active .play-stick-knob { transition: none; }
+ .touch-actions {
+ position: absolute;
+ right: max(var(--s3), env(safe-area-inset-right));
+ bottom: calc(var(--s3) + env(safe-area-inset-bottom));
+ width: min(10rem, 42vw);
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 5px;
+ pointer-events: auto;
+ }
+ .touch-actions button {
+ min-height: 46px;
+ padding: 5px 7px;
+ border: 1px solid rgba(255,255,255,.18);
+ border-radius: 999px;
+ background: rgba(8,12,17,.78);
+ color: var(--ink-2);
+ box-shadow: var(--shadow);
+ backdrop-filter: var(--blur);
+ -webkit-backdrop-filter: var(--blur);
+ font: inherit;
+ font-size: 9px;
+ letter-spacing: .06em;
+ text-transform: uppercase;
+ touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
+ }
+ .touch-actions button[hidden] { display: none; }
+ .touch-actions button[aria-pressed="true"] {
+ border-color: rgba(242,177,52,.72);
+ background: rgba(242,177,52,.28);
+ color: var(--amber-ink);
+ }
+ body:has(.touch-play-controls:not([hidden])) .mode-dock {
+ bottom: calc(10rem + env(safe-area-inset-bottom));
+ }
+ body:has(.touch-play-controls:not([hidden])) #hint { display: none; }
+ body:has(.touch-play-controls:not([hidden])) .source { bottom: 10rem; }
/* ---- Responsive -------------------------------------------------------
Two breakpoints and no more. At 900 the left column stops being furniture
@@ -850,6 +1052,18 @@
`deviceProfile()` in `stage.ts` keys off this exact 600px edge — so the
stylesheet and the pixel budget cannot drift apart. */
@media (max-width: 600px) {
+ .play-hud {
+ top: calc(var(--s3) + env(safe-area-inset-top) + 2.65rem);
+ min-width: 0;
+ width: calc(100vw - var(--s3) * 2);
+ }
+ .mode-dock {
+ max-width: calc(100vw - var(--s3) * 2);
+ bottom: calc(var(--s3) + env(safe-area-inset-bottom));
+ }
+ .mode-dock button { padding-inline: 8px; }
+ body:has(.drive-controls:not([hidden])) #hint,
+ body:has(.walk-controls:not([hidden])) #hint { display: none; }
/* Everything hit by a finger clears 44px, which is 11 steps of the 4px
rhythm and the smallest target anyone has managed to defend. `#help`
was 17px tall. */
@@ -1039,28 +1253,36 @@
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1082,6 +1304,7 @@
W A S DDrive, walk, or manually fly the California aircraft
VWalk through an office / return to the dollhouse view
Q / EDescend / climb while flying as a crow
+
I / KPitch the crow up / down
SpaceHandbrake while driving
P / RResume assisted drive or flight / reset the vehicle
CSwitch chase / driver-height camera
@@ -1099,7 +1322,8 @@
One fingerOrbit
Two fingersPinch to zoom, drag to move over the ground
Tap a markerIts card, at the bottom of the screen
-
Route controlsChoose 101 or I-5; the driving pad appears at the bottom
+
Play joystickMove with the lower-left stick; current actions appear lower right
+
MapThe play action opens or closes the local-player plan
Tap the mapDismiss the card; tap the ☰ sheet's scrim to close it
Plan viewThe button on the rail shows or hides it, as a sheet above the rail
diff --git a/scripts/performance-budget.mjs b/scripts/performance-budget.mjs
index be66b07..ce3be68 100755
--- a/scripts/performance-budget.mjs
+++ b/scripts/performance-budget.mjs
@@ -18,6 +18,12 @@ const VIEWPORTS = {
};
const SCENES = {
california: { host: "tera.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0 },
+ "california-drive": {
+ host: "tera.lumbridgecorp.com",
+ ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 1,
+ activate: () => document.querySelectorAll("#chapters .chapter")[1]?.click(),
+ active: () => document.querySelector("[data-control-mode='drive']")?.getAttribute("aria-pressed") === "true" && document.getElementById("play-hud")?.hidden === false,
+ },
office: { host: "office.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.getElementById("enter")?.textContent?.includes("Back to the city") === true },
};
@@ -211,6 +217,10 @@ async function measure(browser, port, sceneName, viewportName, budget, requestLo
})).catch(() => null);
throw new Error(`scene readiness timed out: ${JSON.stringify({ state, consoleErrors, requests: requestLog.slice(before) })}`, { cause: error });
}
+ if (scene.activate) {
+ await page.evaluate(scene.activate);
+ await page.waitForFunction(scene.active, null, { timeout: readyTimeoutMs });
+ }
await page.waitForTimeout(warmupMs);
await page.evaluate(() => {
const state = globalThis.__teraPerformanceBudget;
diff --git a/scripts/performance-budgets.json b/scripts/performance-budgets.json
index c01ab6e..5860f5b 100644
--- a/scripts/performance-budgets.json
+++ b/scripts/performance-budgets.json
@@ -5,6 +5,10 @@
"desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 650, "maxTriangles": 750000 },
"mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 650, "maxTriangles": 750000 }
},
+ "california-drive": {
+ "desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 650, "maxTriangles": 750000 },
+ "mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 650, "maxTriangles": 750000 }
+ },
"office": {
"desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 550, "maxTriangles": 550000 },
"mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 550, "maxTriangles": 550000 }
diff --git a/src/engine/minimap.ts b/src/engine/minimap.ts
index b56e400..816cb93 100644
--- a/src/engine/minimap.ts
+++ b/src/engine/minimap.ts
@@ -81,11 +81,21 @@ export interface MinimapOptions {
maxPixelRatio?: number;
}
+export interface MinimapPlayer {
+ lat: number;
+ lng: number;
+ /** Degrees clockwise from true north. */
+ headingDeg: number;
+ kind: "vehicle" | "actor" | "aircraft";
+}
+
export interface Minimap {
/** The widget. The caller inserts it into its own container and sizes it in CSS. */
canvas: HTMLCanvasElement;
setMarkers(markers: Marker[]): void;
setAircraft(aircraft: Aircraft[]): void;
+ /** Local possessed entity. Separate from live traffic so it cannot be duplicated. */
+ setPlayer(player: MinimapPlayer | null): void;
setChapters(chapters: Chapter[], activeId: string): void;
/**
* Solar elevation in degrees, the same number `scene.setSolarElevation` gets.
@@ -208,6 +218,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
let aircraft: Aircraft[] = [];
let chapters: Chapter[] = city.chapters;
let activeChapterId = city.chapters[0]?.id ?? "";
+ let player: MinimapPlayer | null = null;
let night = 0;
let renderedNight = -1;
@@ -219,6 +230,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
let chapterPx = new Float64Array(0);
let activeChapterIndex = -1;
let aircraftPx = new Float64Array(0);
+ let playerPx = new Float64Array(0);
let landPath = new Path2D();
let parkPath = new Path2D();
@@ -491,6 +503,18 @@ export function createMinimap(options: MinimapOptions): Minimap {
});
}
+ function layoutPlayer() {
+ if (scale <= 0 || !player) {
+ playerPx = new Float64Array(0);
+ return;
+ }
+ playerPx = new Float64Array([
+ toPxX(world.projectX(player.lng)),
+ toPxY(world.projectZ(player.lat)),
+ (player.headingDeg * Math.PI) / 180,
+ ]);
+ }
+
// ---- The static map -------------------------------------------------------
function renderStatic() {
@@ -736,6 +760,31 @@ export function createMinimap(options: MinimapOptions): Minimap {
}
}
+ function drawPlayer(ctx: Ctx) {
+ if (!player || playerPx.length < 3) return;
+ const x = playerPx[0] ?? 0;
+ const y = playerPx[1] ?? 0;
+ const a = playerPx[2] ?? 0;
+ const nx = Math.sin(a);
+ const ny = -Math.cos(a);
+ const sx = -ny;
+ const sy = nx;
+ const r = (player.kind === "aircraft" ? 5.2 : player.kind === "vehicle" ? 4.5 : 4) * dpr;
+ ctx.beginPath();
+ ctx.arc(x, y, r + 2.5 * dpr, 0, Math.PI * 2);
+ ctx.strokeStyle = theme.chapterActive;
+ ctx.lineWidth = 1.2 * dpr;
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(x + nx * r * 1.35, y + ny * r * 1.35);
+ ctx.lineTo(x - nx * r * 0.75 + sx * r * 0.72, y - ny * r * 0.75 + sy * r * 0.72);
+ ctx.lineTo(x - nx * r * 0.4, y - ny * r * 0.4);
+ ctx.lineTo(x - nx * r * 0.75 - sx * r * 0.72, y - ny * r * 0.75 - sy * r * 0.72);
+ ctx.closePath();
+ ctx.fillStyle = theme.chapterActive;
+ ctx.fill();
+ }
+
function drawPing(ctx: Ctx, now: number) {
if (pinging === 0) return;
const t = (now - pinging) / PING_MS;
@@ -765,6 +814,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
drawMarkers(ctx);
drawChapters(ctx);
drawAircraft(ctx);
+ drawPlayer(ctx);
drawTarget(ctx);
drawCamera(ctx);
if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
@@ -1031,6 +1081,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
layoutMarkers();
layoutChapters();
layoutAircraft();
+ layoutPlayer();
renderStatic();
dirty = true;
}
@@ -1052,6 +1103,16 @@ export function createMinimap(options: MinimapOptions): Minimap {
dirty = true;
},
+ setPlayer(next) {
+ if (
+ player?.lat === next?.lat && player?.lng === next?.lng &&
+ player?.headingDeg === next?.headingDeg && player?.kind === next?.kind
+ ) return;
+ player = next ? { ...next } : null;
+ layoutPlayer();
+ dirty = true;
+ },
+
setChapters(next, activeId) {
chapters = next;
activeChapterId = activeId;
diff --git a/src/engine/officeMinimap.ts b/src/engine/officeMinimap.ts
index 9c38ebb..dd8b91f 100644
--- a/src/engine/officeMinimap.ts
+++ b/src/engine/officeMinimap.ts
@@ -106,6 +106,15 @@ export interface OfficeMinimapOptions {
maxPixelRatio?: number;
}
+export interface OfficeMinimapPlayer {
+ levelId: string;
+ x: number;
+ z: number;
+ /** Radians in office X/Z space; zero faces local north (-Z). */
+ headingRad: number;
+ kind: "humanoid" | "anonymous-dog";
+}
+
export interface OfficeMinimap {
/** The widget. The caller inserts it into its own container and sizes it in CSS. */
canvas: HTMLCanvasElement;
@@ -145,6 +154,7 @@ export interface OfficeMinimap {
* heading until they have taken a step.
*/
setRobots(robots: readonly PlanRobot[]): void;
+ setPlayer(player: OfficeMinimapPlayer | null): void;
/** Call from the stage tick. Cheap by construction — see the file header. */
tick(): void;
/** Re-do the backing store at the current size and re-rasterise the plan. */
@@ -271,6 +281,7 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
*/
let level: LevelPlan | null = plan.levels[0] ?? null;
let activeViewId: string | null = null;
+ let player: OfficeMinimapPlayer | null = null;
/** Occupied seats on this storey: x, y device pixels per person, laid out once. */
let occupiedPx = new Float64Array(0);
/** Seat id -> label, for the hover readout. Every seat in the building, not just this storey. */
@@ -927,6 +938,29 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
}
}
+ function drawPlayer(ctx: Ctx) {
+ if (!player || !level || player.levelId !== level.id) return;
+ const x = toPxX(player.x);
+ const y = toPxY(player.z);
+ const nx = -Math.sin(player.headingRad);
+ const ny = -Math.cos(player.headingRad);
+ const sx = -ny;
+ const sy = nx;
+ const r = (player.kind === "anonymous-dog" ? 3.6 : 4.2) * dpr;
+ ctx.beginPath();
+ ctx.arc(x, y, r + 2.5 * dpr, 0, Math.PI * 2);
+ ctx.strokeStyle = theme.viewpointActive;
+ ctx.lineWidth = 1.2 * dpr;
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(x + nx * r * 1.35, y + ny * r * 1.35);
+ ctx.lineTo(x - nx * r * 0.65 + sx * r * 0.65, y - ny * r * 0.65 + sy * r * 0.65);
+ ctx.lineTo(x - nx * r * 0.65 - sx * r * 0.65, y - ny * r * 0.65 - sy * r * 0.65);
+ ctx.closePath();
+ ctx.fillStyle = theme.viewpointActive;
+ ctx.fill();
+ }
+
function drawPing(ctx: Ctx, now: number) {
if (pinging === 0) return;
const t = (now - pinging) / PING_MS;
@@ -960,6 +994,7 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
// want to see; the camera is the thing you want to see over everything, and
// that has been the order here since the widget was one function.
drawRobots(ctx);
+ drawPlayer(ctx);
crosshair(ctx, toPxX(controls.target.x), toPxY(controls.target.z), theme.target, 5 * dpr);
drawCamera(ctx);
if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
@@ -1356,6 +1391,16 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
dirty = true;
},
+ setPlayer(next) {
+ if (
+ player?.levelId === next?.levelId && player?.x === next?.x &&
+ player?.z === next?.z && player?.headingRad === next?.headingRad &&
+ player?.kind === next?.kind
+ ) return;
+ player = next ? { ...next } : null;
+ dirty = true;
+ },
+
tick() {
if (!ready || !viewCtx) return;
const now = performance.now();
diff --git a/src/engine/scene.ts b/src/engine/scene.ts
index 0d6aeb8..261e095 100644
--- a/src/engine/scene.ts
+++ b/src/engine/scene.ts
@@ -80,6 +80,7 @@ import type {
} from "../aircraft/controller.ts";
import type { ScenePeers, ScenePeersOptions } from "../realtime/scenePeers.ts";
import type { EntityPoseSnapshot } from "../realtime/types.ts";
+import { cityControlOwnership, type CityControlMode } from "../play/controlMode.ts";
export type CityRealtimePeersOptions = Omit
;
@@ -195,6 +196,10 @@ export interface SceneHandle {
flyTo(chapterId: string): void;
current(): string;
onChapterChange(fn: (id: string) => void): void;
+ /** Atomically hands local input and follow-camera ownership to one subsystem. */
+ setControlMode(mode: CityControlMode): void;
+ controlMode(): CityControlMode;
+ onControlModeChange(fn: (mode: CityControlMode) => void): void;
/** Device-neutral input for the corridor hero; a no-op on boards without one. */
setVehicleActions(actions: Partial): void;
/** Current playable corridor state, or null on a city-scale board. */
@@ -482,6 +487,33 @@ export async function createScene(
let currentChapter = first.id;
const chapterListeners: ((id: string) => void)[] = [];
+ let controlMode: CityControlMode = "overview";
+ const controlModeListeners: ((mode: CityControlMode) => void)[] = [];
+
+ function applyControlMode(requested: CityControlMode): CityControlMode {
+ const next: CityControlMode =
+ requested === "drive" && !roadTraffic ? "overview"
+ : requested === "actor" && !sceneActor ? "overview"
+ : requested === "aircraft" && !sceneAircraft ? "overview"
+ : requested;
+ const ownership = cityControlOwnership(next);
+ sceneActor?.setActive(ownership.actor);
+ sceneAircraft?.setActive(ownership.aircraft);
+ roadTraffic?.setFollowing(ownership.drive);
+ kit.controls.enabled = ownership.orbit;
+ kit.camera.near = next === "actor" ? 0.001 : next === "aircraft" ? 0.01 : 0.1;
+ kit.controls.minDistance = next === "actor"
+ ? 0.001
+ : next === "aircraft"
+ ? 0.01
+ : orbitMinDistance;
+ kit.camera.updateProjectionMatrix();
+ if (next !== controlMode) {
+ controlMode = next;
+ for (const fn of controlModeListeners) fn(next);
+ }
+ return next;
+ }
function chapterPose(ch: Chapter): Pose {
const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
@@ -502,19 +534,12 @@ export async function createScene(
// Named viewpoints are observe/vehicle destinations. Possessing an actor
// is an explicit UI action, so a chapter selection always hands the camera
// back before it moves anywhere else.
- sceneActor?.setActive(false);
- sceneAircraft?.setActive(false);
- kit.controls.minDistance = orbitMinDistance;
- if (kit.camera.near !== 0.1) {
- kit.camera.near = 0.1;
- kit.camera.updateProjectionMatrix();
- }
const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
if (route && roadTraffic) {
roadTraffic.setRoute(route.id);
- roadTraffic.setFollowing(true);
+ applyControlMode("drive");
} else {
- roadTraffic?.setFollowing(false);
+ applyControlMode("overview");
roadTraffic?.setVehicleActions({});
kit.flyTo(chapterPose(ch));
}
@@ -547,14 +572,15 @@ export async function createScene(
// bug.
onExit: () => kit.resetPick(),
tick(dt) {
- const actorPlaying = sceneActor?.active() ?? false;
- const aircraftPlaying = sceneAircraft?.active() ?? false;
- kit.controls.enabled = !actorPlaying && !aircraftPlaying;
+ // Exactly one subsystem owns the camera. In particular, OrbitControls
+ // must stay disabled while the road layer writes its follow pose.
+ const ownership = cityControlOwnership(controlMode);
+ kit.controls.enabled = ownership.orbit;
kit.tick(dt);
sceneActor?.tick(dt);
- if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose());
+ if (ownership.actor && sceneActor) kit.setPose(sceneActor.followPose());
sceneAircraft?.tick(dt);
- if (aircraftPlaying && sceneAircraft) kit.setPose(sceneAircraft.followPose());
+ if (ownership.aircraft && sceneAircraft) kit.setPose(sceneAircraft.followPose());
realtimePeers?.tick(Date.now());
roadTraffic?.tick(dt);
clouds.tick(dt);
@@ -645,6 +671,11 @@ export async function createScene(
onChapterChange(fn) {
chapterListeners.push(fn);
},
+ setControlMode: (mode) => { applyControlMode(mode); },
+ controlMode: () => controlMode,
+ onControlModeChange(fn) {
+ controlModeListeners.push(fn);
+ },
setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions),
vehicleState: () => roadTraffic?.hero() ?? null,
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
@@ -655,31 +686,15 @@ export async function createScene(
attachActorFaceTexture: (texture) => sceneActor?.attachFaceTexture(texture) ?? false,
clearActorFaceTexture: () => { sceneActor?.clearFaceTexture(); },
setActorActive(active) {
- sceneActor?.setActive(active);
- if (active) {
- sceneAircraft?.setActive(false);
- roadTraffic?.setFollowing(false);
- }
- // State boards compress one real metre to a few hundredths of a scene
- // unit. Their possessed actor and chase camera are therefore closer than
- // the map camera's 0.1 near plane; lower it only for play mode so the
- // procedural rig is not clipped away, then restore the depth precision.
- kit.camera.near = active ? 0.001 : 0.1;
- kit.controls.minDistance = active ? 0.001 : orbitMinDistance;
- kit.camera.updateProjectionMatrix();
+ if (active) applyControlMode("actor");
+ else if (controlMode === "actor") applyControlMode("overview");
},
actorActive: () => sceneActor?.active() ?? false,
setAircraftActions: (actions) => { sceneAircraft?.setActions(actions); },
aircraftState: () => sceneAircraft?.state() ?? null,
setAircraftActive(active) {
- sceneAircraft?.setActive(active);
- if (active) {
- sceneActor?.setActive(false);
- roadTraffic?.setFollowing(false);
- }
- kit.camera.near = active ? 0.01 : 0.1;
- kit.controls.minDistance = active ? 0.01 : orbitMinDistance;
- kit.camera.updateProjectionMatrix();
+ if (active) applyControlMode("aircraft");
+ else if (controlMode === "aircraft") applyControlMode("overview");
},
aircraftActive: () => sceneAircraft?.active() ?? false,
upsertRemoteSnapshot: (snapshot) => {
diff --git a/src/input/play.ts b/src/input/play.ts
new file mode 100644
index 0000000..9d47a97
--- /dev/null
+++ b/src/input/play.ts
@@ -0,0 +1,328 @@
+/** Device-neutral, multi-source play input state. */
+
+export type PlayDigitalControl =
+ | "forward"
+ | "backward"
+ | "left"
+ | "right"
+ | "ascend"
+ | "descend"
+ | "pitch-up"
+ | "pitch-down"
+ | "primary"
+ | "secondary";
+
+export type PlayEdgeControl = "assist" | "reset" | "camera";
+
+export interface PlayAxes {
+ moveX: number;
+ moveY: number;
+ lookX: number;
+ lookY: number;
+ throttle: number;
+ brake: number;
+}
+
+export interface PlayInputSnapshot extends PlayAxes {
+ ascend: number;
+ descend: number;
+ primary: boolean;
+ secondary: boolean;
+}
+
+export const NEUTRAL_PLAY_INPUT: Readonly = Object.freeze({
+ moveX: 0,
+ moveY: 0,
+ lookX: 0,
+ lookY: 0,
+ throttle: 0,
+ brake: 0,
+ ascend: 0,
+ descend: 0,
+ primary: false,
+ secondary: false,
+});
+
+interface SourceState {
+ digital: Set;
+ axes: PlayAxes;
+}
+
+function clamp(value: number, min = -1, max = 1): number {
+ return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : 0;
+}
+
+function neutralAxes(): PlayAxes {
+ return { moveX: 0, moveY: 0, lookX: 0, lookY: 0, throttle: 0, brake: 0 };
+}
+
+function strongest(current: number, candidate: number): number {
+ return Math.abs(candidate) > Math.abs(current) ? candidate : current;
+}
+
+/**
+ * Input sources never share held state. A pointer release therefore cannot
+ * cancel a keyboard key or another finger that is still down.
+ */
+export class PlayInputRouter {
+ private readonly sources = new Map();
+ private readonly edges = new Set();
+
+ private source(id: string): SourceState {
+ const existing = this.sources.get(id);
+ if (existing) return existing;
+ const created = { digital: new Set(), axes: neutralAxes() };
+ this.sources.set(id, created);
+ return created;
+ }
+
+ setDigital(sourceId: string, control: PlayDigitalControl, pressed: boolean): void {
+ const source = this.source(sourceId);
+ if (pressed) source.digital.add(control);
+ else source.digital.delete(control);
+ this.dropEmpty(sourceId, source);
+ }
+
+ setAxes(sourceId: string, axes: Partial): void {
+ const source = this.source(sourceId);
+ source.axes = {
+ moveX: clamp(axes.moveX ?? 0),
+ moveY: clamp(axes.moveY ?? 0),
+ lookX: clamp(axes.lookX ?? 0),
+ lookY: clamp(axes.lookY ?? 0),
+ throttle: clamp(axes.throttle ?? 0, 0, 1),
+ brake: clamp(axes.brake ?? 0, 0, 1),
+ };
+ this.dropEmpty(sourceId, source);
+ }
+
+ request(control: PlayEdgeControl): void {
+ this.edges.add(control);
+ }
+
+ consumeRequests(): ReadonlySet {
+ const result = new Set(this.edges);
+ this.edges.clear();
+ return result;
+ }
+
+ clearSource(sourceId: string): void {
+ this.sources.delete(sourceId);
+ }
+
+ clearAll(): void {
+ this.sources.clear();
+ this.edges.clear();
+ }
+
+ snapshot(): PlayInputSnapshot {
+ let moveX = 0;
+ let moveY = 0;
+ let lookX = 0;
+ let lookY = 0;
+ let throttle = 0;
+ let brake = 0;
+ let ascend = 0;
+ let descend = 0;
+ let primary = false;
+ let secondary = false;
+ for (const source of this.sources.values()) {
+ const horizontal = (source.digital.has("right") ? 1 : 0) -
+ (source.digital.has("left") ? 1 : 0);
+ const vertical = (source.digital.has("forward") ? 1 : 0) -
+ (source.digital.has("backward") ? 1 : 0);
+ const pitch = (source.digital.has("pitch-up") ? 1 : 0) -
+ (source.digital.has("pitch-down") ? 1 : 0);
+ moveX = strongest(moveX, strongest(source.axes.moveX, horizontal));
+ moveY = strongest(moveY, strongest(source.axes.moveY, vertical));
+ lookX = strongest(lookX, source.axes.lookX);
+ lookY = strongest(lookY, strongest(source.axes.lookY, pitch));
+ throttle = Math.max(throttle, source.axes.throttle);
+ brake = Math.max(brake, source.axes.brake);
+ ascend = Math.max(ascend, source.digital.has("ascend") ? 1 : 0);
+ descend = Math.max(descend, source.digital.has("descend") ? 1 : 0);
+ primary ||= source.digital.has("primary");
+ secondary ||= source.digital.has("secondary");
+ }
+ return {
+ moveX,
+ moveY,
+ lookX,
+ lookY,
+ throttle,
+ brake,
+ ascend,
+ descend,
+ primary,
+ secondary,
+ };
+ }
+
+ activeSourceCount(): number {
+ return this.sources.size;
+ }
+
+ private dropEmpty(id: string, source: SourceState): void {
+ const axes = source.axes;
+ if (
+ source.digital.size === 0 && axes.moveX === 0 && axes.moveY === 0 &&
+ axes.lookX === 0 && axes.lookY === 0 && axes.throttle === 0 && axes.brake === 0
+ ) this.sources.delete(id);
+ }
+}
+
+export interface StandardPlayGamepadLike {
+ axes: readonly number[];
+ buttons: readonly { pressed: boolean; value: number }[];
+}
+
+export interface StandardPlayGamepadButtons {
+ assist: boolean;
+ reset: boolean;
+ camera: boolean;
+}
+
+export interface StandardPlayGamepadSample {
+ axes: PlayAxes;
+ digital: ReadonlySet;
+ requests: ReadonlySet;
+ buttons: StandardPlayGamepadButtons;
+}
+
+function axis(value: number | undefined, deadzone = 0.12): number {
+ const raw = clamp(value ?? 0);
+ if (Math.abs(raw) <= deadzone) return 0;
+ return Math.sign(raw) * ((Math.abs(raw) - deadzone) / (1 - deadzone));
+}
+
+function button(pad: StandardPlayGamepadLike, index: number): number {
+ const found = pad.buttons[index];
+ if (!found) return 0;
+ return clamp(Number.isFinite(found.value) ? found.value : found.pressed ? 1 : 0, 0, 1);
+}
+
+/** Standard layout: two sticks, triggers, shoulders and rising-edge face buttons. */
+export function sampleStandardPlayGamepad(
+ pad: StandardPlayGamepadLike,
+ previous: StandardPlayGamepadButtons = { assist: false, reset: false, camera: false },
+): StandardPlayGamepadSample {
+ const buttons = {
+ assist: button(pad, 3) > 0.5,
+ reset: button(pad, 2) > 0.5,
+ camera: button(pad, 9) > 0.5,
+ };
+ const digital = new Set();
+ if (button(pad, 5) > 0.5) digital.add("ascend");
+ if (button(pad, 4) > 0.5) digital.add("descend");
+ if (button(pad, 1) > 0.5) digital.add("primary");
+ if (button(pad, 0) > 0.5) digital.add("secondary");
+ const requests = new Set();
+ if (buttons.assist && !previous.assist) requests.add("assist");
+ if (buttons.reset && !previous.reset) requests.add("reset");
+ if (buttons.camera && !previous.camera) requests.add("camera");
+ return {
+ axes: {
+ moveX: axis(pad.axes[0]),
+ moveY: -axis(pad.axes[1]),
+ lookX: axis(pad.axes[2]),
+ lookY: -axis(pad.axes[3]),
+ brake: button(pad, 6),
+ throttle: button(pad, 7),
+ },
+ digital,
+ requests,
+ buttons,
+ };
+}
+
+export interface PlanarDirection {
+ x: number;
+ z: number;
+}
+
+/** Camera-relative input without importing a renderer or allocating vectors. */
+export function cameraRelativePlanar(
+ input: Pick,
+ cameraForward: Readonly,
+): PlanarDirection {
+ const length = Math.hypot(cameraForward.x, cameraForward.z);
+ const fx = length > 1e-8 ? cameraForward.x / length : 0;
+ const fz = length > 1e-8 ? cameraForward.z / length : -1;
+ const rx = -fz;
+ const rz = fx;
+ const x = rx * input.moveX + fx * input.moveY;
+ const z = rz * input.moveX + fz * input.moveY;
+ const magnitude = Math.hypot(x, z);
+ return magnitude > 1 ? { x: x / magnitude, z: z / magnitude } : { x, z };
+}
+
+export function vehicleActionsFromPlay(
+ input: Readonly,
+): {
+ throttle: number;
+ brake: number;
+ steering: number;
+ handbrake: boolean;
+} {
+ return {
+ throttle: Math.max(input.throttle, input.moveY > 0 ? input.moveY : 0),
+ brake: Math.max(input.brake, input.moveY < 0 ? -input.moveY : 0),
+ steering: input.moveX,
+ handbrake: input.primary,
+ };
+}
+
+export function aircraftActionsFromPlay(
+ input: Readonly,
+): { throttle: number; yaw: number; pitch: number; roll: number } {
+ return {
+ throttle: Math.max(input.throttle, input.primary ? 1 : 0),
+ yaw: clamp(input.ascend - input.descend + input.lookX),
+ pitch: input.moveY !== 0 ? input.moveY : input.lookY,
+ roll: input.moveX,
+ };
+}
+
+export function crowActionsFromPlay(
+ input: Readonly,
+): {
+ forward: number;
+ right: number;
+ turn: number;
+ pitch: number;
+ climb: number;
+ sprint: boolean;
+ glide: boolean;
+} {
+ return {
+ forward: input.moveY,
+ right: 0,
+ turn: strongest(input.moveX, input.lookX),
+ pitch: input.lookY,
+ climb: clamp(input.ascend - input.descend),
+ sprint: false,
+ glide: input.secondary,
+ };
+}
+
+/** Convert a desired camera-relative world direction into the actor's local axes. */
+export function groundActorActionsFromPlay(
+ input: Readonly,
+ yaw: number,
+ cameraForward: Readonly,
+): {
+ forward: number;
+ right: number;
+ turn: number;
+ sprint: boolean;
+} {
+ const desired = cameraRelativePlanar(input, cameraForward);
+ const cos = Math.cos(yaw);
+ const sin = Math.sin(yaw);
+ return {
+ right: desired.x * cos - desired.z * sin,
+ forward: -desired.x * sin - desired.z * cos,
+ turn: input.lookX,
+ sprint: input.primary,
+ };
+}
diff --git a/src/input/pointerStick.ts b/src/input/pointerStick.ts
new file mode 100644
index 0000000..fe9df14
--- /dev/null
+++ b/src/input/pointerStick.ts
@@ -0,0 +1,69 @@
+export interface PointerStickAxes {
+ moveX: number;
+ moveY: number;
+}
+
+export interface PointerStickBounds {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+}
+
+/**
+ * One pointer owns one analogue stick gesture. Other fingers may press action
+ * buttons without stealing or releasing it; cancellation always returns zero.
+ */
+export class PointerStick {
+ private pointerId: number | null = null;
+ private bounds: PointerStickBounds | null = null;
+
+ begin(pointerId: number, clientX: number, clientY: number, bounds: PointerStickBounds): PointerStickAxes | null {
+ if (this.pointerId !== null || !validBounds(bounds)) return null;
+ this.pointerId = pointerId;
+ this.bounds = { ...bounds };
+ return this.sample(clientX, clientY);
+ }
+
+ move(pointerId: number, clientX: number, clientY: number): PointerStickAxes | null {
+ if (pointerId !== this.pointerId || !this.bounds) return null;
+ return this.sample(clientX, clientY);
+ }
+
+ end(pointerId: number): boolean {
+ if (pointerId !== this.pointerId) return false;
+ this.pointerId = null;
+ this.bounds = null;
+ return true;
+ }
+
+ cancel(pointerId: number): boolean {
+ return this.end(pointerId);
+ }
+
+ activePointer(): number | null {
+ return this.pointerId;
+ }
+
+ private sample(clientX: number, clientY: number): PointerStickAxes {
+ const bounds = this.bounds;
+ if (!bounds || !Number.isFinite(clientX) || !Number.isFinite(clientY)) {
+ return { moveX: 0, moveY: 0 };
+ }
+ const radius = Math.max(1, Math.min(bounds.width, bounds.height) / 2);
+ let x = (clientX - (bounds.left + bounds.width / 2)) / radius;
+ let y = (clientY - (bounds.top + bounds.height / 2)) / radius;
+ const length = Math.hypot(x, y);
+ if (length > 1) {
+ x /= length;
+ y /= length;
+ }
+ return { moveX: x === 0 ? 0 : x, moveY: y === 0 ? 0 : -y };
+ }
+}
+
+function validBounds(bounds: PointerStickBounds): boolean {
+ return Number.isFinite(bounds.left) && Number.isFinite(bounds.top) &&
+ Number.isFinite(bounds.width) && Number.isFinite(bounds.height) &&
+ bounds.width > 0 && bounds.height > 0;
+}
diff --git a/src/main.ts b/src/main.ts
index 018b628..d5868b2 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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("#fly");
const screensButton = document.querySelector("#screens");
const walkControls = document.querySelector("#walk-controls");
const walkHint = document.querySelector("#walk-hint");
+const touchPlayControls = document.querySelector("#touch-play-controls");
+const playStick = document.querySelector("#play-stick");
+const touchPrimary = document.querySelector("#touch-primary");
+const touchSecondary = document.querySelector("#touch-secondary");
+const touchPitchUp = document.querySelector("#touch-pitch-up");
+const touchPitchDown = document.querySelector("#touch-pitch-down");
+const touchAssist = document.querySelector("#touch-assist");
+const touchReset = document.querySelector("#touch-reset");
+const touchCamera = document.querySelector("#touch-camera");
+const touchMap = document.querySelector("#touch-map");
+const modeDock = document.querySelector("#mode-dock");
+const playHud = document.querySelector("#play-hud");
+const playHudMode = document.querySelector("#play-hud-mode");
+const playHudPrimary = document.querySelector("#play-hud-primary");
+const playHudStatus = document.querySelector("#play-hud-status");
const profileOverlay = document.querySelector("#profile-overlay");
const webcamFaceIndicator = document.querySelector("#webcam-face-indicator");
const screensOverlay = document.querySelector("#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(
+ "[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): 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("#detail");
const body = document.querySelector("#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("[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("[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("[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();
-
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 = {},
-): 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 = {},
-): 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("[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(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("[data-play-control]") ?? []) {
+ const held = new Map();
+ 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("[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("[data-drive-action='assist']")
- ?.addEventListener("click", () => publishVehicleActions({ modeRequest: "assisted" }));
+ ?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); });
driveControls?.querySelector("[data-drive-action='reset']")
- ?.addEventListener("click", () => publishVehicleActions({ reset: true }));
+ ?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); });
driveControls?.querySelector("[data-drive-action='camera']")
- ?.addEventListener("click", () => toggleVehicleCamera());
+ ?.addEventListener("click", () => { playInput.request("camera"); publishPlayActions(); });
walkControls?.querySelector("[data-aircraft-action='assist']")
- ?.addEventListener("click", () => publishAircraftActions({ modeRequest: "assisted" }));
+ ?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); });
walkControls?.querySelector("[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;
}
diff --git a/src/play/controlMode.ts b/src/play/controlMode.ts
new file mode 100644
index 0000000..107220a
--- /dev/null
+++ b/src/play/controlMode.ts
@@ -0,0 +1,104 @@
+/**
+ * Renderer-neutral ownership for the one local player and the one local camera.
+ *
+ * A mode is deliberately more specific than "play": it names the subsystem
+ * allowed to consume held input and write the follow camera. Keeping this as a
+ * small pure state machine lets the DOM, Three.js scene and Journey reducer
+ * agree on a transition without any of them becoming the source of truth for
+ * the others.
+ */
+
+export type CityControlMode = "overview" | "drive" | "actor" | "aircraft";
+export type OfficeControlMode = "office-overview" | "office-walk";
+export type ControlMode = CityControlMode | OfficeControlMode;
+
+export interface ControlModeAvailability {
+ insideOffice: boolean;
+ drive: boolean;
+ actor: boolean;
+ aircraft: boolean;
+ officeWalk: boolean;
+}
+
+export interface ControlModeState {
+ mode: ControlMode;
+ /** Increases only for an accepted change, useful to invalidate stale input. */
+ revision: number;
+}
+
+export interface ControlModeTransition {
+ previous: ControlMode;
+ state: ControlModeState;
+ changed: boolean;
+ accepted: boolean;
+ reason: "unchanged" | "unavailable" | null;
+}
+
+export interface CityControlOwnership {
+ orbit: boolean;
+ drive: boolean;
+ actor: boolean;
+ aircraft: boolean;
+}
+
+/** The renderer consumes this table; exactly one camera writer is always true. */
+export function cityControlOwnership(mode: CityControlMode): CityControlOwnership {
+ return {
+ orbit: mode === "overview",
+ drive: mode === "drive",
+ actor: mode === "actor",
+ aircraft: mode === "aircraft",
+ };
+}
+
+export function createControlModeState(
+ mode: ControlMode = "overview",
+): ControlModeState {
+ return { mode, revision: 0 };
+}
+
+export function cityControlMode(mode: ControlMode): CityControlMode {
+ if (mode === "drive" || mode === "actor" || mode === "aircraft") return mode;
+ return "overview";
+}
+
+export function controlModeAvailable(
+ mode: ControlMode,
+ availability: Readonly,
+): boolean {
+ if (availability.insideOffice) {
+ return mode === "office-overview" || (mode === "office-walk" && availability.officeWalk);
+ }
+ if (mode === "overview") return true;
+ if (mode === "drive") return availability.drive;
+ if (mode === "actor") return availability.actor;
+ if (mode === "aircraft") return availability.aircraft;
+ return false;
+}
+
+/** Resolve one requested transition without performing any side effects. */
+export function transitionControlMode(
+ current: Readonly,
+ requested: ControlMode,
+ availability: Readonly,
+): ControlModeTransition {
+ const fallback: ControlMode = availability.insideOffice ? "office-overview" : "overview";
+ const next = controlModeAvailable(requested, availability) ? requested : fallback;
+ const accepted = next === requested;
+ if (next === current.mode) {
+ return {
+ previous: current.mode,
+ state: { ...current },
+ changed: false,
+ accepted,
+ reason: accepted ? "unchanged" : "unavailable",
+ };
+ }
+ return {
+ previous: current.mode,
+ state: { mode: next, revision: current.revision + 1 },
+ changed: true,
+ accepted,
+ reason: accepted ? null : "unavailable",
+ };
+}
diff --git a/src/test/controlMode.test.ts b/src/test/controlMode.test.ts
new file mode 100644
index 0000000..8d0a68c
--- /dev/null
+++ b/src/test/controlMode.test.ts
@@ -0,0 +1,59 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ cityControlOwnership,
+ cityControlMode,
+ createControlModeState,
+ transitionControlMode,
+} from "../play/controlMode.ts";
+
+const OUTSIDE = {
+ insideOffice: false,
+ drive: true,
+ actor: true,
+ aircraft: true,
+ officeWalk: false,
+};
+
+describe("exclusive play control mode", () => {
+ it("assigns exactly one local camera writer in every city mode", () => {
+ for (const mode of ["overview", "drive", "actor", "aircraft"] as const) {
+ const ownership = cityControlOwnership(mode);
+ assert.equal(Object.values(ownership).filter(Boolean).length, 1);
+ assert.equal(ownership[mode === "overview" ? "orbit" : mode], true);
+ }
+ assert.equal(cityControlOwnership("drive").orbit, false);
+ });
+
+ it("changes exactly one owner and revisions accepted transitions", () => {
+ let state = createControlModeState();
+ const driving = transitionControlMode(state, "drive", OUTSIDE);
+ assert.equal(driving.accepted, true);
+ assert.equal(driving.changed, true);
+ assert.deepEqual(driving.state, { mode: "drive", revision: 1 });
+ state = driving.state;
+ const aircraft = transitionControlMode(state, "aircraft", OUTSIDE);
+ assert.deepEqual(aircraft.state, { mode: "aircraft", revision: 2 });
+ assert.equal(aircraft.previous, "drive");
+ });
+
+ it("falls back safely when a requested subsystem is unavailable", () => {
+ const unavailable = transitionControlMode(
+ createControlModeState("drive"),
+ "aircraft",
+ { ...OUTSIDE, aircraft: false },
+ );
+ assert.equal(unavailable.accepted, false);
+ assert.equal(unavailable.reason, "unavailable");
+ assert.equal(unavailable.state.mode, "overview");
+ });
+
+ it("keeps office and city modes in disjoint places", () => {
+ const office = { ...OUTSIDE, insideOffice: true, officeWalk: true };
+ const entered = transitionControlMode(createControlModeState(), "office-walk", office);
+ assert.equal(entered.state.mode, "office-walk");
+ const rejected = transitionControlMode(entered.state, "drive", office);
+ assert.equal(rejected.state.mode, "office-overview");
+ assert.equal(cityControlMode(rejected.state.mode), "overview");
+ });
+});
diff --git a/src/test/playInput.test.ts b/src/test/playInput.test.ts
new file mode 100644
index 0000000..96da7d0
--- /dev/null
+++ b/src/test/playInput.test.ts
@@ -0,0 +1,112 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ aircraftActionsFromPlay,
+ cameraRelativePlanar,
+ crowActionsFromPlay,
+ groundActorActionsFromPlay,
+ PlayInputRouter,
+ sampleStandardPlayGamepad,
+ vehicleActionsFromPlay,
+} from "../input/play.ts";
+
+function pad(over: { axes?: number[]; buttons?: Record } = {}) {
+ return {
+ axes: over.axes ?? [0, 0, 0, 0],
+ buttons: Array.from({ length: 10 }, (_, index) => ({
+ pressed: (over.buttons?.[index] ?? 0) > 0.5,
+ value: over.buttons?.[index] ?? 0,
+ })),
+ };
+}
+
+describe("multi-source play input", () => {
+ it("does not let one source release another source's held control", () => {
+ const router = new PlayInputRouter();
+ router.setDigital("keyboard", "forward", true);
+ router.setDigital("pointer:7", "forward", true);
+ router.clearSource("pointer:7");
+ assert.equal(router.snapshot().moveY, 1);
+ router.setDigital("keyboard", "forward", false);
+ assert.deepEqual(router.snapshot(), {
+ moveX: 0, moveY: 0, lookX: 0, lookY: 0, throttle: 0, brake: 0,
+ ascend: 0, descend: 0, primary: false, secondary: false,
+ });
+ });
+
+ it("merges strongest analogue intent with independent digital actions", () => {
+ const router = new PlayInputRouter();
+ router.setAxes("gamepad", { moveX: 0.45, moveY: 0.8, throttle: 0.7 });
+ router.setDigital("pointer:1", "left", true);
+ router.setDigital("pointer:2", "ascend", true);
+ const snapshot = router.snapshot();
+ assert.equal(snapshot.moveX, -1);
+ assert.equal(snapshot.moveY, 0.8);
+ assert.equal(snapshot.throttle, 0.7);
+ assert.equal(snapshot.ascend, 1);
+ });
+
+ it("clears sources and one-shot requests atomically on mode changes", () => {
+ const router = new PlayInputRouter();
+ router.setDigital("keyboard", "primary", true);
+ router.request("reset");
+ assert.equal(router.consumeRequests().has("reset"), true);
+ assert.equal(router.consumeRequests().size, 0);
+ router.clearAll();
+ assert.equal(router.activeSourceCount(), 0);
+ assert.equal(router.snapshot().primary, false);
+ });
+
+ it("samples both sticks, triggers, shoulders and rising-edge requests", () => {
+ const first = sampleStandardPlayGamepad(pad({
+ axes: [0.5, -0.7, -0.4, 0.6],
+ buttons: { 3: 1, 2: 1, 9: 1, 4: 1, 5: 1, 7: 0.8, 6: 0.2 },
+ }));
+ assert.ok(first.axes.moveX > 0);
+ assert.ok(first.axes.moveY > 0);
+ assert.ok(first.axes.lookX < 0);
+ assert.ok(first.axes.lookY < 0);
+ assert.equal(first.axes.throttle, 0.8);
+ assert.equal(first.axes.brake, 0.2);
+ assert.equal(first.digital.has("ascend"), true);
+ assert.equal(first.digital.has("descend"), true);
+ assert.deepEqual([...first.requests].sort(), ["assist", "camera", "reset"]);
+ assert.equal(sampleStandardPlayGamepad(pad({ buttons: { 3: 1, 2: 1, 9: 1 } }), first.buttons).requests.size, 0);
+ });
+
+ it("maps one snapshot coherently into each deterministic controller", () => {
+ const router = new PlayInputRouter();
+ router.setAxes("gamepad", {
+ moveX: 0.4, moveY: 0.7, lookX: -0.25, lookY: 0.6,
+ throttle: 0.5, brake: 0.1,
+ });
+ router.setDigital("keyboard", "ascend", true);
+ router.setDigital("keyboard", "primary", true);
+ router.setDigital("keyboard", "secondary", true);
+ const input = router.snapshot();
+ assert.deepEqual(vehicleActionsFromPlay(input), {
+ throttle: 0.7, brake: 0.1, steering: 0.4, handbrake: true,
+ });
+ assert.deepEqual(aircraftActionsFromPlay(input), {
+ throttle: 1, yaw: 0.75, pitch: 0.7, roll: 0.4,
+ });
+ assert.deepEqual(crowActionsFromPlay(input), {
+ forward: 0.7, right: 0, turn: 0.4, pitch: 0.6, climb: 1,
+ sprint: false, glide: true,
+ });
+ });
+
+ it("keeps camera-relative walking normalized and actor-local", () => {
+ const input = { moveX: 1, moveY: 1 };
+ const world = cameraRelativePlanar(input, { x: 0, z: -1 });
+ assert.ok(Math.abs(Math.hypot(world.x, world.z) - 1) < 1e-12);
+ const actor = groundActorActionsFromPlay(
+ { moveX: 0, moveY: 1, lookX: 0, lookY: 0, throttle: 0, brake: 0,
+ ascend: 0, descend: 0, primary: false, secondary: false },
+ Math.PI / 2,
+ { x: 1, z: 0 },
+ );
+ assert.ok(Math.abs(actor.forward + 1) < 1e-12);
+ assert.ok(Math.abs(actor.right) < 1e-12);
+ });
+});
diff --git a/src/test/pointerStick.test.ts b/src/test/pointerStick.test.ts
new file mode 100644
index 0000000..2736c04
--- /dev/null
+++ b/src/test/pointerStick.test.ts
@@ -0,0 +1,34 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { PointerStick } from "../input/pointerStick.ts";
+
+const BOUNDS = { left: 10, top: 20, width: 120, height: 120 };
+
+describe("analogue pointer stick", () => {
+ it("normalizes the disc and preserves the vertical controller convention", () => {
+ const stick = new PointerStick();
+ assert.deepEqual(stick.begin(7, 70, 20, BOUNDS), { moveX: 0, moveY: 1 });
+ const corner = stick.move(7, 130, 20);
+ assert.ok(corner);
+ assert.ok(Math.abs(Math.hypot(corner.moveX, corner.moveY) - 1) < 1e-12);
+ });
+
+ it("does not let another finger steal or release the active gesture", () => {
+ const stick = new PointerStick();
+ stick.begin(7, 70, 20, BOUNDS);
+ assert.equal(stick.begin(8, 10, 80, BOUNDS), null);
+ assert.equal(stick.end(8), false);
+ assert.equal(stick.activePointer(), 7);
+ assert.ok(stick.move(7, 10, 80));
+ });
+
+ it("returns to neutral ownership after release or cancellation", () => {
+ const stick = new PointerStick();
+ stick.begin(3, 70, 20, BOUNDS);
+ assert.equal(stick.end(3), true);
+ assert.equal(stick.activePointer(), null);
+ assert.deepEqual(stick.begin(4, 70, 80, BOUNDS), { moveX: 0, moveY: 0 });
+ assert.equal(stick.cancel(4), true);
+ assert.equal(stick.activePointer(), null);
+ });
+});