Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

full screen #141

Merged opened this in answer to

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

Merged

vesper/cs-engine:vesper/132-full-screen → main 1 commit fork

  • Merged. vesper/132-full-screen was merged into main as 4cda67e.

Review

Sign in to review this change.
34 files changed +742 −167
README.md +16 −7
@@ -95,15 +95,21 @@ whatever the name field said, and a result posted without one is a **guest**.
9595 <kbd>Shift</kbd> walk, mouse aims and fires, <kbd>R</kbd> reload,
9696 <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze
9797 time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns, <kbd>Tab</kbd> shows
98 the scoreboard, <kbd>Esc</kbd> pauses. On a touch screen the left of the view
99 is a thumb stick, the right looks around, and the buttons along the bottom do
100 the rest.
98 the scoreboard, <kbd>F</kbd> puts the stage on the whole screen and
99 <kbd>Esc</kbd> pauses. On a touch screen the left of the view is a thumb stick,
100 the right looks around, and the buttons along the bottom do the rest — including
101 the one in the top corner, which is full screen and is how you get one on a
102 phone, where there is no keyboard and no pointer to lock. A browser that will not
103 put an element full screen — an iPhone will not — never shows that button rather
104 than showing one that does nothing.
101105
102106 At `/range` the same controls drive the second game, on its own bindings:
103107 <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch,
104108 <kbd>Shift</kbd> walk, <kbd>F</kbd> or the mouse fires, <kbd>R</kbd> reload,
105 <kbd>Enter</kbd> starts a thirty second run and <kbd>T</kbd> chooses the drill
106 between runs. There are two. **Pop-up** is the one the range shipped with: three
109 <kbd>Enter</kbd> starts a thirty second run, <kbd>T</kbd> chooses the drill
110 between runs and <kbd>G</kbd> is full screen — G rather than F here, because F is
111 this game's trigger, and at `/example` it is <kbd>F</kbd> again. There are two
112 drills. **Pop-up** is the one the range shipped with: three
107113 plates at a time, standing still down the lane, each worth 100 to 200 points by
108114 how far away it stood. **Tracking** is the second: two at a time, nearer and
109115 longer-lived, each sliding across the lane at 90 to 260 units a second until its
@@ -170,7 +176,9 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
170176 knows nothing about de_forge's alphabet), `mesh` (a box builder), `texture`
171177 (hashed value noise, fbm and an atlas painter), `sound` (a lazy `AudioContext`
172178 with noise bursts and tones), `loop` (a fixed step with a clamp on a long
173 frame), `controls` (a bindings table in, held actions out), `overlay` (the
179 frame), `controls` (a bindings table in, held actions out), `fullscreen` (the stage on
180 the whole screen: whether this browser will, whether it is right now, the way
181 in and out, and a refusal swallowed rather than thrown), `overlay` (the
174182 2D over the scene: a scale and a pad off the canvas width, scaled fonts,
175183 panels, aligned and faded text, a crosshair, a bar, right-aligned columns and
176184 a `m:ss` clock, drawn onto the seventeen members of a 2D context it uses and
@@ -228,7 +236,8 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
228236 `sound` `attenuate`, `createSoundDevice`, `Burst`, `Tone`, `SoundDevice` and
229237 `Ctor`; from `loop` `createLoop`, `Frames`, `Loop` and `LoopOptions`; from
230238 `controls` `createControls`, `attachButtons` and the event and option types
231 they are driven with; from `overlay` `overlay`, `Overlay` and the `Paint`
239 they are driven with; from `fullscreen` `createFullscreen` with `Fullscreen`,
240 `FullscreenHost`, `FullscreenTarget` and `FullscreenOwner`; from `overlay` `overlay`, `Overlay` and the `Paint`
232241 a 2D game draws onto; from `scene` the frame and its pool — `createScene`,
233242 `resetScene`, `nextInstance`, `pushLine`, `Scene`, `SceneCapacity`, `Camera`
234243 and `Instance`; from `renderer` `createRenderer` with `Renderer` and
scripts/build.test.ts +2 −2
@@ -38,8 +38,8 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri
3838 test("the entry table is what the app ships today, each with the size it may not pass", () => {
3939 expect(ENTRIES).toEqual([
4040 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } },
41 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 29_400, gzip: 12_700 } },
42 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 9_700, gzip: 4_700 } },
41 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 33_000, gzip: 14_000 } },
42 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 10_800, gzip: 5_100 } },
4343 ]);
4444 // And it is the registry projected, not a second list beside it: a game's
4545 // source and its ceiling are written down once, in `src/games.ts`.
src/client/binding.test.ts +4
@@ -23,6 +23,10 @@ test("the bindings are the ones the keyboard has always had", () => {
2323 expect(CS_MOUSE[0]).toBe("fire");
2424 expect(CS_MOUSE[2]).toBe("scope");
2525 expect([...CS_TAP]).toEqual(["jump", "reload", "buy"]);
26 // F is the page's, not the world's: `main.ts` hears it on `onAction` and asks
27 // the browser for the screen, and nothing about an `Input` changes for it.
28 expect(CS_KEYS.KeyF).toBe("fullscreen");
29 expect(intent(["fullscreen"])).toEqual(intent([]));
2630 });
2731
2832 test("nothing held is nothing asked for", () => {
src/client/binding.ts +3
@@ -15,6 +15,9 @@ export const CS_KEYS: Record<string, string> = {
1515 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
1616 KeyR: "reload", KeyE: "use", 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.
20 KeyF: "fullscreen",
1821 };
1922
2023 /** Left fires, right scopes. */
src/client/main.test.ts +46
@@ -362,6 +362,52 @@ test("a leaderboard that cannot be reached is not a reason to stop playing", asy
362362 expect(stage.by("menu").hidden).toBe(true);
363363 });
364364
365 test("F and the button put the stage on the whole screen, and take it back", () => {
366 const stage = start(fakeStage());
367 const full = stage.by("full");
368 const box = stage.by("stage");
369 // Shown by the entry, not by the server: a control that needs a script is
370 // rendered hidden and revealed by the script that makes it work.
371 expect(full.hidden).toBe(false);
372 expect(full.getAttribute("aria-pressed")).toBe(null);
373
374 stage.press("KeyF");
375 expect(stage.document.fullscreenElement).toBe(box);
376 expect(box.fullscreens).toBe(1);
377 expect(full.getAttribute("aria-pressed")).toBe("true");
378 expect(full.getAttribute("aria-label")).toBe("Leave full screen");
379
380 stage.press("KeyF");
381 expect(stage.document.fullscreenElement).toBe(null);
382 expect(full.getAttribute("aria-pressed")).toBe("false");
383 expect(full.getAttribute("aria-label")).toBe("Full screen");
384
385 // The button says the same thing with a thumb, which is the only way in on a
386 // phone: there is no keyboard there and the pointer cannot be locked either.
387 full.fire("click");
388 expect(stage.document.fullscreenElement).toBe(box);
389 expect(box.fullscreens).toBe(2);
390 full.fire("click");
391 expect(stage.document.fullscreenElement).toBe(null);
392 });
393
394 test("the screen is the browser's: Escape is heard, and a browser that will not is not lied about", () => {
395 const stage = start(fakeStage());
396 stage.press("KeyF");
397 // Escape, which no code of ours ran. The page hears it from the event.
398 stage.document.fullscreenElement = null;
399 stage.document.fire("fullscreenchange");
400 expect(stage.by("full").getAttribute("aria-pressed")).toBe("false");
401
402 // And a browser with no fullscreen at all keeps the button hidden rather than
403 // showing one that does nothing.
404 const cannot = start(fakeStage({ fullscreen: false }));
405 expect(cannot.by("full").hidden).toBe(true);
406 cannot.press("KeyF");
407 expect(cannot.document.fullscreenElement).toBe(null);
408 expect(cannot.by("stage").fullscreens).toBe(0);
409 });
410
365411 test("no WebGL2 is a sentence on the page, not an exception", () => {
366412 const stage = start(fakeStage({ webgl: false }));
367413 expect(stage.by("menu").innerHTML).toContain("No WebGL2");
src/client/main.ts +35 −3
@@ -25,8 +25,8 @@ 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, createLoop, createRenderer, painter, type Button,
29 type Camera, type Listens, type Mesh,
28 attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer, painter,
29 type Button, type Camera, type Listens, type Mesh,
3030 } from "../engine/index.ts";
3131 import { DE_FORGE } from "./map.ts";
3232 import type { Level } from "./level.ts";
@@ -60,6 +60,12 @@ export type Field = Panel & { value: string };
6060 /** A button this file makes rather than finds. */
6161 export type Pressable = Listens & { type: string; textContent: string | null; disabled: boolean };
6262
63 /** The box the canvases sit in: what goes full screen, when a browser will. */
64 export type Box = Panel & { requestFullscreen?(): unknown };
65
66 /** A button that says which of two states it is in — the full screen one does. */
67 export type Toggle = Panel & { setAttribute(name: string, value: string): void };
68
6369 /** A canvas on the stage: the scene is drawn into, the HUD is painted on. */
6470 export type Stage = Panel & {
6571 readonly clientWidth: number;
@@ -72,10 +78,13 @@ export type Stage = Panel & {
7278
7379 export type PageDocument = Listens & {
7480 readonly pointerLockElement: unknown;
81 /** Whose element is on the screen right now — the engine's `createFullscreen` reads it. */
82 readonly fullscreenElement: unknown;
7583 getElementById(id: string): unknown;
7684 querySelectorAll(selectors: string): ArrayLike<Button>;
7785 createElement(tag: "button"): Pressable;
7886 exitPointerLock(): void;
87 exitFullscreen?(): unknown;
7988 };
8089
8190 /** The window: the frame clock, the pixel ratio and the media query. */
@@ -139,8 +148,10 @@ export function boot(host: Host): Frame {
139148 const win = host.window;
140149 const by = <T extends Panel>(id: string): T => doc.getElementById(id) as T;
141150
151 const stageBox = by<Box>("stage");
142152 const sceneCanvas = by<Stage>("scene");
143153 const hudCanvas = by<Stage>("hud");
154 const fullButton = by<Toggle>("full");
144155 const menu = by("menu");
145156 const pause = by("pause");
146157 const buyPanel = by("buy");
@@ -165,6 +176,25 @@ export function boot(host: Host): Frame {
165176 { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 },
166177 );
167178 attachButtons(doc.querySelectorAll("[data-action]"), controls, { tap: CS_TAP });
179
180 // The stage on the whole screen: the button over it, and F, which is bound to
181 // the "fullscreen" action below like every other key this game reads. Both
182 // arrive inside a gesture, which is the only time a browser says yes. A
183 // browser that will not do it at all leaves the button hidden rather than
184 // showing one that does nothing, and the state is the browser's — Escape
185 // leaves and `onChange` is how this page hears about it.
186 const full = createFullscreen({ element: stageBox, document: doc });
187 const paintFull = (on: boolean): void => {
188 const label = on ? "Leave full screen" : "Full screen";
189 fullButton.setAttribute("aria-pressed", on ? "true" : "false");
190 fullButton.setAttribute("aria-label", label);
191 fullButton.setAttribute("title", label);
192 fullButton.textContent = on ? "⤡" : "⛶";
193 };
194 full.onChange = paintFull;
195 fullButton.hidden = !full.supported;
196 fullButton.addEventListener("click", () => full.toggle());
197
168198 const loop = createLoop({ tick: TICK }, {
169199 requestAnimationFrame: (callback) => win.requestAnimationFrame(callback),
170200 cancelAnimationFrame: (handle) => win.cancelAnimationFrame(handle),
@@ -246,7 +276,9 @@ export function boot(host: Host): Frame {
246276 }
247277
248278 controls.onAction = (action, down) => {
249 if (down && action === "buy") openBuy(buyPanel.hidden);
279 if (!down) return;
280 if (action === "buy") openBuy(buyPanel.hidden);
281 else if (action === "fullscreen") full.toggle();
250282 };
251283 controls.onUnlock = () => {
252284 if (running && buyPanel.hidden) show(pause, true);
src/client/stage.test-helper.ts +9 −5
@@ -150,10 +150,10 @@ export type FakeStage = {
150150 * contract between the page and this file, so `src/server.test.ts` checks the
151151 * served page against this same list rather than a second copy of it.
152152 */
153 export const PAGE_IDS = ["scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "play", "prep", "resume", "quit", "fps"];
153 export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "play", "prep", "resume", "quit", "fps", "full"];
154154
155155 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
156 const HIDDEN = ["pause", "buy", "touch"];
156 const HIDDEN = ["pause", "buy", "touch", "full"];
157157
158158 /** The `[data-action]` buttons the shell renders for this game. */
159159 export const PAGE_ACTIONS = ["duck", "jump", "use", "reload", "buy", "fire"];
@@ -168,16 +168,20 @@ const GLOBALS = ["document", "window", "localStorage", "fetch"];
168168
169169 /**
170170 * The whole page the shooter's entry expects, plus the host around it. Pass
171 * `webgl: false` for a browser that cannot give the renderer a context, and
172 * `coarse: true` for a phone.
171 * `webgl: false` for a browser that cannot give the renderer a context,
172 * `coarse: true` for a phone, and `fullscreen: false` for a browser that will
173 * not put an element on the whole screen.
173174 */
174 export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): FakeStage {
175 export function fakeStage(
176 options: { webgl?: boolean; coarse?: boolean; fullscreen?: boolean } = {},
177 ): FakeStage {
175178 const page = fakePage({
176179 ids: PAGE_IDS,
177180 canvases: ["scene", "hud"],
178181 hidden: HIDDEN,
179182 actions: PAGE_ACTIONS,
180183 coarse: options.coarse,
184 fullscreen: options.fullscreen,
181185 });
182186 // Asked for with `paint` left on, so both canvases have their 2D recorder.
183187 const [scene, hud] = page.canvases as [StageCanvas, StageCanvas];
src/engine/GUIDE.md +21
@@ -326,6 +326,27 @@ export function step(world: World, dt: number, held: ReadonlySet<string>): World
326326 const on = (action: string): number => (held.has(action) ? 1 : 0);
327327 ```
328328
329 ### fullscreen
330
331 The stage on the whole screen, which is three calls with four ways to be
332 absent. `createFullscreen` takes a `FullscreenHost` — the `FullscreenTarget`
333 that goes full screen, usually the box the canvases sit in, and the
334 `FullscreenOwner` that owns `fullscreenElement`, which a document is — and hands
335 back a `Fullscreen`: `Fullscreen.supported`, which is false on a browser that
336 will not do it at all and is how a page hides the button rather than shipping
337 one that does nothing; `Fullscreen.on`, read from the document every time it is
338 asked rather than remembered, because a person leaves with Escape and no code of
339 yours runs; `Fullscreen.enter`, `Fullscreen.exit` and `Fullscreen.toggle`; the
340 `Fullscreen.onChange` callback, which is that event; and a `Fullscreen.detach`
341 for when the game is over.
342
343 A refusal is not an error a game can act on — no user gesture, a policy, an
344 iframe — so a rejected promise and a browser that throws instead are both
345 swallowed: nothing changed, and `Fullscreen.on` still reads false. Ask from
346 inside a click or a keydown, which is the only time a browser says yes. The
347 `:fullscreen` half is CSS: the element keeps whatever aspect ratio and maximum
348 height it had unless a rule says otherwise.
349
329350 ### overlay
330351
331352 The 2D a game draws over its scene. `overlay` takes the surface, the width and
src/engine/boundary.test.ts +8 −3
@@ -284,6 +284,9 @@ test("the example imports the door and nothing else, which is what it is for", (
284284 * physics — `Body`, `Wish`, `MoveTuning`, `Hull`, `accelerate`,
285285 * `airAccelerate`, `move`, `groundUnder`, `stuck`, `eyeHeight`, `hullHeight`.
286286 *
287 * fullscreen — `Fullscreen`, `FullscreenHost`, `FullscreenOwner`,
288 * `FullscreenTarget`, `createFullscreen`.
289 *
287290 * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one
288291 * copy of the surface, read twice, rather than two that drift.
289292 */
@@ -291,7 +294,8 @@ export const SURFACE = [
291294 "Activations", "Adam", "Body", "Box", "Builder", "Burst", "Button", "ButtonOptions",
292295 "CHECKPOINT_CHARS", "Camera", "Complaint", "Controls", "Ctor", "DEFAULT_HYPER", "FakeCanvas",
293296 "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", "Fold",
294 "Folded", "Frames", "Grads", "Grid", "Hit", "Host", "Hull", "Hyper", "Instance", "KeyEventish",
297 "Folded", "Frames", "Fullscreen", "FullscreenHost", "FullscreenOwner", "FullscreenTarget",
298 "Grads", "Grid", "Hit", "Host", "Hull", "Hyper", "Instance", "KeyEventish",
295299 "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh", "MouseEventish", "MoveTuning", "Options",
296300 "Overlay", "Packed", "PageOptions", "Paint", "Painter", "Pin", "Policy", "QUANTUM",
297301 "Renderer", "RendererAssets", "Rollout", "Scene", "SceneCapacity", "Shader", "Shape",
@@ -299,8 +303,9 @@ export const SURFACE = [
299303 "Wish", "accelerate", "add", "addBox", "airAccelerate", "angleDelta", "angleVectors",
300304 "applyGrads", "attachButtons", "attenuate",
301305 "backward", "builder", "cellAt", "clamp", "clearGrads", "colOf", "compare",
302 "computeAdvantages", "copyPolicy", "createAdam", "createControls", "createLoop",
303 "createPolicy", "createRenderer", "createRollout", "createScene", "createSoundDevice",
306 "computeAdvantages", "copyPolicy", "createAdam", "createControls", "createFullscreen",
307 "createLoop", "createPolicy", "createRenderer", "createRollout", "createScene",
308 "createSoundDevice",
304309 "cross", "deg", "dist", "dist2d", "dot", "eyeHeight", "fakePage", "fbm", "findPath", "finish",
305310 "fold", "forward", "greedy", "groundUnder", "hash", "headOffset", "hullHeight", "inside",
306311 "length", "length2d", "lerp",
src/engine/fullscreen.test.ts added +106
@@ -0,0 +1,106 @@
1 /**
2 * Full screen, driven against the headless page next door: asking, leaving,
3 * a browser that will not, and the three ways a real one refuses. Nothing in
4 * here is a browser — `fakePage`'s document is the one that owns
5 * `fullscreenElement`, and it fires `fullscreenchange` the way a browser does.
6 */
7
8 import { expect, test } from "bun:test";
9 import { createFullscreen, type FullscreenOwner, type FullscreenTarget } from "./fullscreen.ts";
10 import { fakePage } from "./page.ts";
11
12 /** A page with a stage on it, and the fullscreen around that stage. */
13 function stage(options: { fullscreen?: boolean } = {}) {
14 const page = fakePage({ ids: ["stage", "other"], fullscreen: options.fullscreen });
15 const element = page.by("stage");
16 const changes: boolean[] = [];
17 const full = createFullscreen({ element, document: page.document });
18 full.onChange = (on) => void changes.push(on);
19 return { page, element, full, changes };
20 }
21
22 test("asking puts the stage on the screen, and leaving takes it back", () => {
23 const { page, element, full, changes } = stage();
24 expect(full.supported).toBe(true);
25 expect(full.on).toBe(false);
26
27 full.enter();
28 expect(full.on).toBe(true);
29 expect(element.fullscreens).toBe(1);
30 expect(page.document.fullscreenElement).toBe(element);
31 expect(changes).toEqual([true]);
32
33 full.exit();
34 expect(full.on).toBe(false);
35 expect(page.document.fullscreenElement).toBe(null);
36 expect(changes).toEqual([true, false]);
37 });
38
39 test("toggle is the same two verbs, and neither is asked for twice", () => {
40 const { element, full, changes } = stage();
41 full.toggle();
42 expect(full.on).toBe(true);
43 // Already there: asking again is not a second request.
44 full.enter();
45 expect(element.fullscreens).toBe(1);
46 full.toggle();
47 expect(full.on).toBe(false);
48 // Not there: leaving is not a call either, so nothing is fired.
49 full.exit();
50 expect(changes).toEqual([true, false]);
51 });
52
53 test("the state is read from the document, never remembered", () => {
54 const { page, full, changes } = stage();
55 full.enter();
56 // Escape, which no code of ours ran: the browser dropped it and said so.
57 page.document.fullscreenElement = null;
58 page.document.fire("fullscreenchange");
59 expect(full.on).toBe(false);
60 expect(changes).toEqual([true, false]);
61 // Somebody else's element is not ours, whatever the document says.
62 page.document.fullscreenElement = page.by("other");
63 expect(full.on).toBe(false);
64 });
65
66 test("a browser that will not do it says so before anything is drawn", () => {
67 const { page, element, full, changes } = stage({ fullscreen: false });
68 expect(full.supported).toBe(false);
69 full.enter();
70 full.toggle();
71 expect(full.on).toBe(false);
72 expect(element.fullscreens).toBe(0);
73 expect(page.document.fullscreenElement).toBe(null);
74 expect(changes).toEqual([]);
75 });
76
77 test("a refusal is not a crash, whichever way the browser refuses", () => {
78 const listening = new Set<string>();
79 const owner: FullscreenOwner = {
80 fullscreenElement: null,
81 exitFullscreen: () => Promise.reject(new Error("no")),
82 addEventListener: (type) => void listening.add(type),
83 removeEventListener: (type) => void listening.delete(type),
84 };
85 // A promise that rejects, and a browser that throws where it should reject.
86 const rejects: FullscreenTarget = { requestFullscreen: () => Promise.reject(new Error("no gesture")) };
87 const throws: FullscreenTarget = {
88 requestFullscreen: () => {
89 throw new Error("not allowed");
90 },
91 };
92 expect(() => createFullscreen({ element: rejects, document: owner }).enter()).not.toThrow();
93 expect(() => createFullscreen({ element: throws, document: owner }).enter()).not.toThrow();
94 // And the same on the way out, where we are the element holding the screen.
95 const held: FullscreenOwner = { ...owner, fullscreenElement: rejects };
96 expect(() => createFullscreen({ element: rejects, document: held }).exit()).not.toThrow();
97 });
98
99 test("detach leaves the document as it found it", () => {
100 const { page, full, changes } = stage();
101 expect(page.document.listening.has("fullscreenchange")).toBe(true);
102 full.detach();
103 expect(page.document.listening.has("fullscreenchange")).toBe(false);
104 page.document.fire("fullscreenchange");
105 expect(changes).toEqual([]);
106 });
src/engine/fullscreen.ts added +103
@@ -0,0 +1,103 @@
1 /**
2 * Full screen: the stage handed to the whole screen, and back again. It is the
3 * smallest module behind this door on purpose — one element, one question
4 * ("are we there right now?") and three verbs — because the Fullscreen API is
5 * three calls with four ways to be absent, and every game here wants the same
6 * three lines around it.
7 *
8 * It knows no game and it knows no browser: the element it puts full screen and
9 * whatever owns `fullscreenElement` arrive as parameters, typed by the members
10 * this file actually touches, so `page.ts`'s headless double drives the whole
11 * thing with no browser in the room.
12 *
13 * Three things a real browser does that this file takes seriously:
14 *
15 * - It may not offer the API at all. An iPhone will not put a `<div>` full
16 * screen, so `requestFullscreen` is simply missing there; `supported` says
17 * so before anything is drawn and a page can hide the button rather than
18 * ship one that does nothing.
19 * - The request is a promise that rejects when the browser says no — no user
20 * gesture, a policy, an iframe without the permission — and older ones
21 * throw instead. Neither is an error a game can act on, so both are
22 * swallowed here: nothing changed, and `on` still reads false.
23 * - The state is the browser's, not ours. A person leaves full screen with
24 * Escape and no code of ours runs, so `on` is read from the document every
25 * time it is asked rather than remembered, and `onChange` is the event.
26 */
27
28 import type { Listens } from "./controls.ts";
29
30 /**
31 * The element that goes full screen. `requestFullscreen` is optional because a
32 * browser that will not do it is a browser where the method is not there.
33 */
34 export type FullscreenTarget = { requestFullscreen?: () => unknown };
35
36 /** Whatever owns the full screen element — a `Document` fits. */
37 export type FullscreenOwner = Listens & {
38 readonly fullscreenElement: unknown;
39 exitFullscreen?: () => unknown;
40 };
41
42 export type FullscreenHost = { element: FullscreenTarget; document: FullscreenOwner };
43
44 export type Fullscreen = {
45 /** Whether this browser offers it at all. False hides a button rather than lying. */
46 readonly supported: boolean;
47 /** Whether `element` is the full screen element right now. Live: read it any time. */
48 readonly on: boolean;
49 /** Ask for it. A browser that refuses is not an error: nothing changes. */
50 enter(): void;
51 /** Give it back, if we are the one holding it. */
52 exit(): void;
53 toggle(): void;
54 /** It changed, however it changed — our call, or a person pressing Escape. */
55 onChange: (on: boolean) => void;
56 detach(): void;
57 };
58
59 /** A promise nobody awaits still rejects loudly; a refusal here is not news. */
60 function swallow(result: unknown): void {
61 if (result instanceof Promise) result.catch(() => {});
62 }
63
64 export function createFullscreen(host: FullscreenHost): Fullscreen {
65 const supported = typeof host.element.requestFullscreen === "function" &&
66 typeof host.document.exitFullscreen === "function";
67 const isOn = (): boolean => host.document.fullscreenElement === host.element;
68 const changed = (): void => fullscreen.onChange(isOn());
69
70 const fullscreen: Fullscreen = {
71 supported,
72 get on() {
73 return isOn();
74 },
75 enter() {
76 if (!supported || isOn()) return;
77 try {
78 swallow(host.element.requestFullscreen?.());
79 } catch {
80 // A browser that throws instead of rejecting. The page is unchanged.
81 }
82 },
83 exit() {
84 if (!isOn()) return;
85 try {
86 swallow(host.document.exitFullscreen?.());
87 } catch {
88 // The same, from the other side.
89 }
90 },
91 toggle() {
92 if (isOn()) fullscreen.exit();
93 else fullscreen.enter();
94 },
95 onChange: () => {},
96 detach() {
97 host.document.removeEventListener("fullscreenchange", changed);
98 },
99 };
100
101 host.document.addEventListener("fullscreenchange", changed);
102 return fullscreen;
103 }
src/engine/guide.test.ts +2 −2
@@ -62,8 +62,8 @@ const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
6262 */
6363 const NOT_THE_ENGINES = new Set([
6464 "AudioContext", "Float32Array", "Int32Array", "TEXTURE_2D_ARRAY", "addEventListener",
65 "cancelAnimationFrame", "de_forge", "pointerLockElement", "removeEventListener",
66 "requestAnimationFrame", "uniformMatrix4fv",
65 "cancelAnimationFrame", "de_forge", "fullscreenElement", "pointerLockElement",
66 "removeEventListener", "requestAnimationFrame", "uniformMatrix4fv",
6767 ]);
6868
6969 const guide = (): string => readFileSync(GUIDE, "utf8");
src/engine/index.ts +4
@@ -58,6 +58,10 @@ export type {
5858 Options, Stick, Surface, TouchEventish, Touchish,
5959 } from "./controls.ts";
6060
61 /** The stage on the whole screen: whether this browser will, whether it is, and the way in and out. */
62 export { createFullscreen } from "./fullscreen.ts";
63 export type { Fullscreen, FullscreenHost, FullscreenOwner, FullscreenTarget } from "./fullscreen.ts";
64
6165 /** The 2D over the scene: panels, scaled text, a crosshair, a bar and a clock. */
6266 export { overlay } from "./overlay.ts";
6367 export type { Overlay, Paint } from "./overlay.ts";
src/engine/page.ts +59 −3
@@ -12,9 +12,12 @@
1212 * committed bundles to say so.
1313 *
1414 * Everything here records rather than does. An element remembers what was
15 * hidden, written and focused; a canvas's 2D context writes down every call it
16 * was asked for, in order, with its arguments; a target remembers what is
17 * listening on it and delivers an event to exactly those handlers. It is honest
15 * hidden, written, focused, set as an attribute and put full screen; a canvas's
16 * 2D context writes down every call it was asked for, in order, with its
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
1821 * about being a double: it has no timer, it touches no global, it throws for
1922 * nothing, and it knows what neither a renderer nor an `AudioContext` is —
2023 * `canvas.gl` is a slot a game fills with its own recorder, because what a
@@ -92,10 +95,22 @@ export type FakeElement = FakeTarget & {
9295 dataset: { action?: string };
9396 /** How many times something asked for the focus. */
9497 focused: number;
98 /** How many times something asked for the screen. See `fakePage`'s `fullscreen`. */
99 fullscreens: number;
95100 readonly children: FakeElement[];
101 /** Every attribute set on this element, by name: `aria-pressed` is one. */
102 readonly attributes: Record<string, string>;
96103 focus(): void;
104 setAttribute(name: string, value: string): void;
105 getAttribute(name: string): string | null;
97106 appendChild(child: FakeElement): void;
98107 querySelector(selector: string): FakeElement | null;
108 /**
109 * Put this element full screen. Absent on a page asked for with
110 * `fullscreen: false`, which is a browser that will not — an iPhone does not
111 * put a `<div>` full screen and does not carry the method either.
112 */
113 requestFullscreen?(): Promise<void>;
99114 };
100115
101116 /**
@@ -108,6 +123,7 @@ const CLASS = /class="([a-z-]+)"/g;
108123
109124 function element(options: { id?: string; tag?: string; action?: string; className?: string } = {}): FakeElement {
110125 const children: FakeElement[] = [];
126 const attributes: Record<string, string> = {};
111127 let html = "";
112128 const self: FakeElement = {
113129 ...target(),
@@ -121,7 +137,9 @@ function element(options: { id?: string; tag?: string; action?: string; classNam
121137 disabled: false,
122138 type: "",
123139 focused: 0,
140 fullscreens: 0,
124141 children,
142 attributes,
125143 get innerHTML() {
126144 return html;
127145 },
@@ -133,6 +151,12 @@ function element(options: { id?: string; tag?: string; action?: string; classNam
133151 focus() {
134152 self.focused += 1;
135153 },
154 setAttribute(name, value) {
155 attributes[name] = value;
156 },
157 getAttribute(name) {
158 return attributes[name] ?? null;
159 },
136160 appendChild(child) {
137161 children.push(child);
138162 },
@@ -235,11 +259,15 @@ export type FakeDocument = FakeTarget & {
235259 /** `"complete"`, so an entry boots on import; set `"loading"` for the other path. */
236260 readyState: string;
237261 pointerLockElement: unknown;
262 /** The element that is full screen right now, or null. The browser owns this. */
263 fullscreenElement: unknown;
238264 getElementById(id: string): FakeElement | null;
239265 /** The `[data-action]` buttons, whatever the selector says: there is one list. */
240266 querySelectorAll(selector: string): FakeElement[];
241267 createElement(tag: string): FakeElement;
242268 exitPointerLock(): void;
269 /** Absent on a page asked for with `fullscreen: false`, like the method above. */
270 exitFullscreen?(): Promise<void>;
243271 };
244272
245273 /** What a game brings: its own ids, its own buttons, and the browser it is on. */
@@ -256,6 +284,13 @@ export type PageOptions = {
256284 paint?: boolean;
257285 /** What `matchMedia` answers. True is a phone. */
258286 coarse?: boolean;
287 /**
288 * Whether this browser can put an element full screen. Default true: asking
289 * sets `document.fullscreenElement` and fires `fullscreenchange` at once.
290 * False takes `requestFullscreen` off every element and `exitFullscreen` off
291 * the document, which is what an iPhone looks like from the inside.
292 */
293 fullscreen?: boolean;
259294 };
260295
261296 export type FakePage = {
@@ -313,11 +348,32 @@ export function fakePage(options: PageOptions = {}): FakePage {
313348 getElementById: (id) => elements.get(id) ?? null,
314349 querySelectorAll: (_selector) => buttons,
315350 createElement: (tag) => element({ tag }),
351 fullscreenElement: null as unknown,
316352 exitPointerLock(): void {
317353 document.pointerLockElement = null;
318354 },
319355 };
320356
357 // The browser's half of full screen: asking puts the element there and fires
358 // the one event a page learns about it from, and leaving fires it again. A
359 // page asked for with `fullscreen: false` has neither method at all, because
360 // that is how a browser that will not do it says so.
361 if (options.fullscreen !== false) {
362 document.exitFullscreen = (): Promise<void> => {
363 document.fullscreenElement = null;
364 document.fire("fullscreenchange");
365 return Promise.resolve();
366 };
367 for (const found of elements.values()) {
368 found.requestFullscreen = (): Promise<void> => {
369 found.fullscreens += 1;
370 document.fullscreenElement = found;
371 document.fire("fullscreenchange");
372 return Promise.resolve();
373 };
374 }
375 }
376
321377 const window: FakeWindow = {
322378 ...target(),
323379 devicePixelRatio: 1,
src/example/boot.test.ts +37 −5
@@ -24,7 +24,9 @@ const { BINDINGS, RUN, boot } = await import("./main.ts");
2424 undoLoading();
2525
2626 /** A booted page, and the undo that puts the globals back. */
27 function booted(options: { has2d?: boolean; coarse?: boolean } = {}): { p: ExamplePage; undo: () => void } {
27 function booted(
28 options: { has2d?: boolean; coarse?: boolean; fullscreen?: boolean } = {},
29 ): { p: ExamplePage; undo: () => void } {
2830 const p = page(options);
2931 const undo = p.install();
3032 boot();
@@ -59,14 +61,44 @@ const marker = (calls: Call[]): [number, number] => {
5961 return [Number(move?.[1] ?? 0), Number(move?.[2] ?? 0)];
6062 };
6163
62 test("the table it binds is the game's own, plus the one key the page adds", () => {
63 // The game says what a key does; the page says what starts a run, because a
64 // run is the page's idea and not the game's.
65 expect(BINDINGS).toEqual({ ...KEYS, Enter: "start" });
64 test("the table it binds is the game's own, plus the two keys the page adds", () => {
65 // The game says what a key does; the page says what starts a run and what puts
66 // the stage on the screen, because neither is the game's idea.
67 expect(BINDINGS).toEqual({ ...KEYS, Enter: "start", KeyF: "fullscreen" });
6668 expect(KEYS.Enter).toBeUndefined();
69 expect(KEYS.KeyF).toBeUndefined();
6770 expect(TICK).toBe(1 / 60);
6871 });
6972
73 test("F and the button put the room on the whole screen, and neither lies about a browser that will not", () => {
74 const { p, undo } = booted();
75 try {
76 const full = p.by("full");
77 const stage = p.by("stage");
78 expect(full.hidden).toBe(false);
79
80 p.window.fire("keydown", { code: "KeyF" });
81 expect(p.document.fullscreenElement).toBe(stage);
82 expect(full.getAttribute("aria-pressed")).toBe("true");
83 p.window.fire("keyup", { code: "KeyF" });
84
85 full.fire("click");
86 expect(p.document.fullscreenElement).toBe(null);
87 expect(full.getAttribute("aria-pressed")).toBe("false");
88 } finally {
89 undo();
90 }
91
92 const cannot = booted({ fullscreen: false });
93 try {
94 expect(cannot.p.by("full").hidden).toBe(true);
95 cannot.p.window.fire("keydown", { code: "KeyF" });
96 expect(cannot.p.document.fullscreenElement).toBe(null);
97 } finally {
98 cannot.undo();
99 }
100 });
101
70102 test("it boots, asks for a frame, and draws the room and the readout", () => {
71103 const { p, undo } = booted();
72104 try {
src/example/main.ts +25 −3
@@ -16,11 +16,15 @@
1616 */
1717
1818 import { KEYS, TICK, createWorld, step, type World } from "../engine/example.ts";
19 import { attachButtons, createControls, createLoop, type Paint } from "../engine/index.ts";
19 import { attachButtons, createControls, createFullscreen, createLoop, type Paint } from "../engine/index.ts";
2020 import { drawPlan, type Phase } from "./plan.ts";
2121
22 /** The game's own table, and the one key the page adds to start a run with. */
23 export const BINDINGS: Record<string, string> = { ...KEYS, Enter: "start" };
22 /**
23 * The game's own table, and the two keys the page adds: the one that starts a
24 * run, and the one that puts the stage on the whole screen. Neither is the
25 * game's — `step` never sees either — and both are the page's to bind.
26 */
27 export const BINDINGS: Record<string, string> = { ...KEYS, Enter: "start", KeyF: "fullscreen" };
2428
2529 /** Seconds a run lasts, and the actions a button press releases by itself. */
2630 export const RUN = 30;
@@ -51,6 +55,24 @@ export function boot(): void {
5155
5256 const controls = createControls({ surface: scene, window, document }, { keys: BINDINGS, requireLock: false });
5357 attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: TAPS });
58
59 // The room on the whole screen: the button over the stage, and <kbd>F</kbd>.
60 // Both arrive inside a gesture, which is when a browser says yes; one that
61 // will not do it at all leaves the button hidden rather than lying.
62 const fullButton = $("full");
63 const full = createFullscreen({ element: $("stage"), document });
64 full.onChange = (on) => {
65 const label = on ? "Leave full screen" : "Full screen";
66 fullButton.setAttribute("aria-pressed", on ? "true" : "false");
67 fullButton.setAttribute("aria-label", label);
68 fullButton.setAttribute("title", label);
69 fullButton.textContent = on ? "\u2921" : "\u26F6";
70 };
71 fullButton.hidden = !full.supported;
72 fullButton.addEventListener("click", () => full.toggle());
73 controls.onAction = (action, down) => {
74 if (down && action === "fullscreen") full.toggle();
75 };
5476 $("touch").hidden = !matchMedia("(pointer: coarse)").matches;
5577 const loop = createLoop({ tick: TICK }, {
5678 requestAnimationFrame: (callback) => requestAnimationFrame(callback),
src/example/stage.test-helper.ts +6 −2
@@ -16,7 +16,7 @@
1616 import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts";
1717
1818 /** Every id `src/pages/example.ts` puts on the page that the entry looks up. */
19 export const EXAMPLE_IDS = ["scene", "hud", "fps", "touch", "note"];
19 export const EXAMPLE_IDS = ["stage", "scene", "hud", "fps", "touch", "note", "full"];
2020
2121 /** The `[data-action]` buttons the page renders: the touch pads, and Start. */
2222 export const EXAMPLE_ACTIONS = ["turnLeft", "turnRight", "forward", "fire", "start"];
@@ -41,13 +41,17 @@ export type ExamplePage = {
4141 };
4242
4343 /** The whole page this game's entry expects. `has2d: false` gives no context. */
44 export function page(options: { has2d?: boolean; coarse?: boolean } = {}): ExamplePage {
44 export function page(
45 options: { has2d?: boolean; coarse?: boolean; fullscreen?: boolean } = {},
46 ): ExamplePage {
4547 const fake = fakePage({
4648 ids: EXAMPLE_IDS,
4749 canvases: ["scene", "hud"],
50 hidden: ["full"],
4851 actions: EXAMPLE_ACTIONS,
4952 paint: options.has2d ?? true,
5053 coarse: options.coarse,
54 fullscreen: options.fullscreen,
5155 });
5256 const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas];
5357
src/games.ts +16 −2
@@ -149,6 +149,20 @@ export type Game = {
149149 * `duckRate`, `jumpVelocity` and `stopSpeed` and finds none of them, while
150150 * `src/client/bundle.test.ts` finds all four and the jump velocity itself.
151151 * Measured 2026-09-20 after #123, on the bundles this branch commits.
152 * #132 put the stage on the whole screen — `src/engine/fullscreen.ts` behind the
153 * door, and in each of the three entries the button over the stage, the key
154 * that toggles it and the label that follows the state — and all three bundles
155 * grew, because unlike the modules above this one is code a browser runs: app
156 * 72,132 → 73,130 raw and 33,552 → 33,948 gzipped, range 28,980 → 29,975 and
157 * 12,323 → 12,704, example 8,754 → 9,735 and 4,228 → 4,620. About a kilobyte
158 * each, and it is the same kilobyte three times: roughly 300 bytes of the
159 * engine's module and the rest the wiring — `aria-pressed`, `aria-label`, the
160 * title and the two glyphs are strings a minifier cannot shorten. The app fits
161 * today's ceiling. The range's and the example's did not, and both are raised
162 * here to today's size plus about 10%, which is what every other number on this
163 * page is: range 29,400/12,700 → 33,000/14,000 (it had 42 bytes of headroom
164 * after #52 and 587 after #88, so this was the raise those two lines said was
165 * coming), example 9,700/4,700 → 10,800/5,100.
152166 * The range's numbers here are #121's moving plates and #125's painter at once:
153167 * both landed a range bundle and neither swept the other's, so static/ held two
154168 * and every reader of it failed until a plain `bun run build` resolved them.
@@ -168,7 +182,7 @@ export const GAMES: readonly Game[] = [
168182 href: "/range",
169183 label: "range",
170184 entry: "src/range/main.ts",
171 ceiling: { raw: 29_400, gzip: 12_700 },
185 ceiling: { raw: 33_000, gzip: 14_000 },
172186 blurb:
173187 "thirty seconds on the clock and two drills down one lane — plates that pop up, and plates that slide across it — a shot traced in three dimensions and a score, every line of it out of the engine and none of it out of the shooter.",
174188 },
@@ -177,7 +191,7 @@ export const GAMES: readonly Game[] = [
177191 href: "/example",
178192 label: "example",
179193 entry: "src/example/main.ts",
180 ceiling: { raw: 9_700, gzip: 4_700 },
194 ceiling: { raw: 10_800, gzip: 5_100 },
181195 blurb:
182196 "the guide's own game, served: one room, one plate, thirty seconds and a score, drawn in 2D through the engine's overlay and nothing else — the price tag on a third game.",
183197 },
src/pages/example.ts +2 −1
@@ -20,7 +20,8 @@ const NOTE = `
2020 <h2>The smallest game this engine can run</h2>
2121 <p>One room seen from above, one plate on the far wall, thirty seconds and a score. The arrow
2222 keys turn you, <kbd>WASD</kbd> walks, <kbd>Space</kbd> fires one shot a press, and the blue
23 dots are where that shot goes.</p>
23 dots are where that shot goes. <kbd>F</kbd>, or the button in the corner of the stage, puts
24 it on the whole screen.</p>
2425 <div class="row"><button type="button" class="primary" data-action="start">Start</button></div>
2526 `;
2627
src/pages/game.ts +2 −1
@@ -86,7 +86,8 @@ const menu = (visitor: Who): string => `
8686 <p class="hint" id="prep">Preparing textures…</p>
8787 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
8888 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy ·
89 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>Esc</kbd> pause</p>
89 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen ·
90 <kbd>Esc</kbd> pause</p>
9091 `;
9192
9293 const PAUSE = `
src/pages/range.ts +2 −2
@@ -30,8 +30,8 @@ const NOTE = `
3030 <div class="row"><button type="button" class="primary" id="look">Capture the mouse</button></div>
3131 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch ·
3232 <kbd>Shift</kbd> walk · <kbd>F</kbd> or the mouse fires · <kbd>R</kbd> reload ·
33 <kbd>Enter</kbd> start · <kbd>T</kbd> the other drill · <kbd>Esc</kbd> gives the mouse
34 back. On a touch screen the left of the view is a stick, the right looks around, and the
33 <kbd>Enter</kbd> start · <kbd>T</kbd> the other drill · <kbd>G</kbd> full screen, which is
34 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
3535 buttons do the rest.</p>
3636 <p class="hint">It makes its own noise, through the engine's sound device and the range's own
3737 recipes: a short dry crack, a clean tone for a hit, a knock as a plate stands up — quieter
src/pages/shell.test.ts +21
@@ -158,6 +158,27 @@ test("the stage keeps the ids the client finds it by", () => {
158158 expect(html).toContain(`<div class="overlay" id="pause" hidden></div>`);
159159 });
160160
161 test("the stage carries the full screen button, hidden, over everything else", () => {
162 const html = stage({
163 touch: [[{ action: "fire", label: "fire" }]],
164 overlays: [{ id: "menu", html: "<h2>go</h2>" }],
165 });
166 // Hidden in the first paint: it is the one control here that does nothing
167 // without a script, so the entry reveals it once it knows the browser will.
168 expect(html).toContain(
169 `<button type="button" id="full" hidden aria-pressed="false" aria-label="Full screen" title="Full screen">⛶</button>`,
170 );
171 // Last, so it paints over the overlays rather than under them.
172 expect(html.indexOf(`id="full"`)).toBeGreaterThan(html.indexOf(`id="menu"`));
173 expect(html.indexOf(`id="full"`)).toBeLessThan(html.lastIndexOf("</div>"));
174 // And it is a 44px target like every other button, with the rule that undoes
175 // the stage's 16:9 box when the stage is the whole screen.
176 const document = page({ ...options, stageHtml: html });
177 expect(document).toContain("button { min-height:44px; min-width:44px;");
178 expect(document).toContain("#stage:fullscreen");
179 expect(document).toContain("aspect-ratio:auto");
180 });
181
161182 test("a touch label and an action are text, not markup", () => {
162183 const html = stage({ touch: [[{ action: XSS, label: XSS }]], overlays: [{ id: XSS, html: "" }] });
163184 expect(html).not.toContain("<script>");
src/pages/shell.ts +20 −3
@@ -62,6 +62,16 @@ button.primary { background:#2563eb; border-color:#2563eb; font-weight:600; }
6262 #buy .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); gap:.5rem; width:min(40rem,100%); }
6363 #buy .hint, .overlay .hint { font-size:.78rem; }
6464 #fps { position:absolute; right:.5rem; bottom:.4rem; font-size:.7rem; color:#8ea; opacity:.7; pointer-events:none; }
65 /* Over the overlays, not under them: the menu covers the stage, and the screen
66 is a thing you ask for from the menu as much as from the game. */
67 #full { position:absolute; right:.5rem; top:.5rem; z-index:2; padding:.4rem; font-size:1.15rem;
68 line-height:1; background:#0f131acc; opacity:.72; }
69 #full:hover, #full:focus-visible { opacity:1; }
70 #full[hidden] { display:none; }
71 /* The stage is a 16:9 box on the page and the whole screen off it: every rule
72 that sized it has to be put back, including the phone's 4:3 below. */
73 #stage:fullscreen { width:100%; height:100%; max-width:none; max-height:none; aspect-ratio:auto;
74 margin:0; border:0; border-radius:0; }
6575 #touch { position:absolute; inset:auto 0 0 0; display:flex; justify-content:space-between; padding:.6rem; pointer-events:none; }
6676 #touch[hidden] { display:none; }
6777 #touch .pad { display:flex; gap:.4rem; flex-wrap:wrap; pointer-events:auto; }
@@ -93,9 +103,15 @@ export type StageOptions = {
93103
94104 /**
95105 * The stage: the two canvases the renderer draws into, the fps readout, the
96 * touch pads and the overlays. Every id in here is found by `getElementById`
97 * in `src/client/main.ts` — `scene`, `hud`, `fps`, `touch` and each overlay's —
98 * so they are the contract, not decoration.
106 * touch pads, the overlays and the full screen button. Every id in here is
107 * found by `getElementById` in a game's entry — `stage`, `scene`, `hud`, `fps`,
108 * `touch`, `full` and each overlay's — so they are the contract, not decoration.
109 *
110 * `#full` is rendered last, so it paints over the overlays rather than under
111 * them, and `hidden`, because it is the one control on this page that does
112 * nothing without a script: a game's entry shows it on the frame it has asked
113 * its browser whether full screen is a thing here at all. It is absolutely
114 * positioned, so revealing it moves nothing.
99115 */
100116 export function stage({ overlays, touch }: StageOptions): string {
101117 const pads = touch
@@ -113,6 +129,7 @@ ${pad.map((b) => ` <button type="button" data-action="${escape(b.action)}
113129 ${pads}
114130 </div>
115131 ${overlays.map((o) => ` <div class="overlay" id="${escape(o.id)}"${o.hidden === true ? " hidden" : ""}>${o.html}</div>`).join("\n")}
132 <button type="button" id="full" hidden aria-pressed="false" aria-label="Full screen" title="Full screen">⛶</button>
116133 </div>`;
117134 }
118135
src/range/binding.test.ts +7 −2
@@ -28,8 +28,13 @@ test("the range's bindings are its own, not the shooter's", () => {
2828 // Nothing cs-shaped: no buy, no scoreboard, no weapon slots.
2929 for (const code of ["KeyB", "Tab", "Digit1", "Digit2", "Digit3"])
3030 expect(RANGE_KEYS[code]).toBeUndefined();
31 // Every key is bound to an action this game actually knows.
32 for (const action of Object.values(RANGE_KEYS)) expect(RANGE_ACTIONS).toContain(action);
31 // G is the screen, and it is G rather than F because F is the trigger here.
32 expect(RANGE_KEYS.KeyG).toBe("fullscreen");
33 // Every key is bound to an action this game actually knows, or to the one the
34 // page owns: full screen is the browser's and the range's game never sees it.
35 expect(RANGE_ACTIONS).not.toContain("fullscreen");
36 for (const action of Object.values(RANGE_KEYS))
37 if (action !== "fullscreen") expect(RANGE_ACTIONS).toContain(action);
3338 for (const action of Object.values(RANGE_MOUSE)) expect(RANGE_ACTIONS).toContain(action);
3439 });
3540
src/range/binding.ts +3
@@ -16,6 +16,9 @@ export const RANGE_KEYS: Record<string, string> = {
1616 ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right",
1717 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
1818 KeyF: "fire", KeyR: "reload", Enter: "start", KeyT: "drill",
19 // Not the range's game: `main.ts` hears it on `onAction` and asks the browser
20 // for the screen. It is G rather than F because F is the trigger here.
21 KeyG: "fullscreen",
1922 };
2023
2124 /** One weapon, one button: the left one. There is nothing to scope here. */
src/range/boot.test.ts +39 −1
@@ -24,7 +24,9 @@ const { boot } = await import("./main.ts");
2424 undoLoading();
2525
2626 /** A booted page, and the undo that puts the globals back. */
27 function booted(options: { has2d?: boolean; webgl?: boolean } = {}): { p: RangePage; undo: () => void } {
27 function booted(
28 options: { has2d?: boolean; webgl?: boolean; fullscreen?: boolean } = {},
29 ): { p: RangePage; undo: () => void } {
2830 const p = page(options);
2931 const undo = p.install();
3032 boot();
@@ -33,6 +35,42 @@ function booted(options: { has2d?: boolean; webgl?: boolean } = {}): { p: RangeP
3335
3436 const hudTexts = (p: RangePage): string[] => p.hud.paint?.texts ?? [];
3537
38 test("G and the button put the lane on the whole screen, and a browser that will not is not lied about", () => {
39 const { p, undo } = booted();
40 try {
41 const full = p.by("full");
42 const stage = p.by("stage");
43 // Revealed by the entry, not by the server: a control that needs a script
44 // is rendered hidden and shown by the script that makes it work.
45 expect(full.hidden).toBe(false);
46
47 p.window.fire("keydown", { code: "KeyG" });
48 expect(p.document.fullscreenElement).toBe(stage);
49 expect(stage.fullscreens).toBe(1);
50 expect(full.getAttribute("aria-pressed")).toBe("true");
51 p.window.fire("keyup", { code: "KeyG" });
52
53 full.fire("click");
54 expect(p.document.fullscreenElement).toBe(null);
55 expect(full.getAttribute("aria-pressed")).toBe("false");
56 expect(full.getAttribute("aria-label")).toBe("Full screen");
57 } finally {
58 undo();
59 }
60 });
61
62 test("a browser with no full screen keeps the button hidden rather than shipping a dead one", () => {
63 const { p, undo } = booted({ fullscreen: false });
64 try {
65 expect(p.by("full").hidden).toBe(true);
66 p.window.fire("keydown", { code: "KeyG" });
67 expect(p.document.fullscreenElement).toBe(null);
68 expect(p.by("stage").fullscreens).toBe(0);
69 } finally {
70 undo();
71 }
72 });
73
3674 test("it boots, asks for a frame, and draws the readout the loop produced", () => {
3775 const { p, undo } = booted();
3876 try {
src/range/main.ts +21 −1
@@ -39,7 +39,7 @@ import {
3939 type DrillName, type Target,
4040 } from "./targets.ts";
4141 import {
42 angleVectors, attachButtons, clamp, createControls, createLoop, createRenderer,
42 angleVectors, attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer,
4343 } from "../engine/index.ts";
4444
4545 /** The range's own step: 50 Hz, deliberately not the shooter's 64. */
@@ -63,6 +63,8 @@ export function boot(): void {
6363 const hud = $<HTMLCanvasElement>("hud");
6464 const note = $("note");
6565 const touch = $("touch");
66 const stage = $("stage");
67 const fullButton = $("full");
6668
6769 // A canvas that gives no context is a sentence on the page, never a throw:
6870 // everything else here — the text, the links, the terminal line — still works.
@@ -157,6 +159,24 @@ export function boot(): void {
157159 controls.onUnlock = () => {
158160 note.hidden = false;
159161 };
162
163 // The lane on the whole screen: the button over the stage, and <kbd>G</kbd>,
164 // which is this game's table's — F is the trigger here, so it could not also
165 // be the screen. Both arrive inside a gesture, which is when a browser says
166 // yes; one that will not do it at all leaves the button hidden.
167 const full = createFullscreen({ element: stage, document });
168 full.onChange = (on) => {
169 const label = on ? "Leave full screen" : "Full screen";
170 fullButton.setAttribute("aria-pressed", on ? "true" : "false");
171 fullButton.setAttribute("aria-label", label);
172 fullButton.setAttribute("title", label);
173 fullButton.textContent = on ? "\u2921" : "\u26F6";
174 };
175 fullButton.hidden = !full.supported;
176 fullButton.addEventListener("click", () => full.toggle());
177 controls.onAction = (action, down) => {
178 if (down && action === "fullscreen") full.toggle();
179 };
160180 touch.hidden = !matchMedia("(pointer: coarse)").matches;
161181
162182 /** Match a canvas's backing store to its box, at most twice the CSS pixels. */
src/range/stage.test-helper.ts +6 −2
@@ -14,7 +14,7 @@
1414 import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts";
1515
1616 /** Every id `src/pages/range.ts` puts on the page that the entry looks up. */
17 export const RANGE_IDS = ["scene", "hud", "note", "touch", "look", "fps"];
17 export const RANGE_IDS = ["stage", "scene", "hud", "note", "touch", "look", "fps", "full"];
1818
1919 /** The `[data-action]` buttons the shell renders for this game. */
2020 export const RANGE_ACTIONS = ["duck", "jump", "fire"];
@@ -183,12 +183,16 @@ const GLOBALS = [
183183 * the draw, and because a browser with no WebGL2 is a real browser this page has
184184 * to keep working on; `has2d: false` is a canvas that gives no context at all.
185185 */
186 export function page(options: { has2d?: boolean; webgl?: boolean } = {}): RangePage {
186 export function page(
187 options: { has2d?: boolean; webgl?: boolean; fullscreen?: boolean } = {},
188 ): RangePage {
187189 const fake = fakePage({
188190 ids: RANGE_IDS,
189191 canvases: ["scene", "hud"],
192 hidden: ["full"],
190193 actions: RANGE_ACTIONS,
191194 paint: options.has2d ?? true,
195 fullscreen: options.fullscreen,
192196 });
193197 const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas];
194198 if (options.webgl === true) scene.gl = glRecorder();
static/app.1d36f87326.js deleted −58

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

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

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

View file
static/example.4055b264fb.js deleted −1

Diff not shown — a line 8,748 characters wide (limit 2,000). −1

View file
static/example.6352072ec9.js added +1

Diff not shown — a line 9,725 characters wide (limit 2,000). +1

View file
static/range.b3648bb3ad.js deleted −58

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

View file
static/range.e985d7477a.js added +58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs