1
0

feat: unify life-sim play controls

This commit is contained in:
2026-08-19 03:04:53 -07:00
parent 120eac878a
commit 264daaab61
14 changed files with 1697 additions and 234 deletions
+16
View File
@@ -72,6 +72,22 @@ npm install
npm run dev 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 ## Headless RL environments
Tera also exports a versioned, renderer-independent Arena contract with five Tera also exports a versioned, renderer-independent Arena contract with five
+246 -22
View File
@@ -765,6 +765,72 @@
than mystery glyphs: the controls disappear everywhere space is tight than mystery glyphs: the controls disappear everywhere space is tight
except on the devices that cannot drive without them. */ except on the devices that cannot drive without them. */
.drive-controls, .walk-controls { display: none; } .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) { @media (pointer: coarse) {
.drive-controls:not([hidden]), .walk-controls:not([hidden]) { .drive-controls:not([hidden]), .walk-controls:not([hidden]) {
position: fixed; position: fixed;
@@ -807,6 +873,142 @@
body:has(.drive-controls:not([hidden])) .source, body:has(.drive-controls:not([hidden])) .source,
body:has(.walk-controls:not([hidden])) .source { bottom: 7.5rem; } 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 ------------------------------------------------------- /* ---- Responsive -------------------------------------------------------
Two breakpoints and no more. At 900 the left column stops being furniture 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 `deviceProfile()` in `stage.ts` keys off this exact 600px edge — so the
stylesheet and the pixel budget cannot drift apart. */ stylesheet and the pixel budget cannot drift apart. */
@media (max-width: 600px) { @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 /* 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` rhythm and the smallest target anyone has managed to defend. `#help`
was 17px tall. */ was 17px tall. */
@@ -1039,28 +1253,36 @@
</div> </div>
</div> </div>
<div id="drive-controls" class="drive-controls" aria-label="Vehicle controls" hidden> <section id="play-hud" class="play-hud" aria-live="polite" hidden>
<button class="drive-control" data-drive-key="a" aria-pressed="false">Left</button> <span id="play-hud-mode" class="play-hud-mode"></span>
<button class="drive-control" data-drive-key="w" aria-pressed="false">Throttle</button> <strong id="play-hud-primary" class="play-hud-primary"></strong>
<button class="drive-control" data-drive-key="s" aria-pressed="false">Brake</button> <span id="play-hud-status" class="play-hud-status"></span>
<button class="drive-control" data-drive-key="d" aria-pressed="false">Right</button> </section>
<button class="drive-control" data-drive-key=" " aria-pressed="false">Handbrake</button>
<button class="drive-control" data-drive-action="assist">Assist</button>
<button class="drive-control" data-drive-action="reset">Reset</button>
<button class="drive-control" data-drive-action="camera">Camera</button>
</div>
<div id="walk-controls" class="walk-controls" aria-label="Walking controls" hidden> <nav id="mode-dock" class="mode-dock" aria-label="Control mode">
<button class="walk-control" data-walk-key="a" data-walk-label="Left" data-aircraft-label="Roll left" aria-pressed="false">Left</button> <button type="button" data-control-mode="overview" aria-pressed="true">View</button>
<button class="walk-control" data-walk-key="w" data-walk-label="Forward" data-aircraft-label="Pitch up" aria-pressed="false">Forward</button> <button type="button" data-control-mode="drive" aria-pressed="false" hidden>Drive</button>
<button class="walk-control" data-walk-key="s" data-walk-label="Back" data-aircraft-label="Pitch down" aria-pressed="false">Back</button> <button type="button" data-control-mode="actor" aria-pressed="false" hidden>Explore</button>
<button class="walk-control" data-walk-key="d" data-walk-label="Right" data-aircraft-label="Roll right" aria-pressed="false">Right</button> <button type="button" data-control-mode="aircraft" aria-pressed="false" hidden>Fly</button>
<button class="walk-control flight-only" data-walk-key="q" data-walk-label="Descend" data-aircraft-label="Yaw left" aria-pressed="false">Descend</button> <button type="button" data-control-mode="office-walk" aria-pressed="false" hidden>Walk</button>
<button class="walk-control flight-only" data-walk-key="e" data-walk-label="Climb" data-aircraft-label="Yaw right" aria-pressed="false">Climb</button> </nav>
<button class="walk-control crow-only" data-walk-key="g" data-walk-label="Glide" aria-pressed="false" hidden>Glide</button>
<button class="walk-control aircraft-only" data-walk-key=" " data-aircraft-label="Throttle" aria-pressed="false" hidden>Throttle</button> <div id="touch-play-controls" class="touch-play-controls" aria-label="Touch play controls" hidden>
<button class="walk-control aircraft-only" data-aircraft-action="assist" hidden>Assist</button> <div id="play-stick" class="play-stick" role="application"
<button class="walk-control aircraft-only" data-aircraft-action="reset" hidden>Reset</button> aria-label="Movement joystick. Drag in any direction.">
<span class="play-stick-ring" aria-hidden="true"></span>
<span id="play-stick-knob" class="play-stick-knob" aria-hidden="true"></span>
</div>
<div class="touch-actions">
<button id="touch-primary" data-play-control="primary" aria-pressed="false">Action</button>
<button id="touch-secondary" data-play-control="secondary" aria-pressed="false" hidden>Glide</button>
<button id="touch-pitch-up" data-play-control="pitch-up" aria-pressed="false" hidden>Pitch +</button>
<button id="touch-pitch-down" data-play-control="pitch-down" aria-pressed="false" hidden>Pitch </button>
<button id="touch-assist" hidden>Assist</button>
<button id="touch-reset" hidden>Reset</button>
<button id="touch-camera" hidden>Camera</button>
<button id="touch-map" aria-pressed="false">Map</button>
</div>
</div> </div>
<p id="source" class="source"></p> <p id="source" class="source"></p>
@@ -1082,6 +1304,7 @@
<dt><kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd></dt><dd>Drive, walk, or manually fly the California aircraft</dd> <dt><kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd></dt><dd>Drive, walk, or manually fly the California aircraft</dd>
<dt><kbd>V</kbd></dt><dd>Walk through an office / return to the dollhouse view</dd> <dt><kbd>V</kbd></dt><dd>Walk through an office / return to the dollhouse view</dd>
<dt><kbd>Q</kbd> / <kbd>E</kbd></dt><dd>Descend / climb while flying as a crow</dd> <dt><kbd>Q</kbd> / <kbd>E</kbd></dt><dd>Descend / climb while flying as a crow</dd>
<dt><kbd>I</kbd> / <kbd>K</kbd></dt><dd>Pitch the crow up / down</dd>
<dt><kbd>Space</kbd></dt><dd>Handbrake while driving</dd> <dt><kbd>Space</kbd></dt><dd>Handbrake while driving</dd>
<dt><kbd>P</kbd> / <kbd>R</kbd></dt><dd>Resume assisted drive or flight / reset the vehicle</dd> <dt><kbd>P</kbd> / <kbd>R</kbd></dt><dd>Resume assisted drive or flight / reset the vehicle</dd>
<dt><kbd>C</kbd></dt><dd>Switch chase / driver-height camera</dd> <dt><kbd>C</kbd></dt><dd>Switch chase / driver-height camera</dd>
@@ -1099,7 +1322,8 @@
<dt>One finger</dt><dd>Orbit</dd> <dt>One finger</dt><dd>Orbit</dd>
<dt>Two fingers</dt><dd>Pinch to zoom, drag to move over the ground</dd> <dt>Two fingers</dt><dd>Pinch to zoom, drag to move over the ground</dd>
<dt>Tap a marker</dt><dd>Its card, at the bottom of the screen</dd> <dt>Tap a marker</dt><dd>Its card, at the bottom of the screen</dd>
<dt>Route controls</dt><dd>Choose 101 or I-5; the driving pad appears at the bottom</dd> <dt>Play joystick</dt><dd>Move with the lower-left stick; current actions appear lower right</dd>
<dt>Map</dt><dd>The play action opens or closes the local-player plan</dd>
<dt>Tap the map</dt><dd>Dismiss the card; tap the ☰ sheet's scrim to close it</dd> <dt>Tap the map</dt><dd>Dismiss the card; tap the ☰ sheet's scrim to close it</dd>
<dt>Plan view</dt><dd>The button on the rail shows or hides it, as a sheet above the rail</dd> <dt>Plan view</dt><dd>The button on the rail shows or hides it, as a sheet above the rail</dd>
</dl> </dl>
+10
View File
@@ -18,6 +18,12 @@ const VIEWPORTS = {
}; };
const SCENES = { const SCENES = {
california: { host: "tera.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0 }, 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 }, 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); })).catch(() => null);
throw new Error(`scene readiness timed out: ${JSON.stringify({ state, consoleErrors, requests: requestLog.slice(before) })}`, { cause: error }); 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.waitForTimeout(warmupMs);
await page.evaluate(() => { await page.evaluate(() => {
const state = globalThis.__teraPerformanceBudget; const state = globalThis.__teraPerformanceBudget;
+4
View File
@@ -5,6 +5,10 @@
"desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 650, "maxTriangles": 750000 }, "desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 650, "maxTriangles": 750000 },
"mobile": { "p95FrameIntervalMs": 33.3, "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": { "office": {
"desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 550, "maxTriangles": 550000 }, "desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 550, "maxTriangles": 550000 },
"mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 550, "maxTriangles": 550000 } "mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 550, "maxTriangles": 550000 }
+61
View File
@@ -81,11 +81,21 @@ export interface MinimapOptions {
maxPixelRatio?: number; maxPixelRatio?: number;
} }
export interface MinimapPlayer {
lat: number;
lng: number;
/** Degrees clockwise from true north. */
headingDeg: number;
kind: "vehicle" | "actor" | "aircraft";
}
export interface Minimap { export interface Minimap {
/** The widget. The caller inserts it into its own container and sizes it in CSS. */ /** The widget. The caller inserts it into its own container and sizes it in CSS. */
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
setMarkers(markers: Marker[]): void; setMarkers(markers: Marker[]): void;
setAircraft(aircraft: Aircraft[]): 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; setChapters(chapters: Chapter[], activeId: string): void;
/** /**
* Solar elevation in degrees, the same number `scene.setSolarElevation` gets. * Solar elevation in degrees, the same number `scene.setSolarElevation` gets.
@@ -208,6 +218,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
let aircraft: Aircraft[] = []; let aircraft: Aircraft[] = [];
let chapters: Chapter[] = city.chapters; let chapters: Chapter[] = city.chapters;
let activeChapterId = city.chapters[0]?.id ?? ""; let activeChapterId = city.chapters[0]?.id ?? "";
let player: MinimapPlayer | null = null;
let night = 0; let night = 0;
let renderedNight = -1; let renderedNight = -1;
@@ -219,6 +230,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
let chapterPx = new Float64Array(0); let chapterPx = new Float64Array(0);
let activeChapterIndex = -1; let activeChapterIndex = -1;
let aircraftPx = new Float64Array(0); let aircraftPx = new Float64Array(0);
let playerPx = new Float64Array(0);
let landPath = new Path2D(); let landPath = new Path2D();
let parkPath = 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 ------------------------------------------------------- // ---- The static map -------------------------------------------------------
function renderStatic() { 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) { function drawPing(ctx: Ctx, now: number) {
if (pinging === 0) return; if (pinging === 0) return;
const t = (now - pinging) / PING_MS; const t = (now - pinging) / PING_MS;
@@ -765,6 +814,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
drawMarkers(ctx); drawMarkers(ctx);
drawChapters(ctx); drawChapters(ctx);
drawAircraft(ctx); drawAircraft(ctx);
drawPlayer(ctx);
drawTarget(ctx); drawTarget(ctx);
drawCamera(ctx); drawCamera(ctx);
if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr); if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
@@ -1031,6 +1081,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
layoutMarkers(); layoutMarkers();
layoutChapters(); layoutChapters();
layoutAircraft(); layoutAircraft();
layoutPlayer();
renderStatic(); renderStatic();
dirty = true; dirty = true;
} }
@@ -1052,6 +1103,16 @@ export function createMinimap(options: MinimapOptions): Minimap {
dirty = true; 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) { setChapters(next, activeId) {
chapters = next; chapters = next;
activeChapterId = activeId; activeChapterId = activeId;
+45
View File
@@ -106,6 +106,15 @@ export interface OfficeMinimapOptions {
maxPixelRatio?: number; 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 { export interface OfficeMinimap {
/** The widget. The caller inserts it into its own container and sizes it in CSS. */ /** The widget. The caller inserts it into its own container and sizes it in CSS. */
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
@@ -145,6 +154,7 @@ export interface OfficeMinimap {
* heading until they have taken a step. * heading until they have taken a step.
*/ */
setRobots(robots: readonly PlanRobot[]): void; setRobots(robots: readonly PlanRobot[]): void;
setPlayer(player: OfficeMinimapPlayer | null): void;
/** Call from the stage tick. Cheap by construction — see the file header. */ /** Call from the stage tick. Cheap by construction — see the file header. */
tick(): void; tick(): void;
/** Re-do the backing store at the current size and re-rasterise the plan. */ /** 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 level: LevelPlan | null = plan.levels[0] ?? null;
let activeViewId: string | null = 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. */ /** Occupied seats on this storey: x, y device pixels per person, laid out once. */
let occupiedPx = new Float64Array(0); let occupiedPx = new Float64Array(0);
/** Seat id -> label, for the hover readout. Every seat in the building, not just this storey. */ /** 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) { function drawPing(ctx: Ctx, now: number) {
if (pinging === 0) return; if (pinging === 0) return;
const t = (now - pinging) / PING_MS; 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 // 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. // that has been the order here since the widget was one function.
drawRobots(ctx); drawRobots(ctx);
drawPlayer(ctx);
crosshair(ctx, toPxX(controls.target.x), toPxY(controls.target.z), theme.target, 5 * dpr); crosshair(ctx, toPxX(controls.target.x), toPxY(controls.target.z), theme.target, 5 * dpr);
drawCamera(ctx); drawCamera(ctx);
if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr); if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
@@ -1356,6 +1391,16 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
dirty = true; 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() { tick() {
if (!ready || !viewCtx) return; if (!ready || !viewCtx) return;
const now = performance.now(); const now = performance.now();
+49 -34
View File
@@ -80,6 +80,7 @@ import type {
} from "../aircraft/controller.ts"; } from "../aircraft/controller.ts";
import type { ScenePeers, ScenePeersOptions } from "../realtime/scenePeers.ts"; import type { ScenePeers, ScenePeersOptions } from "../realtime/scenePeers.ts";
import type { EntityPoseSnapshot } from "../realtime/types.ts"; import type { EntityPoseSnapshot } from "../realtime/types.ts";
import { cityControlOwnership, type CityControlMode } from "../play/controlMode.ts";
export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">; export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;
@@ -195,6 +196,10 @@ export interface SceneHandle {
flyTo(chapterId: string): void; flyTo(chapterId: string): void;
current(): string; current(): string;
onChapterChange(fn: (id: string) => void): void; 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. */ /** Device-neutral input for the corridor hero; a no-op on boards without one. */
setVehicleActions(actions: Partial<VehicleActionSnapshot>): void; setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
/** Current playable corridor state, or null on a city-scale board. */ /** Current playable corridor state, or null on a city-scale board. */
@@ -482,6 +487,33 @@ export async function createScene(
let currentChapter = first.id; let currentChapter = first.id;
const chapterListeners: ((id: string) => void)[] = []; 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 { function chapterPose(ch: Chapter): Pose {
const [x, z] = world.project(ch.focus.lat, ch.focus.lng); 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 // Named viewpoints are observe/vehicle destinations. Possessing an actor
// is an explicit UI action, so a chapter selection always hands the camera // is an explicit UI action, so a chapter selection always hands the camera
// back before it moves anywhere else. // 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); const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
if (route && roadTraffic) { if (route && roadTraffic) {
roadTraffic.setRoute(route.id); roadTraffic.setRoute(route.id);
roadTraffic.setFollowing(true); applyControlMode("drive");
} else { } else {
roadTraffic?.setFollowing(false); applyControlMode("overview");
roadTraffic?.setVehicleActions({}); roadTraffic?.setVehicleActions({});
kit.flyTo(chapterPose(ch)); kit.flyTo(chapterPose(ch));
} }
@@ -547,14 +572,15 @@ export async function createScene(
// bug. // bug.
onExit: () => kit.resetPick(), onExit: () => kit.resetPick(),
tick(dt) { tick(dt) {
const actorPlaying = sceneActor?.active() ?? false; // Exactly one subsystem owns the camera. In particular, OrbitControls
const aircraftPlaying = sceneAircraft?.active() ?? false; // must stay disabled while the road layer writes its follow pose.
kit.controls.enabled = !actorPlaying && !aircraftPlaying; const ownership = cityControlOwnership(controlMode);
kit.controls.enabled = ownership.orbit;
kit.tick(dt); kit.tick(dt);
sceneActor?.tick(dt); sceneActor?.tick(dt);
if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose()); if (ownership.actor && sceneActor) kit.setPose(sceneActor.followPose());
sceneAircraft?.tick(dt); sceneAircraft?.tick(dt);
if (aircraftPlaying && sceneAircraft) kit.setPose(sceneAircraft.followPose()); if (ownership.aircraft && sceneAircraft) kit.setPose(sceneAircraft.followPose());
realtimePeers?.tick(Date.now()); realtimePeers?.tick(Date.now());
roadTraffic?.tick(dt); roadTraffic?.tick(dt);
clouds.tick(dt); clouds.tick(dt);
@@ -645,6 +671,11 @@ export async function createScene(
onChapterChange(fn) { onChapterChange(fn) {
chapterListeners.push(fn); chapterListeners.push(fn);
}, },
setControlMode: (mode) => { applyControlMode(mode); },
controlMode: () => controlMode,
onControlModeChange(fn) {
controlModeListeners.push(fn);
},
setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions), setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions),
vehicleState: () => roadTraffic?.hero() ?? null, vehicleState: () => roadTraffic?.hero() ?? null,
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode), setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
@@ -655,31 +686,15 @@ export async function createScene(
attachActorFaceTexture: (texture) => sceneActor?.attachFaceTexture(texture) ?? false, attachActorFaceTexture: (texture) => sceneActor?.attachFaceTexture(texture) ?? false,
clearActorFaceTexture: () => { sceneActor?.clearFaceTexture(); }, clearActorFaceTexture: () => { sceneActor?.clearFaceTexture(); },
setActorActive(active) { setActorActive(active) {
sceneActor?.setActive(active); if (active) applyControlMode("actor");
if (active) { else if (controlMode === "actor") applyControlMode("overview");
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();
}, },
actorActive: () => sceneActor?.active() ?? false, actorActive: () => sceneActor?.active() ?? false,
setAircraftActions: (actions) => { sceneAircraft?.setActions(actions); }, setAircraftActions: (actions) => { sceneAircraft?.setActions(actions); },
aircraftState: () => sceneAircraft?.state() ?? null, aircraftState: () => sceneAircraft?.state() ?? null,
setAircraftActive(active) { setAircraftActive(active) {
sceneAircraft?.setActive(active); if (active) applyControlMode("aircraft");
if (active) { else if (controlMode === "aircraft") applyControlMode("overview");
sceneActor?.setActive(false);
roadTraffic?.setFollowing(false);
}
kit.camera.near = active ? 0.01 : 0.1;
kit.controls.minDistance = active ? 0.01 : orbitMinDistance;
kit.camera.updateProjectionMatrix();
}, },
aircraftActive: () => sceneAircraft?.active() ?? false, aircraftActive: () => sceneAircraft?.active() ?? false,
upsertRemoteSnapshot: (snapshot) => { upsertRemoteSnapshot: (snapshot) => {
+328
View File
@@ -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<PlayInputSnapshot> = 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<PlayDigitalControl>;
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<string, SourceState>();
private readonly edges = new Set<PlayEdgeControl>();
private source(id: string): SourceState {
const existing = this.sources.get(id);
if (existing) return existing;
const created = { digital: new Set<PlayDigitalControl>(), 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<PlayAxes>): 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<PlayEdgeControl> {
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<PlayDigitalControl>;
requests: ReadonlySet<PlayEdgeControl>;
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<PlayDigitalControl>();
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<PlayEdgeControl>();
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<PlayInputSnapshot, "moveX" | "moveY">,
cameraForward: Readonly<PlanarDirection>,
): 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<PlayInputSnapshot>,
): {
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<PlayInputSnapshot>,
): { 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<PlayInputSnapshot>,
): {
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<PlayInputSnapshot>,
yaw: number,
cameraForward: Readonly<PlanarDirection>,
): {
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,
};
}
+69
View File
@@ -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;
}
+554 -172
View File
@@ -37,11 +37,17 @@ import SAN_FRANCISCO from "./cities/sf.ts";
import SOCAL from "./cities/socal.ts"; import SOCAL from "./cities/socal.ts";
import CALIFORNIA_TRANSPORT from "./transport/california.ts"; import CALIFORNIA_TRANSPORT from "./transport/california.ts";
import { import {
mergeVehicleActions, aircraftActionsFromPlay,
sampleStandardGamepad, cameraRelativePlanar,
type GamepadButtonState, crowActionsFromPlay,
} from "./input/vehicle.ts"; groundActorActionsFromPlay,
import type { VehicleActionSnapshot } from "./transport/vehicleController.ts"; PlayInputRouter,
sampleStandardPlayGamepad,
vehicleActionsFromPlay,
type PlayDigitalControl,
type StandardPlayGamepadButtons,
} from "./input/play.ts";
import { PointerStick } from "./input/pointerStick.ts";
import { import {
createTeraClient, createTeraClient,
describeLiveness, describeLiveness,
@@ -91,8 +97,13 @@ import { SRGBColorSpace, VideoTexture } from "three";
import { import {
CALIFORNIA_AIR_ROUTE, CALIFORNIA_AIR_ROUTE,
createAircraftPoseSnapshot, createAircraftPoseSnapshot,
type AircraftActionSnapshot,
} from "./aircraft/index.ts"; } from "./aircraft/index.ts";
import {
cityControlMode,
createControlModeState,
transitionControlMode,
type ControlMode,
} from "./play/controlMode.ts";
import { import {
createOfficeScreenPanel, createOfficeScreenPanel,
type MediaSurfaceDescriptor, type MediaSurfaceDescriptor,
@@ -285,6 +296,8 @@ let office: OfficeScene | null = null;
/** The pack used by `office`; kept separate from the currently selected door. */ /** The pack used by `office`; kept separate from the currently selected door. */
let builtOfficeId: string | null = null; let builtOfficeId: string | null = null;
let inside = false; let inside = false;
let controlModeState = createControlModeState();
const playInput = new PlayInputRouter();
let markers: Marker[] = SAMPLE_MARKERS; let markers: Marker[] = SAMPLE_MARKERS;
let realtimeClient: RealtimeClient | null = null; let realtimeClient: RealtimeClient | null = null;
let presenceIndicator: PresenceIndicator | null = null; let presenceIndicator: PresenceIndicator | null = null;
@@ -804,6 +817,8 @@ async function mountCity(id: string) {
weatherWatch = null; weatherWatch = null;
poseEditor?.destroy(); poseEditor?.destroy();
poseEditor = null; poseEditor = null;
clearPublishedPlayInput();
controlModeState = createControlModeState();
disposeLoadedOffice(); disposeLoadedOffice();
inside = false; inside = false;
minimap?.dispose(); minimap?.dispose();
@@ -1062,6 +1077,7 @@ async function mountCity(id: string) {
marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null, marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null,
}); });
city.onChapterChange(() => renderLegend()); city.onChapterChange(() => renderLegend());
city.onControlModeChange((mode) => adoptCityControlMode(mode));
/** /**
* The plan view, built last, because it reads the finished `World` the * 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. // definition of "phone", in `stage.ts`, read by both.
maxPixelRatio: deviceProfile().maxPixelRatio, maxPixelRatio: deviceProfile().maxPixelRatio,
onSeek(lat, lng) { 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 * 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": * 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(function pumpMinimap() {
requestAnimationFrame(pumpMinimap); requestAnimationFrame(pumpMinimap);
syncJourneyVehicle(performance.now()); syncJourneyVehicle(performance.now());
updateLocalPlayerMaps();
renderPlayHud();
// Only the mounted one. The other is still constructed and still holds a live // 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 // 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 // `resize()` puts it back to `ready = false`, and ticking it would be a
@@ -1149,6 +1167,61 @@ requestAnimationFrame(function pumpMinimap() {
pollLiveness(); 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. * Whether the corner label is still telling the truth, once a second.
* *
@@ -1302,8 +1375,13 @@ async function enterOffice() {
office.onViewChange(() => renderLegend()); office.onViewChange(() => renderLegend());
officePlan = buildOfficePlan(createOfficeMinimap, office); officePlan = buildOfficePlan(createOfficeMinimap, office);
} }
requestControlMode("overview");
city.stage.setScene(office); city.stage.setScene(office);
inside = true; inside = true;
controlModeState = {
mode: "office-overview",
revision: controlModeState.revision + 1,
};
attachCurrentWebcamFace(); attachCurrentWebcamFace();
moveRealtimePresence(); moveRealtimePresence();
const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area"; const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area";
@@ -1344,6 +1422,7 @@ function buildOfficePlan(
// exact drift `deviceProfile` exists in one file to prevent. // exact drift `deviceProfile` exists in one file to prevent.
maxPixelRatio: deviceProfile().maxPixelRatio, maxPixelRatio: deviceProfile().maxPixelRatio,
onSeek(x, z) { onSeek(x, z) {
if (controlModeState.mode !== "office-overview") return;
/** /**
* The same move the city plan makes, and for the same reason: slide the * 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 * 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. // rather than publish into a room the user has already left.
stopWatchingOccupancy(); stopWatchingOccupancy();
disposeOfficeScreenUi(); disposeOfficeScreenUi();
clearPublishedPlayInput();
office?.walker?.setActive(false); office?.walker?.setActive(false);
office?.walker?.setAction({ x: 0, z: 0 });
dispatchJourney({ type: "leave-office" }); dispatchJourney({ type: "leave-office" });
city.stage.setScene(city.stageScene); city.stage.setScene(city.stageScene);
inside = false; inside = false;
controlModeState = {
mode: "overview",
revision: controlModeState.revision + 1,
};
city.setControlMode("overview");
attachCurrentWebcamFace(); attachCurrentWebcamFace();
moveRealtimePresence(); moveRealtimePresence();
showPlan(); showPlan();
@@ -1614,6 +1698,21 @@ const flyButton = document.querySelector<HTMLButtonElement>("#fly");
const screensButton = document.querySelector<HTMLButtonElement>("#screens"); const screensButton = document.querySelector<HTMLButtonElement>("#screens");
const walkControls = document.querySelector<HTMLElement>("#walk-controls"); const walkControls = document.querySelector<HTMLElement>("#walk-controls");
const walkHint = document.querySelector<HTMLElement>("#walk-hint"); 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 profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
const webcamFaceIndicator = document.querySelector<HTMLElement>("#webcam-face-indicator"); const webcamFaceIndicator = document.querySelector<HTMLElement>("#webcam-face-indicator");
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay"); const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
@@ -1623,6 +1722,100 @@ let webcamFaceTexture: WebcamFaceTextureAdapter | null = null;
let webcamFaceConsent = createWebcamFaceConsent(); let webcamFaceConsent = createWebcamFaceConsent();
let webcamCapture: WebcamCaptureController | null = null; 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) { function showDetail(text: string | null) {
const card = document.querySelector<HTMLElement>("#detail"); const card = document.querySelector<HTMLElement>("#detail");
const body = document.querySelector<HTMLElement>("#detail-text"); const body = document.querySelector<HTMLElement>("#detail-text");
@@ -1762,6 +1955,25 @@ function renderLegend() {
const walking = inside && (office?.walker?.active() ?? false); const walking = inside && (office?.walker?.active() ?? false);
const exploring = !inside && (city.actorActive() ?? false); const exploring = !inside && (city.actorActive() ?? false);
const flying = !inside && (city.aircraftActive() ?? 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) { if (walkButton) {
walkButton.hidden = inside ? office?.walker === null : city.actorState() === null; walkButton.hidden = inside ? office?.walker === null : city.actorState() === null;
walkButton.setAttribute("aria-pressed", String(walking || exploring)); walkButton.setAttribute("aria-pressed", String(walking || exploring));
@@ -1791,6 +2003,8 @@ function renderLegend() {
? walking ? walking
? `${officeName()}, following your ${office?.walker?.state().actor === "anonymous-dog" ? "dog" : "humanoid"}. Use W A S D to move.` ? `${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.` : `${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 : flying
? `${cityLabel}, following your electric aircraft. Use W A S D to fly or P to resume assisted flight.` ? `${cityLabel}, following your electric aircraft. Use W A S D to fly or P to resume assisted flight.`
: exploring : exploring
@@ -1816,6 +2030,32 @@ function renderLegend() {
: "Walking controls", : "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]") ?? []) { for (const control of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
control.textContent = flying control.textContent = flying
? control.dataset.aircraftLabel ?? control.textContent ? control.dataset.aircraftLabel ?? control.textContent
@@ -1837,7 +2077,46 @@ function renderLegend() {
? "WASD fly · P assisted · R reset" ? "WASD fly · P assisted · R reset"
: inside : inside
? "V walk · WASD move" ? "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) { function flyToIndex(index: number) {
const view = currentViews()[index]; const view = currentViews()[index];
if (!view) return; if (!view) return;
if (inside && office) office.flyTo(view.id); if (inside && office) {
requestControlMode("office-overview");
office.flyTo(view.id);
renderLegend();
}
else { else {
const destination = cityId === "california" ? CALIFORNIA_DESTINATIONS.get(view.id) : undefined; const destination = cityId === "california" ? CALIFORNIA_DESTINATIONS.get(view.id) : undefined;
if (destination) { if (destination) {
requestControlMode("overview");
officeId = destination.officeId; officeId = destination.officeId;
journeyToCity(destination.cityId === "socal" ? "socal" : "bay-area"); journeyToCity(destination.cityId === "socal" ? "socal" : "bay-area");
switchCity(destination.cityId); switchCity(destination.cityId);
return; return;
} }
if (view.id === "la-sf-us-101" || view.id === "la-sf-i-5") { 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); 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) { function switchCity(id: string) {
requestControlMode(inside ? "office-overview" : "overview");
if (inside && leaveToCity(id)) return; if (inside && leaveToCity(id)) return;
if (inside) leaveOffice(); if (inside) leaveOffice();
if (id === wantedCity) return; if (id === wantedCity) return;
@@ -2950,9 +3242,8 @@ enterButton?.addEventListener("click", () => void toggleOffice());
function toggleOfficeWalk(): boolean { function toggleOfficeWalk(): boolean {
if (!inside) { if (!inside) {
if (!city?.actorState()) return false; if (!city?.actorState()) return false;
const active = !city.actorActive(); const active = city.controlMode() !== "actor";
city.setActorActive(active); requestControlMode(active ? "actor" : "overview");
if (!active) city.setActorActions({});
if (active && window.innerWidth <= 600) { if (active && window.innerWidth <= 600) {
panelOpen = false; panelOpen = false;
applyPanel(); applyPanel();
@@ -2963,8 +3254,7 @@ function toggleOfficeWalk(): boolean {
const walker = office?.walker; const walker = office?.walker;
if (!walker) return false; if (!walker) return false;
const active = !walker.active(); const active = !walker.active();
walker.setActive(active); requestControlMode(active ? "office-walk" : "office-overview");
if (!active) walker.setAction({ x: 0, z: 0 });
if (active && window.innerWidth <= 600) { if (active && window.innerWidth <= 600) {
panelOpen = false; panelOpen = false;
applyPanel(); applyPanel();
@@ -2977,9 +3267,8 @@ walkButton?.addEventListener("click", () => toggleOfficeWalk());
function toggleAircraft(): boolean { function toggleAircraft(): boolean {
if (inside || cityId !== "california" || !city?.aircraftState()) return false; if (inside || cityId !== "california" || !city?.aircraftState()) return false;
const active = !city.aircraftActive(); const active = city.controlMode() !== "aircraft";
city.setAircraftActive(active); requestControlMode(active ? "aircraft" : "overview");
if (!active) city.setAircraftActions({});
if (active && window.innerWidth <= 600) { if (active && window.innerWidth <= 600) {
panelOpen = false; panelOpen = false;
applyPanel(); applyPanel();
@@ -2990,6 +3279,17 @@ function toggleAircraft(): boolean {
flyButton?.addEventListener("click", () => toggleAircraft()); 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. * Clicking a building on the city walks into it.
* *
@@ -3038,6 +3338,7 @@ function applyPanel() {
function applyPlan() { function applyPlan() {
document.body.classList.toggle("minimap-off", !planOpen); document.body.classList.toggle("minimap-off", !planOpen);
planToggle?.setAttribute("aria-pressed", String(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(); 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 { function routeDriveIsActive(): boolean {
const state = !inside ? city?.vehicleState() : null; const state = !inside ? city?.vehicleState() : null;
return state !== null && state !== undefined && !city?.actorActive() && return state !== null && state !== undefined && city?.controlMode() === "drive" &&
!city?.aircraftActive() && city?.current() === state.routeId; city.current() === state.routeId;
}
function publishVehicleActions(
supplement: Partial<VehicleActionSnapshot> = {},
): boolean {
if (!routeDriveIsActive() || !city) return false;
const left = heldDriveKeys.has("a");
const right = heldDriveKeys.has("d");
city.setVehicleActions(
mergeVehicleActions(
{
throttle: heldDriveKeys.has("w") ? 1 : 0,
brake: heldDriveKeys.has("s") ? 1 : 0,
steering: (right ? 1 : 0) - (left ? 1 : 0),
handbrake: heldDriveKeys.has(" "),
},
supplement,
),
);
return true;
}
function publishOfficeWalkActions(): boolean {
const walker = inside ? office?.walker : null;
if (!walker?.active()) return false;
walker.setAction({
x: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
z: (heldDriveKeys.has("s") ? 1 : 0) - (heldDriveKeys.has("w") ? 1 : 0),
});
return true;
}
function publishCityActorActions(): boolean {
if (inside || !city?.actorActive()) return false;
city.setActorActions({
forward: (heldDriveKeys.has("w") ? 1 : 0) - (heldDriveKeys.has("s") ? 1 : 0),
right: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
turn: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
sprint: heldDriveKeys.has(" "),
climb: (heldDriveKeys.has("e") || heldDriveKeys.has(" ") ? 1 : 0) -
(heldDriveKeys.has("q") ? 1 : 0),
glide: heldDriveKeys.has("g"),
});
return true;
}
function publishAircraftActions(
supplement: Partial<AircraftActionSnapshot> = {},
): boolean {
if (inside || !city?.aircraftActive()) return false;
city.setAircraftActions({
throttle: heldDriveKeys.has(" ") ? 1 : 0,
pitch: (heldDriveKeys.has("w") ? 1 : 0) - (heldDriveKeys.has("s") ? 1 : 0),
roll: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
yaw: (heldDriveKeys.has("e") ? 1 : 0) - (heldDriveKeys.has("q") ? 1 : 0),
modeRequest: supplement.modeRequest ?? "none",
reset: supplement.reset ?? false,
});
return true;
} }
function toggleVehicleCamera(): boolean { function toggleVehicleCamera(): boolean {
@@ -3183,98 +3422,257 @@ function toggleVehicleCamera(): boolean {
return true; return true;
} }
for (const button of driveControls?.querySelectorAll<HTMLButtonElement>("[data-drive-key]") ?? []) { function cameraForward(
const key = button.dataset.driveKey; camera: { position: { x: number; z: number } },
if (key === undefined) continue; 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) => { const release = (event: PointerEvent) => {
heldDriveKeys.delete(key); playInput.clearSource(`pointer:${event.pointerId}`);
button.setAttribute("aria-pressed", "false"); button.setAttribute("aria-pressed", "false");
publishVehicleActions(); publishPlayActions();
event.preventDefault(); event.preventDefault();
}; };
button.addEventListener("pointerdown", (event) => { button.addEventListener("pointerdown", (event) => {
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
button.setPointerCapture(event.pointerId); button.setPointerCapture(event.pointerId);
heldDriveKeys.add(key); playInput.setDigital(`pointer:${event.pointerId}`, control, true);
button.setAttribute("aria-pressed", "true"); button.setAttribute("aria-pressed", "true");
publishVehicleActions(); publishPlayActions();
event.preventDefault(); event.preventDefault();
}); });
button.addEventListener("pointerup", release); button.addEventListener("pointerup", release);
button.addEventListener("pointercancel", release); button.addEventListener("pointercancel", release);
button.addEventListener("lostpointercapture", 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]") ?? []) { touchAssist?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); });
const key = button.dataset.walkKey; touchReset?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); });
if (key === undefined) continue; touchCamera?.addEventListener("click", () => { playInput.request("camera"); publishPlayActions(); });
const release = (event: PointerEvent) => { touchMap?.addEventListener("click", () => {
heldDriveKeys.delete(key); togglePlan();
button.setAttribute("aria-pressed", "false"); touchMap.setAttribute("aria-pressed", String(planOpen));
publishOfficeWalkActions(); });
publishAircraftActions();
publishCityActorActions();
event.preventDefault();
};
button.addEventListener("pointerdown", (event) => {
button.setPointerCapture(event.pointerId);
heldDriveKeys.add(key);
button.setAttribute("aria-pressed", "true");
publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions();
event.preventDefault();
});
button.addEventListener("pointerup", release);
button.addEventListener("pointercancel", release);
button.addEventListener("lostpointercapture", release);
}
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='assist']") 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']") 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']") driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
?.addEventListener("click", () => toggleVehicleCamera()); ?.addEventListener("click", () => { playInput.request("camera"); publishPlayActions(); });
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='assist']") 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']") walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='reset']")
?.addEventListener("click", () => publishAircraftActions({ reset: true })); ?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); });
window.addEventListener("keyup", (event) => { window.addEventListener("keyup", (event) => {
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; const control = controlForKey(event.key);
if (!heldDriveKeys.delete(key)) return; if (!control) return;
if ( playInput.setDigital("keyboard", control, false);
publishVehicleActions() || publishOfficeWalkActions() || if (publishPlayActions()) event.preventDefault();
publishAircraftActions() || publishCityActorActions()
) event.preventDefault();
}); });
window.addEventListener("blur", () => { function releaseAllPlayInput(): void {
heldDriveKeys.clear(); clearPublishedPlayInput();
publishVehicleActions(); publishPlayActions();
publishOfficeWalkActions(); }
publishAircraftActions(); window.addEventListener("blur", releaseAllPlayInput);
publishCityActorActions(); document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") releaseAllPlayInput();
}); });
let gamepadButtons: GamepadButtonState = { assist: false, reset: false }; let gamepadButtons: StandardPlayGamepadButtons = { assist: false, reset: false, camera: false };
function pollDriveGamepad() { function pollPlayGamepad() {
try { try {
const pad = navigator.getGamepads?.().find((candidate) => candidate !== null); const pad = navigator.getGamepads?.().find((candidate) => candidate !== null);
if (pad && routeDriveIsActive()) { if (pad) {
const sample = sampleStandardGamepad(pad, gamepadButtons); const sample = sampleStandardPlayGamepad(pad, gamepadButtons);
gamepadButtons = sample.buttons; 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 { } else {
gamepadButtons = { assist: false, reset: false }; playInput.clearSource("gamepad");
gamepadButtons = { assist: false, reset: false, camera: false };
} }
} catch { } catch {
// Some privacy-hardened browsers expose the method but throw until a pad // Some privacy-hardened browsers expose the method but throw until a pad
// has produced a trusted event. Keyboard/touch remain fully functional. // 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. * Keyboard access to everything the mouse can reach.
@@ -3286,7 +3684,7 @@ requestAnimationFrame(pollDriveGamepad);
* belongs to that control, not to this. * belongs to that control, not to this.
*/ */
window.addEventListener("keydown", (event) => { 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; const target = event.target;
if ( if (
target instanceof HTMLInputElement || target instanceof HTMLInputElement ||
@@ -3324,45 +3722,29 @@ window.addEventListener("keydown", (event) => {
return; return;
} }
const lower = event.key.toLowerCase(); const lower = event.key.toLowerCase();
if ( const control = controlForKey(event.key);
lower === "w" || lower === "a" || lower === "s" || lower === "d" || if (control) {
lower === "q" || lower === "e" || lower === "g" || event.key === " " playInput.setDigital("keyboard", control, true);
) { if (publishPlayActions()) {
heldDriveKeys.add(event.key === " " ? " " : lower);
if (publishVehicleActions()) {
event.preventDefault();
return;
}
if (publishOfficeWalkActions()) {
event.preventDefault();
return;
}
if (publishAircraftActions()) {
event.preventDefault();
return;
}
if (publishCityActorActions()) {
event.preventDefault(); event.preventDefault();
return; return;
} }
} }
if (lower === "p" && publishVehicleActions({ modeRequest: "assisted" })) { if (lower === "p" && (routeDriveIsActive() || city?.controlMode() === "aircraft")) {
playInput.request("assist");
publishPlayActions();
event.preventDefault(); event.preventDefault();
return; return;
} }
if (lower === "p" && publishAircraftActions({ modeRequest: "assisted" })) { if (lower === "r" && (routeDriveIsActive() || city?.controlMode() === "aircraft")) {
playInput.request("reset");
publishPlayActions();
event.preventDefault(); event.preventDefault();
return; return;
} }
if (lower === "r" && publishVehicleActions({ reset: true })) { if (lower === "c" && routeDriveIsActive()) {
event.preventDefault(); playInput.request("camera");
return; publishPlayActions();
}
if (lower === "r" && publishAircraftActions({ reset: true })) {
event.preventDefault();
return;
}
if (lower === "c" && toggleVehicleCamera()) {
event.preventDefault(); event.preventDefault();
return; return;
} }
+104
View File
@@ -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<ControlModeAvailability>,
): 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<ControlModeState>,
requested: ControlMode,
availability: Readonly<ControlModeAvailability>,
): 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",
};
}
+59
View File
@@ -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");
});
});
+112
View File
@@ -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<number, number> } = {}) {
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);
});
});
+34
View File
@@ -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);
});
});