feat: unify life-sim play controls
This commit is contained in:
@@ -72,6 +72,22 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Play controls
|
||||
|
||||
The bottom mode dock is the local-player source of truth: View, Drive, Explore,
|
||||
Fly, or office Walk. A transition clears stale held input and atomically hands
|
||||
the follow camera to one subsystem. `WASD` is movement; `Q/E` is vertical or
|
||||
yaw, `I/K` pitches the crow, Space is the primary action, `G` glides, `P`
|
||||
resumes assistance, `R` resets, and `C` switches the driving camera. A standard
|
||||
gamepad maps both sticks, triggers, shoulders, and rising-edge action buttons.
|
||||
|
||||
Touch play uses a pointer-ID analogue stick at lower left and only the actions
|
||||
that apply to the current mode at lower right. The Map button remains available
|
||||
during possession. Touch, keyboard, and gamepad state are independent, so a
|
||||
released or cancelled finger cannot clear another source that is still held.
|
||||
The UI and follow camera are presentation adapters only; they never enter Arena
|
||||
observations, rewards, snapshots, traces, or simulator hashes.
|
||||
|
||||
## Headless RL environments
|
||||
|
||||
Tera also exports a versioned, renderer-independent Arena contract with five
|
||||
|
||||
+246
-22
@@ -765,6 +765,72 @@
|
||||
than mystery glyphs: the controls disappear everywhere space is tight
|
||||
except on the devices that cannot drive without them. */
|
||||
.drive-controls, .walk-controls { display: none; }
|
||||
.play-hud {
|
||||
position: fixed;
|
||||
top: calc(var(--s4) + env(safe-area-inset-top));
|
||||
left: 50%;
|
||||
z-index: 4;
|
||||
min-width: min(27rem, calc(100vw - 12rem));
|
||||
max-width: calc(100vw - var(--s4) * 2);
|
||||
transform: translateX(-50%);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: var(--s2) var(--s3);
|
||||
padding: var(--s2) var(--s3);
|
||||
pointer-events: none;
|
||||
color: var(--ink-2);
|
||||
background: rgba(8, 12, 17, 0.78);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.play-hud[hidden] { display: none; }
|
||||
.play-hud-mode { color: var(--amber-ink); text-transform: uppercase; letter-spacing: 0.11em; }
|
||||
.play-hud-primary { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.play-hud-status { text-align: right; color: var(--ink-3); }
|
||||
.play-hud-status.warning { color: #ff9d72; }
|
||||
.mode-dock {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
|
||||
z-index: 4;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 999px;
|
||||
background: rgba(8, 12, 17, 0.82);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
}
|
||||
.mode-dock button {
|
||||
min-width: 44px;
|
||||
min-height: 38px;
|
||||
padding: 5px 10px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: var(--ink-3);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mode-dock button[aria-pressed="true"] {
|
||||
color: var(--amber-ink);
|
||||
background: rgba(242, 177, 52, 0.2);
|
||||
}
|
||||
.mode-dock button[hidden] { display: none; }
|
||||
@media (pointer: coarse) {
|
||||
.drive-controls:not([hidden]), .walk-controls:not([hidden]) {
|
||||
position: fixed;
|
||||
@@ -807,6 +873,142 @@
|
||||
body:has(.drive-controls:not([hidden])) .source,
|
||||
body:has(.walk-controls:not([hidden])) .source { bottom: 7.5rem; }
|
||||
}
|
||||
body.touch-capable .drive-controls:not([hidden]),
|
||||
body.touch-capable .walk-controls:not([hidden]) {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(var(--s3) + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
width: min(23rem, calc(100vw - var(--s4) * 2));
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--s1);
|
||||
padding: var(--s1);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r);
|
||||
background: var(--glass-strong);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
body.touch-capable .drive-control,
|
||||
body.touch-capable .walk-control {
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-sm);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
body.touch-capable .drive-control[aria-pressed="true"],
|
||||
body.touch-capable .walk-control[aria-pressed="true"] {
|
||||
border-color: rgba(242, 177, 52, 0.65);
|
||||
background: rgba(242, 177, 52, 0.28);
|
||||
color: var(--amber-ink);
|
||||
}
|
||||
body:has(.drive-controls:not([hidden])) .mode-dock,
|
||||
body:has(.drive-controls:not([hidden])) .rail { bottom: calc(8rem + env(safe-area-inset-bottom)); }
|
||||
body:has(.walk-controls:not([hidden])) .mode-dock,
|
||||
body:has(.walk-controls:not([hidden])) .rail { bottom: calc(11.5rem + env(safe-area-inset-bottom)); }
|
||||
|
||||
/* GTA-like touch surface: analogue movement on the left, only the
|
||||
actions meaningful to the current vehicle/actor on the right. */
|
||||
.touch-play-controls { display: none; }
|
||||
@media (pointer: coarse) {
|
||||
.touch-play-controls:not([hidden]) { display: block; }
|
||||
}
|
||||
body.touch-capable .touch-play-controls:not([hidden]) { display: block; }
|
||||
.touch-play-controls:not([hidden]) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.play-stick {
|
||||
--stick-x: 0px;
|
||||
--stick-y: 0px;
|
||||
position: absolute;
|
||||
left: max(var(--s3), env(safe-area-inset-left));
|
||||
bottom: calc(var(--s3) + env(safe-area-inset-bottom));
|
||||
width: clamp(7.5rem, 31vw, 9rem);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background: radial-gradient(circle, rgba(255,255,255,.08) 0 38%, rgba(8,12,17,.78) 40% 100%);
|
||||
border: 1px solid rgba(255,255,255,.18);
|
||||
box-shadow: inset 0 0 24px rgba(0,0,0,.35), var(--shadow);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
}
|
||||
.play-stick-ring {
|
||||
position: absolute;
|
||||
inset: 21%;
|
||||
border: 1px solid rgba(242,177,52,.32);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.play-stick-knob {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
transform: translate(calc(-50% + var(--stick-x)), calc(-50% + var(--stick-y)));
|
||||
border-radius: 50%;
|
||||
background: rgba(242,177,52,.28);
|
||||
border: 1px solid rgba(242,177,52,.72);
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,.45);
|
||||
transition: transform 80ms ease-out;
|
||||
}
|
||||
.play-stick.active .play-stick-knob { transition: none; }
|
||||
.touch-actions {
|
||||
position: absolute;
|
||||
right: max(var(--s3), env(safe-area-inset-right));
|
||||
bottom: calc(var(--s3) + env(safe-area-inset-bottom));
|
||||
width: min(10rem, 42vw);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.touch-actions button {
|
||||
min-height: 46px;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid rgba(255,255,255,.18);
|
||||
border-radius: 999px;
|
||||
background: rgba(8,12,17,.78);
|
||||
color: var(--ink-2);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
font: inherit;
|
||||
font-size: 9px;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.touch-actions button[hidden] { display: none; }
|
||||
.touch-actions button[aria-pressed="true"] {
|
||||
border-color: rgba(242,177,52,.72);
|
||||
background: rgba(242,177,52,.28);
|
||||
color: var(--amber-ink);
|
||||
}
|
||||
body:has(.touch-play-controls:not([hidden])) .mode-dock {
|
||||
bottom: calc(10rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
body:has(.touch-play-controls:not([hidden])) #hint { display: none; }
|
||||
body:has(.touch-play-controls:not([hidden])) .source { bottom: 10rem; }
|
||||
|
||||
/* ---- Responsive -------------------------------------------------------
|
||||
Two breakpoints and no more. At 900 the left column stops being furniture
|
||||
@@ -850,6 +1052,18 @@
|
||||
`deviceProfile()` in `stage.ts` keys off this exact 600px edge — so the
|
||||
stylesheet and the pixel budget cannot drift apart. */
|
||||
@media (max-width: 600px) {
|
||||
.play-hud {
|
||||
top: calc(var(--s3) + env(safe-area-inset-top) + 2.65rem);
|
||||
min-width: 0;
|
||||
width: calc(100vw - var(--s3) * 2);
|
||||
}
|
||||
.mode-dock {
|
||||
max-width: calc(100vw - var(--s3) * 2);
|
||||
bottom: calc(var(--s3) + env(safe-area-inset-bottom));
|
||||
}
|
||||
.mode-dock button { padding-inline: 8px; }
|
||||
body:has(.drive-controls:not([hidden])) #hint,
|
||||
body:has(.walk-controls:not([hidden])) #hint { display: none; }
|
||||
/* Everything hit by a finger clears 44px, which is 11 steps of the 4px
|
||||
rhythm and the smallest target anyone has managed to defend. `#help`
|
||||
was 17px tall. */
|
||||
@@ -1039,28 +1253,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="drive-controls" class="drive-controls" aria-label="Vehicle controls" hidden>
|
||||
<button class="drive-control" data-drive-key="a" aria-pressed="false">Left</button>
|
||||
<button class="drive-control" data-drive-key="w" aria-pressed="false">Throttle</button>
|
||||
<button class="drive-control" data-drive-key="s" aria-pressed="false">Brake</button>
|
||||
<button class="drive-control" data-drive-key="d" aria-pressed="false">Right</button>
|
||||
<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>
|
||||
<section id="play-hud" class="play-hud" aria-live="polite" hidden>
|
||||
<span id="play-hud-mode" class="play-hud-mode"></span>
|
||||
<strong id="play-hud-primary" class="play-hud-primary"></strong>
|
||||
<span id="play-hud-status" class="play-hud-status"></span>
|
||||
</section>
|
||||
|
||||
<div id="walk-controls" class="walk-controls" aria-label="Walking controls" hidden>
|
||||
<button class="walk-control" data-walk-key="a" data-walk-label="Left" data-aircraft-label="Roll left" aria-pressed="false">Left</button>
|
||||
<button class="walk-control" data-walk-key="w" data-walk-label="Forward" data-aircraft-label="Pitch up" aria-pressed="false">Forward</button>
|
||||
<button class="walk-control" data-walk-key="s" data-walk-label="Back" data-aircraft-label="Pitch down" aria-pressed="false">Back</button>
|
||||
<button class="walk-control" data-walk-key="d" data-walk-label="Right" data-aircraft-label="Roll right" aria-pressed="false">Right</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 class="walk-control flight-only" data-walk-key="e" data-walk-label="Climb" data-aircraft-label="Yaw right" aria-pressed="false">Climb</button>
|
||||
<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>
|
||||
<button class="walk-control aircraft-only" data-aircraft-action="assist" hidden>Assist</button>
|
||||
<button class="walk-control aircraft-only" data-aircraft-action="reset" hidden>Reset</button>
|
||||
<nav id="mode-dock" class="mode-dock" aria-label="Control mode">
|
||||
<button type="button" data-control-mode="overview" aria-pressed="true">View</button>
|
||||
<button type="button" data-control-mode="drive" aria-pressed="false" hidden>Drive</button>
|
||||
<button type="button" data-control-mode="actor" aria-pressed="false" hidden>Explore</button>
|
||||
<button type="button" data-control-mode="aircraft" aria-pressed="false" hidden>Fly</button>
|
||||
<button type="button" data-control-mode="office-walk" aria-pressed="false" hidden>Walk</button>
|
||||
</nav>
|
||||
|
||||
<div id="touch-play-controls" class="touch-play-controls" aria-label="Touch play controls" hidden>
|
||||
<div id="play-stick" class="play-stick" role="application"
|
||||
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>
|
||||
|
||||
<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>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>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>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>
|
||||
@@ -1099,7 +1322,8 @@
|
||||
<dt>One finger</dt><dd>Orbit</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>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>Plan view</dt><dd>The button on the rail shows or hides it, as a sheet above the rail</dd>
|
||||
</dl>
|
||||
|
||||
@@ -18,6 +18,12 @@ const VIEWPORTS = {
|
||||
};
|
||||
const SCENES = {
|
||||
california: { host: "tera.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0 },
|
||||
"california-drive": {
|
||||
host: "tera.lumbridgecorp.com",
|
||||
ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 1,
|
||||
activate: () => document.querySelectorAll("#chapters .chapter")[1]?.click(),
|
||||
active: () => document.querySelector("[data-control-mode='drive']")?.getAttribute("aria-pressed") === "true" && document.getElementById("play-hud")?.hidden === false,
|
||||
},
|
||||
office: { host: "office.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.getElementById("enter")?.textContent?.includes("Back to the city") === true },
|
||||
};
|
||||
|
||||
@@ -211,6 +217,10 @@ async function measure(browser, port, sceneName, viewportName, budget, requestLo
|
||||
})).catch(() => null);
|
||||
throw new Error(`scene readiness timed out: ${JSON.stringify({ state, consoleErrors, requests: requestLog.slice(before) })}`, { cause: error });
|
||||
}
|
||||
if (scene.activate) {
|
||||
await page.evaluate(scene.activate);
|
||||
await page.waitForFunction(scene.active, null, { timeout: readyTimeoutMs });
|
||||
}
|
||||
await page.waitForTimeout(warmupMs);
|
||||
await page.evaluate(() => {
|
||||
const state = globalThis.__teraPerformanceBudget;
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
"desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 650, "maxTriangles": 750000 },
|
||||
"mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 650, "maxTriangles": 750000 }
|
||||
},
|
||||
"california-drive": {
|
||||
"desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 650, "maxTriangles": 750000 },
|
||||
"mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 650, "maxTriangles": 750000 }
|
||||
},
|
||||
"office": {
|
||||
"desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 550, "maxTriangles": 550000 },
|
||||
"mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 550, "maxTriangles": 550000 }
|
||||
|
||||
@@ -81,11 +81,21 @@ export interface MinimapOptions {
|
||||
maxPixelRatio?: number;
|
||||
}
|
||||
|
||||
export interface MinimapPlayer {
|
||||
lat: number;
|
||||
lng: number;
|
||||
/** Degrees clockwise from true north. */
|
||||
headingDeg: number;
|
||||
kind: "vehicle" | "actor" | "aircraft";
|
||||
}
|
||||
|
||||
export interface Minimap {
|
||||
/** The widget. The caller inserts it into its own container and sizes it in CSS. */
|
||||
canvas: HTMLCanvasElement;
|
||||
setMarkers(markers: Marker[]): void;
|
||||
setAircraft(aircraft: Aircraft[]): void;
|
||||
/** Local possessed entity. Separate from live traffic so it cannot be duplicated. */
|
||||
setPlayer(player: MinimapPlayer | null): void;
|
||||
setChapters(chapters: Chapter[], activeId: string): void;
|
||||
/**
|
||||
* Solar elevation in degrees, the same number `scene.setSolarElevation` gets.
|
||||
@@ -208,6 +218,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
|
||||
let aircraft: Aircraft[] = [];
|
||||
let chapters: Chapter[] = city.chapters;
|
||||
let activeChapterId = city.chapters[0]?.id ?? "";
|
||||
let player: MinimapPlayer | null = null;
|
||||
let night = 0;
|
||||
let renderedNight = -1;
|
||||
|
||||
@@ -219,6 +230,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
|
||||
let chapterPx = new Float64Array(0);
|
||||
let activeChapterIndex = -1;
|
||||
let aircraftPx = new Float64Array(0);
|
||||
let playerPx = new Float64Array(0);
|
||||
|
||||
let landPath = new Path2D();
|
||||
let parkPath = new Path2D();
|
||||
@@ -491,6 +503,18 @@ export function createMinimap(options: MinimapOptions): Minimap {
|
||||
});
|
||||
}
|
||||
|
||||
function layoutPlayer() {
|
||||
if (scale <= 0 || !player) {
|
||||
playerPx = new Float64Array(0);
|
||||
return;
|
||||
}
|
||||
playerPx = new Float64Array([
|
||||
toPxX(world.projectX(player.lng)),
|
||||
toPxY(world.projectZ(player.lat)),
|
||||
(player.headingDeg * Math.PI) / 180,
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- The static map -------------------------------------------------------
|
||||
|
||||
function renderStatic() {
|
||||
@@ -736,6 +760,31 @@ export function createMinimap(options: MinimapOptions): Minimap {
|
||||
}
|
||||
}
|
||||
|
||||
function drawPlayer(ctx: Ctx) {
|
||||
if (!player || playerPx.length < 3) return;
|
||||
const x = playerPx[0] ?? 0;
|
||||
const y = playerPx[1] ?? 0;
|
||||
const a = playerPx[2] ?? 0;
|
||||
const nx = Math.sin(a);
|
||||
const ny = -Math.cos(a);
|
||||
const sx = -ny;
|
||||
const sy = nx;
|
||||
const r = (player.kind === "aircraft" ? 5.2 : player.kind === "vehicle" ? 4.5 : 4) * dpr;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r + 2.5 * dpr, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = theme.chapterActive;
|
||||
ctx.lineWidth = 1.2 * dpr;
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + nx * r * 1.35, y + ny * r * 1.35);
|
||||
ctx.lineTo(x - nx * r * 0.75 + sx * r * 0.72, y - ny * r * 0.75 + sy * r * 0.72);
|
||||
ctx.lineTo(x - nx * r * 0.4, y - ny * r * 0.4);
|
||||
ctx.lineTo(x - nx * r * 0.75 - sx * r * 0.72, y - ny * r * 0.75 - sy * r * 0.72);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = theme.chapterActive;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawPing(ctx: Ctx, now: number) {
|
||||
if (pinging === 0) return;
|
||||
const t = (now - pinging) / PING_MS;
|
||||
@@ -765,6 +814,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
|
||||
drawMarkers(ctx);
|
||||
drawChapters(ctx);
|
||||
drawAircraft(ctx);
|
||||
drawPlayer(ctx);
|
||||
drawTarget(ctx);
|
||||
drawCamera(ctx);
|
||||
if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
|
||||
@@ -1031,6 +1081,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
|
||||
layoutMarkers();
|
||||
layoutChapters();
|
||||
layoutAircraft();
|
||||
layoutPlayer();
|
||||
renderStatic();
|
||||
dirty = true;
|
||||
}
|
||||
@@ -1052,6 +1103,16 @@ export function createMinimap(options: MinimapOptions): Minimap {
|
||||
dirty = true;
|
||||
},
|
||||
|
||||
setPlayer(next) {
|
||||
if (
|
||||
player?.lat === next?.lat && player?.lng === next?.lng &&
|
||||
player?.headingDeg === next?.headingDeg && player?.kind === next?.kind
|
||||
) return;
|
||||
player = next ? { ...next } : null;
|
||||
layoutPlayer();
|
||||
dirty = true;
|
||||
},
|
||||
|
||||
setChapters(next, activeId) {
|
||||
chapters = next;
|
||||
activeChapterId = activeId;
|
||||
|
||||
@@ -106,6 +106,15 @@ export interface OfficeMinimapOptions {
|
||||
maxPixelRatio?: number;
|
||||
}
|
||||
|
||||
export interface OfficeMinimapPlayer {
|
||||
levelId: string;
|
||||
x: number;
|
||||
z: number;
|
||||
/** Radians in office X/Z space; zero faces local north (-Z). */
|
||||
headingRad: number;
|
||||
kind: "humanoid" | "anonymous-dog";
|
||||
}
|
||||
|
||||
export interface OfficeMinimap {
|
||||
/** The widget. The caller inserts it into its own container and sizes it in CSS. */
|
||||
canvas: HTMLCanvasElement;
|
||||
@@ -145,6 +154,7 @@ export interface OfficeMinimap {
|
||||
* heading until they have taken a step.
|
||||
*/
|
||||
setRobots(robots: readonly PlanRobot[]): void;
|
||||
setPlayer(player: OfficeMinimapPlayer | null): void;
|
||||
/** Call from the stage tick. Cheap by construction — see the file header. */
|
||||
tick(): void;
|
||||
/** Re-do the backing store at the current size and re-rasterise the plan. */
|
||||
@@ -271,6 +281,7 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
*/
|
||||
let level: LevelPlan | null = plan.levels[0] ?? null;
|
||||
let activeViewId: string | null = null;
|
||||
let player: OfficeMinimapPlayer | null = null;
|
||||
/** Occupied seats on this storey: x, y device pixels per person, laid out once. */
|
||||
let occupiedPx = new Float64Array(0);
|
||||
/** Seat id -> label, for the hover readout. Every seat in the building, not just this storey. */
|
||||
@@ -927,6 +938,29 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
}
|
||||
}
|
||||
|
||||
function drawPlayer(ctx: Ctx) {
|
||||
if (!player || !level || player.levelId !== level.id) return;
|
||||
const x = toPxX(player.x);
|
||||
const y = toPxY(player.z);
|
||||
const nx = -Math.sin(player.headingRad);
|
||||
const ny = -Math.cos(player.headingRad);
|
||||
const sx = -ny;
|
||||
const sy = nx;
|
||||
const r = (player.kind === "anonymous-dog" ? 3.6 : 4.2) * dpr;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r + 2.5 * dpr, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = theme.viewpointActive;
|
||||
ctx.lineWidth = 1.2 * dpr;
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + nx * r * 1.35, y + ny * r * 1.35);
|
||||
ctx.lineTo(x - nx * r * 0.65 + sx * r * 0.65, y - ny * r * 0.65 + sy * r * 0.65);
|
||||
ctx.lineTo(x - nx * r * 0.65 - sx * r * 0.65, y - ny * r * 0.65 - sy * r * 0.65);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = theme.viewpointActive;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawPing(ctx: Ctx, now: number) {
|
||||
if (pinging === 0) return;
|
||||
const t = (now - pinging) / PING_MS;
|
||||
@@ -960,6 +994,7 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
// want to see; the camera is the thing you want to see over everything, and
|
||||
// that has been the order here since the widget was one function.
|
||||
drawRobots(ctx);
|
||||
drawPlayer(ctx);
|
||||
crosshair(ctx, toPxX(controls.target.x), toPxY(controls.target.z), theme.target, 5 * dpr);
|
||||
drawCamera(ctx);
|
||||
if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
|
||||
@@ -1356,6 +1391,16 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
dirty = true;
|
||||
},
|
||||
|
||||
setPlayer(next) {
|
||||
if (
|
||||
player?.levelId === next?.levelId && player?.x === next?.x &&
|
||||
player?.z === next?.z && player?.headingRad === next?.headingRad &&
|
||||
player?.kind === next?.kind
|
||||
) return;
|
||||
player = next ? { ...next } : null;
|
||||
dirty = true;
|
||||
},
|
||||
|
||||
tick() {
|
||||
if (!ready || !viewCtx) return;
|
||||
const now = performance.now();
|
||||
|
||||
+49
-34
@@ -80,6 +80,7 @@ import type {
|
||||
} from "../aircraft/controller.ts";
|
||||
import type { ScenePeers, ScenePeersOptions } from "../realtime/scenePeers.ts";
|
||||
import type { EntityPoseSnapshot } from "../realtime/types.ts";
|
||||
import { cityControlOwnership, type CityControlMode } from "../play/controlMode.ts";
|
||||
|
||||
export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;
|
||||
|
||||
@@ -195,6 +196,10 @@ export interface SceneHandle {
|
||||
flyTo(chapterId: string): void;
|
||||
current(): string;
|
||||
onChapterChange(fn: (id: string) => void): void;
|
||||
/** Atomically hands local input and follow-camera ownership to one subsystem. */
|
||||
setControlMode(mode: CityControlMode): void;
|
||||
controlMode(): CityControlMode;
|
||||
onControlModeChange(fn: (mode: CityControlMode) => void): void;
|
||||
/** Device-neutral input for the corridor hero; a no-op on boards without one. */
|
||||
setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
|
||||
/** Current playable corridor state, or null on a city-scale board. */
|
||||
@@ -482,6 +487,33 @@ export async function createScene(
|
||||
|
||||
let currentChapter = first.id;
|
||||
const chapterListeners: ((id: string) => void)[] = [];
|
||||
let controlMode: CityControlMode = "overview";
|
||||
const controlModeListeners: ((mode: CityControlMode) => void)[] = [];
|
||||
|
||||
function applyControlMode(requested: CityControlMode): CityControlMode {
|
||||
const next: CityControlMode =
|
||||
requested === "drive" && !roadTraffic ? "overview"
|
||||
: requested === "actor" && !sceneActor ? "overview"
|
||||
: requested === "aircraft" && !sceneAircraft ? "overview"
|
||||
: requested;
|
||||
const ownership = cityControlOwnership(next);
|
||||
sceneActor?.setActive(ownership.actor);
|
||||
sceneAircraft?.setActive(ownership.aircraft);
|
||||
roadTraffic?.setFollowing(ownership.drive);
|
||||
kit.controls.enabled = ownership.orbit;
|
||||
kit.camera.near = next === "actor" ? 0.001 : next === "aircraft" ? 0.01 : 0.1;
|
||||
kit.controls.minDistance = next === "actor"
|
||||
? 0.001
|
||||
: next === "aircraft"
|
||||
? 0.01
|
||||
: orbitMinDistance;
|
||||
kit.camera.updateProjectionMatrix();
|
||||
if (next !== controlMode) {
|
||||
controlMode = next;
|
||||
for (const fn of controlModeListeners) fn(next);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function chapterPose(ch: Chapter): Pose {
|
||||
const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
|
||||
@@ -502,19 +534,12 @@ export async function createScene(
|
||||
// Named viewpoints are observe/vehicle destinations. Possessing an actor
|
||||
// is an explicit UI action, so a chapter selection always hands the camera
|
||||
// back before it moves anywhere else.
|
||||
sceneActor?.setActive(false);
|
||||
sceneAircraft?.setActive(false);
|
||||
kit.controls.minDistance = orbitMinDistance;
|
||||
if (kit.camera.near !== 0.1) {
|
||||
kit.camera.near = 0.1;
|
||||
kit.camera.updateProjectionMatrix();
|
||||
}
|
||||
const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
|
||||
if (route && roadTraffic) {
|
||||
roadTraffic.setRoute(route.id);
|
||||
roadTraffic.setFollowing(true);
|
||||
applyControlMode("drive");
|
||||
} else {
|
||||
roadTraffic?.setFollowing(false);
|
||||
applyControlMode("overview");
|
||||
roadTraffic?.setVehicleActions({});
|
||||
kit.flyTo(chapterPose(ch));
|
||||
}
|
||||
@@ -547,14 +572,15 @@ export async function createScene(
|
||||
// bug.
|
||||
onExit: () => kit.resetPick(),
|
||||
tick(dt) {
|
||||
const actorPlaying = sceneActor?.active() ?? false;
|
||||
const aircraftPlaying = sceneAircraft?.active() ?? false;
|
||||
kit.controls.enabled = !actorPlaying && !aircraftPlaying;
|
||||
// Exactly one subsystem owns the camera. In particular, OrbitControls
|
||||
// must stay disabled while the road layer writes its follow pose.
|
||||
const ownership = cityControlOwnership(controlMode);
|
||||
kit.controls.enabled = ownership.orbit;
|
||||
kit.tick(dt);
|
||||
sceneActor?.tick(dt);
|
||||
if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose());
|
||||
if (ownership.actor && sceneActor) kit.setPose(sceneActor.followPose());
|
||||
sceneAircraft?.tick(dt);
|
||||
if (aircraftPlaying && sceneAircraft) kit.setPose(sceneAircraft.followPose());
|
||||
if (ownership.aircraft && sceneAircraft) kit.setPose(sceneAircraft.followPose());
|
||||
realtimePeers?.tick(Date.now());
|
||||
roadTraffic?.tick(dt);
|
||||
clouds.tick(dt);
|
||||
@@ -645,6 +671,11 @@ export async function createScene(
|
||||
onChapterChange(fn) {
|
||||
chapterListeners.push(fn);
|
||||
},
|
||||
setControlMode: (mode) => { applyControlMode(mode); },
|
||||
controlMode: () => controlMode,
|
||||
onControlModeChange(fn) {
|
||||
controlModeListeners.push(fn);
|
||||
},
|
||||
setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions),
|
||||
vehicleState: () => roadTraffic?.hero() ?? null,
|
||||
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
|
||||
@@ -655,31 +686,15 @@ export async function createScene(
|
||||
attachActorFaceTexture: (texture) => sceneActor?.attachFaceTexture(texture) ?? false,
|
||||
clearActorFaceTexture: () => { sceneActor?.clearFaceTexture(); },
|
||||
setActorActive(active) {
|
||||
sceneActor?.setActive(active);
|
||||
if (active) {
|
||||
sceneAircraft?.setActive(false);
|
||||
roadTraffic?.setFollowing(false);
|
||||
}
|
||||
// State boards compress one real metre to a few hundredths of a scene
|
||||
// unit. Their possessed actor and chase camera are therefore closer than
|
||||
// the map camera's 0.1 near plane; lower it only for play mode so the
|
||||
// procedural rig is not clipped away, then restore the depth precision.
|
||||
kit.camera.near = active ? 0.001 : 0.1;
|
||||
kit.controls.minDistance = active ? 0.001 : orbitMinDistance;
|
||||
kit.camera.updateProjectionMatrix();
|
||||
if (active) applyControlMode("actor");
|
||||
else if (controlMode === "actor") applyControlMode("overview");
|
||||
},
|
||||
actorActive: () => sceneActor?.active() ?? false,
|
||||
setAircraftActions: (actions) => { sceneAircraft?.setActions(actions); },
|
||||
aircraftState: () => sceneAircraft?.state() ?? null,
|
||||
setAircraftActive(active) {
|
||||
sceneAircraft?.setActive(active);
|
||||
if (active) {
|
||||
sceneActor?.setActive(false);
|
||||
roadTraffic?.setFollowing(false);
|
||||
}
|
||||
kit.camera.near = active ? 0.01 : 0.1;
|
||||
kit.controls.minDistance = active ? 0.01 : orbitMinDistance;
|
||||
kit.camera.updateProjectionMatrix();
|
||||
if (active) applyControlMode("aircraft");
|
||||
else if (controlMode === "aircraft") applyControlMode("overview");
|
||||
},
|
||||
aircraftActive: () => sceneAircraft?.active() ?? false,
|
||||
upsertRemoteSnapshot: (snapshot) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+553
-171
@@ -37,11 +37,17 @@ import SAN_FRANCISCO from "./cities/sf.ts";
|
||||
import SOCAL from "./cities/socal.ts";
|
||||
import CALIFORNIA_TRANSPORT from "./transport/california.ts";
|
||||
import {
|
||||
mergeVehicleActions,
|
||||
sampleStandardGamepad,
|
||||
type GamepadButtonState,
|
||||
} from "./input/vehicle.ts";
|
||||
import type { VehicleActionSnapshot } from "./transport/vehicleController.ts";
|
||||
aircraftActionsFromPlay,
|
||||
cameraRelativePlanar,
|
||||
crowActionsFromPlay,
|
||||
groundActorActionsFromPlay,
|
||||
PlayInputRouter,
|
||||
sampleStandardPlayGamepad,
|
||||
vehicleActionsFromPlay,
|
||||
type PlayDigitalControl,
|
||||
type StandardPlayGamepadButtons,
|
||||
} from "./input/play.ts";
|
||||
import { PointerStick } from "./input/pointerStick.ts";
|
||||
import {
|
||||
createTeraClient,
|
||||
describeLiveness,
|
||||
@@ -91,8 +97,13 @@ import { SRGBColorSpace, VideoTexture } from "three";
|
||||
import {
|
||||
CALIFORNIA_AIR_ROUTE,
|
||||
createAircraftPoseSnapshot,
|
||||
type AircraftActionSnapshot,
|
||||
} from "./aircraft/index.ts";
|
||||
import {
|
||||
cityControlMode,
|
||||
createControlModeState,
|
||||
transitionControlMode,
|
||||
type ControlMode,
|
||||
} from "./play/controlMode.ts";
|
||||
import {
|
||||
createOfficeScreenPanel,
|
||||
type MediaSurfaceDescriptor,
|
||||
@@ -285,6 +296,8 @@ let office: OfficeScene | null = null;
|
||||
/** The pack used by `office`; kept separate from the currently selected door. */
|
||||
let builtOfficeId: string | null = null;
|
||||
let inside = false;
|
||||
let controlModeState = createControlModeState();
|
||||
const playInput = new PlayInputRouter();
|
||||
let markers: Marker[] = SAMPLE_MARKERS;
|
||||
let realtimeClient: RealtimeClient | null = null;
|
||||
let presenceIndicator: PresenceIndicator | null = null;
|
||||
@@ -804,6 +817,8 @@ async function mountCity(id: string) {
|
||||
weatherWatch = null;
|
||||
poseEditor?.destroy();
|
||||
poseEditor = null;
|
||||
clearPublishedPlayInput();
|
||||
controlModeState = createControlModeState();
|
||||
disposeLoadedOffice();
|
||||
inside = false;
|
||||
minimap?.dispose();
|
||||
@@ -1062,6 +1077,7 @@ async function mountCity(id: string) {
|
||||
marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null,
|
||||
});
|
||||
city.onChapterChange(() => renderLegend());
|
||||
city.onControlModeChange((mode) => adoptCityControlMode(mode));
|
||||
|
||||
/**
|
||||
* The plan view, built last, because it reads the finished `World` — the
|
||||
@@ -1081,7 +1097,7 @@ async function mountCity(id: string) {
|
||||
// definition of "phone", in `stage.ts`, read by both.
|
||||
maxPixelRatio: deviceProfile().maxPixelRatio,
|
||||
onSeek(lat, lng) {
|
||||
if (!city) return;
|
||||
if (!city || city.controlMode() !== "overview") return;
|
||||
/**
|
||||
* Slide the orbit target and carry the camera with it, keeping the offset
|
||||
* between them. A seek is "look over there", not "go to chapter three":
|
||||
@@ -1135,6 +1151,8 @@ async function mountCity(id: string) {
|
||||
requestAnimationFrame(function pumpMinimap() {
|
||||
requestAnimationFrame(pumpMinimap);
|
||||
syncJourneyVehicle(performance.now());
|
||||
updateLocalPlayerMaps();
|
||||
renderPlayHud();
|
||||
// Only the mounted one. The other is still constructed and still holds a live
|
||||
// camera, but its canvas is out of the document, so `clientWidth` is 0, every
|
||||
// `resize()` puts it back to `ready = false`, and ticking it would be a
|
||||
@@ -1149,6 +1167,61 @@ requestAnimationFrame(function pumpMinimap() {
|
||||
pollLiveness();
|
||||
});
|
||||
|
||||
function updateLocalPlayerMaps(): void {
|
||||
if (!city) return;
|
||||
if (inside) {
|
||||
minimap?.setPlayer(null);
|
||||
const walker = controlModeState.mode === "office-walk" ? office?.walker?.state() : null;
|
||||
officePlan?.setPlayer(walker
|
||||
? {
|
||||
levelId: walker.levelId,
|
||||
x: walker.position.x,
|
||||
z: walker.position.z,
|
||||
headingRad: Math.atan2(-walker.facing.x, -walker.facing.z),
|
||||
kind: walker.actor,
|
||||
}
|
||||
: null);
|
||||
return;
|
||||
}
|
||||
officePlan?.setPlayer(null);
|
||||
if (city.controlMode() === "drive") {
|
||||
const state = city.vehicleState();
|
||||
minimap?.setPlayer(state
|
||||
? { lat: state.lat, lng: state.lng, headingDeg: state.headingDeg, kind: "vehicle" }
|
||||
: null);
|
||||
return;
|
||||
}
|
||||
if (city.controlMode() === "aircraft") {
|
||||
const state = city.aircraftState();
|
||||
minimap?.setPlayer(state
|
||||
? { lat: state.lat, lng: state.lng, headingDeg: state.headingDeg, kind: "aircraft" }
|
||||
: null);
|
||||
return;
|
||||
}
|
||||
if (city.controlMode() === "actor") {
|
||||
const state = city.actorState();
|
||||
const pack = CITIES.find((candidate) => candidate.id === cityId)?.city;
|
||||
if (!state || !pack) {
|
||||
minimap?.setPlayer(null);
|
||||
return;
|
||||
}
|
||||
const anchor = cityId === "california" ? { lat: 35.5, lng: -119.5 } : pack.center;
|
||||
const [originX, originZ] = city.world.project(anchor.lat, anchor.lng);
|
||||
const [lat, lng] = city.world.unproject(
|
||||
originX + state.x / city.world.metresPerUnit,
|
||||
originZ + state.z / city.world.metresPerUnit,
|
||||
);
|
||||
minimap?.setPlayer({
|
||||
lat,
|
||||
lng,
|
||||
headingDeg: ((-state.yaw * 180 / Math.PI) % 360 + 360) % 360,
|
||||
kind: "actor",
|
||||
});
|
||||
return;
|
||||
}
|
||||
minimap?.setPlayer(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the corner label is still telling the truth, once a second.
|
||||
*
|
||||
@@ -1302,8 +1375,13 @@ async function enterOffice() {
|
||||
office.onViewChange(() => renderLegend());
|
||||
officePlan = buildOfficePlan(createOfficeMinimap, office);
|
||||
}
|
||||
requestControlMode("overview");
|
||||
city.stage.setScene(office);
|
||||
inside = true;
|
||||
controlModeState = {
|
||||
mode: "office-overview",
|
||||
revision: controlModeState.revision + 1,
|
||||
};
|
||||
attachCurrentWebcamFace();
|
||||
moveRealtimePresence();
|
||||
const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area";
|
||||
@@ -1344,6 +1422,7 @@ function buildOfficePlan(
|
||||
// exact drift `deviceProfile` exists in one file to prevent.
|
||||
maxPixelRatio: deviceProfile().maxPixelRatio,
|
||||
onSeek(x, z) {
|
||||
if (controlModeState.mode !== "office-overview") return;
|
||||
/**
|
||||
* The same move the city plan makes, and for the same reason: slide the
|
||||
* orbit target and carry the camera with it, keeping the offset between
|
||||
@@ -1426,11 +1505,16 @@ function leaveOffice() {
|
||||
// rather than publish into a room the user has already left.
|
||||
stopWatchingOccupancy();
|
||||
disposeOfficeScreenUi();
|
||||
clearPublishedPlayInput();
|
||||
office?.walker?.setActive(false);
|
||||
office?.walker?.setAction({ x: 0, z: 0 });
|
||||
dispatchJourney({ type: "leave-office" });
|
||||
city.stage.setScene(city.stageScene);
|
||||
inside = false;
|
||||
controlModeState = {
|
||||
mode: "overview",
|
||||
revision: controlModeState.revision + 1,
|
||||
};
|
||||
city.setControlMode("overview");
|
||||
attachCurrentWebcamFace();
|
||||
moveRealtimePresence();
|
||||
showPlan();
|
||||
@@ -1614,6 +1698,21 @@ const flyButton = document.querySelector<HTMLButtonElement>("#fly");
|
||||
const screensButton = document.querySelector<HTMLButtonElement>("#screens");
|
||||
const walkControls = document.querySelector<HTMLElement>("#walk-controls");
|
||||
const walkHint = document.querySelector<HTMLElement>("#walk-hint");
|
||||
const touchPlayControls = document.querySelector<HTMLElement>("#touch-play-controls");
|
||||
const playStick = document.querySelector<HTMLElement>("#play-stick");
|
||||
const touchPrimary = document.querySelector<HTMLButtonElement>("#touch-primary");
|
||||
const touchSecondary = document.querySelector<HTMLButtonElement>("#touch-secondary");
|
||||
const touchPitchUp = document.querySelector<HTMLButtonElement>("#touch-pitch-up");
|
||||
const touchPitchDown = document.querySelector<HTMLButtonElement>("#touch-pitch-down");
|
||||
const touchAssist = document.querySelector<HTMLButtonElement>("#touch-assist");
|
||||
const touchReset = document.querySelector<HTMLButtonElement>("#touch-reset");
|
||||
const touchCamera = document.querySelector<HTMLButtonElement>("#touch-camera");
|
||||
const touchMap = document.querySelector<HTMLButtonElement>("#touch-map");
|
||||
const modeDock = document.querySelector<HTMLElement>("#mode-dock");
|
||||
const playHud = document.querySelector<HTMLElement>("#play-hud");
|
||||
const playHudMode = document.querySelector<HTMLElement>("#play-hud-mode");
|
||||
const playHudPrimary = document.querySelector<HTMLElement>("#play-hud-primary");
|
||||
const playHudStatus = document.querySelector<HTMLElement>("#play-hud-status");
|
||||
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
|
||||
const webcamFaceIndicator = document.querySelector<HTMLElement>("#webcam-face-indicator");
|
||||
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
|
||||
@@ -1623,6 +1722,100 @@ let webcamFaceTexture: WebcamFaceTextureAdapter | null = null;
|
||||
let webcamFaceConsent = createWebcamFaceConsent();
|
||||
let webcamCapture: WebcamCaptureController | null = null;
|
||||
|
||||
function availableControlModes() {
|
||||
const route = city?.current();
|
||||
return {
|
||||
insideOffice: inside,
|
||||
drive: !inside && city?.vehicleState() !== null &&
|
||||
(route === "la-sf-us-101" || route === "la-sf-i-5"),
|
||||
actor: !inside && city?.actorState() !== null,
|
||||
aircraft: !inside && city?.aircraftState() !== null,
|
||||
officeWalk: inside && office?.walker !== null && office?.walker !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function clearPublishedPlayInput(): void {
|
||||
playInput.clearAll();
|
||||
for (const button of document.querySelectorAll<HTMLElement>(
|
||||
"[data-drive-key][aria-pressed], [data-walk-key][aria-pressed], [data-play-control][aria-pressed]",
|
||||
)) button.setAttribute("aria-pressed", "false");
|
||||
resetPlayStick();
|
||||
city?.setVehicleActions({});
|
||||
city?.setActorActions({});
|
||||
city?.setAircraftActions({});
|
||||
office?.walker?.setAction({ x: 0, z: 0 });
|
||||
}
|
||||
|
||||
function adoptCityControlMode(mode: ReturnType<typeof cityControlMode>): void {
|
||||
if (inside) return;
|
||||
const previous = controlModeState.mode;
|
||||
if (controlModeState.mode !== mode) {
|
||||
clearPublishedPlayInput();
|
||||
controlModeState = { mode, revision: controlModeState.revision + 1 };
|
||||
}
|
||||
if (
|
||||
previous === "overview" && mode !== "overview" &&
|
||||
(document.body.classList.contains("touch-capable") ||
|
||||
window.matchMedia?.("(pointer: coarse)").matches === true)
|
||||
) {
|
||||
planOpen = false;
|
||||
planChosen = true;
|
||||
applyPlan();
|
||||
}
|
||||
renderLegend();
|
||||
}
|
||||
|
||||
/** One transaction updates Journey, simulation ownership, input and chrome. */
|
||||
function requestControlMode(requested: ControlMode): boolean {
|
||||
const transition = transitionControlMode(controlModeState, requested, availableControlModes());
|
||||
if (transition.changed) clearPublishedPlayInput();
|
||||
controlModeState = transition.state;
|
||||
const next = transition.state.mode;
|
||||
|
||||
if (inside) {
|
||||
city?.setControlMode("overview");
|
||||
const walking = next === "office-walk";
|
||||
office?.walker?.setActive(walking);
|
||||
if (!walking) office?.walker?.setAction({ x: 0, z: 0 });
|
||||
} else {
|
||||
office?.walker?.setActive(false);
|
||||
city?.setControlMode(cityControlMode(next));
|
||||
}
|
||||
|
||||
if (next === "drive") {
|
||||
const routeId = city?.current();
|
||||
if (routeId === "la-sf-us-101" || routeId === "la-sf-i-5") {
|
||||
dispatchJourney({ type: "set-mode", mode: "play" });
|
||||
if (journey.route?.routeId !== routeId) {
|
||||
dispatchJourney({ type: "select-route", routeId, direction: 1 });
|
||||
}
|
||||
if (!journey.vehicle) dispatchJourney({ type: "enter-vehicle", vehicleId: "model-x-black" });
|
||||
}
|
||||
} else if (journey.vehicle) {
|
||||
dispatchJourney({ type: "exit-vehicle" });
|
||||
}
|
||||
if (next !== "overview" && next !== "office-overview") {
|
||||
dispatchJourney({ type: "set-mode", mode: "play" });
|
||||
} else if (!journey.vehicle) {
|
||||
dispatchJourney({ type: "set-mode", mode: "observe" });
|
||||
}
|
||||
if (
|
||||
transition.changed &&
|
||||
(transition.previous === "overview" || transition.previous === "office-overview") &&
|
||||
next !== "overview" && next !== "office-overview" &&
|
||||
(document.body.classList.contains("touch-capable") ||
|
||||
window.matchMedia?.("(pointer: coarse)").matches === true)
|
||||
) {
|
||||
planOpen = false;
|
||||
planChosen = true;
|
||||
applyPlan();
|
||||
}
|
||||
|
||||
renderLegend();
|
||||
publishPlayActions();
|
||||
return transition.accepted;
|
||||
}
|
||||
|
||||
function showDetail(text: string | null) {
|
||||
const card = document.querySelector<HTMLElement>("#detail");
|
||||
const body = document.querySelector<HTMLElement>("#detail-text");
|
||||
@@ -1762,6 +1955,25 @@ function renderLegend() {
|
||||
const walking = inside && (office?.walker?.active() ?? false);
|
||||
const exploring = !inside && (city.actorActive() ?? false);
|
||||
const flying = !inside && (city.aircraftActive() ?? false);
|
||||
const actualMode: ControlMode = inside
|
||||
? walking ? "office-walk" : "office-overview"
|
||||
: city.controlMode();
|
||||
if (controlModeState.mode !== actualMode) {
|
||||
controlModeState = { mode: actualMode, revision: controlModeState.revision + 1 };
|
||||
}
|
||||
const availability = availableControlModes();
|
||||
for (const button of modeDock?.querySelectorAll<HTMLButtonElement>("[data-control-mode]") ?? []) {
|
||||
const mode = button.dataset.controlMode as ControlMode;
|
||||
button.hidden = mode === "drive" ? !availability.drive
|
||||
: mode === "actor" ? !availability.actor
|
||||
: mode === "aircraft" ? !availability.aircraft
|
||||
: mode === "office-walk" ? !availability.officeWalk
|
||||
: false;
|
||||
const pressed = mode === "overview"
|
||||
? actualMode === "overview" || actualMode === "office-overview"
|
||||
: mode === actualMode;
|
||||
button.setAttribute("aria-pressed", String(pressed));
|
||||
}
|
||||
if (walkButton) {
|
||||
walkButton.hidden = inside ? office?.walker === null : city.actorState() === null;
|
||||
walkButton.setAttribute("aria-pressed", String(walking || exploring));
|
||||
@@ -1791,6 +2003,8 @@ function renderLegend() {
|
||||
? walking
|
||||
? `${officeName()}, following your ${office?.walker?.state().actor === "anonymous-dog" ? "dog" : "humanoid"}. Use W A S D to move.`
|
||||
: `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
|
||||
: routeDriveIsActive()
|
||||
? `${cityLabel}, following your car on ${city.vehicleState()?.roadName ?? "the selected route"}. Use W A S D to drive.`
|
||||
: flying
|
||||
? `${cityLabel}, following your electric aircraft. Use W A S D to fly or P to resume assisted flight.`
|
||||
: exploring
|
||||
@@ -1816,6 +2030,32 @@ function renderLegend() {
|
||||
: "Walking controls",
|
||||
);
|
||||
}
|
||||
const touchModeActive = routeDriveIsActive() || walking || exploring || flying;
|
||||
if (touchPlayControls) {
|
||||
touchPlayControls.hidden = !touchModeActive;
|
||||
touchPlayControls.setAttribute("aria-label", `${actualMode.replace("office-", "")} touch controls`);
|
||||
}
|
||||
const crowPlaying = exploring && city.actorState()?.kind === "crow" &&
|
||||
city.actorState()?.mode === "flight";
|
||||
if (touchPrimary) {
|
||||
touchPrimary.hidden = walking;
|
||||
touchPrimary.dataset.playControl = crowPlaying ? "ascend" : "primary";
|
||||
touchPrimary.textContent = routeDriveIsActive() ? "Handbrake"
|
||||
: flying ? "Throttle"
|
||||
: crowPlaying ? "Climb"
|
||||
: "Sprint";
|
||||
}
|
||||
if (touchSecondary) {
|
||||
touchSecondary.hidden = !crowPlaying;
|
||||
touchSecondary.dataset.playControl = "secondary";
|
||||
touchSecondary.textContent = "Glide";
|
||||
}
|
||||
if (touchPitchUp) touchPitchUp.hidden = !crowPlaying;
|
||||
if (touchPitchDown) touchPitchDown.hidden = !crowPlaying;
|
||||
if (touchAssist) touchAssist.hidden = !(routeDriveIsActive() || flying);
|
||||
if (touchReset) touchReset.hidden = !(routeDriveIsActive() || flying);
|
||||
if (touchCamera) touchCamera.hidden = !routeDriveIsActive();
|
||||
if (touchMap) touchMap.setAttribute("aria-pressed", String(planOpen));
|
||||
for (const control of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
|
||||
control.textContent = flying
|
||||
? control.dataset.aircraftLabel ?? control.textContent
|
||||
@@ -1837,7 +2077,46 @@ function renderLegend() {
|
||||
? "WASD fly · P assisted · R reset"
|
||||
: inside
|
||||
? "V walk · WASD move"
|
||||
: "V explore · WASD · Q/E altitude · G glide";
|
||||
: "V explore · WASD · Q/E altitude · I/K pitch · G glide";
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlayHud(): void {
|
||||
if (!playHud || !playHudMode || !playHudPrimary || !playHudStatus || !city) return;
|
||||
const mode = controlModeState.mode;
|
||||
playHud.hidden = mode === "overview" || mode === "office-overview";
|
||||
playHudStatus.classList.remove("warning");
|
||||
if (mode === "drive") {
|
||||
const state = city.vehicleState();
|
||||
if (!state) return;
|
||||
playHudMode.textContent = "Drive";
|
||||
playHudPrimary.textContent = `${Math.round(state.speedMps * 2.23694)} mph · ${state.roadName}`;
|
||||
playHudStatus.textContent = `${state.mode} · ${Math.round(state.progress * 100)}% · ${city.vehicleCamera() ?? "chase"}`;
|
||||
playHudStatus.classList.toggle("warning", state.guardrailContact || state.collisionRisk > 0.55);
|
||||
} else if (mode === "actor") {
|
||||
const state = city.actorState();
|
||||
if (!state) return;
|
||||
playHudMode.textContent = state.kind === "crow" ? "Crow" : "Explore";
|
||||
playHudPrimary.textContent = state.kind === "crow"
|
||||
? `${state.speedMps.toFixed(1)} m/s · ${Math.max(0, state.y).toFixed(0)} m alt`
|
||||
: `${state.speedMps.toFixed(1)} m/s · ${state.distanceM.toFixed(0)} m travelled`;
|
||||
playHudStatus.textContent = state.kind === "crow"
|
||||
? `${state.crowPose} · ${Math.round(state.flightEnergy * 100)}% energy`
|
||||
: `${state.mode} · ${state.identity.displayName}`;
|
||||
playHudStatus.classList.toggle("warning", state.altitudeBoundContact !== "none");
|
||||
} else if (mode === "aircraft") {
|
||||
const state = city.aircraftState();
|
||||
if (!state) return;
|
||||
playHudMode.textContent = "Flight";
|
||||
playHudPrimary.textContent = `${Math.round(state.speedMps * 1.94384)} kt · ${Math.round(state.altitudeM).toLocaleString()} m`;
|
||||
playHudStatus.textContent = `${state.mode} · ${Math.round(state.batteryWh)} Wh${state.stalled ? " · STALL" : ""}`;
|
||||
playHudStatus.classList.toggle("warning", state.stalled || state.hardLanding || state.envelopeContact);
|
||||
} else if (mode === "office-walk") {
|
||||
const state = office?.walker?.state();
|
||||
if (!state) return;
|
||||
playHudMode.textContent = "Office";
|
||||
playHudPrimary.textContent = `${officeName()} · ${state.distance.toFixed(0)} m walked`;
|
||||
playHudStatus.textContent = `${state.position.x.toFixed(1)}, ${state.position.z.toFixed(1)} m`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2824,21 +3103,33 @@ function currentViews(): View[] {
|
||||
function flyToIndex(index: number) {
|
||||
const view = currentViews()[index];
|
||||
if (!view) return;
|
||||
if (inside && office) office.flyTo(view.id);
|
||||
if (inside && office) {
|
||||
requestControlMode("office-overview");
|
||||
office.flyTo(view.id);
|
||||
renderLegend();
|
||||
}
|
||||
else {
|
||||
const destination = cityId === "california" ? CALIFORNIA_DESTINATIONS.get(view.id) : undefined;
|
||||
if (destination) {
|
||||
requestControlMode("overview");
|
||||
officeId = destination.officeId;
|
||||
journeyToCity(destination.cityId === "socal" ? "socal" : "bay-area");
|
||||
switchCity(destination.cityId);
|
||||
return;
|
||||
}
|
||||
if (view.id === "la-sf-us-101" || view.id === "la-sf-i-5") {
|
||||
dispatchJourney({ type: "set-mode", mode: "play" });
|
||||
dispatchJourney({ type: "select-route", routeId: view.id, direction: 1 });
|
||||
dispatchJourney({ type: "enter-vehicle", vehicleId: "model-x-black" });
|
||||
}
|
||||
city?.flyTo(view.id);
|
||||
requestControlMode("drive");
|
||||
if (window.innerWidth <= 600) {
|
||||
panelOpen = false;
|
||||
applyPanel();
|
||||
}
|
||||
renderLegend();
|
||||
return;
|
||||
}
|
||||
requestControlMode("overview");
|
||||
city?.flyTo(view.id);
|
||||
renderLegend();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2870,6 +3161,7 @@ function leaveToCity(cityWanted?: string): boolean {
|
||||
}
|
||||
|
||||
function switchCity(id: string) {
|
||||
requestControlMode(inside ? "office-overview" : "overview");
|
||||
if (inside && leaveToCity(id)) return;
|
||||
if (inside) leaveOffice();
|
||||
if (id === wantedCity) return;
|
||||
@@ -2950,9 +3242,8 @@ enterButton?.addEventListener("click", () => void toggleOffice());
|
||||
function toggleOfficeWalk(): boolean {
|
||||
if (!inside) {
|
||||
if (!city?.actorState()) return false;
|
||||
const active = !city.actorActive();
|
||||
city.setActorActive(active);
|
||||
if (!active) city.setActorActions({});
|
||||
const active = city.controlMode() !== "actor";
|
||||
requestControlMode(active ? "actor" : "overview");
|
||||
if (active && window.innerWidth <= 600) {
|
||||
panelOpen = false;
|
||||
applyPanel();
|
||||
@@ -2963,8 +3254,7 @@ function toggleOfficeWalk(): boolean {
|
||||
const walker = office?.walker;
|
||||
if (!walker) return false;
|
||||
const active = !walker.active();
|
||||
walker.setActive(active);
|
||||
if (!active) walker.setAction({ x: 0, z: 0 });
|
||||
requestControlMode(active ? "office-walk" : "office-overview");
|
||||
if (active && window.innerWidth <= 600) {
|
||||
panelOpen = false;
|
||||
applyPanel();
|
||||
@@ -2977,9 +3267,8 @@ walkButton?.addEventListener("click", () => toggleOfficeWalk());
|
||||
|
||||
function toggleAircraft(): boolean {
|
||||
if (inside || cityId !== "california" || !city?.aircraftState()) return false;
|
||||
const active = !city.aircraftActive();
|
||||
city.setAircraftActive(active);
|
||||
if (!active) city.setAircraftActions({});
|
||||
const active = city.controlMode() !== "aircraft";
|
||||
requestControlMode(active ? "aircraft" : "overview");
|
||||
if (active && window.innerWidth <= 600) {
|
||||
panelOpen = false;
|
||||
applyPanel();
|
||||
@@ -2990,6 +3279,17 @@ function toggleAircraft(): boolean {
|
||||
|
||||
flyButton?.addEventListener("click", () => toggleAircraft());
|
||||
|
||||
for (const button of modeDock?.querySelectorAll<HTMLButtonElement>("[data-control-mode]") ?? []) {
|
||||
button.addEventListener("click", () => {
|
||||
const mode = button.dataset.controlMode as ControlMode;
|
||||
requestControlMode(mode);
|
||||
if (mode !== "overview" && mode !== "office-overview" && window.innerWidth <= 600) {
|
||||
panelOpen = false;
|
||||
applyPanel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicking a building on the city walks into it.
|
||||
*
|
||||
@@ -3038,6 +3338,7 @@ function applyPanel() {
|
||||
function applyPlan() {
|
||||
document.body.classList.toggle("minimap-off", !planOpen);
|
||||
planToggle?.setAttribute("aria-pressed", String(planOpen));
|
||||
touchMap?.setAttribute("aria-pressed", String(planOpen));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3109,72 +3410,10 @@ shortcutsCard?.addEventListener("click", (event) => {
|
||||
if (event.target === shortcutsCard) closeShortcuts();
|
||||
});
|
||||
|
||||
/** Held keyboard state translated into the same snapshot a gamepad/touch UI uses. */
|
||||
const heldDriveKeys = new Set<string>();
|
||||
|
||||
function routeDriveIsActive(): boolean {
|
||||
const state = !inside ? city?.vehicleState() : null;
|
||||
return state !== null && state !== undefined && !city?.actorActive() &&
|
||||
!city?.aircraftActive() && city?.current() === state.routeId;
|
||||
}
|
||||
|
||||
function publishVehicleActions(
|
||||
supplement: Partial<VehicleActionSnapshot> = {},
|
||||
): boolean {
|
||||
if (!routeDriveIsActive() || !city) return false;
|
||||
const left = heldDriveKeys.has("a");
|
||||
const right = heldDriveKeys.has("d");
|
||||
city.setVehicleActions(
|
||||
mergeVehicleActions(
|
||||
{
|
||||
throttle: heldDriveKeys.has("w") ? 1 : 0,
|
||||
brake: heldDriveKeys.has("s") ? 1 : 0,
|
||||
steering: (right ? 1 : 0) - (left ? 1 : 0),
|
||||
handbrake: heldDriveKeys.has(" "),
|
||||
},
|
||||
supplement,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function publishOfficeWalkActions(): boolean {
|
||||
const walker = inside ? office?.walker : null;
|
||||
if (!walker?.active()) return false;
|
||||
walker.setAction({
|
||||
x: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
|
||||
z: (heldDriveKeys.has("s") ? 1 : 0) - (heldDriveKeys.has("w") ? 1 : 0),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function publishCityActorActions(): boolean {
|
||||
if (inside || !city?.actorActive()) return false;
|
||||
city.setActorActions({
|
||||
forward: (heldDriveKeys.has("w") ? 1 : 0) - (heldDriveKeys.has("s") ? 1 : 0),
|
||||
right: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
|
||||
turn: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
|
||||
sprint: heldDriveKeys.has(" "),
|
||||
climb: (heldDriveKeys.has("e") || heldDriveKeys.has(" ") ? 1 : 0) -
|
||||
(heldDriveKeys.has("q") ? 1 : 0),
|
||||
glide: heldDriveKeys.has("g"),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function publishAircraftActions(
|
||||
supplement: Partial<AircraftActionSnapshot> = {},
|
||||
): boolean {
|
||||
if (inside || !city?.aircraftActive()) return false;
|
||||
city.setAircraftActions({
|
||||
throttle: heldDriveKeys.has(" ") ? 1 : 0,
|
||||
pitch: (heldDriveKeys.has("w") ? 1 : 0) - (heldDriveKeys.has("s") ? 1 : 0),
|
||||
roll: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
|
||||
yaw: (heldDriveKeys.has("e") ? 1 : 0) - (heldDriveKeys.has("q") ? 1 : 0),
|
||||
modeRequest: supplement.modeRequest ?? "none",
|
||||
reset: supplement.reset ?? false,
|
||||
});
|
||||
return true;
|
||||
return state !== null && state !== undefined && city?.controlMode() === "drive" &&
|
||||
city.current() === state.routeId;
|
||||
}
|
||||
|
||||
function toggleVehicleCamera(): boolean {
|
||||
@@ -3183,98 +3422,257 @@ function toggleVehicleCamera(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const button of driveControls?.querySelectorAll<HTMLButtonElement>("[data-drive-key]") ?? []) {
|
||||
const key = button.dataset.driveKey;
|
||||
if (key === undefined) continue;
|
||||
function cameraForward(
|
||||
camera: { position: { x: number; z: number } },
|
||||
controls: { target: { x: number; z: number } },
|
||||
) {
|
||||
return { x: controls.target.x - camera.position.x, z: controls.target.z - camera.position.z };
|
||||
}
|
||||
|
||||
function publishPlayActions(): boolean {
|
||||
const input = playInput.snapshot();
|
||||
const requests = playInput.consumeRequests();
|
||||
if (routeDriveIsActive() && city) {
|
||||
city.setVehicleActions({
|
||||
...vehicleActionsFromPlay(input),
|
||||
modeRequest: requests.has("assist") ? "assisted" : "none",
|
||||
reset: requests.has("reset"),
|
||||
});
|
||||
if (requests.has("camera")) toggleVehicleCamera();
|
||||
return true;
|
||||
}
|
||||
const walker = inside && controlModeState.mode === "office-walk" ? office?.walker : null;
|
||||
if (walker?.active() && office) {
|
||||
walker.setAction(cameraRelativePlanar(input, cameraForward(office.camera, office.controls)));
|
||||
return true;
|
||||
}
|
||||
if (!inside && city?.controlMode() === "actor") {
|
||||
const state = city.actorState();
|
||||
if (!state) return false;
|
||||
if (state.kind === "crow" && state.mode === "flight") {
|
||||
city.setActorActions({
|
||||
...crowActionsFromPlay(input), modeRequest: "none", kindRequest: "none", reset: false,
|
||||
});
|
||||
} else {
|
||||
city.setActorActions({
|
||||
...groundActorActionsFromPlay(
|
||||
input,
|
||||
state.yaw,
|
||||
cameraForward(city.stageScene.camera, city.stageScene.controls),
|
||||
),
|
||||
pitch: 0, climb: 0, glide: false, modeRequest: "none", kindRequest: "none", reset: false,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!inside && city?.controlMode() === "aircraft") {
|
||||
city.setAircraftActions({
|
||||
...aircraftActionsFromPlay(input),
|
||||
modeRequest: requests.has("assist") ? "assisted" : "none",
|
||||
reset: requests.has("reset"),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function controlForKey(key: string): PlayDigitalControl | null {
|
||||
switch (key === " " ? key : key.toLowerCase()) {
|
||||
case "w": return "forward";
|
||||
case "s": return "backward";
|
||||
case "a": return "left";
|
||||
case "d": return "right";
|
||||
case "q": return "descend";
|
||||
case "e": return "ascend";
|
||||
case "i": return "pitch-up";
|
||||
case "k": return "pitch-down";
|
||||
case " ": return "primary";
|
||||
case "g": return "secondary";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function bindPointerControls(
|
||||
container: HTMLElement | null,
|
||||
selector: "[data-drive-key]" | "[data-walk-key]",
|
||||
attribute: "driveKey" | "walkKey",
|
||||
): void {
|
||||
for (const button of container?.querySelectorAll<HTMLButtonElement>(selector) ?? []) {
|
||||
const key = button.dataset[attribute];
|
||||
const control = key === undefined ? null : controlForKey(key);
|
||||
if (!control) continue;
|
||||
const release = (event: PointerEvent) => {
|
||||
heldDriveKeys.delete(key);
|
||||
playInput.clearSource(`pointer:${event.pointerId}`);
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
publishVehicleActions();
|
||||
publishPlayActions();
|
||||
event.preventDefault();
|
||||
};
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
|
||||
button.setPointerCapture(event.pointerId);
|
||||
heldDriveKeys.add(key);
|
||||
playInput.setDigital(`pointer:${event.pointerId}`, control, true);
|
||||
button.setAttribute("aria-pressed", "true");
|
||||
publishVehicleActions();
|
||||
publishPlayActions();
|
||||
event.preventDefault();
|
||||
});
|
||||
button.addEventListener("pointerup", release);
|
||||
button.addEventListener("pointercancel", release);
|
||||
button.addEventListener("lostpointercapture", release);
|
||||
}
|
||||
}
|
||||
|
||||
bindPointerControls(driveControls, "[data-drive-key]", "driveKey");
|
||||
bindPointerControls(walkControls, "[data-walk-key]", "walkKey");
|
||||
|
||||
const pointerStick = new PointerStick();
|
||||
|
||||
function paintPlayStick(axes = { moveX: 0, moveY: 0 }): void {
|
||||
if (!playStick) return;
|
||||
const travel = Math.max(0, (playStick.getBoundingClientRect().width - 52) / 2);
|
||||
playStick.style.setProperty("--stick-x", `${axes.moveX * travel}px`);
|
||||
playStick.style.setProperty("--stick-y", `${-axes.moveY * travel}px`);
|
||||
}
|
||||
|
||||
function resetPlayStick(): void {
|
||||
const pointerId = pointerStick.activePointer();
|
||||
if (pointerId !== null) {
|
||||
pointerStick.end(pointerId);
|
||||
playInput.clearSource(`stick:${pointerId}`);
|
||||
}
|
||||
playStick?.classList.remove("active");
|
||||
paintPlayStick();
|
||||
}
|
||||
|
||||
if (playStick) {
|
||||
playStick.addEventListener("pointerdown", (event) => {
|
||||
const axes = pointerStick.begin(event.pointerId, event.clientX, event.clientY, playStick.getBoundingClientRect());
|
||||
if (!axes) return;
|
||||
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
|
||||
playStick.setPointerCapture(event.pointerId);
|
||||
playStick.classList.add("active");
|
||||
playInput.setAxes(`stick:${event.pointerId}`, axes);
|
||||
paintPlayStick(axes);
|
||||
publishPlayActions();
|
||||
event.preventDefault();
|
||||
});
|
||||
playStick.addEventListener("pointermove", (event) => {
|
||||
const axes = pointerStick.move(event.pointerId, event.clientX, event.clientY);
|
||||
if (!axes) return;
|
||||
playInput.setAxes(`stick:${event.pointerId}`, axes);
|
||||
paintPlayStick(axes);
|
||||
publishPlayActions();
|
||||
event.preventDefault();
|
||||
});
|
||||
const finishStick = (event: PointerEvent, cancelled: boolean) => {
|
||||
const finished = cancelled ? pointerStick.cancel(event.pointerId) : pointerStick.end(event.pointerId);
|
||||
if (!finished) return;
|
||||
playInput.clearSource(`stick:${event.pointerId}`);
|
||||
playStick.classList.remove("active");
|
||||
paintPlayStick();
|
||||
publishPlayActions();
|
||||
event.preventDefault();
|
||||
};
|
||||
playStick.addEventListener("pointerup", (event) => finishStick(event, false));
|
||||
playStick.addEventListener("pointercancel", (event) => finishStick(event, true));
|
||||
playStick.addEventListener("lostpointercapture", (event) => finishStick(event, true));
|
||||
}
|
||||
|
||||
function digitalControlFromData(raw: string | undefined): PlayDigitalControl | null {
|
||||
if (
|
||||
raw === "forward" || raw === "backward" || raw === "left" || raw === "right" ||
|
||||
raw === "ascend" || raw === "descend" || raw === "pitch-up" || raw === "pitch-down" ||
|
||||
raw === "primary" || raw === "secondary"
|
||||
) return raw;
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const button of touchPlayControls?.querySelectorAll<HTMLButtonElement>("[data-play-control]") ?? []) {
|
||||
const held = new Map<number, PlayDigitalControl>();
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
const control = digitalControlFromData(button.dataset.playControl);
|
||||
if (!control) return;
|
||||
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
|
||||
button.setPointerCapture(event.pointerId);
|
||||
held.set(event.pointerId, control);
|
||||
playInput.setDigital(`action:${event.pointerId}`, control, true);
|
||||
button.setAttribute("aria-pressed", "true");
|
||||
publishPlayActions();
|
||||
event.preventDefault();
|
||||
});
|
||||
const release = (event: PointerEvent) => {
|
||||
if (!held.delete(event.pointerId)) return;
|
||||
playInput.clearSource(`action:${event.pointerId}`);
|
||||
button.setAttribute("aria-pressed", String(held.size > 0));
|
||||
publishPlayActions();
|
||||
event.preventDefault();
|
||||
};
|
||||
button.addEventListener("pointerup", release);
|
||||
button.addEventListener("pointercancel", release);
|
||||
button.addEventListener("lostpointercapture", release);
|
||||
}
|
||||
|
||||
for (const button of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
|
||||
const key = button.dataset.walkKey;
|
||||
if (key === undefined) continue;
|
||||
const release = (event: PointerEvent) => {
|
||||
heldDriveKeys.delete(key);
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
publishOfficeWalkActions();
|
||||
publishAircraftActions();
|
||||
publishCityActorActions();
|
||||
event.preventDefault();
|
||||
};
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
button.setPointerCapture(event.pointerId);
|
||||
heldDriveKeys.add(key);
|
||||
button.setAttribute("aria-pressed", "true");
|
||||
publishOfficeWalkActions();
|
||||
publishAircraftActions();
|
||||
publishCityActorActions();
|
||||
event.preventDefault();
|
||||
touchAssist?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); });
|
||||
touchReset?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); });
|
||||
touchCamera?.addEventListener("click", () => { playInput.request("camera"); publishPlayActions(); });
|
||||
touchMap?.addEventListener("click", () => {
|
||||
togglePlan();
|
||||
touchMap.setAttribute("aria-pressed", String(planOpen));
|
||||
});
|
||||
button.addEventListener("pointerup", release);
|
||||
button.addEventListener("pointercancel", release);
|
||||
button.addEventListener("lostpointercapture", release);
|
||||
}
|
||||
|
||||
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='assist']")
|
||||
?.addEventListener("click", () => publishVehicleActions({ modeRequest: "assisted" }));
|
||||
?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); });
|
||||
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
|
||||
?.addEventListener("click", () => publishVehicleActions({ reset: true }));
|
||||
?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); });
|
||||
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
|
||||
?.addEventListener("click", () => toggleVehicleCamera());
|
||||
?.addEventListener("click", () => { playInput.request("camera"); publishPlayActions(); });
|
||||
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='assist']")
|
||||
?.addEventListener("click", () => publishAircraftActions({ modeRequest: "assisted" }));
|
||||
?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); });
|
||||
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='reset']")
|
||||
?.addEventListener("click", () => publishAircraftActions({ reset: true }));
|
||||
?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); });
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||
if (!heldDriveKeys.delete(key)) return;
|
||||
if (
|
||||
publishVehicleActions() || publishOfficeWalkActions() ||
|
||||
publishAircraftActions() || publishCityActorActions()
|
||||
) event.preventDefault();
|
||||
const control = controlForKey(event.key);
|
||||
if (!control) return;
|
||||
playInput.setDigital("keyboard", control, false);
|
||||
if (publishPlayActions()) event.preventDefault();
|
||||
});
|
||||
|
||||
window.addEventListener("blur", () => {
|
||||
heldDriveKeys.clear();
|
||||
publishVehicleActions();
|
||||
publishOfficeWalkActions();
|
||||
publishAircraftActions();
|
||||
publishCityActorActions();
|
||||
function releaseAllPlayInput(): void {
|
||||
clearPublishedPlayInput();
|
||||
publishPlayActions();
|
||||
}
|
||||
window.addEventListener("blur", releaseAllPlayInput);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible") releaseAllPlayInput();
|
||||
});
|
||||
|
||||
let gamepadButtons: GamepadButtonState = { assist: false, reset: false };
|
||||
function pollDriveGamepad() {
|
||||
let gamepadButtons: StandardPlayGamepadButtons = { assist: false, reset: false, camera: false };
|
||||
function pollPlayGamepad() {
|
||||
try {
|
||||
const pad = navigator.getGamepads?.().find((candidate) => candidate !== null);
|
||||
if (pad && routeDriveIsActive()) {
|
||||
const sample = sampleStandardGamepad(pad, gamepadButtons);
|
||||
if (pad) {
|
||||
const sample = sampleStandardPlayGamepad(pad, gamepadButtons);
|
||||
gamepadButtons = sample.buttons;
|
||||
publishVehicleActions(sample.actions);
|
||||
playInput.clearSource("gamepad");
|
||||
playInput.setAxes("gamepad", sample.axes);
|
||||
for (const control of sample.digital) playInput.setDigital("gamepad", control, true);
|
||||
for (const request of sample.requests) playInput.request(request);
|
||||
publishPlayActions();
|
||||
} else {
|
||||
gamepadButtons = { assist: false, reset: false };
|
||||
playInput.clearSource("gamepad");
|
||||
gamepadButtons = { assist: false, reset: false, camera: false };
|
||||
}
|
||||
} catch {
|
||||
// Some privacy-hardened browsers expose the method but throw until a pad
|
||||
// has produced a trusted event. Keyboard/touch remain fully functional.
|
||||
}
|
||||
requestAnimationFrame(pollDriveGamepad);
|
||||
requestAnimationFrame(pollPlayGamepad);
|
||||
}
|
||||
requestAnimationFrame(pollDriveGamepad);
|
||||
requestAnimationFrame(pollPlayGamepad);
|
||||
|
||||
window.addEventListener("pointerdown", (event) => {
|
||||
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
|
||||
}, { capture: true });
|
||||
|
||||
/**
|
||||
* Keyboard access to everything the mouse can reach.
|
||||
@@ -3286,7 +3684,7 @@ requestAnimationFrame(pollDriveGamepad);
|
||||
* belongs to that control, not to this.
|
||||
*/
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
@@ -3324,45 +3722,29 @@ window.addEventListener("keydown", (event) => {
|
||||
return;
|
||||
}
|
||||
const lower = event.key.toLowerCase();
|
||||
if (
|
||||
lower === "w" || lower === "a" || lower === "s" || lower === "d" ||
|
||||
lower === "q" || lower === "e" || lower === "g" || event.key === " "
|
||||
) {
|
||||
heldDriveKeys.add(event.key === " " ? " " : lower);
|
||||
if (publishVehicleActions()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (publishOfficeWalkActions()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (publishAircraftActions()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (publishCityActorActions()) {
|
||||
const control = controlForKey(event.key);
|
||||
if (control) {
|
||||
playInput.setDigital("keyboard", control, true);
|
||||
if (publishPlayActions()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (lower === "p" && publishVehicleActions({ modeRequest: "assisted" })) {
|
||||
if (lower === "p" && (routeDriveIsActive() || city?.controlMode() === "aircraft")) {
|
||||
playInput.request("assist");
|
||||
publishPlayActions();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (lower === "p" && publishAircraftActions({ modeRequest: "assisted" })) {
|
||||
if (lower === "r" && (routeDriveIsActive() || city?.controlMode() === "aircraft")) {
|
||||
playInput.request("reset");
|
||||
publishPlayActions();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (lower === "r" && publishVehicleActions({ reset: true })) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (lower === "r" && publishAircraftActions({ reset: true })) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (lower === "c" && toggleVehicleCamera()) {
|
||||
if (lower === "c" && routeDriveIsActive()) {
|
||||
playInput.request("camera");
|
||||
publishPlayActions();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user