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