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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/178-better-escape-handling-on-mac-it-keeps-m → main 2 commits fork

  • Merged. vesper/178-better-escape-handling-on-mac-it-keeps-m was merged into main as e1ac8bd.

Review

Sign in to review this change.
21 files changed +854 −156
README.md +22 −3
@@ -110,13 +110,27 @@ flashbang, if you bought them,
110110 <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze
111111 time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns, <kbd>Tab</kbd> shows
112112 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,
114114 the right looks around, and the buttons along the bottom do the rest — including
115115 the one in the top corner, which is full screen and is how you get one on a
116116 phone, where there is no keyboard and no pointer to lock. A browser that will not
117117 put an element full screen — an iPhone will not — never shows that button rather
118118 than showing one that does nothing.
119119
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
120134 The radar sits in the top corner: the level from above, north up and never
121135 turning, with the rooms in grey, the two bombsites lettered A and B, your side
122136 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
232246 with noise bursts and tones), `loop` (a fixed step with a clamp on a long
233247 frame), `controls` (a bindings table in, held actions out), `fullscreen` (the stage on
234248 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
236253 2D over the scene: a scale and a pad off the canvas width, scaled fonts,
237254 panels, aligned and faded text, a crosshair, a bar, right-aligned columns and
238255 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
299316 `Ctor`; from `loop` `createLoop`, `Frames`, `Loop` and `LoopOptions`; from
300317 `controls` `createControls`, `attachButtons` and the event and option types
301318 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`
303322 a 2D game draws onto; from `scene` the frame and its pool — `createScene`,
304323 `resetScene`, `nextInstance`, `pushLine`, `Scene`, `SceneCapacity`, `Camera`
305324 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", () => {
2929 // the browser for the screen, and nothing about an `Input` changes for it.
3030 expect(CS_KEYS.KeyF).toBe("fullscreen");
3131 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([]));
3236 });
3337
3438 test("nothing held is nothing asked for", () => {
src/client/binding.ts +6 −2
@@ -15,9 +15,13 @@ export const CS_KEYS: Record<string, string> = {
1515 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
1616 KeyR: "reload", KeyE: "use", KeyG: "grenade", KeyT: "flash", KeyB: "buy", Tab: "scoreboard",
1717 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.
2023 KeyF: "fullscreen",
24 KeyP: "pause",
2125 };
2226
2327 /** Left fires, right scopes. */
src/client/main.test.ts +98 −1
@@ -52,7 +52,9 @@ function painted(stage: FakeStage): FakeStage {
5252 }
5353
5454 /** 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 {
5658 const stage = painted(start(fakeStage(options)));
5759 stage.by("play").fire("click");
5860 return stage;
@@ -257,6 +259,101 @@ test("losing the pointer pauses a running match, and does not while the buy pane
257259 expect(stage.by("pause").hidden).toBe(true);
258260 });
259261
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
260357 test("leaving the match puts the menu back and stops the frame", () => {
261358 const stage = playing();
262359 stage.step(FRAME);
src/client/main.ts +89 −11
@@ -25,9 +25,10 @@ import { createAudio, type Audio } from "./audio.ts";
2525 import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts";
2626 import { drawHud, type Paint } from "./hud.ts";
2727 import {
28 attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer, effectAt,
28 attachButtons, clamp, createControls, createEscape, createFullscreen, createLoop, createRenderer,
29 effectAt,
2930 painter,
30 type Button, type Camera, type Listens, type Mesh,
31 type Button, type Camera, type KeyboardLock, type Listens, type Mesh,
3132 } from "../engine/index.ts";
3233 import { generateLevel, seedFrom } from "./generate.ts";
3334 import { DE_FORGE } from "./map.ts";
@@ -131,6 +132,12 @@ export type BuildRenderer = (atlas: Uint8Array) => Draws;
131132 export type Host = {
132133 document: PageDocument;
133134 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;
134141 storage: Store;
135142 fetch(url: string, init: { method: string; headers: Record<string, string>; body: string }): Promise<Reply>;
136143 /** A fresh match id. `crypto.randomUUID` is one; `src/result.ts` checks it. */
@@ -175,6 +182,9 @@ export function boot(host: Host): Frame {
175182 const onMap = by("onmap");
176183 const prep = by("prep");
177184 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");
178188
179189 const build = host.createRenderer(sceneCanvas);
180190 if (build === null) {
@@ -193,8 +203,9 @@ export function boot(host: Host): Frame {
193203 // the "fullscreen" action below like every other key this game reads. Both
194204 // arrive inside a gesture, which is the only time a browser says yes. A
195205 // 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.
198209 const full = createFullscreen({ element: stageBox, document: doc });
199210 const paintFull = (on: boolean): void => {
200211 const label = on ? "Leave full screen" : "Full screen";
@@ -205,7 +216,33 @@ export function boot(host: Host): Frame {
205216 };
206217 full.onChange = paintFull;
207218 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);
209246
210247 const loop = createLoop({ tick: TICK }, {
211248 requestAnimationFrame: (callback) => win.requestAnimationFrame(callback),
@@ -293,10 +330,44 @@ export function boot(host: Host): Frame {
293330 }
294331 }
295332
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
296366 controls.onAction = (action, down) => {
297367 if (!down) return;
298368 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();
300371 // Dead, the trigger is the only thing left to press: it walks the camera
301372 // along your side, and the aim button walks it back. The touch pad's fire
302373 // 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 {
304375 if (action === "fire") cycle(world, spectator, 1);
305376 else if (action === "scope") cycle(world, spectator, -1);
306377 };
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.
307381 controls.onUnlock = () => {
308 if (running && buyPanel.hidden) show(pause, true);
382 if (running && buyPanel.hidden) pauseMatch();
309383 };
310384
311385 // --- results, sent as the match goes rather than only at the end
@@ -371,8 +445,11 @@ export function boot(host: Host): Frame {
371445 }
372446
373447 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", () => {
375451 show(pause, false);
452 restoreFull();
376453 void sceneCanvas.requestPointerLock();
377454 });
378455 by("quit").addEventListener("click", () => {
@@ -382,9 +459,6 @@ export function boot(host: Host): Frame {
382459 show(menu, true);
383460 show(touchPanel, false);
384461 });
385 win.addEventListener("keydown", (event: { code: string }) => {
386 if (event.code === "Escape" && !buyPanel.hidden) openBuy(false);
387 });
388462 const saved = host.storage.getItem("cs.name");
389463 if (saved !== null && nameField.value === "") nameField.value = saved;
390464
@@ -510,6 +584,10 @@ export function gameMeshes(level: Level = DE_FORGE): Record<string, Mesh> {
510584 const browser = (): Host => ({
511585 document,
512586 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,
513591 storage: localStorage,
514592 fetch: (url, init) => fetch(url, init),
515593 randomUUID: () => crypto.randomUUID(),
src/client/stage.test-helper.ts +12 −5
@@ -28,7 +28,7 @@
2828 * and it no longer needs to: what the two shared is behind the door now.
2929 */
3030
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";
3232 import type { Audio } from "./audio.ts";
3333 import type { Mesh } from "../engine/index.ts";
3434 import type { BuildRenderer, Draws, Host, Stage } from "./main.ts";
@@ -105,6 +105,8 @@ export type FakeStage = {
105105 host: Host;
106106 document: FakeDocument;
107107 window: FakeWindow;
108 /** `navigator.keyboard`: who holds Escape, and how often the game asked. */
109 keyboard: FakeKeyboard;
108110 scene: StageCanvas;
109111 hud: StageCanvas;
110112 by(id: string): FakeElement;
@@ -153,7 +155,7 @@ export type FakeStage = {
153155 * contract between the page and this file, so `src/server.test.ts` checks the
154156 * served page against this same list rather than a second copy of it.
155157 */
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"];
157159
158160 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
159161 const HIDDEN = ["pause", "buy", "touch", "full"];
@@ -172,11 +174,13 @@ const GLOBALS = ["document", "window", "localStorage", "fetch"];
172174 /**
173175 * The whole page the shooter's entry expects, plus the host around it. Pass
174176 * `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.
177181 */
178182 export function fakeStage(
179 options: { webgl?: boolean; coarse?: boolean; fullscreen?: boolean } = {},
183 options: { webgl?: boolean; coarse?: boolean; fullscreen?: boolean; keyboardLock?: boolean } = {},
180184 ): FakeStage {
181185 const page = fakePage({
182186 ids: PAGE_IDS,
@@ -185,6 +189,7 @@ export function fakeStage(
185189 actions: PAGE_ACTIONS,
186190 coarse: options.coarse,
187191 fullscreen: options.fullscreen,
192 keyboardLock: options.keyboardLock,
188193 });
189194 // Asked for with `paint` left on, so both canvases have their 2D recorder.
190195 const [scene, hud] = page.canvases as [StageCanvas, StageCanvas];
@@ -222,6 +227,7 @@ export function fakeStage(
222227 host: {
223228 document: page.document,
224229 window: page.window,
230 keyboard: page.keyboard,
225231 storage: {
226232 getItem: (key) => store.get(key) ?? null,
227233 setItem: (key, value) => void store.set(key, value),
@@ -249,6 +255,7 @@ export function fakeStage(
249255 },
250256 document: page.document,
251257 window: page.window,
258 keyboard: page.keyboard,
252259 scene,
253260 hud,
254261 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
357357 `:fullscreen` half is CSS: the element keeps whatever aspect ratio and maximum
358358 height it had unless a rule says otherwise.
359359
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
360389 ### overlay
361390
362391 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:
499528 `PageOptions.canvases` says which of those are canvases (the first one is the
500529 surface), `PageOptions.hidden` is the ones the server renders already hidden,
501530 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,
504536 that many milliseconds after the last one — that is the whole clock, and there
505537 is no timer anywhere in the file.
506538
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", (
287287 * fullscreen — `Fullscreen`, `FullscreenHost`, `FullscreenOwner`,
288288 * `FullscreenTarget`, `createFullscreen`.
289289 *
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 *
290294 * effects — `Effects`, `createEffects`, `pushEffect`, `expireEffects`,
291295 * `effectAt`, `resetEffects`.
292296 *
@@ -295,20 +299,21 @@ test("the example imports the door and nothing else, which is what it is for", (
295299 */
296300 export const SURFACE = [
297301 "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",
299304 "FakeCanvas",
300 "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", "Fold",
305 "FakeDocument", "FakeElement", "FakeKeyboard", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", "Fold",
301306 "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",
303308 "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh", "MouseEventish", "MoveTuning", "Options",
304309 "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",
306311 "SoundDevice", "Stick", "Surface", "Tone", "TouchEventish", "Touchish", "UpdateStats", "Vec3",
307312 "Wish", "accelerate", "add", "addBox", "airAccelerate", "angleDelta", "angleVectors",
308313 "applyGrads", "attachButtons", "attenuate",
309314 "backward", "builder", "cellAt", "clamp", "clearGrads", "colOf", "compare",
310315 "computeAdvantages", "copyPolicy", "createAdam", "createControls", "createEffects",
311 "createFullscreen",
316 "createEscape", "createFullscreen",
312317 "createLoop", "createPolicy", "createRenderer", "createRollout", "createScene",
313318 "createSoundDevice",
314319 "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 {
6262 export { createFullscreen } from "./fullscreen.ts";
6363 export type { Fullscreen, FullscreenHost, FullscreenOwner, FullscreenTarget } from "./fullscreen.ts";
6464
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
6572 /** The 2D over the scene: panels, scaled text, a crosshair, a bar and a clock. */
6673 export { overlay } from "./overlay.ts";
6774 export type { Overlay, Paint } from "./overlay.ts";
@@ -97,7 +104,8 @@ export type { Renderer, RendererAssets } from "./renderer.ts";
97104 */
98105 export { fakePage } from "./page.ts";
99106 export type {
100 FakeCanvas, FakeDocument, FakeElement, FakePage, FakePaint, FakeTarget, FakeWindow, PageOptions,
107 FakeCanvas, FakeDocument, FakeElement, FakeKeyboard, FakePage, FakePaint, FakeTarget, FakeWindow,
108 PageOptions,
101109 } from "./page.ts";
102110
103111 /**
src/engine/page.ts +52 −3
@@ -15,9 +15,10 @@
1515 * hidden, written, focused, set as an attribute and put full screen; a canvas's
1616 * 2D context writes down every call it was asked for, in order, with its
1717 * 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
2122 * about being a double: it has no timer, it touches no global, it throws for
2223 * nothing, and it knows what neither a renderer nor an `AudioContext` is —
2324 * `canvas.gl` is a slot a game fills with its own recorder, because what a
@@ -29,6 +30,7 @@
2930 * are each the game's half, built on this one.
3031 */
3132
33 import type { KeyboardLock } from "./escape.ts";
3234 import type { Paint } from "./overlay.ts";
3335
3436 /**
@@ -270,6 +272,21 @@ export type FakeDocument = FakeTarget & {
270272 exitFullscreen?(): Promise<void>;
271273 };
272274
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
273290 /** What a game brings: its own ids, its own buttons, and the browser it is on. */
274291 export type PageOptions = {
275292 /** Every id `getElementById` answers for. Anything else answers null. */
@@ -291,6 +308,12 @@ export type PageOptions = {
291308 * the document, which is what an iPhone looks like from the inside.
292309 */
293310 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;
294317 };
295318
296319 export type FakePage = {
@@ -302,6 +325,8 @@ export type FakePage = {
302325 surface: FakeCanvas;
303326 window: FakeWindow;
304327 document: FakeDocument;
328 /** `navigator.keyboard`: who holds Escape, and how often it was asked for. */
329 keyboard: FakeKeyboard;
305330 /** The canvases, in the order `canvases` named them. */
306331 canvases: FakeCanvas[];
307332 /** The `[data-action]` elements, in the order `actions` named them. */
@@ -374,6 +399,29 @@ export function fakePage(options: PageOptions = {}): FakePage {
374399 }
375400 }
376401
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
377425 const window: FakeWindow = {
378426 ...target(),
379427 devicePixelRatio: 1,
@@ -387,6 +435,7 @@ export function fakePage(options: PageOptions = {}): FakePage {
387435 surface: canvases[0] ?? canvasElement("", paint),
388436 window,
389437 document,
438 keyboard,
390439 canvases,
391440 buttons,
392441 frames,
src/pages/game.ts +12 −1
@@ -94,14 +94,25 @@ const menu = (visitor: Who): string => `
9494 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang ·
9595 <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy ·
9696 <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>
98102 <p class="hint">Dead, you watch a teammate until the round ends: fire for the next player,
99103 aim for the one before.</p>
100104 `;
101105
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 */
102112 const PAUSE = `
103113 <h2>Paused</h2>
104114 <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>
105116 `;
106117
107118 export function gamePage(db: Database, { who: visitor, ...options }: PageOptions): string {
src/pages/range.ts +3 −1
@@ -33,7 +33,9 @@ const NOTE = `
3333 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch ·
3434 <kbd>Shift</kbd> walk · <kbd>F</kbd> or the mouse fires · <kbd>R</kbd> reload ·
3535 <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
3739 buttons do the rest.</p>
3840 <p class="hint">It makes its own noise, through the engine's sound device and the range's own
3941 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
5959 }
6060 });
6161
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
62103 test("a browser with no full screen keeps the button hidden rather than shipping a dead one", () => {
63104 const { p, undo } = booted({ fullscreen: false });
64105 try {
src/range/main.ts +35 −4
@@ -49,8 +49,8 @@ import {
4949 type DrillName, type Target,
5050 } from "./targets.ts";
5151 import {
52 angleVectors, attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer,
53 move,
52 angleVectors, attachButtons, clamp, createControls, createEscape, createFullscreen, createLoop,
53 createRenderer, move, type KeyboardLock,
5454 } from "../engine/index.ts";
5555
5656 /** The range's own step: 50 Hz, deliberately not the shooter's 64. */
@@ -168,6 +168,10 @@ export function boot(): void {
168168 // either way the note gets out of the way and the touch look still works.
169169 $("look").addEventListener("click", () => {
170170 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();
171175 try {
172176 const asked: unknown = scene.requestPointerLock?.();
173177 if (asked instanceof Promise) asked.catch(() => {});
@@ -192,9 +196,36 @@ export function boot(): void {
192196 fullButton.textContent = on ? "\u2921" : "\u26F6";
193197 };
194198 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);
196214 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();
198229 };
199230 touch.hidden = !matchMedia("(pointer: coarse)").matches;
200231
static/app.467be451f6.js deleted −58

Diff not shown — a line 71,064 characters wide (limit 2,000). −58

View file
static/app.c520f46081.js added +58

Diff not shown — a line 71,555 characters wide (limit 2,000). +58

View file
static/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 file
static/range.15d5b4d3e8.js added +58

Diff not shown — a line 18,798 characters wide (limit 2,000). +58

View file
static/range.9ac9632ff0.js deleted −58

Diff not shown — a line 18,622 characters wide (limit 2,000). −58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs