Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
full screen #141
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/132-full-screen → main 1 commit fork
README.md +16 −7
| @@ -95,15 +95,21 @@ whatever the name field said, and a result posted without one is a **guest**. | ||
| 95 | 95 | <kbd>Shift</kbd> walk, mouse aims and fires, <kbd>R</kbd> reload, |
| 96 | 96 | <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze |
| 97 | 97 | 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. | |
| 101 | 105 | |
| 102 | 106 | At `/range` the same controls drive the second game, on its own bindings: |
| 103 | 107 | <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch, |
| 104 | 108 | <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 | |
| 107 | 113 | plates at a time, standing still down the lane, each worth 100 to 200 points by |
| 108 | 114 | how far away it stood. **Tracking** is the second: two at a time, nearer and |
| 109 | 115 | 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 | ||
| 170 | 176 | knows nothing about de_forge's alphabet), `mesh` (a box builder), `texture` |
| 171 | 177 | (hashed value noise, fbm and an atlas painter), `sound` (a lazy `AudioContext` |
| 172 | 178 | 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 | |
| 174 | 182 | 2D over the scene: a scale and a pad off the canvas width, scaled fonts, |
| 175 | 183 | panels, aligned and faded text, a crosshair, a bar, right-aligned columns and |
| 176 | 184 | 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 | ||
| 228 | 236 | `sound` `attenuate`, `createSoundDevice`, `Burst`, `Tone`, `SoundDevice` and |
| 229 | 237 | `Ctor`; from `loop` `createLoop`, `Frames`, `Loop` and `LoopOptions`; from |
| 230 | 238 | `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` | |
| 232 | 241 | a 2D game draws onto; from `scene` the frame and its pool — `createScene`, |
| 233 | 242 | `resetScene`, `nextInstance`, `pushLine`, `Scene`, `SceneCapacity`, `Camera` |
| 234 | 243 | 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 | ||
| 38 | 38 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 39 | 39 | expect(ENTRIES).toEqual([ |
| 40 | 40 | { 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 } }, | |
| 43 | 43 | ]); |
| 44 | 44 | // And it is the registry projected, not a second list beside it: a game's |
| 45 | 45 | // 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", () => { | ||
| 23 | 23 | expect(CS_MOUSE[0]).toBe("fire"); |
| 24 | 24 | expect(CS_MOUSE[2]).toBe("scope"); |
| 25 | 25 | 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([])); | |
| 26 | 30 | }); |
| 27 | 31 | |
| 28 | 32 | test("nothing held is nothing asked for", () => { |
src/client/binding.ts +3
| @@ -15,6 +15,9 @@ export const CS_KEYS: Record<string, string> = { | ||
| 15 | 15 | Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk", |
| 16 | 16 | KeyR: "reload", KeyE: "use", KeyB: "buy", Tab: "scoreboard", |
| 17 | 17 | Digit1: "slot1", Digit2: "slot2", Digit3: "slot3", |
| 18 | // Not a thing the world knows about: `main.ts` hears it on `onAction` and | |
| 19 | // asks the browser for the screen, which `readIntent` below never sees. | |
| 20 | KeyF: "fullscreen", | |
| 18 | 21 | }; |
| 19 | 22 | |
| 20 | 23 | /** 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 | ||
| 362 | 362 | expect(stage.by("menu").hidden).toBe(true); |
| 363 | 363 | }); |
| 364 | 364 | |
| 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 | ||
| 365 | 411 | test("no WebGL2 is a sentence on the page, not an exception", () => { |
| 366 | 412 | const stage = start(fakeStage({ webgl: false })); |
| 367 | 413 | 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"; | ||
| 25 | 25 | import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts"; |
| 26 | 26 | import { drawHud, type Paint } from "./hud.ts"; |
| 27 | 27 | import { |
| 28 | attachButtons, clamp, createControls, 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, | |
| 30 | 30 | } from "../engine/index.ts"; |
| 31 | 31 | import { DE_FORGE } from "./map.ts"; |
| 32 | 32 | import type { Level } from "./level.ts"; |
| @@ -60,6 +60,12 @@ export type Field = Panel & { value: string }; | ||
| 60 | 60 | /** A button this file makes rather than finds. */ |
| 61 | 61 | export type Pressable = Listens & { type: string; textContent: string | null; disabled: boolean }; |
| 62 | 62 | |
| 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 | ||
| 63 | 69 | /** A canvas on the stage: the scene is drawn into, the HUD is painted on. */ |
| 64 | 70 | export type Stage = Panel & { |
| 65 | 71 | readonly clientWidth: number; |
| @@ -72,10 +78,13 @@ export type Stage = Panel & { | ||
| 72 | 78 | |
| 73 | 79 | export type PageDocument = Listens & { |
| 74 | 80 | readonly pointerLockElement: unknown; |
| 81 | /** Whose element is on the screen right now — the engine's `createFullscreen` reads it. */ | |
| 82 | readonly fullscreenElement: unknown; | |
| 75 | 83 | getElementById(id: string): unknown; |
| 76 | 84 | querySelectorAll(selectors: string): ArrayLike<Button>; |
| 77 | 85 | createElement(tag: "button"): Pressable; |
| 78 | 86 | exitPointerLock(): void; |
| 87 | exitFullscreen?(): unknown; | |
| 79 | 88 | }; |
| 80 | 89 | |
| 81 | 90 | /** The window: the frame clock, the pixel ratio and the media query. */ |
| @@ -139,8 +148,10 @@ export function boot(host: Host): Frame { | ||
| 139 | 148 | const win = host.window; |
| 140 | 149 | const by = <T extends Panel>(id: string): T => doc.getElementById(id) as T; |
| 141 | 150 | |
| 151 | const stageBox = by<Box>("stage"); | |
| 142 | 152 | const sceneCanvas = by<Stage>("scene"); |
| 143 | 153 | const hudCanvas = by<Stage>("hud"); |
| 154 | const fullButton = by<Toggle>("full"); | |
| 144 | 155 | const menu = by("menu"); |
| 145 | 156 | const pause = by("pause"); |
| 146 | 157 | const buyPanel = by("buy"); |
| @@ -165,6 +176,25 @@ export function boot(host: Host): Frame { | ||
| 165 | 176 | { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 }, |
| 166 | 177 | ); |
| 167 | 178 | 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 | ||
| 168 | 198 | const loop = createLoop({ tick: TICK }, { |
| 169 | 199 | requestAnimationFrame: (callback) => win.requestAnimationFrame(callback), |
| 170 | 200 | cancelAnimationFrame: (handle) => win.cancelAnimationFrame(handle), |
| @@ -246,7 +276,9 @@ export function boot(host: Host): Frame { | ||
| 246 | 276 | } |
| 247 | 277 | |
| 248 | 278 | 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(); | |
| 250 | 282 | }; |
| 251 | 283 | controls.onUnlock = () => { |
| 252 | 284 | if (running && buyPanel.hidden) show(pause, true); |
src/client/stage.test-helper.ts +9 −5
| @@ -150,10 +150,10 @@ export type FakeStage = { | ||
| 150 | 150 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 151 | 151 | * served page against this same list rather than a second copy of it. |
| 152 | 152 | */ |
| 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"]; | |
| 154 | 154 | |
| 155 | 155 | /** 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"]; | |
| 157 | 157 | |
| 158 | 158 | /** The `[data-action]` buttons the shell renders for this game. */ |
| 159 | 159 | export const PAGE_ACTIONS = ["duck", "jump", "use", "reload", "buy", "fire"]; |
| @@ -168,16 +168,20 @@ const GLOBALS = ["document", "window", "localStorage", "fetch"]; | ||
| 168 | 168 | |
| 169 | 169 | /** |
| 170 | 170 | * 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. | |
| 173 | 174 | */ |
| 174 | export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): FakeStage { | |
| 175 | export function fakeStage( | |
| 176 | options: { webgl?: boolean; coarse?: boolean; fullscreen?: boolean } = {}, | |
| 177 | ): FakeStage { | |
| 175 | 178 | const page = fakePage({ |
| 176 | 179 | ids: PAGE_IDS, |
| 177 | 180 | canvases: ["scene", "hud"], |
| 178 | 181 | hidden: HIDDEN, |
| 179 | 182 | actions: PAGE_ACTIONS, |
| 180 | 183 | coarse: options.coarse, |
| 184 | fullscreen: options.fullscreen, | |
| 181 | 185 | }); |
| 182 | 186 | // Asked for with `paint` left on, so both canvases have their 2D recorder. |
| 183 | 187 | 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 | ||
| 326 | 326 | const on = (action: string): number => (held.has(action) ? 1 : 0); |
| 327 | 327 | |
| 328 | 328 | |
| 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 | ||
| 329 | 350 | ### overlay |
| 330 | 351 | |
| 331 | 352 | 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", ( | ||
| 284 | 284 | * physics — `Body`, `Wish`, `MoveTuning`, `Hull`, `accelerate`, |
| 285 | 285 | * `airAccelerate`, `move`, `groundUnder`, `stuck`, `eyeHeight`, `hullHeight`. |
| 286 | 286 | * |
| 287 | * fullscreen — `Fullscreen`, `FullscreenHost`, `FullscreenOwner`, | |
| 288 | * `FullscreenTarget`, `createFullscreen`. | |
| 289 | * | |
| 287 | 290 | * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one |
| 288 | 291 | * copy of the surface, read twice, rather than two that drift. |
| 289 | 292 | */ |
| @@ -291,7 +294,8 @@ export const SURFACE = [ | ||
| 291 | 294 | "Activations", "Adam", "Body", "Box", "Builder", "Burst", "Button", "ButtonOptions", |
| 292 | 295 | "CHECKPOINT_CHARS", "Camera", "Complaint", "Controls", "Ctor", "DEFAULT_HYPER", "FakeCanvas", |
| 293 | 296 | "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", | |
| 295 | 299 | "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh", "MouseEventish", "MoveTuning", "Options", |
| 296 | 300 | "Overlay", "Packed", "PageOptions", "Paint", "Painter", "Pin", "Policy", "QUANTUM", |
| 297 | 301 | "Renderer", "RendererAssets", "Rollout", "Scene", "SceneCapacity", "Shader", "Shape", |
| @@ -299,8 +303,9 @@ export const SURFACE = [ | ||
| 299 | 303 | "Wish", "accelerate", "add", "addBox", "airAccelerate", "angleDelta", "angleVectors", |
| 300 | 304 | "applyGrads", "attachButtons", "attenuate", |
| 301 | 305 | "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", | |
| 304 | 309 | "cross", "deg", "dist", "dist2d", "dot", "eyeHeight", "fakePage", "fbm", "findPath", "finish", |
| 305 | 310 | "fold", "forward", "greedy", "groundUnder", "hash", "headOffset", "hullHeight", "inside", |
| 306 | 311 | "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_$]*$/; | ||
| 62 | 62 | */ |
| 63 | 63 | const NOT_THE_ENGINES = new Set([ |
| 64 | 64 | "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", | |
| 67 | 67 | ]); |
| 68 | 68 | |
| 69 | 69 | const guide = (): string => readFileSync(GUIDE, "utf8"); |
src/engine/index.ts +4
| @@ -58,6 +58,10 @@ export type { | ||
| 58 | 58 | Options, Stick, Surface, TouchEventish, Touchish, |
| 59 | 59 | } from "./controls.ts"; |
| 60 | 60 | |
| 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 | ||
| 61 | 65 | /** The 2D over the scene: panels, scaled text, a crosshair, a bar and a clock. */ |
| 62 | 66 | export { overlay } from "./overlay.ts"; |
| 63 | 67 | export type { Overlay, Paint } from "./overlay.ts"; |
src/engine/page.ts +59 −3
| @@ -12,9 +12,12 @@ | ||
| 12 | 12 | * committed bundles to say so. |
| 13 | 13 | * |
| 14 | 14 | * 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 | |
| 18 | 21 | * about being a double: it has no timer, it touches no global, it throws for |
| 19 | 22 | * nothing, and it knows what neither a renderer nor an `AudioContext` is — |
| 20 | 23 | * `canvas.gl` is a slot a game fills with its own recorder, because what a |
| @@ -92,10 +95,22 @@ export type FakeElement = FakeTarget & { | ||
| 92 | 95 | dataset: { action?: string }; |
| 93 | 96 | /** How many times something asked for the focus. */ |
| 94 | 97 | focused: number; |
| 98 | /** How many times something asked for the screen. See `fakePage`'s `fullscreen`. */ | |
| 99 | fullscreens: number; | |
| 95 | 100 | readonly children: FakeElement[]; |
| 101 | /** Every attribute set on this element, by name: `aria-pressed` is one. */ | |
| 102 | readonly attributes: Record<string, string>; | |
| 96 | 103 | focus(): void; |
| 104 | setAttribute(name: string, value: string): void; | |
| 105 | getAttribute(name: string): string | null; | |
| 97 | 106 | appendChild(child: FakeElement): void; |
| 98 | 107 | 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>; | |
| 99 | 114 | }; |
| 100 | 115 | |
| 101 | 116 | /** |
| @@ -108,6 +123,7 @@ const CLASS = /class="([a-z-]+)"/g; | ||
| 108 | 123 | |
| 109 | 124 | function element(options: { id?: string; tag?: string; action?: string; className?: string } = {}): FakeElement { |
| 110 | 125 | const children: FakeElement[] = []; |
| 126 | const attributes: Record<string, string> = {}; | |
| 111 | 127 | let html = ""; |
| 112 | 128 | const self: FakeElement = { |
| 113 | 129 | ...target(), |
| @@ -121,7 +137,9 @@ function element(options: { id?: string; tag?: string; action?: string; classNam | ||
| 121 | 137 | disabled: false, |
| 122 | 138 | type: "", |
| 123 | 139 | focused: 0, |
| 140 | fullscreens: 0, | |
| 124 | 141 | children, |
| 142 | attributes, | |
| 125 | 143 | get innerHTML() { |
| 126 | 144 | return html; |
| 127 | 145 | }, |
| @@ -133,6 +151,12 @@ function element(options: { id?: string; tag?: string; action?: string; classNam | ||
| 133 | 151 | focus() { |
| 134 | 152 | self.focused += 1; |
| 135 | 153 | }, |
| 154 | setAttribute(name, value) { | |
| 155 | attributes[name] = value; | |
| 156 | }, | |
| 157 | getAttribute(name) { | |
| 158 | return attributes[name] ?? null; | |
| 159 | }, | |
| 136 | 160 | appendChild(child) { |
| 137 | 161 | children.push(child); |
| 138 | 162 | }, |
| @@ -235,11 +259,15 @@ export type FakeDocument = FakeTarget & { | ||
| 235 | 259 | /** `"complete"`, so an entry boots on import; set `"loading"` for the other path. */ |
| 236 | 260 | readyState: string; |
| 237 | 261 | pointerLockElement: unknown; |
| 262 | /** The element that is full screen right now, or null. The browser owns this. */ | |
| 263 | fullscreenElement: unknown; | |
| 238 | 264 | getElementById(id: string): FakeElement | null; |
| 239 | 265 | /** The `[data-action]` buttons, whatever the selector says: there is one list. */ |
| 240 | 266 | querySelectorAll(selector: string): FakeElement[]; |
| 241 | 267 | createElement(tag: string): FakeElement; |
| 242 | 268 | exitPointerLock(): void; |
| 269 | /** Absent on a page asked for with `fullscreen: false`, like the method above. */ | |
| 270 | exitFullscreen?(): Promise<void>; | |
| 243 | 271 | }; |
| 244 | 272 | |
| 245 | 273 | /** What a game brings: its own ids, its own buttons, and the browser it is on. */ |
| @@ -256,6 +284,13 @@ export type PageOptions = { | ||
| 256 | 284 | paint?: boolean; |
| 257 | 285 | /** What `matchMedia` answers. True is a phone. */ |
| 258 | 286 | 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; | |
| 259 | 294 | }; |
| 260 | 295 | |
| 261 | 296 | export type FakePage = { |
| @@ -313,11 +348,32 @@ export function fakePage(options: PageOptions = {}): FakePage { | ||
| 313 | 348 | getElementById: (id) => elements.get(id) ?? null, |
| 314 | 349 | querySelectorAll: (_selector) => buttons, |
| 315 | 350 | createElement: (tag) => element({ tag }), |
| 351 | fullscreenElement: null as unknown, | |
| 316 | 352 | exitPointerLock(): void { |
| 317 | 353 | document.pointerLockElement = null; |
| 318 | 354 | }, |
| 319 | 355 | }; |
| 320 | 356 | |
| 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 | ||
| 321 | 377 | const window: FakeWindow = { |
| 322 | 378 | ...target(), |
| 323 | 379 | devicePixelRatio: 1, |
src/example/boot.test.ts +37 −5
| @@ -24,7 +24,9 @@ const { BINDINGS, RUN, boot } = await import("./main.ts"); | ||
| 24 | 24 | undoLoading(); |
| 25 | 25 | |
| 26 | 26 | /** 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 } { | |
| 28 | 30 | const p = page(options); |
| 29 | 31 | const undo = p.install(); |
| 30 | 32 | boot(); |
| @@ -59,14 +61,44 @@ const marker = (calls: Call[]): [number, number] => { | ||
| 59 | 61 | return [Number(move?.[1] ?? 0), Number(move?.[2] ?? 0)]; |
| 60 | 62 | }; |
| 61 | 63 | |
| 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" }); | |
| 66 | 68 | expect(KEYS.Enter).toBeUndefined(); |
| 69 | expect(KEYS.KeyF).toBeUndefined(); | |
| 67 | 70 | expect(TICK).toBe(1 / 60); |
| 68 | 71 | }); |
| 69 | 72 | |
| 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 | ||
| 70 | 102 | test("it boots, asks for a frame, and draws the room and the readout", () => { |
| 71 | 103 | const { p, undo } = booted(); |
| 72 | 104 | try { |
src/example/main.ts +25 −3
| @@ -16,11 +16,15 @@ | ||
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | 18 | 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"; | |
| 20 | 20 | import { drawPlan, type Phase } from "./plan.ts"; |
| 21 | 21 | |
| 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" }; | |
| 24 | 28 | |
| 25 | 29 | /** Seconds a run lasts, and the actions a button press releases by itself. */ |
| 26 | 30 | export const RUN = 30; |
| @@ -51,6 +55,24 @@ export function boot(): void { | ||
| 51 | 55 | |
| 52 | 56 | const controls = createControls({ surface: scene, window, document }, { keys: BINDINGS, requireLock: false }); |
| 53 | 57 | 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 | }; | |
| 54 | 76 | $("touch").hidden = !matchMedia("(pointer: coarse)").matches; |
| 55 | 77 | const loop = createLoop({ tick: TICK }, { |
| 56 | 78 | requestAnimationFrame: (callback) => requestAnimationFrame(callback), |
src/example/stage.test-helper.ts +6 −2
| @@ -16,7 +16,7 @@ | ||
| 16 | 16 | import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts"; |
| 17 | 17 | |
| 18 | 18 | /** 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"]; | |
| 20 | 20 | |
| 21 | 21 | /** The `[data-action]` buttons the page renders: the touch pads, and Start. */ |
| 22 | 22 | export const EXAMPLE_ACTIONS = ["turnLeft", "turnRight", "forward", "fire", "start"]; |
| @@ -41,13 +41,17 @@ export type ExamplePage = { | ||
| 41 | 41 | }; |
| 42 | 42 | |
| 43 | 43 | /** 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 { | |
| 45 | 47 | const fake = fakePage({ |
| 46 | 48 | ids: EXAMPLE_IDS, |
| 47 | 49 | canvases: ["scene", "hud"], |
| 50 | hidden: ["full"], | |
| 48 | 51 | actions: EXAMPLE_ACTIONS, |
| 49 | 52 | paint: options.has2d ?? true, |
| 50 | 53 | coarse: options.coarse, |
| 54 | fullscreen: options.fullscreen, | |
| 51 | 55 | }); |
| 52 | 56 | const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas]; |
| 53 | 57 |
src/games.ts +16 −2
| @@ -149,6 +149,20 @@ export type Game = { | ||
| 149 | 149 | * `duckRate`, `jumpVelocity` and `stopSpeed` and finds none of them, while |
| 150 | 150 | * `src/client/bundle.test.ts` finds all four and the jump velocity itself. |
| 151 | 151 | * 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. | |
| 152 | 166 | * The range's numbers here are #121's moving plates and #125's painter at once: |
| 153 | 167 | * both landed a range bundle and neither swept the other's, so static/ held two |
| 154 | 168 | * and every reader of it failed until a plain `bun run build` resolved them. |
| @@ -168,7 +182,7 @@ export const GAMES: readonly Game[] = [ | ||
| 168 | 182 | href: "/range", |
| 169 | 183 | label: "range", |
| 170 | 184 | entry: "src/range/main.ts", |
| 171 | ceiling: { raw: 29_400, gzip: 12_700 }, | |
| 185 | ceiling: { raw: 33_000, gzip: 14_000 }, | |
| 172 | 186 | blurb: |
| 173 | 187 | "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.", |
| 174 | 188 | }, |
| @@ -177,7 +191,7 @@ export const GAMES: readonly Game[] = [ | ||
| 177 | 191 | href: "/example", |
| 178 | 192 | label: "example", |
| 179 | 193 | entry: "src/example/main.ts", |
| 180 | ceiling: { raw: 9_700, gzip: 4_700 }, | |
| 194 | ceiling: { raw: 10_800, gzip: 5_100 }, | |
| 181 | 195 | blurb: |
| 182 | 196 | "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.", |
| 183 | 197 | }, |
src/pages/example.ts +2 −1
| @@ -20,7 +20,8 @@ const NOTE = ` | ||
| 20 | 20 | <h2>The smallest game this engine can run</h2> |
| 21 | 21 | <p>One room seen from above, one plate on the far wall, thirty seconds and a score. The arrow |
| 22 | 22 | 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> | |
| 24 | 25 | <div class="row"><button type="button" class="primary" data-action="start">Start</button></div> |
| 25 | 26 | `; |
| 26 | 27 |
src/pages/game.ts +2 −1
| @@ -86,7 +86,8 @@ const menu = (visitor: Who): string => ` | ||
| 86 | 86 | <p class="hint" id="prep">Preparing textures…</p> |
| 87 | 87 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 88 | 88 | 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> | |
| 90 | 91 | `; |
| 91 | 92 | |
| 92 | 93 | const PAUSE = ` |
src/pages/range.ts +2 −2
| @@ -30,8 +30,8 @@ const NOTE = ` | ||
| 30 | 30 | <div class="row"><button type="button" class="primary" id="look">Capture the mouse</button></div> |
| 31 | 31 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · |
| 32 | 32 | <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 | |
| 35 | 35 | buttons do the rest.</p> |
| 36 | 36 | <p class="hint">It makes its own noise, through the engine's sound device and the range's own |
| 37 | 37 | 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", () => { | ||
| 158 | 158 | expect(html).toContain(`<div class="overlay" id="pause" hidden></div>`); |
| 159 | 159 | }); |
| 160 | 160 | |
| 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 | ||
| 161 | 182 | test("a touch label and an action are text, not markup", () => { |
| 162 | 183 | const html = stage({ touch: [[{ action: XSS, label: XSS }]], overlays: [{ id: XSS, html: "" }] }); |
| 163 | 184 | 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; } | ||
| 62 | 62 | #buy .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); gap:.5rem; width:min(40rem,100%); } |
| 63 | 63 | #buy .hint, .overlay .hint { font-size:.78rem; } |
| 64 | 64 | #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; } | |
| 65 | 75 | #touch { position:absolute; inset:auto 0 0 0; display:flex; justify-content:space-between; padding:.6rem; pointer-events:none; } |
| 66 | 76 | #touch[hidden] { display:none; } |
| 67 | 77 | #touch .pad { display:flex; gap:.4rem; flex-wrap:wrap; pointer-events:auto; } |
| @@ -93,9 +103,15 @@ export type StageOptions = { | ||
| 93 | 103 | |
| 94 | 104 | /** |
| 95 | 105 | * 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. | |
| 99 | 115 | */ |
| 100 | 116 | export function stage({ overlays, touch }: StageOptions): string { |
| 101 | 117 | const pads = touch |
| @@ -113,6 +129,7 @@ ${pad.map((b) => ` <button type="button" data-action="${escape(b.action)} | ||
| 113 | 129 | ${pads} |
| 114 | 130 | </div> |
| 115 | 131 | ${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> | |
| 116 | 133 | </div>`; |
| 117 | 134 | } |
| 118 | 135 |
src/range/binding.test.ts +7 −2
| @@ -28,8 +28,13 @@ test("the range's bindings are its own, not the shooter's", () => { | ||
| 28 | 28 | // Nothing cs-shaped: no buy, no scoreboard, no weapon slots. |
| 29 | 29 | for (const code of ["KeyB", "Tab", "Digit1", "Digit2", "Digit3"]) |
| 30 | 30 | 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); | |
| 33 | 38 | for (const action of Object.values(RANGE_MOUSE)) expect(RANGE_ACTIONS).toContain(action); |
| 34 | 39 | }); |
| 35 | 40 |
src/range/binding.ts +3
| @@ -16,6 +16,9 @@ export const RANGE_KEYS: Record<string, string> = { | ||
| 16 | 16 | ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right", |
| 17 | 17 | Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk", |
| 18 | 18 | 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", | |
| 19 | 22 | }; |
| 20 | 23 | |
| 21 | 24 | /** 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"); | ||
| 24 | 24 | undoLoading(); |
| 25 | 25 | |
| 26 | 26 | /** 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 } { | |
| 28 | 30 | const p = page(options); |
| 29 | 31 | const undo = p.install(); |
| 30 | 32 | boot(); |
| @@ -33,6 +35,42 @@ function booted(options: { has2d?: boolean; webgl?: boolean } = {}): { p: RangeP | ||
| 33 | 35 | |
| 34 | 36 | const hudTexts = (p: RangePage): string[] => p.hud.paint?.texts ?? []; |
| 35 | 37 | |
| 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 | ||
| 36 | 74 | test("it boots, asks for a frame, and draws the readout the loop produced", () => { |
| 37 | 75 | const { p, undo } = booted(); |
| 38 | 76 | try { |
src/range/main.ts +21 −1
| @@ -39,7 +39,7 @@ import { | ||
| 39 | 39 | type DrillName, type Target, |
| 40 | 40 | } from "./targets.ts"; |
| 41 | 41 | import { |
| 42 | angleVectors, attachButtons, clamp, createControls, createLoop, createRenderer, | |
| 42 | angleVectors, attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer, | |
| 43 | 43 | } from "../engine/index.ts"; |
| 44 | 44 | |
| 45 | 45 | /** The range's own step: 50 Hz, deliberately not the shooter's 64. */ |
| @@ -63,6 +63,8 @@ export function boot(): void { | ||
| 63 | 63 | const hud = $<HTMLCanvasElement>("hud"); |
| 64 | 64 | const note = $("note"); |
| 65 | 65 | const touch = $("touch"); |
| 66 | const stage = $("stage"); | |
| 67 | const fullButton = $("full"); | |
| 66 | 68 | |
| 67 | 69 | // A canvas that gives no context is a sentence on the page, never a throw: |
| 68 | 70 | // everything else here — the text, the links, the terminal line — still works. |
| @@ -157,6 +159,24 @@ export function boot(): void { | ||
| 157 | 159 | controls.onUnlock = () => { |
| 158 | 160 | note.hidden = false; |
| 159 | 161 | }; |
| 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 | }; | |
| 160 | 180 | touch.hidden = !matchMedia("(pointer: coarse)").matches; |
| 161 | 181 | |
| 162 | 182 | /** 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 @@ | ||
| 14 | 14 | import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts"; |
| 15 | 15 | |
| 16 | 16 | /** 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"]; | |
| 18 | 18 | |
| 19 | 19 | /** The `[data-action]` buttons the shell renders for this game. */ |
| 20 | 20 | export const RANGE_ACTIONS = ["duck", "jump", "fire"]; |
| @@ -183,12 +183,16 @@ const GLOBALS = [ | ||
| 183 | 183 | * the draw, and because a browser with no WebGL2 is a real browser this page has |
| 184 | 184 | * to keep working on; `has2d: false` is a canvas that gives no context at all. |
| 185 | 185 | */ |
| 186 | export function page(options: { has2d?: boolean; webgl?: boolean } = {}): RangePage { | |
| 186 | export function page( | |
| 187 | options: { has2d?: boolean; webgl?: boolean; fullscreen?: boolean } = {}, | |
| 188 | ): RangePage { | |
| 187 | 189 | const fake = fakePage({ |
| 188 | 190 | ids: RANGE_IDS, |
| 189 | 191 | canvases: ["scene", "hud"], |
| 192 | hidden: ["full"], | |
| 190 | 193 | actions: RANGE_ACTIONS, |
| 191 | 194 | paint: options.has2d ?? true, |
| 195 | fullscreen: options.fullscreen, | |
| 192 | 196 | }); |
| 193 | 197 | const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas]; |
| 194 | 198 | 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 filestatic/app.cc09e8f5e8.js added +58
Diff not shown — a line 57,588 characters wide (limit 2,000). +58
View filestatic/example.4055b264fb.js deleted −1
Diff not shown — a line 8,748 characters wide (limit 2,000). −1
View filestatic/example.6352072ec9.js added +1
Diff not shown — a line 9,725 characters wide (limit 2,000). +1
View filestatic/range.b3648bb3ad.js deleted −58
Diff not shown — a line 17,032 characters wide (limit 2,000). −58
View filestatic/range.e985d7477a.js added +58
Diff not shown — a line 17,423 characters wide (limit 2,000). +58
View file