Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
better escape handling. on mac, it keeps minimizing my window #180
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/178-better-escape-handling-on-mac-it-keeps-m → main 2 commits fork
README.md +22 −3
| @@ -110,13 +110,27 @@ flashbang, if you bought them, | ||
| 110 | 110 | <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze |
| 111 | 111 | time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns, <kbd>Tab</kbd> shows |
| 112 | 112 | the scoreboard, <kbd>F</kbd> puts the stage on the whole screen and |
| 113 | <kbd>Esc</kbd> pauses. On a touch screen the left of the view is a thumb stick, | |
| 113 | <kbd>Esc</kbd> or <kbd>P</kbd> pauses. On a touch screen the left of the view is a thumb stick, | |
| 114 | 114 | the right looks around, and the buttons along the bottom do the rest — including |
| 115 | 115 | the one in the top corner, which is full screen and is how you get one on a |
| 116 | 116 | phone, where there is no keyboard and no pointer to lock. A browser that will not |
| 117 | 117 | put an element full screen — an iPhone will not — never shows that button rather |
| 118 | 118 | than showing one that does nothing. |
| 119 | 119 | |
| 120 | Escape is the one key the browser reads before the page does, and on a Mac what | |
| 121 | it means is *give the window back*: full screen drops with the whole animation, | |
| 122 | mid-round. So the game asks for the key rather than obeying it. Where a browser | |
| 123 | has the Keyboard Lock API — Chrome and Edge — `src/engine/escape.ts` holds | |
| 124 | Escape while the stage has the screen, so a pause is a pause and the screen | |
| 125 | stays; that browser leaves full screen when Escape is *held* down, which is the | |
| 126 | platform's own way out. Safari and Firefox have no such API and take the screen | |
| 127 | whatever the page asks, so the pause panel says so and **Resume** puts it back — | |
| 128 | a click is a gesture, which is the only thing a browser will hand a screen to. | |
| 129 | <kbd>P</kbd> pauses the same way and is a key no browser has a meaning of its | |
| 130 | own for, so it never costs the screen on any of them. The same thing happens at | |
| 131 | `/range`, where Escape gives the mouse back: it keeps the screen where it can, | |
| 132 | and **Capture the mouse** takes back what a browser took. | |
| 133 | ||
| 120 | 134 | The radar sits in the top corner: the level from above, north up and never |
| 121 | 135 | turning, with the rooms in grey, the two bombsites lettered A and B, your side |
| 122 | 136 | in your side's colour and a white wedge for where you are and which way you are |
| @@ -232,7 +246,10 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 232 | 246 | with noise bursts and tones), `loop` (a fixed step with a clamp on a long |
| 233 | 247 | frame), `controls` (a bindings table in, held actions out), `fullscreen` (the stage on |
| 234 | 248 | the whole screen: whether this browser will, whether it is right now, the way |
| 235 | in and out, and a refusal swallowed rather than thrown), `overlay` (the | |
| 249 | in and out, and a refusal swallowed rather than thrown), `escape` (the Escape | |
| 250 | key asked for rather than obeyed: the Keyboard Lock API held while the stage | |
| 251 | has the screen, released when it loses it, and the press handed to the game | |
| 252 | whether or not the lock was granted), `overlay` (the | |
| 236 | 253 | 2D over the scene: a scale and a pad off the canvas width, scaled fonts, |
| 237 | 254 | panels, aligned and faded text, a crosshair, a bar, right-aligned columns and |
| 238 | 255 | a `m:ss` clock, drawn onto the seventeen members of a 2D context it uses and |
| @@ -299,7 +316,9 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 299 | 316 | `Ctor`; from `loop` `createLoop`, `Frames`, `Loop` and `LoopOptions`; from |
| 300 | 317 | `controls` `createControls`, `attachButtons` and the event and option types |
| 301 | 318 | they are driven with; from `fullscreen` `createFullscreen` with `Fullscreen`, |
| 302 | `FullscreenHost`, `FullscreenTarget` and `FullscreenOwner`; from `overlay` `overlay`, `Overlay` and the `Paint` | |
| 319 | `FullscreenHost`, `FullscreenTarget` and `FullscreenOwner`; from `escape` | |
| 320 | `createEscape` and `ESCAPE` with `Escape`, `EscapeHost`, `KeyboardLock` and the | |
| 321 | `Screen` it reads full screen off; from `overlay` `overlay`, `Overlay` and the `Paint` | |
| 303 | 322 | a 2D game draws onto; from `scene` the frame and its pool — `createScene`, |
| 304 | 323 | `resetScene`, `nextInstance`, `pushLine`, `Scene`, `SceneCapacity`, `Camera` |
| 305 | 324 | and `Instance`; from `effects` the ring of what just happened — |
src/client/binding.test.ts +4
| @@ -29,6 +29,10 @@ test("the bindings are the ones the keyboard has always had", () => { | ||
| 29 | 29 | // the browser for the screen, and nothing about an `Input` changes for it. |
| 30 | 30 | expect(CS_KEYS.KeyF).toBe("fullscreen"); |
| 31 | 31 | expect(intent(["fullscreen"])).toEqual(intent([])); |
| 32 | // P is the page's in the same way: it pauses, which is what Escape means too, | |
| 33 | // and no browser has a meaning of its own for it to take the window back with. | |
| 34 | expect(CS_KEYS.KeyP).toBe("pause"); | |
| 35 | expect(intent(["pause"])).toEqual(intent([])); | |
| 32 | 36 | }); |
| 33 | 37 | |
| 34 | 38 | test("nothing held is nothing asked for", () => { |
src/client/binding.ts +6 −2
| @@ -15,9 +15,13 @@ export const CS_KEYS: Record<string, string> = { | ||
| 15 | 15 | Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk", |
| 16 | 16 | KeyR: "reload", KeyE: "use", KeyG: "grenade", KeyT: "flash", KeyB: "buy", Tab: "scoreboard", |
| 17 | 17 | Digit1: "slot1", Digit2: "slot2", Digit3: "slot3", |
| 18 | // Not a thing the world knows about: `main.ts` hears it on `onAction` and | |
| 19 | // asks the browser for the screen, which `readIntent` below never sees. | |
| 18 | // Neither of these is a thing the world knows about: `main.ts` hears them on | |
| 19 | // `onAction`, and `readIntent` below never sees either. F asks the browser for | |
| 20 | // the screen; P pauses, which is what Escape means too — and P is a key no | |
| 21 | // browser has its own meaning for, so it pauses without also handing the | |
| 22 | // window back, which is what Escape does on a Mac. | |
| 20 | 23 | KeyF: "fullscreen", |
| 24 | KeyP: "pause", | |
| 21 | 25 | }; |
| 22 | 26 | |
| 23 | 27 | /** Left fires, right scopes. */ |
src/client/main.test.ts +98 −1
| @@ -52,7 +52,9 @@ function painted(stage: FakeStage): FakeStage { | ||
| 52 | 52 | } |
| 53 | 53 | |
| 54 | 54 | /** A booted page, its atlas painted, and the match already started on it. */ |
| 55 | function playing(options: { webgl?: boolean; coarse?: boolean } = {}): FakeStage { | |
| 55 | function playing( | |
| 56 | options: { webgl?: boolean; coarse?: boolean; fullscreen?: boolean; keyboardLock?: boolean } = {}, | |
| 57 | ): FakeStage { | |
| 56 | 58 | const stage = painted(start(fakeStage(options))); |
| 57 | 59 | stage.by("play").fire("click"); |
| 58 | 60 | return stage; |
| @@ -257,6 +259,101 @@ test("losing the pointer pauses a running match, and does not while the buy pane | ||
| 257 | 259 | expect(stage.by("pause").hidden).toBe(true); |
| 258 | 260 | }); |
| 259 | 261 | |
| 262 | test("Escape pauses and keeps the screen where the browser hands the key over", async () => { | |
| 263 | const stage = playing(); | |
| 264 | stage.press("KeyF"); | |
| 265 | stage.lock(true); | |
| 266 | const box = stage.by("stage"); | |
| 267 | expect(stage.document.fullscreenElement).toBe(box); | |
| 268 | // The key was asked for on the way into full screen, and granted a microtask | |
| 269 | // later: from here on Escape is the page's and not the browser's. | |
| 270 | expect(stage.keyboard.locked).toEqual(["Escape"]); | |
| 271 | await Promise.resolve(); | |
| 272 | ||
| 273 | stage.window.fire("keydown", { code: "Escape" }); | |
| 274 | // Paused, the pointer let go — under the lock the browser does not do that for | |
| 275 | // us — and the screen still ours, which is the whole point of holding the key. | |
| 276 | expect(stage.by("pause").hidden).toBe(false); | |
| 277 | expect(stage.document.pointerLockElement).toBe(null); | |
| 278 | expect(stage.document.fullscreenElement).toBe(box); | |
| 279 | expect(stage.by("esc").textContent).toContain("kept the screen"); | |
| 280 | expect(stage.by("resume").focused).toBe(1); | |
| 281 | ||
| 282 | // And Resume goes back into the match without asking for a screen it already | |
| 283 | // has: one pointer lock, no second `requestFullscreen`. | |
| 284 | stage.by("resume").fire("click"); | |
| 285 | expect(stage.by("pause").hidden).toBe(true); | |
| 286 | expect(box.fullscreens).toBe(1); | |
| 287 | expect(stage.scene.locks).toBe(2); | |
| 288 | }); | |
| 289 | ||
| 290 | test("a browser that keeps Escape for itself pauses just the same, and Resume puts the screen back", () => { | |
| 291 | // Safari on a Mac: no Keyboard Lock API, so Escape leaves full screen whatever | |
| 292 | // the page asks — the window drops out of it with the whole animation, which is | |
| 293 | // what this test is about. | |
| 294 | const stage = playing({ keyboardLock: false }); | |
| 295 | stage.press("KeyF"); | |
| 296 | stage.lock(true); | |
| 297 | const box = stage.by("stage"); | |
| 298 | expect(box.fullscreens).toBe(1); | |
| 299 | expect(stage.keyboard.grabs).toBe(0); | |
| 300 | ||
| 301 | // Escape, as that browser delivers it: the screen is gone before the page hears | |
| 302 | // the key, and the pointer goes with it. | |
| 303 | stage.document.fullscreenElement = null; | |
| 304 | stage.document.fire("fullscreenchange"); | |
| 305 | stage.lock(false); | |
| 306 | stage.window.fire("keydown", { code: "Escape" }); | |
| 307 | ||
| 308 | expect(stage.by("pause").hidden).toBe(false); | |
| 309 | expect(stage.by("esc").textContent).toContain("Resume puts it back"); | |
| 310 | // Resume is a gesture, which is the only thing a browser will give a screen | |
| 311 | // back to: the stage goes full screen again and the pointer with it. | |
| 312 | stage.by("resume").fire("click"); | |
| 313 | expect(stage.document.fullscreenElement).toBe(box); | |
| 314 | expect(box.fullscreens).toBe(2); | |
| 315 | expect(stage.scene.locks).toBe(2); | |
| 316 | ||
| 317 | // A player who left full screen on purpose is not dragged back into it. | |
| 318 | stage.press("KeyF"); | |
| 319 | expect(stage.document.fullscreenElement).toBe(null); | |
| 320 | stage.lock(false); | |
| 321 | expect(stage.by("pause").hidden).toBe(false); | |
| 322 | stage.by("resume").fire("click"); | |
| 323 | expect(stage.document.fullscreenElement).toBe(null); | |
| 324 | expect(box.fullscreens).toBe(2); | |
| 325 | }); | |
| 326 | ||
| 327 | test("P pauses too, and a key no browser wants costs no screen", () => { | |
| 328 | const stage = playing({ keyboardLock: false }); | |
| 329 | stage.press("KeyF"); | |
| 330 | stage.lock(true); | |
| 331 | stage.press("KeyP"); | |
| 332 | expect(stage.by("pause").hidden).toBe(false); | |
| 333 | expect(stage.document.fullscreenElement).toBe(stage.by("stage")); | |
| 334 | expect(stage.document.pointerLockElement).toBe(null); | |
| 335 | // Nothing was taken, and the line in the panel says as much rather than | |
| 336 | // promising to put a screen back that never went anywhere. | |
| 337 | expect(stage.by("esc").textContent).toContain("Esc or P pauses"); | |
| 338 | }); | |
| 339 | ||
| 340 | test("Escape closes the buy menu before it is a pause", () => { | |
| 341 | const stage = playing(); | |
| 342 | stage.lock(true); | |
| 343 | stage.press("KeyB"); | |
| 344 | expect(stage.by("buy").hidden).toBe(false); | |
| 345 | stage.window.fire("keydown", { code: "Escape" }); | |
| 346 | expect(stage.by("buy").hidden).toBe(true); | |
| 347 | expect(stage.by("pause").hidden).toBe(true); | |
| 348 | // The next one is the pause, now that there is nothing in front of it. | |
| 349 | stage.window.fire("keydown", { code: "Escape" }); | |
| 350 | expect(stage.by("pause").hidden).toBe(false); | |
| 351 | // A pause is not a second pause: the key held down does not stack panels or | |
| 352 | // steal the focus again. | |
| 353 | stage.window.fire("keydown", { code: "Escape", repeat: true }); | |
| 354 | expect(stage.by("resume").focused).toBe(1); | |
| 355 | }); | |
| 356 | ||
| 260 | 357 | test("leaving the match puts the menu back and stops the frame", () => { |
| 261 | 358 | const stage = playing(); |
| 262 | 359 | stage.step(FRAME); |
src/client/main.ts +89 −11
| @@ -25,9 +25,10 @@ import { createAudio, type Audio } from "./audio.ts"; | ||
| 25 | 25 | import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts"; |
| 26 | 26 | import { drawHud, type Paint } from "./hud.ts"; |
| 27 | 27 | import { |
| 28 | attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer, effectAt, | |
| 28 | attachButtons, clamp, createControls, createEscape, createFullscreen, createLoop, createRenderer, | |
| 29 | effectAt, | |
| 29 | 30 | painter, |
| 30 | type Button, type Camera, type Listens, type Mesh, | |
| 31 | type Button, type Camera, type KeyboardLock, type Listens, type Mesh, | |
| 31 | 32 | } from "../engine/index.ts"; |
| 32 | 33 | import { generateLevel, seedFrom } from "./generate.ts"; |
| 33 | 34 | import { DE_FORGE } from "./map.ts"; |
| @@ -131,6 +132,12 @@ export type BuildRenderer = (atlas: Uint8Array) => Draws; | ||
| 131 | 132 | export type Host = { |
| 132 | 133 | document: PageDocument; |
| 133 | 134 | window: PageWindow; |
| 135 | /** | |
| 136 | * `navigator.keyboard`, when this browser has one: the Keyboard Lock API, and | |
| 137 | * the only way to be handed Escape instead of the browser's meaning for it. | |
| 138 | * Absent in Safari and Firefox, where the key keeps that meaning. | |
| 139 | */ | |
| 140 | keyboard?: KeyboardLock; | |
| 134 | 141 | storage: Store; |
| 135 | 142 | fetch(url: string, init: { method: string; headers: Record<string, string>; body: string }): Promise<Reply>; |
| 136 | 143 | /** A fresh match id. `crypto.randomUUID` is one; `src/result.ts` checks it. */ |
| @@ -175,6 +182,9 @@ export function boot(host: Host): Frame { | ||
| 175 | 182 | const onMap = by("onmap"); |
| 176 | 183 | const prep = by("prep"); |
| 177 | 184 | const play = by<Panel & { disabled: boolean }>("play"); |
| 185 | const resumeButton = by("resume"); | |
| 186 | // The line in the pause panel that says what Escape just did on this browser. | |
| 187 | const escHint = by("esc"); | |
| 178 | 188 | |
| 179 | 189 | const build = host.createRenderer(sceneCanvas); |
| 180 | 190 | if (build === null) { |
| @@ -193,8 +203,9 @@ export function boot(host: Host): Frame { | ||
| 193 | 203 | // the "fullscreen" action below like every other key this game reads. Both |
| 194 | 204 | // arrive inside a gesture, which is the only time a browser says yes. A |
| 195 | 205 | // browser that will not do it at all leaves the button hidden rather than |
| 196 | // showing one that does nothing, and the state is the browser's — Escape | |
| 197 | // leaves and `onChange` is how this page hears about it. | |
| 206 | // showing one that does nothing, and the state is the browser's — a browser | |
| 207 | // that takes the screen back says so with `onChange`, which is how this page | |
| 208 | // hears about it and, below, how it knows there is something to put back. | |
| 198 | 209 | const full = createFullscreen({ element: stageBox, document: doc }); |
| 199 | 210 | const paintFull = (on: boolean): void => { |
| 200 | 211 | const label = on ? "Leave full screen" : "Full screen"; |
| @@ -205,7 +216,33 @@ export function boot(host: Host): Frame { | ||
| 205 | 216 | }; |
| 206 | 217 | full.onChange = paintFull; |
| 207 | 218 | fullButton.hidden = !full.supported; |
| 208 | fullButton.addEventListener("click", () => full.toggle()); | |
| 219 | /** | |
| 220 | * What the player asked for, which is not the same as what the browser is | |
| 221 | * giving us. On a Mac, Escape hands the screen back whether or not the page | |
| 222 | * wanted to keep it — the window drops out of full screen with the whole | |
| 223 | * animation, mid-round — and there is no way to refuse that from a keydown. | |
| 224 | * So the wish is remembered here, and `restoreFull` puts the screen back from | |
| 225 | * inside the Resume click, which is a gesture a browser will honour. | |
| 226 | */ | |
| 227 | let wantsFull = false; | |
| 228 | const askFull = (): void => { | |
| 229 | wantsFull = !full.on; | |
| 230 | full.toggle(); | |
| 231 | }; | |
| 232 | const restoreFull = (): void => { | |
| 233 | if (wantsFull && !full.on) full.enter(); | |
| 234 | }; | |
| 235 | fullButton.addEventListener("click", askFull); | |
| 236 | ||
| 237 | /** | |
| 238 | * Escape, asked for rather than obeyed. Where a browser has the Keyboard Lock | |
| 239 | * API — Chrome and Edge — the key is ours while the stage has the screen, so a | |
| 240 | * pause is a pause and the screen stays; where it has not, the key still | |
| 241 | * arrives and the pause is the same, and what the browser also took Resume | |
| 242 | * gives back. `P` is the same press for a player who would rather not find out | |
| 243 | * which browser they are on. | |
| 244 | */ | |
| 245 | const escape = createEscape({ window: win, document: doc, keyboard: host.keyboard }, full); | |
| 209 | 246 | |
| 210 | 247 | const loop = createLoop({ tick: TICK }, { |
| 211 | 248 | requestAnimationFrame: (callback) => win.requestAnimationFrame(callback), |
| @@ -293,10 +330,44 @@ export function boot(host: Host): Frame { | ||
| 293 | 330 | } |
| 294 | 331 | } |
| 295 | 332 | |
| 333 | /** | |
| 334 | * Stop the match and say what the key just cost. The panel is shown, the | |
| 335 | * pointer is let go — under the keyboard lock the browser does not do that for | |
| 336 | * us, which is the point of holding the key — and Resume takes the focus, so | |
| 337 | * the way back is one press of Enter wherever the pointer is. | |
| 338 | * | |
| 339 | * The sentence is the honest one for the browser this is: the key kept the | |
| 340 | * screen, or it took it and Resume will put it back, or full screen was never | |
| 341 | * asked for and the key cost nothing. | |
| 342 | */ | |
| 343 | function pauseMatch(): void { | |
| 344 | if (!running) return; | |
| 345 | show(pause, true); | |
| 346 | // Already gone when a lost lock is what paused us; the call is a no-op then, | |
| 347 | // and the one thing that has to happen when the key was ours instead. | |
| 348 | doc.exitPointerLock(); | |
| 349 | escHint.textContent = | |
| 350 | escape.held | |
| 351 | ? "Paused. The game kept the screen: Esc never leaves full screen here." | |
| 352 | : wantsFull && !full.on | |
| 353 | ? "Paused. This browser also left full screen on Esc — Resume puts it back." | |
| 354 | : "Paused. Esc or P pauses, and Resume goes back."; | |
| 355 | resumeButton.focus(); | |
| 356 | } | |
| 357 | ||
| 358 | /** What Escape and P both mean: close the buy menu if it is open, else pause. */ | |
| 359 | function pauseKey(): void { | |
| 360 | if (!buyPanel.hidden) return openBuy(false); | |
| 361 | if (pause.hidden) pauseMatch(); | |
| 362 | } | |
| 363 | ||
| 364 | escape.onPress = pauseKey; | |
| 365 | ||
| 296 | 366 | controls.onAction = (action, down) => { |
| 297 | 367 | if (!down) return; |
| 298 | 368 | if (action === "buy") return openBuy(buyPanel.hidden); |
| 299 | if (action === "fullscreen") return full.toggle(); | |
| 369 | if (action === "pause") return pauseKey(); | |
| 370 | if (action === "fullscreen") return askFull(); | |
| 300 | 371 | // Dead, the trigger is the only thing left to press: it walks the camera |
| 301 | 372 | // along your side, and the aim button walks it back. The touch pad's fire |
| 302 | 373 | // is the same action, so a phone gets it without a button of its own. |
| @@ -304,8 +375,11 @@ export function boot(host: Host): Frame { | ||
| 304 | 375 | if (action === "fire") cycle(world, spectator, 1); |
| 305 | 376 | else if (action === "scope") cycle(world, spectator, -1); |
| 306 | 377 | }; |
| 378 | // The pointer went away and we are still in a match: however it went — Escape | |
| 379 | // on a browser that keeps the key, a window losing focus, a browser policy — | |
| 380 | // the match stops rather than playing on without a mouse. | |
| 307 | 381 | controls.onUnlock = () => { |
| 308 | if (running && buyPanel.hidden) show(pause, true); | |
| 382 | if (running && buyPanel.hidden) pauseMatch(); | |
| 309 | 383 | }; |
| 310 | 384 | |
| 311 | 385 | // --- results, sent as the match goes rather than only at the end |
| @@ -371,8 +445,11 @@ export function boot(host: Host): Frame { | ||
| 371 | 445 | } |
| 372 | 446 | |
| 373 | 447 | play.addEventListener("click", start); |
| 374 | by("resume").addEventListener("click", () => { | |
| 448 | // Resume is the gesture that puts back whatever the pause cost: the screen | |
| 449 | // first, if a browser took it, and then the pointer. | |
| 450 | resumeButton.addEventListener("click", () => { | |
| 375 | 451 | show(pause, false); |
| 452 | restoreFull(); | |
| 376 | 453 | void sceneCanvas.requestPointerLock(); |
| 377 | 454 | }); |
| 378 | 455 | by("quit").addEventListener("click", () => { |
| @@ -382,9 +459,6 @@ export function boot(host: Host): Frame { | ||
| 382 | 459 | show(menu, true); |
| 383 | 460 | show(touchPanel, false); |
| 384 | 461 | }); |
| 385 | win.addEventListener("keydown", (event: { code: string }) => { | |
| 386 | if (event.code === "Escape" && !buyPanel.hidden) openBuy(false); | |
| 387 | }); | |
| 388 | 462 | const saved = host.storage.getItem("cs.name"); |
| 389 | 463 | if (saved !== null && nameField.value === "") nameField.value = saved; |
| 390 | 464 | |
| @@ -510,6 +584,10 @@ export function gameMeshes(level: Level = DE_FORGE): Record<string, Mesh> { | ||
| 510 | 584 | const browser = (): Host => ({ |
| 511 | 585 | document, |
| 512 | 586 | window, |
| 587 | // The Keyboard Lock API, if this browser has one. It is read off `navigator` | |
| 588 | // rather than asked for: a browser without it has no such member, which is | |
| 589 | // exactly what `createEscape` takes as its answer. | |
| 590 | keyboard: (navigator as { keyboard?: KeyboardLock }).keyboard, | |
| 513 | 591 | storage: localStorage, |
| 514 | 592 | fetch: (url, init) => fetch(url, init), |
| 515 | 593 | randomUUID: () => crypto.randomUUID(), |
src/client/stage.test-helper.ts +12 −5
| @@ -28,7 +28,7 @@ | ||
| 28 | 28 | * and it no longer needs to: what the two shared is behind the door now. |
| 29 | 29 | */ |
| 30 | 30 | |
| 31 | import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakePaint, type FakeWindow } from "../engine/index.ts"; | |
| 31 | import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeKeyboard, type FakePaint, type FakeWindow } from "../engine/index.ts"; | |
| 32 | 32 | import type { Audio } from "./audio.ts"; |
| 33 | 33 | import type { Mesh } from "../engine/index.ts"; |
| 34 | 34 | import type { BuildRenderer, Draws, Host, Stage } from "./main.ts"; |
| @@ -105,6 +105,8 @@ export type FakeStage = { | ||
| 105 | 105 | host: Host; |
| 106 | 106 | document: FakeDocument; |
| 107 | 107 | window: FakeWindow; |
| 108 | /** `navigator.keyboard`: who holds Escape, and how often the game asked. */ | |
| 109 | keyboard: FakeKeyboard; | |
| 108 | 110 | scene: StageCanvas; |
| 109 | 111 | hud: StageCanvas; |
| 110 | 112 | by(id: string): FakeElement; |
| @@ -153,7 +155,7 @@ export type FakeStage = { | ||
| 153 | 155 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 154 | 156 | * served page against this same list rather than a second copy of it. |
| 155 | 157 | */ |
| 156 | export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "map", "onmap", "play", "prep", "resume", "quit", "fps", "full"]; | |
| 158 | export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "map", "onmap", "play", "prep", "resume", "quit", "esc", "fps", "full"]; | |
| 157 | 159 | |
| 158 | 160 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 159 | 161 | const HIDDEN = ["pause", "buy", "touch", "full"]; |
| @@ -172,11 +174,13 @@ const GLOBALS = ["document", "window", "localStorage", "fetch"]; | ||
| 172 | 174 | /** |
| 173 | 175 | * The whole page the shooter's entry expects, plus the host around it. Pass |
| 174 | 176 | * `webgl: false` for a browser that cannot give the renderer a context, |
| 175 | * `coarse: true` for a phone, and `fullscreen: false` for a browser that will | |
| 176 | * not put an element on the whole screen. | |
| 177 | * `coarse: true` for a phone, `fullscreen: false` for a browser that will not | |
| 178 | * put an element on the whole screen, and `keyboardLock: false` for one that | |
| 179 | * will not hand the page the Escape key — which is Safari, and the browser this | |
| 180 | * game's pause has to be honest on. | |
| 177 | 181 | */ |
| 178 | 182 | export function fakeStage( |
| 179 | options: { webgl?: boolean; coarse?: boolean; fullscreen?: boolean } = {}, | |
| 183 | options: { webgl?: boolean; coarse?: boolean; fullscreen?: boolean; keyboardLock?: boolean } = {}, | |
| 180 | 184 | ): FakeStage { |
| 181 | 185 | const page = fakePage({ |
| 182 | 186 | ids: PAGE_IDS, |
| @@ -185,6 +189,7 @@ export function fakeStage( | ||
| 185 | 189 | actions: PAGE_ACTIONS, |
| 186 | 190 | coarse: options.coarse, |
| 187 | 191 | fullscreen: options.fullscreen, |
| 192 | keyboardLock: options.keyboardLock, | |
| 188 | 193 | }); |
| 189 | 194 | // Asked for with `paint` left on, so both canvases have their 2D recorder. |
| 190 | 195 | const [scene, hud] = page.canvases as [StageCanvas, StageCanvas]; |
| @@ -222,6 +227,7 @@ export function fakeStage( | ||
| 222 | 227 | host: { |
| 223 | 228 | document: page.document, |
| 224 | 229 | window: page.window, |
| 230 | keyboard: page.keyboard, | |
| 225 | 231 | storage: { |
| 226 | 232 | getItem: (key) => store.get(key) ?? null, |
| 227 | 233 | setItem: (key, value) => void store.set(key, value), |
| @@ -249,6 +255,7 @@ export function fakeStage( | ||
| 249 | 255 | }, |
| 250 | 256 | document: page.document, |
| 251 | 257 | window: page.window, |
| 258 | keyboard: page.keyboard, | |
| 252 | 259 | scene, |
| 253 | 260 | hud, |
| 254 | 261 | buttons: page.buttons, |
src/engine/GUIDE.md +34 −2
| @@ -357,6 +357,35 @@ inside a click or a keydown, which is the only time a browser says yes. The | ||
| 357 | 357 | `:fullscreen` half is CSS: the element keeps whatever aspect ratio and maximum |
| 358 | 358 | height it had unless a rule says otherwise. |
| 359 | 359 | |
| 360 | ### escape | |
| 361 | ||
| 362 | Escape is the one key the browser reads before your page does, and on a Mac what | |
| 363 | it means is "give the window back": the screen drops out of full screen, which a | |
| 364 | player in the middle of a round reads as their window being thrown away. There is | |
| 365 | exactly one way to ask for the key rather than its meaning — the Keyboard Lock | |
| 366 | API — and this is the three lines of it. | |
| 367 | ||
| 368 | `createEscape` takes an `EscapeHost` — the window the keydown arrives on, the | |
| 369 | document that announces full screen, and an optional `KeyboardLock`, which is | |
| 370 | `navigator.keyboard` and is absent in Safari and Firefox — plus a `Screen`, which | |
| 371 | is anything with a live `Screen.on`, so a `Fullscreen` is one. It hands back an | |
| 372 | `Escape`: `Escape.supported`, false on a browser with no such API, so a page can | |
| 373 | say what the key will do rather than guess; `Escape.held`, whether the key is | |
| 374 | ours right now, which is the grant *and* still being full screen, read live | |
| 375 | rather than remembered; the `Escape.onPress` callback, one call per press and | |
| 376 | never for a repeat; and `Escape.detach`, which gives the key back. `ESCAPE` is | |
| 377 | the code it is about, and the only one it ever asks for. | |
| 378 | ||
| 379 | The lock follows the screen, because a browser only grants it in full screen and | |
| 380 | drops it when full screen goes: it is asked for on the way in and released on the | |
| 381 | way out, and a refusal — a rejected promise, or a browser that throws instead — | |
| 382 | leaves `Escape.held` false and is not an error a game can act on. Under the lock | |
| 383 | the browser stops acting on the key and leaves full screen only when it is *held* | |
| 384 | down, so `Escape.onPress` is the whole of what the key means and your game decides | |
| 385 | that: close a panel, pause the match, let the pointer go. What this does not do is | |
| 386 | put anything back — a browser that took the screen took it inside its own gesture, | |
| 387 | and only another gesture can ask again, which is your Resume button. | |
| 388 | ||
| 360 | 389 | ### overlay |
| 361 | 390 | |
| 362 | 391 | The 2D a game draws over its scene. `overlay` takes the surface, the width and |
| @@ -499,8 +528,11 @@ What you bring is `PageOptions`, which is your game and not the engine's: | ||
| 499 | 528 | `PageOptions.canvases` says which of those are canvases (the first one is the |
| 500 | 529 | surface), `PageOptions.hidden` is the ones the server renders already hidden, |
| 501 | 530 | and `PageOptions.actions` is one `[data-action]` element per name. |
| 502 | `paint: false` is a browser whose canvas gives no 2D context, and `coarse: true` | |
| 503 | is a phone. Then `FakePage.step(ms)` runs the oldest frame that was asked for, | |
| 531 | `paint: false` is a browser whose canvas gives no 2D context, `coarse: true` | |
| 532 | is a phone, `fullscreen: false` is a browser that will not put an element on the | |
| 533 | whole screen, and `keyboardLock: false` is one with no Keyboard Lock API — which | |
| 534 | takes both methods off `FakePage.keyboard`, the `FakeKeyboard` that records what | |
| 535 | is locked, how often it was asked and whether it is refusing. Then `FakePage.step(ms)` runs the oldest frame that was asked for, | |
| 504 | 536 | that many milliseconds after the last one — that is the whole clock, and there |
| 505 | 537 | is no timer anywhere in the file. |
| 506 | 538 |
src/engine/boundary.test.ts +10 −5
| @@ -287,6 +287,10 @@ test("the example imports the door and nothing else, which is what it is for", ( | ||
| 287 | 287 | * fullscreen — `Fullscreen`, `FullscreenHost`, `FullscreenOwner`, |
| 288 | 288 | * `FullscreenTarget`, `createFullscreen`. |
| 289 | 289 | * |
| 290 | * escape — `ESCAPE`, `Escape`, `EscapeHost`, `KeyboardLock`, `Screen`, | |
| 291 | * `createEscape`; and `FakeKeyboard`, which is the browser's half of it on the | |
| 292 | * headless page. | |
| 293 | * | |
| 290 | 294 | * effects — `Effects`, `createEffects`, `pushEffect`, `expireEffects`, |
| 291 | 295 | * `effectAt`, `resetEffects`. |
| 292 | 296 | * |
| @@ -295,20 +299,21 @@ test("the example imports the door and nothing else, which is what it is for", ( | ||
| 295 | 299 | */ |
| 296 | 300 | export const SURFACE = [ |
| 297 | 301 | "Activations", "Adam", "Body", "Box", "Builder", "Burst", "Button", "ButtonOptions", |
| 298 | "CHECKPOINT_CHARS", "Camera", "Complaint", "Controls", "Ctor", "DEFAULT_HYPER", "Effects", | |
| 302 | "CHECKPOINT_CHARS", "Camera", "Complaint", "Controls", "Ctor", "DEFAULT_HYPER", "ESCAPE", | |
| 303 | "Effects", "Escape", "EscapeHost", | |
| 299 | 304 | "FakeCanvas", |
| 300 | "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", "Fold", | |
| 305 | "FakeDocument", "FakeElement", "FakeKeyboard", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", "Fold", | |
| 301 | 306 | "Folded", "Frames", "Fullscreen", "FullscreenHost", "FullscreenOwner", "FullscreenTarget", |
| 302 | "Grads", "Grid", "Hit", "Host", "Hull", "Hyper", "Instance", "KeyEventish", | |
| 307 | "Grads", "Grid", "Hit", "Host", "Hull", "Hyper", "Instance", "KeyEventish", "KeyboardLock", | |
| 303 | 308 | "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh", "MouseEventish", "MoveTuning", "Options", |
| 304 | 309 | "Overlay", "Packed", "PageOptions", "Paint", "Painter", "Pin", "Policy", "QUANTUM", |
| 305 | "Renderer", "RendererAssets", "Rollout", "Scene", "SceneCapacity", "Shader", "Shape", | |
| 310 | "Renderer", "RendererAssets", "Rollout", "Scene", "SceneCapacity", "Screen", "Shader", "Shape", | |
| 306 | 311 | "SoundDevice", "Stick", "Surface", "Tone", "TouchEventish", "Touchish", "UpdateStats", "Vec3", |
| 307 | 312 | "Wish", "accelerate", "add", "addBox", "airAccelerate", "angleDelta", "angleVectors", |
| 308 | 313 | "applyGrads", "attachButtons", "attenuate", |
| 309 | 314 | "backward", "builder", "cellAt", "clamp", "clearGrads", "colOf", "compare", |
| 310 | 315 | "computeAdvantages", "copyPolicy", "createAdam", "createControls", "createEffects", |
| 311 | "createFullscreen", | |
| 316 | "createEscape", "createFullscreen", | |
| 312 | 317 | "createLoop", "createPolicy", "createRenderer", "createRollout", "createScene", |
| 313 | 318 | "createSoundDevice", |
| 314 | 319 | "cross", "deg", "dist", "dist2d", "dot", "effectAt", "expireEffects", "eyeHeight", "fakePage", |
src/engine/escape.test.ts added +161
| @@ -0,0 +1,161 @@ | ||
| 1 | /** | |
| 2 | * Escape, driven against the headless page next door: the key heard, the lock | |
| 3 | * asked for when the stage takes the screen and given back when it loses it, a | |
| 4 | * browser with no Keyboard Lock API, a browser that refuses, and the stale grant | |
| 5 | * that must not outlive the screen it was granted for. Nothing in here is a | |
| 6 | * browser — `fakePage`'s `keyboard` is the one that hands the key over, and its | |
| 7 | * document fires `fullscreenchange` the way a real one does. | |
| 8 | */ | |
| 9 | ||
| 10 | import { expect, test } from "bun:test"; | |
| 11 | import { createEscape, ESCAPE, type EscapeHost } from "./escape.ts"; | |
| 12 | import { createFullscreen } from "./fullscreen.ts"; | |
| 13 | import { fakePage } from "./page.ts"; | |
| 14 | ||
| 15 | /** A page with a stage on it, full screen around the stage, and Escape over both. */ | |
| 16 | function stage(options: { keyboardLock?: boolean } = {}) { | |
| 17 | const page = fakePage({ ids: ["stage"], keyboardLock: options.keyboardLock }); | |
| 18 | const full = createFullscreen({ element: page.by("stage"), document: page.document }); | |
| 19 | const presses: number[] = []; | |
| 20 | const escape = createEscape( | |
| 21 | { window: page.window, document: page.document, keyboard: page.keyboard }, | |
| 22 | full, | |
| 23 | ); | |
| 24 | escape.onPress = () => void presses.push(presses.length + 1); | |
| 25 | return { page, full, escape, presses }; | |
| 26 | } | |
| 27 | ||
| 28 | test("the key is heard once per press, and its browser meaning is refused", () => { | |
| 29 | const { page, presses } = stage(); | |
| 30 | let prevented = 0; | |
| 31 | page.window.fire("keydown", { code: ESCAPE, preventDefault: () => void (prevented += 1) }); | |
| 32 | expect(presses.length).toBe(1); | |
| 33 | expect(prevented).toBe(1); | |
| 34 | ||
| 35 | // A key held down repeats. One press is one pause, not sixty. | |
| 36 | page.window.fire("keydown", { code: ESCAPE, repeat: true }); | |
| 37 | expect(presses.length).toBe(1); | |
| 38 | // And nothing else on the keyboard is this module's business. | |
| 39 | page.window.fire("keydown", { code: "KeyW" }); | |
| 40 | page.window.fire("keyup", { code: ESCAPE }); | |
| 41 | expect(presses.length).toBe(1); | |
| 42 | }); | |
| 43 | ||
| 44 | test("the lock follows the screen: asked for on the way in, given back on the way out", async () => { | |
| 45 | const { page, full, escape } = stage(); | |
| 46 | expect(escape.supported).toBe(true); | |
| 47 | // Nothing is asked for by a page that is not full screen. | |
| 48 | expect(page.keyboard.grabs).toBe(0); | |
| 49 | expect(escape.held).toBe(false); | |
| 50 | ||
| 51 | full.enter(); | |
| 52 | expect(page.keyboard.grabs).toBe(1); | |
| 53 | expect(page.keyboard.locked).toEqual([ESCAPE]); | |
| 54 | // The grant is a promise: it is ours on the microtask it resolves, not before. | |
| 55 | expect(escape.held).toBe(false); | |
| 56 | await Promise.resolve(); | |
| 57 | expect(escape.held).toBe(true); | |
| 58 | ||
| 59 | full.exit(); | |
| 60 | expect(page.keyboard.locked).toBe(null); | |
| 61 | expect(escape.held).toBe(false); | |
| 62 | }); | |
| 63 | ||
| 64 | test("held is read from the screen, never remembered", async () => { | |
| 65 | const { page, full, escape } = stage(); | |
| 66 | full.enter(); | |
| 67 | await Promise.resolve(); | |
| 68 | expect(escape.held).toBe(true); | |
| 69 | // Escape held down, which a browser answers by taking the screen back. The | |
| 70 | // event is all we get, and the key is the browser's again the moment it fires. | |
| 71 | page.document.fullscreenElement = null; | |
| 72 | page.document.fire("fullscreenchange"); | |
| 73 | expect(escape.held).toBe(false); | |
| 74 | expect(page.keyboard.locked).toBe(null); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a grant that arrives after the screen is gone does not claim the key", async () => { | |
| 78 | const { page, full, escape } = stage(); | |
| 79 | full.enter(); | |
| 80 | // Out again before the lock's promise has had a chance to settle: the grant | |
| 81 | // belongs to a lock the browser has already dropped. | |
| 82 | full.exit(); | |
| 83 | await Promise.resolve(); | |
| 84 | await Promise.resolve(); | |
| 85 | expect(escape.held).toBe(false); | |
| 86 | expect(page.keyboard.locked).toBe(null); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a browser with no Keyboard Lock API says so, and is not asked", () => { | |
| 90 | const { page, full, escape, presses } = stage({ keyboardLock: false }); | |
| 91 | expect(escape.supported).toBe(false); | |
| 92 | full.enter(); | |
| 93 | expect(page.keyboard.grabs).toBe(0); | |
| 94 | expect(escape.held).toBe(false); | |
| 95 | // The key still arrives; this is the browser where it also leaves full screen. | |
| 96 | page.window.fire("keydown", { code: ESCAPE }); | |
| 97 | expect(presses.length).toBe(1); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a refusal is not a crash, whichever way the browser refuses", async () => { | |
| 101 | const { page, full, escape } = stage(); | |
| 102 | page.keyboard.refuse = true; | |
| 103 | expect(() => full.enter()).not.toThrow(); | |
| 104 | await Promise.resolve(); | |
| 105 | await Promise.resolve(); | |
| 106 | expect(escape.held).toBe(false); | |
| 107 | ||
| 108 | // And a browser that throws where it should have rejected. | |
| 109 | const thrower: EscapeHost = { | |
| 110 | window: page.window, | |
| 111 | document: page.document, | |
| 112 | keyboard: { | |
| 113 | lock: () => { | |
| 114 | throw new Error("not allowed"); | |
| 115 | }, | |
| 116 | unlock: () => { | |
| 117 | throw new Error("not allowed either"); | |
| 118 | }, | |
| 119 | }, | |
| 120 | }; | |
| 121 | const rude = createEscape(thrower, { on: true }); | |
| 122 | expect(rude.held).toBe(false); | |
| 123 | expect(() => rude.detach()).not.toThrow(); | |
| 124 | }); | |
| 125 | ||
| 126 | test("a page booted already full screen holds the key from the start", async () => { | |
| 127 | const page = fakePage({ ids: ["stage"] }); | |
| 128 | page.document.fullscreenElement = page.by("stage"); | |
| 129 | const escape = createEscape( | |
| 130 | { window: page.window, document: page.document, keyboard: page.keyboard }, | |
| 131 | { on: true }, | |
| 132 | ); | |
| 133 | await Promise.resolve(); | |
| 134 | expect(escape.held).toBe(true); | |
| 135 | expect(page.keyboard.grabs).toBe(1); | |
| 136 | }); | |
| 137 | ||
| 138 | test("detach leaves the page as it found it, key included", async () => { | |
| 139 | const { page, full, escape, presses } = stage(); | |
| 140 | full.enter(); | |
| 141 | await Promise.resolve(); | |
| 142 | expect(page.window.listening.has("keydown")).toBe(true); | |
| 143 | expect(page.document.listening.has("fullscreenchange")).toBe(true); | |
| 144 | ||
| 145 | escape.detach(); | |
| 146 | expect(page.keyboard.locked).toBe(null); | |
| 147 | expect(escape.held).toBe(false); | |
| 148 | expect(page.window.listening.has("keydown")).toBe(false); | |
| 149 | page.window.fire("keydown", { code: ESCAPE }); | |
| 150 | expect(presses.length).toBe(0); | |
| 151 | // The full screen module is still the one listening for its own event. | |
| 152 | expect(page.document.listening.has("fullscreenchange")).toBe(true); | |
| 153 | }); | |
| 154 | ||
| 155 | test("a browser with no keyboard at all is a browser, not an exception", () => { | |
| 156 | const page = fakePage({ ids: ["stage"] }); | |
| 157 | const escape = createEscape({ window: page.window, document: page.document }, { on: true }); | |
| 158 | expect(escape.supported).toBe(false); | |
| 159 | expect(escape.held).toBe(false); | |
| 160 | expect(() => page.window.fire("keydown", { code: ESCAPE })).not.toThrow(); | |
| 161 | }); |
src/engine/escape.ts added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | /** | |
| 2 | * Escape: the one key a browser reads before the page does. Every game here | |
| 3 | * wants it to mean "pause", and on a Mac it means "give the window back" — the | |
| 4 | * screen drops out of full screen with the whole macOS animation, which is what | |
| 5 | * a player sees as their window being thrown away mid-round. | |
| 6 | * | |
| 7 | * There is exactly one way to ask for the key rather than the meaning, and this | |
| 8 | * module is the three lines of it: the Keyboard Lock API. `keyboard.lock(["Escape"])` | |
| 9 | * says "deliver that key to the page while it is full screen"; the browser then | |
| 10 | * only leaves full screen when Escape is *held*, which is the behaviour the | |
| 11 | * platform intends and the one a game wants. It is Chrome's and Edge's; Safari | |
| 12 | * and Firefox do not have it, so `supported` says so and a game that asks gets | |
| 13 | * the honest answer rather than a promise nobody kept. | |
| 14 | * | |
| 15 | * Three things this file takes seriously, all three of them a real browser: | |
| 16 | * | |
| 17 | * - The API may be missing entirely — `navigator.keyboard` is undefined in | |
| 18 | * Safari — so the whole host member is optional and a missing one is not an | |
| 19 | * error, it is a browser where Escape keeps its own meaning. | |
| 20 | * - The lock is only granted in full screen and is dropped when full screen | |
| 21 | * goes. So it is asked for when the stage takes the screen and released when | |
| 22 | * it gives it back, and `held` is read live — granted *and* still there — | |
| 23 | * rather than remembered, the way `fullscreen.ts` reads `on`. | |
| 24 | * - The key still arrives as a keydown whether the lock was granted or not. | |
| 25 | * `onPress` is that event, once per press and never for a repeat, and it is | |
| 26 | * the game's whole interface to the key: what Escape means — close the buy | |
| 27 | * menu, pause the match, show the note — is the game's business, and this | |
| 28 | * file has no opinion about it. | |
| 29 | * | |
| 30 | * What it does not do is put anything back. A browser that took the screen took | |
| 31 | * it inside its own gesture, and only another gesture can ask again: that is the | |
| 32 | * game's Resume button, and the game is the one that knows whether the player | |
| 33 | * wanted full screen in the first place. | |
| 34 | */ | |
| 35 | ||
| 36 | import type { Listens } from "./controls.ts"; | |
| 37 | ||
| 38 | /** The code of the key this module is about, and the only one it ever asks for. */ | |
| 39 | export const ESCAPE = "Escape"; | |
| 40 | ||
| 41 | /** | |
| 42 | * `navigator.keyboard`, as far as this file is concerned. Both methods are | |
| 43 | * optional because a browser has either both or neither, and `lock` hands back a | |
| 44 | * promise that rejects when the browser will not. | |
| 45 | */ | |
| 46 | export type KeyboardLock = { | |
| 47 | lock?(codes: readonly string[]): unknown; | |
| 48 | unlock?(): unknown; | |
| 49 | }; | |
| 50 | ||
| 51 | /** | |
| 52 | * Where the key is heard, where full screen is announced, and the keyboard to | |
| 53 | * ask — `window`, `document` and `navigator.keyboard` in a browser, three plain | |
| 54 | * objects in a test. | |
| 55 | */ | |
| 56 | export type EscapeHost = { | |
| 57 | window: Listens; | |
| 58 | document: Listens; | |
| 59 | /** Absent on a browser with no Keyboard Lock API, which is most of them. */ | |
| 60 | keyboard?: KeyboardLock; | |
| 61 | }; | |
| 62 | ||
| 63 | /** Whether the stage is on the whole screen right now. A `Fullscreen` is one. */ | |
| 64 | export type Screen = { readonly on: boolean }; | |
| 65 | ||
| 66 | export type Escape = { | |
| 67 | /** Whether this browser can hand the key over at all. False is not a failure. */ | |
| 68 | readonly supported: boolean; | |
| 69 | /** Whether it is ours right now: granted, and still full screen. Live. */ | |
| 70 | readonly held: boolean; | |
| 71 | /** Escape went down. Once per press; a held key does not repeat into this. */ | |
| 72 | onPress: () => void; | |
| 73 | detach(): void; | |
| 74 | }; | |
| 75 | ||
| 76 | export function createEscape(host: EscapeHost, screen: Screen): Escape { | |
| 77 | const keyboard = host.keyboard; | |
| 78 | const supported = typeof keyboard?.lock === "function" && typeof keyboard?.unlock === "function"; | |
| 79 | // Whether the last request was granted. `held` is this *and* the screen: the | |
| 80 | // browser drops the lock when full screen goes, so remembering only the | |
| 81 | // promise would be a claim that outlived the thing it was true about. | |
| 82 | let granted = false; | |
| 83 | // Which request is the current one. A promise that resolves after the screen | |
| 84 | // was handed back belongs to a lock that is already gone and may not claim the | |
| 85 | // key; without this, a fast Escape leaves `held` true forever. | |
| 86 | let asked = 0; | |
| 87 | ||
| 88 | const ask = (): void => { | |
| 89 | if (!supported) return; | |
| 90 | const mine = ++asked; | |
| 91 | if (!screen.on) { | |
| 92 | granted = false; | |
| 93 | try { | |
| 94 | keyboard?.unlock?.(); | |
| 95 | } catch { | |
| 96 | // A browser that throws where it should not. The key is the browser's | |
| 97 | // again either way, which is exactly what we just asked for. | |
| 98 | } | |
| 99 | return; | |
| 100 | } | |
| 101 | try { | |
| 102 | // A promise nobody awaits still rejects loudly, and a refusal here is not | |
| 103 | // news: it is a browser saying the key stays its own. | |
| 104 | const asking = keyboard?.lock?.([ESCAPE]); | |
| 105 | // Only the current request may claim the key: a stale one says nothing at | |
| 106 | // all, rather than answering for the lock that replaced it. | |
| 107 | if (asking instanceof Promise) asking.then(() => void (mine === asked && (granted = true)), () => {}); | |
| 108 | else granted = true; | |
| 109 | } catch { | |
| 110 | granted = false; | |
| 111 | } | |
| 112 | }; | |
| 113 | ||
| 114 | const key = (event: { code: string; repeat?: boolean; preventDefault(): void }): void => { | |
| 115 | if (event.code !== ESCAPE || event.repeat === true) return; | |
| 116 | // The browser's own meaning for the key, refused where refusing is allowed: | |
| 117 | // under the lock this is the whole point, and without it it costs nothing. | |
| 118 | event.preventDefault(); | |
| 119 | escape.onPress(); | |
| 120 | }; | |
| 121 | ||
| 122 | const escape: Escape = { | |
| 123 | supported, | |
| 124 | get held() { | |
| 125 | return granted && screen.on; | |
| 126 | }, | |
| 127 | onPress: () => {}, | |
| 128 | detach() { | |
| 129 | host.window.removeEventListener("keydown", key); | |
| 130 | host.document.removeEventListener("fullscreenchange", ask); | |
| 131 | asked += 1; | |
| 132 | granted = false; | |
| 133 | if (supported) { | |
| 134 | try { | |
| 135 | keyboard?.unlock?.(); | |
| 136 | } catch { | |
| 137 | // Leaving the page as we found it is best effort by definition. | |
| 138 | } | |
| 139 | } | |
| 140 | }, | |
| 141 | }; | |
| 142 | ||
| 143 | host.window.addEventListener("keydown", key); | |
| 144 | host.document.addEventListener("fullscreenchange", ask); | |
| 145 | // A page that is already full screen when this is wired — a game booted into | |
| 146 | // one — is a page that should already hold the key. | |
| 147 | ask(); | |
| 148 | return escape; | |
| 149 | } |
src/engine/index.ts +9 −1
| @@ -62,6 +62,13 @@ export type { | ||
| 62 | 62 | export { createFullscreen } from "./fullscreen.ts"; |
| 63 | 63 | export type { Fullscreen, FullscreenHost, FullscreenOwner, FullscreenTarget } from "./fullscreen.ts"; |
| 64 | 64 | |
| 65 | /** | |
| 66 | * Escape, asked for rather than obeyed: the Keyboard Lock API around the one key | |
| 67 | * a browser reads before the page does, so full screen survives a pause. | |
| 68 | */ | |
| 69 | export { createEscape, ESCAPE } from "./escape.ts"; | |
| 70 | export type { Escape, EscapeHost, KeyboardLock, Screen } from "./escape.ts"; | |
| 71 | ||
| 65 | 72 | /** The 2D over the scene: panels, scaled text, a crosshair, a bar and a clock. */ |
| 66 | 73 | export { overlay } from "./overlay.ts"; |
| 67 | 74 | export type { Overlay, Paint } from "./overlay.ts"; |
| @@ -97,7 +104,8 @@ export type { Renderer, RendererAssets } from "./renderer.ts"; | ||
| 97 | 104 | */ |
| 98 | 105 | export { fakePage } from "./page.ts"; |
| 99 | 106 | export type { |
| 100 | FakeCanvas, FakeDocument, FakeElement, FakePage, FakePaint, FakeTarget, FakeWindow, PageOptions, | |
| 107 | FakeCanvas, FakeDocument, FakeElement, FakeKeyboard, FakePage, FakePaint, FakeTarget, FakeWindow, | |
| 108 | PageOptions, | |
| 101 | 109 | } from "./page.ts"; |
| 102 | 110 | |
| 103 | 111 | /** |
src/engine/page.ts +52 −3
| @@ -15,9 +15,10 @@ | ||
| 15 | 15 | * hidden, written, focused, set as an attribute and put full screen; a canvas's |
| 16 | 16 | * 2D context writes down every call it was asked for, in order, with its |
| 17 | 17 | * arguments; a target remembers what is listening on it and delivers an event |
| 18 | * to exactly those handlers. The two pieces of state a browser owns rather than | |
| 19 | * a page — the pointer lock and the full screen element — live on the document | |
| 20 | * here too, and asking for either fires the event a page hears it from. It is honest | |
| 18 | * to exactly those handlers. The pieces of state a browser owns rather than a | |
| 19 | * page live here too — the pointer lock and the full screen element on the | |
| 20 | * document, and who holds Escape on `keyboard` beside it — and asking for any of | |
| 21 | * them fires the event a page hears it from. It is honest | |
| 21 | 22 | * about being a double: it has no timer, it touches no global, it throws for |
| 22 | 23 | * nothing, and it knows what neither a renderer nor an `AudioContext` is — |
| 23 | 24 | * `canvas.gl` is a slot a game fills with its own recorder, because what a |
| @@ -29,6 +30,7 @@ | ||
| 29 | 30 | * are each the game's half, built on this one. |
| 30 | 31 | */ |
| 31 | 32 | |
| 33 | import type { KeyboardLock } from "./escape.ts"; | |
| 32 | 34 | import type { Paint } from "./overlay.ts"; |
| 33 | 35 | |
| 34 | 36 | /** |
| @@ -270,6 +272,21 @@ export type FakeDocument = FakeTarget & { | ||
| 270 | 272 | exitFullscreen?(): Promise<void>; |
| 271 | 273 | }; |
| 272 | 274 | |
| 275 | /** | |
| 276 | * `navigator.keyboard`: the third thing on this page the browser owns rather | |
| 277 | * than the document. It records what is locked and how many times something | |
| 278 | * asked, and `refuse` is a browser saying no — which a real one does when the | |
| 279 | * page is not full screen. | |
| 280 | */ | |
| 281 | export type FakeKeyboard = KeyboardLock & { | |
| 282 | /** The codes held by the page right now, or null when the browser has them. */ | |
| 283 | locked: readonly string[] | null; | |
| 284 | /** How many times something asked for a lock, granted or refused. */ | |
| 285 | grabs: number; | |
| 286 | /** Refuse every lock from here on, the way a browser out of full screen does. */ | |
| 287 | refuse: boolean; | |
| 288 | }; | |
| 289 | ||
| 273 | 290 | /** What a game brings: its own ids, its own buttons, and the browser it is on. */ |
| 274 | 291 | export type PageOptions = { |
| 275 | 292 | /** Every id `getElementById` answers for. Anything else answers null. */ |
| @@ -291,6 +308,12 @@ export type PageOptions = { | ||
| 291 | 308 | * the document, which is what an iPhone looks like from the inside. |
| 292 | 309 | */ |
| 293 | 310 | fullscreen?: boolean; |
| 311 | /** | |
| 312 | * Whether this browser has the Keyboard Lock API. Default true: asking holds | |
| 313 | * the codes and the promise resolves. False takes both methods off | |
| 314 | * `keyboard`, which is what Safari and Firefox look like from the inside. | |
| 315 | */ | |
| 316 | keyboardLock?: boolean; | |
| 294 | 317 | }; |
| 295 | 318 | |
| 296 | 319 | export type FakePage = { |
| @@ -302,6 +325,8 @@ export type FakePage = { | ||
| 302 | 325 | surface: FakeCanvas; |
| 303 | 326 | window: FakeWindow; |
| 304 | 327 | document: FakeDocument; |
| 328 | /** `navigator.keyboard`: who holds Escape, and how often it was asked for. */ | |
| 329 | keyboard: FakeKeyboard; | |
| 305 | 330 | /** The canvases, in the order `canvases` named them. */ |
| 306 | 331 | canvases: FakeCanvas[]; |
| 307 | 332 | /** The `[data-action]` elements, in the order `actions` named them. */ |
| @@ -374,6 +399,29 @@ export function fakePage(options: PageOptions = {}): FakePage { | ||
| 374 | 399 | } |
| 375 | 400 | } |
| 376 | 401 | |
| 402 | // The browser's half of the Keyboard Lock API. A real one only grants it in | |
| 403 | // full screen and drops it when full screen goes; this one grants whatever it | |
| 404 | // is asked for until a test sets `refuse`, because which of those a game does | |
| 405 | // about is `escape.ts`'s business and not this file's. | |
| 406 | const keyboard: FakeKeyboard = { | |
| 407 | locked: null, | |
| 408 | grabs: 0, | |
| 409 | refuse: false, | |
| 410 | lock(codes): Promise<void> { | |
| 411 | keyboard.grabs += 1; | |
| 412 | if (keyboard.refuse) return Promise.reject(new Error("the page is not full screen")); | |
| 413 | keyboard.locked = [...codes]; | |
| 414 | return Promise.resolve(); | |
| 415 | }, | |
| 416 | unlock(): void { | |
| 417 | keyboard.locked = null; | |
| 418 | }, | |
| 419 | }; | |
| 420 | if (options.keyboardLock === false) { | |
| 421 | delete keyboard.lock; | |
| 422 | delete keyboard.unlock; | |
| 423 | } | |
| 424 | ||
| 377 | 425 | const window: FakeWindow = { |
| 378 | 426 | ...target(), |
| 379 | 427 | devicePixelRatio: 1, |
| @@ -387,6 +435,7 @@ export function fakePage(options: PageOptions = {}): FakePage { | ||
| 387 | 435 | surface: canvases[0] ?? canvasElement("", paint), |
| 388 | 436 | window, |
| 389 | 437 | document, |
| 438 | keyboard, | |
| 390 | 439 | canvases, |
| 391 | 440 | buttons, |
| 392 | 441 | frames, |
src/pages/game.ts +12 −1
| @@ -94,14 +94,25 @@ const menu = (visitor: Who): string => ` | ||
| 94 | 94 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang · |
| 95 | 95 | <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy · |
| 96 | 96 | <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen · |
| 97 | <kbd>Esc</kbd> pause</p> | |
| 97 | <kbd>Esc</kbd> or <kbd>P</kbd> pause</p> | |
| 98 | <p class="hint">Full screen keeps the screen through a pause where a browser lets it: | |
| 99 | Chrome and Edge hand the game the <kbd>Esc</kbd> key, and hold it down to leave. Safari and | |
| 100 | Firefox leave full screen on <kbd>Esc</kbd> whatever the page asks, so Resume puts it back — | |
| 101 | or pause with <kbd>P</kbd>, which no browser has a meaning of its own for.</p> | |
| 98 | 102 | <p class="hint">Dead, you watch a teammate until the round ends: fire for the next player, |
| 99 | 103 | aim for the one before.</p> |
| 100 | 104 | `; |
| 101 | 105 | |
| 106 | /** | |
| 107 | * The pause panel. Its line about Escape is rendered with the sentence that is | |
| 108 | * true before any key has been pressed, and `src/client/main.ts` replaces it on | |
| 109 | * every pause with the one that is true for the browser it turned out to be: | |
| 110 | * whether Escape kept the screen, or took it and Resume will put it back. | |
| 111 | */ | |
| 102 | 112 | const PAUSE = ` |
| 103 | 113 | <h2>Paused</h2> |
| 104 | 114 | <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div> |
| 115 | <p class="hint" id="esc"><kbd>Esc</kbd> or <kbd>P</kbd> pauses, and Resume goes back.</p> | |
| 105 | 116 | `; |
| 106 | 117 | |
| 107 | 118 | export function gamePage(db: Database, { who: visitor, ...options }: PageOptions): string { |
src/pages/range.ts +3 −1
| @@ -33,7 +33,9 @@ const NOTE = ` | ||
| 33 | 33 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · |
| 34 | 34 | <kbd>Shift</kbd> walk · <kbd>F</kbd> or the mouse fires · <kbd>R</kbd> reload · |
| 35 | 35 | <kbd>Enter</kbd> start · <kbd>T</kbd> the other drill · <kbd>G</kbd> full screen, which is |
| 36 | G here because F is the trigger · <kbd>Esc</kbd> gives the mouse back. On a touch screen the left of the view is a stick, the right looks around, and the | |
| 36 | G here because F is the trigger · <kbd>Esc</kbd> gives the mouse back, and keeps the screen | |
| 37 | where a browser lets the page hold the key — Chrome and Edge do, and Safari and Firefox | |
| 38 | leave full screen with it, so Capture the mouse puts that back too. On a touch screen the left of the view is a stick, the right looks around, and the | |
| 37 | 39 | buttons do the rest.</p> |
| 38 | 40 | <p class="hint">It makes its own noise, through the engine's sound device and the range's own |
| 39 | 41 | recipes: a short dry crack, a clean tone for a hit, a knock as a plate stands up — quieter |
src/range/boot.test.ts +41
| @@ -59,6 +59,47 @@ test("G and the button put the lane on the whole screen, and a browser that will | ||
| 59 | 59 | } |
| 60 | 60 | }); |
| 61 | 61 | |
| 62 | test("Escape gives the mouse back, and the screen a browser took comes back with Capture", () => { | |
| 63 | // Bun has no Keyboard Lock API, so this is the browser where Escape keeps its | |
| 64 | // own meaning — Safari on a Mac — and the way back is the one this page owns. | |
| 65 | const { p, undo } = booted(); | |
| 66 | try { | |
| 67 | const stage = p.by("stage"); | |
| 68 | p.window.fire("keydown", { code: "KeyG" }); | |
| 69 | p.window.fire("keyup", { code: "KeyG" }); | |
| 70 | p.by("look").fire("click"); | |
| 71 | p.document.pointerLockElement = p.scene; | |
| 72 | p.document.fire("pointerlockchange"); | |
| 73 | expect(p.document.fullscreenElement).toBe(stage); | |
| 74 | expect(p.by("note").hidden).toBe(true); | |
| 75 | ||
| 76 | // Escape: the page lets the pointer go itself, because under a lock the | |
| 77 | // browser would not have, and the note comes back with it. | |
| 78 | p.window.fire("keydown", { code: "Escape" }); | |
| 79 | expect(p.document.pointerLockElement).toBe(null); | |
| 80 | p.document.fire("pointerlockchange"); | |
| 81 | expect(p.by("note").hidden).toBe(false); | |
| 82 | ||
| 83 | // And that browser also took the screen. Capture is a gesture, so it is what | |
| 84 | // asks for it again — the lane comes back whole, not half. | |
| 85 | p.document.fullscreenElement = null; | |
| 86 | p.document.fire("fullscreenchange"); | |
| 87 | p.by("look").fire("click"); | |
| 88 | expect(p.document.fullscreenElement).toBe(stage); | |
| 89 | expect(stage.fullscreens).toBe(2); | |
| 90 | ||
| 91 | // A person who left full screen on purpose is not dragged back into it. | |
| 92 | p.window.fire("keydown", { code: "KeyG" }); | |
| 93 | p.window.fire("keyup", { code: "KeyG" }); | |
| 94 | expect(p.document.fullscreenElement).toBe(null); | |
| 95 | p.by("look").fire("click"); | |
| 96 | expect(p.document.fullscreenElement).toBe(null); | |
| 97 | expect(stage.fullscreens).toBe(2); | |
| 98 | } finally { | |
| 99 | undo(); | |
| 100 | } | |
| 101 | }); | |
| 102 | ||
| 62 | 103 | test("a browser with no full screen keeps the button hidden rather than shipping a dead one", () => { |
| 63 | 104 | const { p, undo } = booted({ fullscreen: false }); |
| 64 | 105 | try { |
src/range/main.ts +35 −4
| @@ -49,8 +49,8 @@ import { | ||
| 49 | 49 | type DrillName, type Target, |
| 50 | 50 | } from "./targets.ts"; |
| 51 | 51 | import { |
| 52 | angleVectors, attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer, | |
| 53 | move, | |
| 52 | angleVectors, attachButtons, clamp, createControls, createEscape, createFullscreen, createLoop, | |
| 53 | createRenderer, move, type KeyboardLock, | |
| 54 | 54 | } from "../engine/index.ts"; |
| 55 | 55 | |
| 56 | 56 | /** The range's own step: 50 Hz, deliberately not the shooter's 64. */ |
| @@ -168,6 +168,10 @@ export function boot(): void { | ||
| 168 | 168 | // either way the note gets out of the way and the touch look still works. |
| 169 | 169 | $("look").addEventListener("click", () => { |
| 170 | 170 | note.hidden = true; |
| 171 | // The click is a gesture, so it is also the way back into a screen a browser | |
| 172 | // took: on a Mac, Escape hands the window back whether the page wanted it or | |
| 173 | // not, and `wantsFull` below is how this page remembers that it did. | |
| 174 | restoreFull(); | |
| 171 | 175 | try { |
| 172 | 176 | const asked: unknown = scene.requestPointerLock?.(); |
| 173 | 177 | if (asked instanceof Promise) asked.catch(() => {}); |
| @@ -192,9 +196,36 @@ export function boot(): void { | ||
| 192 | 196 | fullButton.textContent = on ? "\u2921" : "\u26F6"; |
| 193 | 197 | }; |
| 194 | 198 | fullButton.hidden = !full.supported; |
| 195 | fullButton.addEventListener("click", () => full.toggle()); | |
| 199 | /** | |
| 200 | * What a person asked for, which is not the same as what the browser is giving | |
| 201 | * us: Escape takes the screen back on a Mac and no code here gets a say. The | |
| 202 | * wish is remembered so the Capture button above — a click, and therefore a | |
| 203 | * gesture a browser will honour — can put it back. | |
| 204 | */ | |
| 205 | let wantsFull = false; | |
| 206 | const askFull = (): void => { | |
| 207 | wantsFull = !full.on; | |
| 208 | full.toggle(); | |
| 209 | }; | |
| 210 | const restoreFull = (): void => { | |
| 211 | if (wantsFull && !full.on) full.enter(); | |
| 212 | }; | |
| 213 | fullButton.addEventListener("click", askFull); | |
| 196 | 214 | controls.onAction = (action, down) => { |
| 197 | if (down && action === "fullscreen") full.toggle(); | |
| 215 | if (down && action === "fullscreen") askFull(); | |
| 216 | }; | |
| 217 | ||
| 218 | // Escape, asked for rather than obeyed. Where a browser has the Keyboard Lock | |
| 219 | // API the key is this page's while the lane has the screen, so it gives the | |
| 220 | // mouse back — which is what it always meant here — and the screen stays. | |
| 221 | // Where it has not, the key still arrives, the mouse still comes back, and the | |
| 222 | // Capture button is what takes the screen again. | |
| 223 | const escape = createEscape( | |
| 224 | { window, document, keyboard: (navigator as { keyboard?: KeyboardLock }).keyboard }, | |
| 225 | full, | |
| 226 | ); | |
| 227 | escape.onPress = () => { | |
| 228 | if (controls.locked) document.exitPointerLock(); | |
| 198 | 229 | }; |
| 199 | 230 | touch.hidden = !matchMedia("(pointer: coarse)").matches; |
| 200 | 231 |
static/app.467be451f6.js deleted −58
Diff not shown — a line 71,064 characters wide (limit 2,000). −58
View filestatic/app.c520f46081.js added +58
Diff not shown — a line 71,555 characters wide (limit 2,000). +58
View filestatic/example.f335e943f5.js → static/example.fad143299f.js renamed +1 −1
Diff not shown — a line 9,725 characters wide (limit 2,000). +1 −1
View filestatic/range.15d5b4d3e8.js added +58
Diff not shown — a line 18,798 characters wide (limit 2,000). +58
View filestatic/range.9ac9632ff0.js deleted −58
Diff not shown — a line 18,622 characters wide (limit 2,000). −58
View file