Public: anyone can read this; the crew can change it.
forked from trevato/cs
buggy on windows. tried playing with a mouse and keyboard on windows in edge… #329
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/307-buggy-on-windows-tried-playing-with-a-mo → main 7 commits fork
42 files
changed +2187
−221
README.md +50
| @@ -419,6 +419,56 @@ phone, where there is no keyboard and no pointer to lock. A browser that will no | ||
| 419 | 419 | put an element full screen — an iPhone will not — never shows that button rather |
| 420 | 420 | than showing one that does nothing. |
| 421 | 421 | |
| 422 | **The whole mouse plays, not just its two big buttons.** The wheel jumps either | |
| 423 | way it is turned — counted in notches, so one click of a wheel is one press | |
| 424 | whether the browser sends a hundred pixels, three lines or a page, and a | |
| 425 | trackpad's stream of small deltas has to travel a click's distance to be one — | |
| 426 | the wheel pressed pulls the knife, and the two buttons under your thumb plant or | |
| 427 | defuse and throw an HE. On Windows those two are the browser's Back and Forward: | |
| 428 | while the game has the mouse their `mousedown` and their `auxclick` are | |
| 429 | prevented, so a thumb mid-round cannot leave the page with the match on it. At | |
| 430 | `/range` the thumb pair reloads and switches drill and the middle button is left | |
| 431 | to the browser. A button no game binds keeps whatever meaning the browser gave it. | |
| 432 | ||
| 433 | **Windows is asked for the mouse the way Windows needs.** `Controls.lock()` asks | |
| 434 | for `unadjustedMovement`, because without it Chromium on Windows hands the page | |
| 435 | a `movementX` with the OS's pointer speed and "enhance pointer precision" | |
| 436 | already applied — the same flick is worth a different angle than it is on a Mac, | |
| 437 | and non-linearly so — and falls back to a plain ask on a browser that will not | |
| 438 | take the dictionary. The answer is read rather than dropped: a refusal leaves the | |
| 439 | pause panel up with a sentence, instead of a match running with a dead mouse. The | |
| 440 | Keyboard Lock API is asked for every key each game binds, which on Windows | |
| 441 | matters twice over: crouch is <kbd>Ctrl</kbd>, so crouch-and-forward is Ctrl+W | |
| 442 | and crouch-and-reload is Ctrl+R, and out of full screen no browser hands those | |
| 443 | over — so a match in progress puts a `beforeunload` confirmation in the way. | |
| 444 | ||
| 445 | **And it is sharp at 125%.** Windows ships display scaling at 125% and 150% far | |
| 446 | more often than anything else does, and a crosshair *stroked* through the centre | |
| 447 | of a 1601-pixel-wide backing store covers three columns of pixels at two thirds | |
| 448 | of its colour each: a dim, smeared cross that a 1.0 laptop never shows you. | |
| 449 | `src/engine/pixels.ts` is the one place that says how large a canvas's backing | |
| 450 | store is and where a bar of pixels has to start for both its edges to land on | |
| 451 | one; the overlay draws in device pixels with no context transform, and a | |
| 452 | crosshair arm is a rectangle filled on whole pixels whose thickness bends by a | |
| 453 | pixel rather than letting the centre move by half of one. Sizes are written in | |
| 454 | CSS pixels and turned into whole device pixels there, so the HUD is the same size | |
| 455 | on the glass at 1, 1.25, 1.5 and 2 instead of shrinking to 70% on a dense screen. | |
| 456 | All three games go through it, including the range's no-WebGL2 horizon, which is | |
| 457 | the picture a machine whose driver refused the shaders actually gets. | |
| 458 | ||
| 459 | **If it still misbehaves, there is a line to paste.** The menu prints one: the | |
| 460 | browser and the platform, what the WebGL2 driver calls itself — on Windows the | |
| 461 | ANGLE string naming the Direct3D device — the stage's size, the pixel ratio, | |
| 462 | whether the pointer lock is raw and whether the keyboard lock was granted. | |
| 463 | `src/client/support.ts` is a pure function of those facts; every one of them | |
| 464 | comes from outside the page, so every one is flattened of control characters, | |
| 465 | stripped of the separator and cut to 120 characters, and it reaches the DOM | |
| 466 | through `textContent`. When the picture cannot be built at all — ANGLE on D3D11 | |
| 467 | refuses shaders a desktop driver waves through — the boot catches the throw, the | |
| 468 | menu says so in words with the driver's own log on the end of that line, and the | |
| 469 | loop does not start. A black canvas with no sentence is the one bug report | |
| 470 | nobody can answer. | |
| 471 | ||
| 422 | 472 | Escape is the one key the browser reads before the page does, and on a Mac what |
| 423 | 473 | it means is *give the window back*: full screen drops with the whole animation, |
| 424 | 474 | mid-round. So the game asks for the key rather than obeying it. Where a browser |
scripts/build.test.ts +3 −3
| @@ -49,9 +49,9 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri | ||
| 49 | 49 | |
| 50 | 50 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 51 | 51 | expect(ENTRIES).toEqual([ |
| 52 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 122_000, gzip: 47_500 } }, | |
| 53 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 41_500, gzip: 17_200 } }, | |
| 54 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 23_500, gzip: 10_200 } }, | |
| 52 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 132_000, gzip: 51_500 } }, | |
| 53 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 44_000, gzip: 18_200 } }, | |
| 54 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 26_800, gzip: 11_400 } }, | |
| 55 | 55 | ]); |
| 56 | 56 | // And it is the registry projected, not a second list beside it: a game's |
| 57 | 57 | // source and its ceiling are written down once, in `src/games.ts`. |
src/client/binding.test.ts +20 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { CS_KEYS, CS_MOUSE, CS_TAP, NUMBER_ROW, readIntent, slotAction, slotIndex } from "./binding.ts"; | |
| 2 | import { CS_KEYS, CS_MOUSE, CS_TAP, CS_WHEEL, NUMBER_ROW, readIntent, slotAction, slotIndex } from "./binding.ts"; | |
| 3 | 3 | |
| 4 | 4 | const NONE = { x: 0, y: 0 }; |
| 5 | 5 | const intent = (actions: string[], stick = NONE) => readIntent(new Set(actions), stick); |
| @@ -25,6 +25,16 @@ test("the bindings are the ones the keyboard has always had", () => { | ||
| 25 | 25 | expect(CS_KEYS.Digit3).toBe("slot3"); |
| 26 | 26 | expect(CS_MOUSE[0]).toBe("fire"); |
| 27 | 27 | expect(CS_MOUSE[2]).toBe("scope"); |
| 28 | // The whole mouse, because an unbound button is one the browser keeps: 3 and | |
| 29 | // 4 are Back and Forward on Windows, and the wheel pressed is the quick knife. | |
| 30 | expect(CS_MOUSE[1]).toBe("knife"); | |
| 31 | expect(CS_MOUSE[3]).toBe("use"); | |
| 32 | expect(CS_MOUSE[4]).toBe("grenade"); | |
| 33 | // The wheel jumps whichever way it is turned, which is how half of this | |
| 34 | // game's players jump at all. | |
| 35 | expect(CS_WHEEL.up).toBe("jump"); | |
| 36 | expect(CS_WHEEL.down).toBe("jump"); | |
| 37 | expect(intent(["jump"]).input.jump).toBe(true); | |
| 28 | 38 | expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade", "flash", "smoke"]); |
| 29 | 39 | // F is the page's, not the world's: `main.ts` hears it on `onAction` and asks |
| 30 | 40 | // the browser for the screen, and nothing about an `Input` changes for it. |
| @@ -64,6 +74,15 @@ test("only the first three of the row are pockets: the rest are the panel's and | ||
| 64 | 74 | } |
| 65 | 75 | }); |
| 66 | 76 | |
| 77 | test("the wheel pressed is the knife pocket, and it is not a tile of the buy panel", () => { | |
| 78 | // The same pocket `3` is, by a name no tile is printed with: with the shop | |
| 79 | // open a key of the number row buys, and a thumb on the wheel means the knife | |
| 80 | // rather than whatever the third tile happens to be. | |
| 81 | expect(intent(["knife"]).slotWanted).toBe(3); | |
| 82 | expect(slotIndex("knife")).toBeNull(); | |
| 83 | expect(intent(["knife"]).input).toEqual(intent([]).input); | |
| 84 | }); | |
| 85 | ||
| 67 | 86 | test("nothing held is nothing asked for", () => { |
| 68 | 87 | const { input, scoreboard, slotWanted } = intent([]); |
| 69 | 88 | expect(input.forward).toBe(0); |
src/client/binding.ts +25 −4
| @@ -59,8 +59,27 @@ export const CS_KEYS: Record<string, string> = { | ||
| 59 | 59 | KeyX: "rebuy", |
| 60 | 60 | }; |
| 61 | 61 | |
| 62 | /** Left fires, right scopes. */ | |
| 63 | export const CS_MOUSE: Record<number, string> = { 0: "fire", 2: "scope" }; | |
| 62 | /** | |
| 63 | * Left fires, right scopes, and the three buttons a five-button mouse has as | |
| 64 | * well — all of them, because a bound button is a button the engine takes the | |
| 65 | * browser's meaning away from and an unbound one is left alone. | |
| 66 | * | |
| 67 | * That matters most for the two under the thumb: to a browser on Windows they | |
| 68 | * are Back and Forward, so a thumb that finds one mid-round in Edge leaves the | |
| 69 | * page and takes the match with it. Bound here, they are the game's. Which | |
| 70 | * actions they get is the pair a thumb reaches for: plant-or-defuse, and the HE. | |
| 71 | * | |
| 72 | * The wheel pressed is the quick knife every other shooter puts there — it is | |
| 73 | * `knife` rather than `slot3` on purpose, because the number row is also the | |
| 74 | * buy panel's and a middle click with the shop open would otherwise spend the | |
| 75 | * money on whatever tile is printed with a 3. | |
| 76 | */ | |
| 77 | export const CS_MOUSE: Record<number, string> = { | |
| 78 | 0: "fire", 1: "knife", 2: "scope", 3: "use", 4: "grenade", | |
| 79 | }; | |
| 80 | ||
| 81 | /** The wheel jumps, both ways, which is the bind half this game plays with. */ | |
| 82 | export const CS_WHEEL: Record<string, string> = { up: "jump", down: "jump" }; | |
| 64 | 83 | |
| 65 | 84 | /** On-screen buttons that are a tap, not a hold. */ |
| 66 | 85 | export const CS_TAP = ["jump", "reload", "buy", "grenade", "flash", "smoke"] as const; |
| @@ -102,7 +121,9 @@ export function readIntent(held: ReadonlySet<string>, stick: { x: number; y: num | ||
| 102 | 121 | scoreboard: down("scoreboard"), |
| 103 | 122 | // Three pockets, and only three: the rest of the number row belongs to the |
| 104 | 123 | // buy panel, and a world that has never heard of `slot4` cannot be steered |
| 105 | // by the key that buys the fourth tile. | |
| 106 | slotWanted: down("slot3") ? 3 : down("slot2") ? 2 : down("slot1") ? 1 : null, | |
| 124 | // by the key that buys the fourth tile. `knife` is the third pocket by | |
| 125 | // another name — the wheel pressed, which no tile is printed with. | |
| 126 | slotWanted: | |
| 127 | down("slot3") || down("knife") ? 3 : down("slot2") ? 2 : down("slot1") ? 1 : null, | |
| 107 | 128 | }; |
| 108 | 129 | } |
src/client/hud.test.ts +93 −23
| @@ -83,12 +83,14 @@ const HEIGHT = 720; | ||
| 83 | 83 | */ |
| 84 | 84 | function draw( |
| 85 | 85 | world: World, |
| 86 | options: { width?: number; height?: number; scoreboard?: boolean; view?: Player } = {}, | |
| 86 | options: { | |
| 87 | width?: number; height?: number; scoreboard?: boolean; view?: Player; dpr?: number; | |
| 88 | } = {}, | |
| 87 | 89 | ): Recorder { |
| 88 | 90 | const paint = recorder(); |
| 89 | 91 | drawHud( |
| 90 | 92 | paint, world, options.width ?? WIDTH, options.height ?? HEIGHT, |
| 91 | options.scoreboard ?? false, options.view ?? world.me, | |
| 93 | options.scoreboard ?? false, options.view ?? world.me, options.dpr ?? 1, | |
| 92 | 94 | ); |
| 93 | 95 | return paint; |
| 94 | 96 | } |
| @@ -111,19 +113,28 @@ function line(paint: Recorder, text: string): Call { | ||
| 111 | 113 | // --- the crosshair --------------------------------------------------------- |
| 112 | 114 | |
| 113 | 115 | /** |
| 114 | * The crosshair's first arm, as how far each of its two ends is from the middle | |
| 115 | * of the screen. Which end is stroked first is not something anybody can see — | |
| 116 | * the engine's `crosshair` draws an arm outer end inwards, the HUD used to draw | |
| 117 | * it inner end out, and it is the same segment — so this reads near and far | |
| 118 | * rather than first and second. | |
| 116 | * The crosshair's arm reaching right, as how far each of its two ends is from | |
| 117 | * the middle of the screen. An arm is a filled rectangle of whole device pixels | |
| 118 | * now rather than a stroked segment — see `engine/pixels.ts` — so it is found | |
| 119 | * as the first fill that starts right of the centre and straddles the middle | |
| 120 | * row, which nothing else on the HUD does. | |
| 119 | 121 | */ |
| 120 | function armOf(paint: Recorder, width = WIDTH): { near: number; far: number } { | |
| 121 | const start = paint.calls.findIndex((c) => c.op === "moveTo"); | |
| 122 | const ends = [paint.calls[start], paint.calls[start + 1]] | |
| 123 | .map((c) => Math.abs(Number(c?.args[0]) - width / 2)); | |
| 124 | return { near: Math.min(...ends), far: Math.max(...ends) }; | |
| 122 | function armOf(paint: Recorder, width = WIDTH, height = HEIGHT): { near: number; far: number } { | |
| 123 | const cx = width / 2, cy = height / 2; | |
| 124 | const arm = paint.calls.find((c) => | |
| 125 | c.op === "fillRect" && Number(c.args[0]) > cx && | |
| 126 | Number(c.args[1]) <= cy && Number(c.args[1]) + Number(c.args[3]) >= cy); | |
| 127 | if (arm === undefined) throw new Error("no crosshair arm was filled"); | |
| 128 | const near = Number(arm.args[0]) - cx; | |
| 129 | return { near, far: near + Number(arm.args[2]) }; | |
| 125 | 130 | } |
| 126 | 131 | |
| 132 | /** Whether a crosshair was drawn at all: four arms, or none. */ | |
| 133 | const aimed = (paint: Recorder, width = WIDTH, height = HEIGHT): boolean => | |
| 134 | paint.calls.some((c) => | |
| 135 | c.op === "fillRect" && Number(c.args[0]) > width / 2 && | |
| 136 | Number(c.args[1]) <= height / 2 && Number(c.args[1]) + Number(c.args[3]) >= height / 2); | |
| 137 | ||
| 127 | 138 | /** How far the crosshair's first arm starts from the middle of the screen. */ |
| 128 | 139 | const gapOf = (paint: Recorder, width = WIDTH): number => armOf(paint, width).near; |
| 129 | 140 | |
| @@ -138,14 +149,17 @@ const spreadOf = (world: World, moving: number): number => | ||
| 138 | 149 | test("the crosshair opens exactly as far as the gun is inaccurate, and is gone when you are", () => { |
| 139 | 150 | const still = scene(); |
| 140 | 151 | const rest = draw(still); |
| 141 | // At a standstill it is the gun's own spread and nothing else. | |
| 142 | expect(gapOf(rest)).toBeCloseTo(4 + spreadOf(still, 0) * 14, 10); | |
| 152 | // At a standstill it is the gun's own spread and nothing else — in whole | |
| 153 | // device pixels, because half a pixel of crosshair is a grey smear and not a | |
| 154 | // finer aim. At a ratio of 1, which is what this file draws at, a device | |
| 155 | // pixel is a CSS pixel and the number is the one it always was, rounded. | |
| 156 | expect(gapOf(rest)).toBe(Math.round(4 + spreadOf(still, 0) * 14)); | |
| 143 | 157 | // Each arm is seven pixels long, from the gap outwards. |
| 144 | expect(armOf(rest).far).toBeCloseTo(gapOf(rest) + 7, 10); | |
| 158 | expect(armOf(rest).far).toBe(gapOf(rest) + 7); | |
| 145 | 159 | |
| 146 | 160 | // Running wide open: any speed at or over the walk cap counts as fully moving. |
| 147 | 161 | const running = scene((w) => { w.me.vel = [1000, 0, 0]; }); |
| 148 | expect(gapOf(draw(running))).toBeCloseTo(4 + spreadOf(running, 1) * 14, 10); | |
| 162 | expect(gapOf(draw(running))).toBe(Math.round(4 + spreadOf(running, 1) * 14)); | |
| 149 | 163 | expect(gapOf(draw(running))).toBeGreaterThan(gapOf(rest)); |
| 150 | 164 | |
| 151 | 165 | // Scoped it draws tight, and darkens everything outside the sight. |
| @@ -157,7 +171,54 @@ test("the crosshair opens exactly as far as the gun is inaccurate, and is gone w | ||
| 157 | 171 | // Dead players do not aim: no crosshair, no sight, nothing stroked at all. |
| 158 | 172 | const dead = draw(scene((w) => { w.me.alive = false; })); |
| 159 | 173 | expect(dead.calls.some((c) => c.op === "stroke")).toBe(false); |
| 160 | expect(dead.calls.some((c) => c.op === "moveTo")).toBe(false); | |
| 174 | expect(aimed(dead)).toBe(false); | |
| 175 | }); | |
| 176 | ||
| 177 | test("the crosshair is whole device pixels and centred, at every ratio Windows has", () => { | |
| 178 | // The report behind this: Windows ships display scaling at 125% and 150%, and | |
| 179 | // a crosshair stroked through a centre at 800.5 is a grey smear three columns | |
| 180 | // wide. Every arm is a filled rectangle of whole pixels now, and the four of | |
| 181 | // them are symmetrical about the true centre of the canvas. | |
| 182 | for (const dpr of [1, 1.25, 1.5, 2]) { | |
| 183 | // An even backing store and an odd one: 125% of an odd CSS width is where | |
| 184 | // the centre lands in the middle of a pixel rather than on a boundary. | |
| 185 | for (const width of [Math.round(1280 * dpr), Math.round(1280 * dpr) + 1]) { | |
| 186 | const height = width - 1; | |
| 187 | const paint = draw(scene(), { width, height, dpr }); | |
| 188 | const arms = paint.calls.filter((c) => | |
| 189 | c.op === "fillRect" && Number(c.args[2]) < width / 4 && Number(c.args[3]) < height / 4); | |
| 190 | expect(arms.length).toBeGreaterThanOrEqual(4); | |
| 191 | for (const arm of arms.slice(0, 4)) | |
| 192 | for (const n of arm.args.slice(0, 4)) expect(Number.isInteger(Number(n))).toBe(true); | |
| 193 | // The arm reaching right and the one reaching left are the same distance | |
| 194 | // from the centre, so the cross does not lean at 125%. | |
| 195 | const { near, far } = armOf(paint, width, height); | |
| 196 | const left = paint.calls.filter((c) => | |
| 197 | c.op === "fillRect" && Number(c.args[0]) + Number(c.args[2]) < width / 2 && | |
| 198 | Number(c.args[1]) <= height / 2 && Number(c.args[1]) + Number(c.args[3]) >= height / 2); | |
| 199 | const mirror = left[0] as Call; | |
| 200 | expect(width / 2 - (Number(mirror.args[0]) + Number(mirror.args[2]))).toBe(near); | |
| 201 | expect(width / 2 - Number(mirror.args[0])).toBe(far); | |
| 202 | } | |
| 203 | } | |
| 204 | }); | |
| 205 | ||
| 206 | test("a crosshair is the same size on the glass however dense the screen is", () => { | |
| 207 | // Seven CSS pixels of arm is seven device pixels at 1 and fourteen at 2. It | |
| 208 | // used to be seven either way, so on a retina screen the cross was half the | |
| 209 | // size it is on a plain one — and at 125% it was four fifths of it. | |
| 210 | const at = (dpr: number): number => { | |
| 211 | const { near, far } = armOf( | |
| 212 | draw(scene(), { width: Math.round(1280 * dpr), height: Math.round(720 * dpr), dpr }), | |
| 213 | Math.round(1280 * dpr), Math.round(720 * dpr), | |
| 214 | ); | |
| 215 | return far - near; | |
| 216 | }; | |
| 217 | // Within half a device pixel of seven CSS pixels at each ratio, which is as | |
| 218 | // exact as whole pixels allow — and the point is that it scales at all. | |
| 219 | for (const dpr of [1, 1.25, 1.5, 2]) expect(Math.abs(at(dpr) - 7 * dpr)).toBeLessThanOrEqual(0.5); | |
| 220 | expect(at(1)).toBe(7); | |
| 221 | expect(at(2)).toBe(14); | |
| 161 | 222 | }); |
| 162 | 223 | |
| 163 | 224 | test("a hit is marked white, a headshot red, and both are gone a quarter second later", () => { |
| @@ -165,21 +226,26 @@ test("a hit is marked white, a headshot red, and both are gone a quarter second | ||
| 165 | 226 | w.time = 10; |
| 166 | 227 | w.effects = only({ kind: "hitmark", at: 9.9, headshot: false }); |
| 167 | 228 | })); |
| 229 | // One stroke, and it is the marker: the crosshair is filled, not stroked. | |
| 168 | 230 | const body = hit.calls.filter((c) => c.op === "stroke"); |
| 169 | expect(body.length).toBe(2); // the crosshair, then the marker | |
| 170 | expect(body[1]?.stroke).toBe("rgba(255,255,255,0.9)"); | |
| 231 | expect(body.length).toBe(1); | |
| 232 | expect(aimed(hit)).toBe(true); | |
| 233 | expect(body[0]?.stroke).toBe("rgba(255,255,255,0.9)"); | |
| 171 | 234 | |
| 172 | 235 | const head = draw(scene((w) => { |
| 173 | 236 | w.time = 10; |
| 174 | 237 | w.effects = only({ kind: "hitmark", at: 9.9, headshot: true }); |
| 175 | 238 | })); |
| 176 | expect(head.calls.filter((c) => c.op === "stroke")[1]?.stroke).toBe("rgba(255,90,90,0.95)"); | |
| 239 | expect(head.calls.filter((c) => c.op === "stroke")[0]?.stroke).toBe("rgba(255,90,90,0.95)"); | |
| 177 | 240 | |
| 178 | 241 | const old = draw(scene((w) => { |
| 179 | 242 | w.time = 10; |
| 180 | 243 | w.effects = only({ kind: "hitmark", at: 9.7, headshot: true }); |
| 181 | 244 | })); |
| 182 | expect(old.calls.filter((c) => c.op === "stroke").length).toBe(1); | |
| 245 | // A quarter second later the marker is gone and nothing is stroked at all — | |
| 246 | // the crosshair, which is still drawn, is four fills. | |
| 247 | expect(old.calls.filter((c) => c.op === "stroke").length).toBe(0); | |
| 248 | expect(aimed(old)).toBe(true); | |
| 183 | 249 | }); |
| 184 | 250 | |
| 185 | 251 | // --- health, money and armour ---------------------------------------------- |
| @@ -586,7 +652,9 @@ test("the same canvas turned sideways is drawn at the new size, not the one befo | ||
| 586 | 652 | // The clock bar is 240 units wide at the scale the *new* width says. It is |
| 587 | 653 | // the first filled rectangle that is not a full-screen wash — this world has |
| 588 | 654 | // somebody shooting back in it, and the pain flash goes on before anything. |
| 589 | const bar = after.find((c) => c.op === "fillRect" && c.args[2] !== 360) as Call; | |
| 655 | // (The four narrow rectangles before it are the crosshair's arms.) | |
| 656 | const bar = after.find((c) => | |
| 657 | c.op === "fillRect" && c.args[2] !== 360 && Number(c.args[2]) > 100) as Call; | |
| 590 | 658 | expect(bar.args[2]).toBe(Math.round(240 * 0.7)); |
| 591 | 659 | }); |
| 592 | 660 | |
| @@ -622,7 +690,9 @@ test("a flashed player gets white over the world, and the crosshair back before | ||
| 622 | 690 | expect(whiteout(0.45)).toBeLessThan(0.5); |
| 623 | 691 | const half = draw(scene((w) => { (w.me as Player).blind = 0.45; })); |
| 624 | 692 | const wash = washes(half)[0] as Call; |
| 625 | const crosshair = half.calls.findIndex((c) => c.op === "stroke"); | |
| 693 | const crosshair = half.calls.findIndex((c) => | |
| 694 | c.op === "fillRect" && Number(c.args[0]) > WIDTH / 2 && | |
| 695 | Number(c.args[1]) <= HEIGHT / 2 && Number(c.args[1]) + Number(c.args[3]) >= HEIGHT / 2); | |
| 626 | 696 | expect(crosshair).toBeGreaterThanOrEqual(0); |
| 627 | 697 | expect(half.calls.indexOf(wash)).toBeGreaterThan(crosshair); |
| 628 | 698 |
src/client/hud.ts +24 −8
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | */ |
| 27 | 27 | |
| 28 | 28 | import { |
| 29 | angleDelta, clamp, effectAt, overlay, | |
| 29 | angleDelta, clamp, effectAt, overlay, ratioOf, | |
| 30 | 30 | type Overlay, type Paint as Surface, type Vec3, |
| 31 | 31 | } from "../engine/index.ts"; |
| 32 | 32 | import { drawMinimap } from "./minimap.ts"; |
| @@ -77,9 +77,12 @@ const NOTHING: readonly string[] = []; | ||
| 77 | 77 | let tools: Overlay | null = null; |
| 78 | 78 | let builtOn: Paint | null = null; |
| 79 | 79 | |
| 80 | function toolsFor(ctx: Paint, width: number, height: number): Overlay { | |
| 81 | if (tools === null || builtOn !== ctx || tools.width !== width || tools.height !== height) { | |
| 82 | tools = overlay(ctx, width, height); | |
| 80 | function toolsFor(ctx: Paint, width: number, height: number, dpr: number): Overlay { | |
| 81 | if ( | |
| 82 | tools === null || builtOn !== ctx || | |
| 83 | tools.width !== width || tools.height !== height || tools.ratio !== ratioOf(dpr) | |
| 84 | ) { | |
| 85 | tools = overlay(ctx, width, height, dpr); | |
| 83 | 86 | builtOn = ctx; |
| 84 | 87 | } |
| 85 | 88 | return tools; |
| @@ -191,6 +194,11 @@ export const FOG = 0.9; | ||
| 191 | 194 | * round, the feed and the scoreboard belong to the match and are the same from |
| 192 | 195 | * anybody's eyes. Left out, the view is you, which is every frame of a round |
| 193 | 196 | * you are alive in. |
| 197 | * | |
| 198 | * `width` and `height` are the canvas's backing store — device pixels, not CSS | |
| 199 | * pixels — and `dpr` is how many of those go to a CSS pixel, so that the | |
| 200 | * crosshair is the same size on the glass and exact on the grid at 125% and | |
| 201 | * 150% as well as at 100%. Left out it is 1, which is what every test draws at. | |
| 194 | 202 | */ |
| 195 | 203 | export function drawHud( |
| 196 | 204 | ctx: Paint, |
| @@ -199,8 +207,9 @@ export function drawHud( | ||
| 199 | 207 | height: number, |
| 200 | 208 | showScoreboard: boolean, |
| 201 | 209 | view: Player = world.me, |
| 210 | dpr = 1, | |
| 202 | 211 | ): void { |
| 203 | const ui = toolsFor(ctx, width, height); | |
| 212 | const ui = toolsFor(ctx, width, height, dpr); | |
| 204 | 213 | ctx.clearRect(0, 0, width, height); |
| 205 | 214 | ctx.textBaseline = "alphabetic"; |
| 206 | 215 | const weapon = currentWeapon(view); |
| @@ -236,7 +245,11 @@ export function drawHud( | ||
| 236 | 245 | ducking: view.duckAmount > 0.5, |
| 237 | 246 | scoped: view.scoped, |
| 238 | 247 | }); |
| 239 | ui.crosshair(width / 2, height / 2, 4 + spread * 14, 7, CROSSHAIR); | |
| 248 | // The gap and the arm in CSS pixels, turned into whole device pixels here: | |
| 249 | // the cross is the same size on the glass at 100%, 125% and 200%, and every | |
| 250 | // edge of it is a pixel's edge. `px` never rounds down to nothing, so the | |
| 251 | // tightest scoped crosshair is still a cross. | |
| 252 | ui.crosshair(width / 2, height / 2, ui.px(4 + spread * 14), ui.px(7), CROSSHAIR); | |
| 240 | 253 | if (view.scoped) { |
| 241 | 254 | ctx.fillStyle = "rgba(0,0,0,0.55)"; |
| 242 | 255 | ctx.beginPath(); |
| @@ -254,8 +267,11 @@ export function drawHud( | ||
| 254 | 267 | const age = world.time - effect.at; |
| 255 | 268 | if (age > 0.25) continue; |
| 256 | 269 | ctx.strokeStyle = effect.headshot ? "rgba(255,90,90,0.95)" : "rgba(255,255,255,0.9)"; |
| 257 | ctx.lineWidth = 2; | |
| 258 | const r = 9 + age * 20; | |
| 270 | // On the diagonals, so there is no pixel grid to land on and no point | |
| 271 | // pretending there is — but it is still the same size on the glass at every | |
| 272 | // ratio, which is the half of this a dense screen used to lose. | |
| 273 | ctx.lineWidth = ui.px(2); | |
| 274 | const r = ui.px(9 + age * 20); | |
| 259 | 275 | ctx.beginPath(); |
| 260 | 276 | const arms: [number, number][] = [[1, 1], [-1, -1], [1, -1], [-1, 1]]; |
| 261 | 277 | for (const [dx, dy] of arms) { |
src/client/main.test.ts +131 −6
| @@ -32,6 +32,7 @@ import { expect, test } from "bun:test"; | ||
| 32 | 32 | import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts"; |
| 33 | 33 | import type { Level } from "./level.ts"; |
| 34 | 34 | import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, type Frame } from "./main.ts"; |
| 35 | import { NO_PICTURE } from "./support.ts"; | |
| 35 | 36 | import { HINT } from "./buy.ts"; |
| 36 | 37 | import { DE_FORGE } from "./map.ts"; |
| 37 | 38 | import { endRound, SHORT } from "./match.ts"; |
| @@ -45,7 +46,7 @@ import { WEAPONS } from "./weapons.ts"; | ||
| 45 | 46 | import { matchOf } from "../matchid.ts"; |
| 46 | 47 | import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts"; |
| 47 | 48 | import { parseResult, type Result } from "../result.ts"; |
| 48 | import { fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts"; | |
| 49 | import { DRIVER, fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts"; | |
| 49 | 50 | import { parseSituation } from "../plan/situation.ts"; |
| 50 | 51 | import { createWorld, type World } from "./world.ts"; |
| 51 | 52 | |
| @@ -180,6 +181,12 @@ test("clicking play starts a match: the menu goes, the pointer is asked for, the | ||
| 180 | 181 | expect(stage.by("menu").hidden).toBe(true); |
| 181 | 182 | expect(stage.by("pause").hidden).toBe(true); |
| 182 | 183 | expect(stage.scene.locks).toBe(1); |
| 184 | // Raw counts, asked for by name. Without the dictionary Windows applies its | |
| 185 | // own pointer speed and "enhance pointer precision" to `movementX` before the | |
| 186 | // page sees it: the same flick of the same mouse is worth a different angle | |
| 187 | // depending on how fast it was flicked, and the sensitivity in the menu is | |
| 188 | // not the sensitivity you get. | |
| 189 | expect(stage.scene.lockOptions).toEqual([{ unadjustedMovement: true }]); | |
| 183 | 190 | expect(stage.ids.length).toBe(1); |
| 184 | 191 | expect(stage.sounds).toContain("resume"); |
| 185 | 192 | |
| @@ -198,6 +205,25 @@ test("clicking play starts a match: the menu goes, the pointer is asked for, the | ||
| 198 | 205 | expect(stage.by("fps").textContent).toContain("ms"); |
| 199 | 206 | }); |
| 200 | 207 | |
| 208 | test("the HUD's backing store follows the screen's ratio, at 125% as well as at 100%", () => { | |
| 209 | // Windows at 125% is where #307 came from: the canvas box is still 800×450 | |
| 210 | // CSS pixels and the backing store behind it is a quarter larger, so the HUD | |
| 211 | // and the scene under it are drawn on the same grid rather than two. | |
| 212 | const stage = playing(); | |
| 213 | stage.window.devicePixelRatio = 1.25; | |
| 214 | stage.step(FRAME); | |
| 215 | expect([stage.hud.width, stage.hud.height]).toEqual([1000, 563]); | |
| 216 | // Every arm of the crosshair lands on a whole pixel: no smeared cross. | |
| 217 | const arms = stage.hud.paint.calls.filter((c) => c[0] === "fillRect" && Number(c[3]) < 250); | |
| 218 | expect(arms.length).toBeGreaterThanOrEqual(4); | |
| 219 | for (const arm of arms.slice(0, 4)) | |
| 220 | for (const n of arm.slice(1, 5)) expect(Number.isInteger(Number(n))).toBe(true); | |
| 221 | // And a ratio past the cap is the cap: pixels past two cost and show nothing. | |
| 222 | stage.window.devicePixelRatio = 4; | |
| 223 | stage.step(FRAME); | |
| 224 | expect([stage.hud.width, stage.hud.height]).toEqual([1600, 900]); | |
| 225 | }); | |
| 226 | ||
| 201 | 227 | test("nothing is heard before a match, and then your own boots are heard as your own", () => { |
| 202 | 228 | const stage = painted(start(fakeStage())); |
| 203 | 229 | // Nine frames of painting an atlas in front of a menu: the voice has not |
| @@ -296,6 +322,29 @@ test("the mouse turns the view, and while scoped it turns it by the zoom's share | ||
| 296 | 322 | expect(world.me.yaw).toBeCloseTo(plainFrom - 0.484, 9); |
| 297 | 323 | }); |
| 298 | 324 | |
| 325 | test("the whole mouse plays the game: the wheel jumps and the thumb buttons do not navigate", () => { | |
| 326 | const stage = playing(); | |
| 327 | stage.lock(true); | |
| 328 | const world = (stage.step(FRAME), worldOf(stage)) as World; | |
| 329 | world.match.phase = "live"; | |
| 330 | world.match.timer = 60; | |
| 331 | expect(world.me.vel[2]).toBe(0); | |
| 332 | ||
| 333 | // A notch of the wheel is the jump a lot of people play with. | |
| 334 | stage.window.fire("wheel", { deltaY: -120 }); | |
| 335 | stage.step(FRAME); | |
| 336 | expect(world.me.vel[2]).toBeGreaterThan(0); | |
| 337 | ||
| 338 | // And the two buttons under a thumb are Back and Forward in Edge on Windows: | |
| 339 | // pressed here they are bound actions and the browser is told to do nothing, | |
| 340 | // so a five-button mouse mid-round cannot take the page away from the match. | |
| 341 | let navigated = 0; | |
| 342 | stage.scene.fire("mousedown", { button: 3, preventDefault: () => void (navigated += 1) }); | |
| 343 | expect(navigated).toBe(1); | |
| 344 | stage.scene.fire("auxclick", { button: 3, preventDefault: () => void (navigated += 1) }); | |
| 345 | expect(navigated).toBe(2); | |
| 346 | }); | |
| 347 | ||
| 299 | 348 | test("B opens the buy overlay: it is drawn, it keeps the pointer and the shelf takes the focus", () => { |
| 300 | 349 | const stage = playing(); |
| 301 | 350 | stage.lock(true); |
| @@ -447,14 +496,16 @@ test("the buy overlay open suppresses fire, but not movement", () => { | ||
| 447 | 496 | expect(stage.sounds.some((s) => s.startsWith("shot:"))).toBe(true); |
| 448 | 497 | }); |
| 449 | 498 | |
| 450 | test("losing the pointer pauses a running match, buy panel open or not", () => { | |
| 499 | test("losing the pointer pauses a running match, buy panel open or not", async () => { | |
| 451 | 500 | const stage = playing(); |
| 452 | 501 | stage.lock(true); |
| 453 | 502 | stage.lock(false); |
| 454 | 503 | expect(stage.by("pause").hidden).toBe(false); |
| 455 | 504 | |
| 456 | // Resume asks for the pointer back. | |
| 505 | // Resume asks for the pointer back — and the panel goes when the browser has | |
| 506 | // answered, which is a promise and so a turn of the loop later. | |
| 457 | 507 | stage.by("resume").fire("click"); |
| 508 | await settled(); | |
| 458 | 509 | expect(stage.by("pause").hidden).toBe(true); |
| 459 | 510 | expect(stage.scene.locks).toBe(2); |
| 460 | 511 | |
| @@ -470,15 +521,47 @@ test("losing the pointer pauses a running match, buy panel open or not", () => { | ||
| 470 | 521 | expect(stage.by("pause").hidden).toBe(false); |
| 471 | 522 | }); |
| 472 | 523 | |
| 524 | test("a refused Resume stays paused and says so, rather than a match with a dead mouse", async () => { | |
| 525 | const stage = playing(); | |
| 526 | stage.lock(true); | |
| 527 | stage.lock(false); | |
| 528 | expect(stage.by("pause").hidden).toBe(false); | |
| 529 | ||
| 530 | // Chromium — Edge included — will not grant a lock for about a second after | |
| 531 | // one was released, which is exactly how fast a person presses Resume after | |
| 532 | // Escape. The old code hid the panel on the way out and never read the | |
| 533 | // answer: the match ran on with no look, no trigger and nothing to click. | |
| 534 | stage.scene.refuseLock = true; | |
| 535 | stage.by("resume").fire("click"); | |
| 536 | await settled(); | |
| 537 | expect(stage.by("pause").hidden).toBe(false); | |
| 538 | expect(stage.by("esc").textContent).toContain("press Resume again"); | |
| 539 | expect(stage.by("resume").focused).toBeGreaterThan(0); | |
| 540 | ||
| 541 | // Pressed again a moment later, when the browser will give it: back in. | |
| 542 | stage.scene.refuseLock = false; | |
| 543 | stage.by("resume").fire("click"); | |
| 544 | await settled(); | |
| 545 | expect(stage.by("pause").hidden).toBe(true); | |
| 546 | }); | |
| 547 | ||
| 473 | 548 | test("Escape pauses and keeps the screen where the browser hands the key over", async () => { |
| 474 | 549 | const stage = playing(); |
| 475 | 550 | stage.press("KeyF"); |
| 476 | 551 | stage.lock(true); |
| 477 | 552 | const box = stage.by("stage"); |
| 478 | 553 | expect(stage.document.fullscreenElement).toBe(box); |
| 479 | // The key was asked for on the way into full screen, and granted a microtask | |
| 480 | // later: from here on Escape is the page's and not the browser's. | |
| 481 | expect(stage.keyboard.locked).toEqual(["Escape"]); | |
| 554 | // The keys were asked for on the way into full screen, and granted a microtask | |
| 555 | // later: from here on Escape is the page's and not the browser's — and so is | |
| 556 | // every key this game binds, because on Windows the browser's own shortcuts | |
| 557 | // are Ctrl and a letter, and Ctrl here is crouch: crouch-and-forward is | |
| 558 | // Ctrl+W, which closes the tab, and crouch-and-reload is Ctrl+R, which | |
| 559 | // reloads the match away. | |
| 560 | const locked = stage.keyboard.locked ?? []; | |
| 561 | expect(locked[0]).toBe("Escape"); | |
| 562 | for (const code of ["KeyW", "KeyR", "KeyD", "Digit1", "Tab"]) expect(locked).toContain(code); | |
| 563 | // Asked for once each, however many tables name it. | |
| 564 | expect(new Set(locked).size).toBe(locked.length); | |
| 482 | 565 | await Promise.resolve(); |
| 483 | 566 | |
| 484 | 567 | stage.window.fire("keydown", { code: "Escape" }); |
| @@ -493,6 +576,7 @@ test("Escape pauses and keeps the screen where the browser hands the key over", | ||
| 493 | 576 | // And Resume goes back into the match without asking for a screen it already |
| 494 | 577 | // has: one pointer lock, no second `requestFullscreen`. |
| 495 | 578 | stage.by("resume").fire("click"); |
| 579 | await settled(); | |
| 496 | 580 | expect(stage.by("pause").hidden).toBe(true); |
| 497 | 581 | expect(box.fullscreens).toBe(1); |
| 498 | 582 | expect(stage.scene.locks).toBe(2); |
| @@ -958,6 +1042,47 @@ test("no WebGL2 is a sentence on the page, not an exception", () => { | ||
| 958 | 1042 | expect(stage.atlases.length).toBe(0); |
| 959 | 1043 | }); |
| 960 | 1044 | |
| 1045 | test("a refused shader is a sentence and a line to paste, not a black canvas", () => { | |
| 1046 | const log = "ERROR: 0:7: 'texture' : no matching overloaded function found"; | |
| 1047 | const stage = painted(start(fakeStage({ refuseShaders: log }))); | |
| 1048 | // The driver took the context and then refused the program, which is what | |
| 1049 | // ANGLE on Direct3D does. The menu says so in words. | |
| 1050 | expect(stage.by("menu").innerHTML).toContain(NO_PICTURE); | |
| 1051 | expect(stage.by("menu").innerHTML).toContain("Nothing else on the page is affected"); | |
| 1052 | // And the line under it carries the browser, the driver and what it said — | |
| 1053 | // which is the whole of what a bug report from a Windows machine was missing. | |
| 1054 | const line = stage.by("supportline").textContent ?? ""; | |
| 1055 | expect(line).toContain("Edge 128 on Windows"); | |
| 1056 | expect(line).toContain("Direct3D11"); | |
| 1057 | expect(line).toContain("no matching overloaded function found"); | |
| 1058 | // The page is alive: nothing is drawn, nothing throws on the next frame, and | |
| 1059 | // the atlas that was paid for is the one that was painted. | |
| 1060 | expect(stage.draws.length).toBe(0); | |
| 1061 | stage.step(FRAME); | |
| 1062 | expect(stage.by("menu").hidden).toBe(false); | |
| 1063 | }); | |
| 1064 | ||
| 1065 | test("the line says what this browser is, and says so before anything is built", () => { | |
| 1066 | const stage = start(fakeStage()); | |
| 1067 | const line = stage.by("supportline").textContent ?? ""; | |
| 1068 | expect(line).toBe("Edge 128 on Windows · " + DRIVER + " · 960×540 at 1× dpr"); | |
| 1069 | // A browser that refuses `WEBGL_debug_renderer_info` is named as one. | |
| 1070 | const quiet = start(fakeStage({ driver: null })); | |
| 1071 | expect(quiet.by("supportline").textContent).toContain("driver not named"); | |
| 1072 | // Even a browser with no WebGL2 at all has the line: it is the report that | |
| 1073 | // says which of the two happened. | |
| 1074 | const none = start(fakeStage({ webgl: false })); | |
| 1075 | expect(none.by("supportline").textContent).toContain("Edge 128 on Windows"); | |
| 1076 | }); | |
| 1077 | ||
| 1078 | test("the copy control copies the line it is under, and hides where there is no clipboard", () => { | |
| 1079 | const stage = start(fakeStage()); | |
| 1080 | const button = stage.by("copysupport"); | |
| 1081 | expect(button.hidden).toBe(false); | |
| 1082 | button.fire("click"); | |
| 1083 | expect(stage.copied).toEqual([stage.by("supportline").textContent ?? ""]); | |
| 1084 | }); | |
| 1085 | ||
| 961 | 1086 | test("every mesh name a scene of this game can place is handed in at construction", () => { |
| 962 | 1087 | // The renderer keeps what it is given and resolves nothing, so a name this |
| 963 | 1088 | // table misses is an exception on the frame a player first holds that gun. |
src/client/main.ts +206 −19
| @@ -67,10 +67,12 @@ | ||
| 67 | 67 | */ |
| 68 | 68 | |
| 69 | 69 | import { createAudio, type Audio } from "./audio.ts"; |
| 70 | import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent, slotIndex } from "./binding.ts"; | |
| 70 | import { CS_KEYS, CS_MOUSE, CS_TAP, CS_WHEEL, readIntent, slotIndex } from "./binding.ts"; | |
| 71 | 71 | import { drawHud, type Paint } from "./hud.ts"; |
| 72 | import { NO_PICTURE, NO_PICTURE_SAYS, supportLine, type Facts, type Running } from "./support.ts"; | |
| 72 | 73 | import { |
| 73 | attachButtons, clamp, createControls, createEscape, createFullscreen, createLoop, createRenderer, | |
| 74 | attachButtons, backing, clamp, createControls, createEscape, createFullscreen, createLoop, | |
| 75 | createRenderer, | |
| 74 | 76 | effectAt, |
| 75 | 77 | painter, |
| 76 | 78 | type Button, type Camera, type KeyboardLock, type Listens, type Mesh, |
| @@ -155,6 +157,9 @@ export const NO_MAKE = "A room could not be made right now. Try again in a momen | ||
| 155 | 157 | export const COPIED = "Copied"; |
| 156 | 158 | export const NOT_COPIED = "Copy failed — the link still works"; |
| 157 | 159 | |
| 160 | /** The same, for the line about this browser: there is no link to fall back to. */ | |
| 161 | export const NOT_COPIED_LINE = "Copy failed — the line above can be selected"; | |
| 162 | ||
| 158 | 163 | // --- the host: what a page has to be for this frame to run on it ----------- |
| 159 | 164 | |
| 160 | 165 | /** One element on the page, as far as this file is concerned. */ |
| @@ -196,8 +201,14 @@ export type Link = Panel & { getAttribute(name: string): string | null }; | ||
| 196 | 201 | /** A button that says which of two states it is in — the full screen one does. */ |
| 197 | 202 | export type Toggle = Panel & { setAttribute(name: string, value: string): void }; |
| 198 | 203 | |
| 199 | /** A canvas on the stage: the scene is drawn into, the HUD is painted on. */ | |
| 200 | export type Stage = Sheet & { requestPointerLock(): unknown }; | |
| 204 | /** | |
| 205 | * A canvas on the stage: the scene is drawn into, the HUD is painted on. | |
| 206 | * | |
| 207 | * The pointer is asked for through `Controls.lock` rather than here, so this | |
| 208 | * says only that a browser may have the method — an iPhone has not, and a game | |
| 209 | * that called it blind would throw on the first press of Play. | |
| 210 | */ | |
| 211 | export type Stage = Sheet & { requestPointerLock?(options?: { unadjustedMovement?: boolean }): unknown }; | |
| 201 | 212 | |
| 202 | 213 | export type PageDocument = Listens & { |
| 203 | 214 | readonly pointerLockElement: unknown; |
| @@ -289,6 +300,13 @@ export type Host = { | ||
| 289 | 300 | now(): number; |
| 290 | 301 | /** Null when this browser has no WebGL2: the menu says so and nothing starts. */ |
| 291 | 302 | createRenderer(canvas: Stage): BuildRenderer | null; |
| 303 | /** | |
| 304 | * What this browser is, for the one line a bug report pastes: the user agent, | |
| 305 | * the name the WebGL2 driver gives itself, the stage's size and the pixel | |
| 306 | * ratio. Gathered by the host because every one of those is a browser's, and | |
| 307 | * turned into a sentence by `./support.ts`, which is a pure function. | |
| 308 | */ | |
| 309 | facts(canvas: Stage): Facts; | |
| 292 | 310 | createAudio(): Audio; |
| 293 | 311 | /** |
| 294 | 312 | * Where this page is, so a link to a map is a whole URL somebody can paste |
| @@ -417,6 +435,47 @@ export function boot(host: Host): Frame { | ||
| 417 | 435 | const againButton = by("again"); |
| 418 | 436 | const leaveButton = by("leave"); |
| 419 | 437 | |
| 438 | // What this browser is, said once, in the one place a person can copy it. It | |
| 439 | // is gathered before anything is built, so a boot that fails still has it. | |
| 440 | const supportSays = by("supportline"); | |
| 441 | const copySupport = by<Panel & { disabled: boolean }>("copysupport"); | |
| 442 | const facts = host.facts(sceneCanvas); | |
| 443 | /** | |
| 444 | * The moving half of the line — how the pointer was granted, whose Escape it | |
| 445 | * is, what a frame costs — asked for at the moment the line is written rather | |
| 446 | * than kept up to date behind a closed panel. It answers nothing until the | |
| 447 | * engine's pieces exist, a few dozen lines below: the line is on the page | |
| 448 | * before the first press of Play, and a browser with no WebGL2 never gets past | |
| 449 | * the guard that follows. | |
| 450 | */ | |
| 451 | let doing: () => Running = () => ({}); | |
| 452 | /** | |
| 453 | * The line, on the page. `failure` is what the picture died of, when it died: | |
| 454 | * `textContent` and never `innerHTML`, because every piece of this came from | |
| 455 | * a driver or a user agent and neither is this page's to trust. | |
| 456 | */ | |
| 457 | const saySupport = (failure?: string): void => { | |
| 458 | supportSays.textContent = supportLine(facts, { ...doing(), failure }); | |
| 459 | }; | |
| 460 | saySupport(); | |
| 461 | // Written again each time the panel is opened, because half of it is only true | |
| 462 | // while a match is running and a person opens this *after* the trouble. A | |
| 463 | // closed panel costs nothing: no timer, no frame, no listener but this one. | |
| 464 | by("support").addEventListener("toggle", () => saySupport()); | |
| 465 | // The copy control is hidden until a clipboard is known to be there: the line | |
| 466 | // is selectable text either way, and a button that copies nothing is worse | |
| 467 | // than no button. | |
| 468 | if (host.copy !== undefined) { | |
| 469 | copySupport.hidden = false; | |
| 470 | copySupport.addEventListener<{ preventDefault(): void }>("click", () => { | |
| 471 | const said = supportSays.textContent ?? ""; | |
| 472 | void host.copy?.(said).then( | |
| 473 | () => void (copySupport.textContent = COPIED), | |
| 474 | () => void (copySupport.textContent = NOT_COPIED_LINE), | |
| 475 | ); | |
| 476 | }); | |
| 477 | } | |
| 478 | ||
| 420 | 479 | const maybeBuild = host.createRenderer(sceneCanvas); |
| 421 | 480 | if (maybeBuild === null) { |
| 422 | 481 | menu.innerHTML = "<h2>No WebGL2</h2><p>This browser cannot run the game. Everything else on the page still works.</p>"; |
| @@ -431,7 +490,7 @@ export function boot(host: Host): Frame { | ||
| 431 | 490 | const audio = host.createAudio(); |
| 432 | 491 | const controls = createControls( |
| 433 | 492 | { surface: sceneCanvas, window: win, document: doc }, |
| 434 | { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 }, | |
| 493 | { keys: CS_KEYS, mouse: CS_MOUSE, wheel: CS_WHEEL, sensitivity: Number(sensField.value) || 2.2 }, | |
| 435 | 494 | ); |
| 436 | 495 | attachButtons(doc.querySelectorAll("[data-action]"), controls, { tap: CS_TAP }); |
| 437 | 496 | |
| @@ -478,7 +537,62 @@ export function boot(host: Host): Frame { | ||
| 478 | 537 | * gives back. `P` is the same press for a player who would rather not find out |
| 479 | 538 | * which browser they are on. |
| 480 | 539 | */ |
| 481 | const escape = createEscape({ window: win, document: doc, keyboard: host.keyboard }, full); | |
| 540 | const escape = createEscape( | |
| 541 | { window: win, document: doc, keyboard: host.keyboard }, | |
| 542 | full, | |
| 543 | // Every key this game binds, asked for by name. On Windows the browser owns | |
| 544 | // half of them the moment crouch is held — Ctrl+R reloads the round away, | |
| 545 | // Ctrl+W closes the tab, Ctrl+1 to Ctrl+9 change it — and full screen plus | |
| 546 | // this lock is the only place a browser hands them over. Out of full screen | |
| 547 | // they stay the browser's, which is what `guardUnload` below is for. | |
| 548 | Object.keys(CS_KEYS), | |
| 549 | ); | |
| 550 | ||
| 551 | /** | |
| 552 | * The other half of the same Windows problem, for the match that is not on | |
| 553 | * the whole screen: out of full screen no lock is granted, so Ctrl+R still | |
| 554 | * reloads and Ctrl+W still closes — and with crouch on Ctrl a player hits one | |
| 555 | * of them by playing. A match in progress is worth a confirmation, so the | |
| 556 | * browser is asked to put its own "leave this page?" in the way. Only while a | |
| 557 | * match is running: a page nobody is playing must not argue about being left. | |
| 558 | */ | |
| 559 | win.addEventListener("beforeunload", (event: { preventDefault(): void; returnValue?: unknown }) => { | |
| 560 | if (!running) return; | |
| 561 | event.preventDefault(); | |
| 562 | // Chromium wants the old property set as well as the new call, and shows | |
| 563 | // its own sentence either way: the string is never read by anybody. | |
| 564 | event.returnValue = ""; | |
| 565 | }); | |
| 566 | ||
| 567 | /** Whether the pointer this page is played with is a finger. */ | |
| 568 | const coarse = (): boolean => win.matchMedia("(pointer: coarse)").matches; | |
| 569 | ||
| 570 | /** | |
| 571 | * Take the pointer, inside the gesture that asked for it, and only leave the | |
| 572 | * pause once the browser has actually handed it over. | |
| 573 | * | |
| 574 | * A refusal is a real thing and not a rare one: Chromium — Edge included — | |
| 575 | * will not grant pointer lock for about a second after Escape released it, | |
| 576 | * which is exactly the moment a paused game asks for it back. The old code | |
| 577 | * asked, ignored the answer and hid the panel anyway, so a player pressing | |
| 578 | * Resume too soon after Esc landed in a running match with a dead mouse: no | |
| 579 | * look, no trigger, nothing to click. Now the panel stays, says so, and | |
| 580 | * Resume is still under the cursor. | |
| 581 | * | |
| 582 | * A phone is the other way round: there is no pointer to lock, the refusal is | |
| 583 | * the honest answer, and the touch controls are what the match is played | |
| 584 | * with — so a coarse pointer plays on. | |
| 585 | */ | |
| 586 | async function takePointer(): Promise<void> { | |
| 587 | const got = await controls.lock(); | |
| 588 | if (!got && !coarse()) { | |
| 589 | escHint.textContent = "Paused. The browser kept the pointer just then — press Resume again."; | |
| 590 | show(pause, true); | |
| 591 | resumeButton.focus(); | |
| 592 | return; | |
| 593 | } | |
| 594 | show(pause, false); | |
| 595 | } | |
| 482 | 596 | |
| 483 | 597 | const loop = createLoop({ tick: TICK }, { |
| 484 | 598 | requestAnimationFrame: (callback) => win.requestAnimationFrame(callback), |
| @@ -544,10 +658,26 @@ export function boot(host: Host): Frame { | ||
| 544 | 658 | let roomBuy: Item | null = null; |
| 545 | 659 | let lastSound = 0; |
| 546 | 660 | let frameAverage = 16.7; |
| 661 | /** Whether a frame has actually been drawn, so the average above means something. */ | |
| 662 | let painted = false; | |
| 547 | 663 | let posted = -1; |
| 548 | 664 | /** Whether the post that said this match is finished has been made. */ |
| 549 | 665 | let postedFinal = false; |
| 550 | 666 | |
| 667 | // Now there is a game to ask, the support line has its second half. Every | |
| 668 | // member is read at the moment the line is written: the pointer the player is | |
| 669 | // aiming with, whose Escape it is on this browser, whether full screen is | |
| 670 | // offered at all, the fixed step, and what a frame costs — left out until one | |
| 671 | // has been drawn, because 16.7 before the first frame is a number nobody | |
| 672 | // measured. A browser with no Keyboard Lock API says nothing rather than "no". | |
| 673 | doing = () => ({ | |
| 674 | pointer: controls.pointer, | |
| 675 | keys: escape.supported ? escape.held : undefined, | |
| 676 | fullscreen: full.supported, | |
| 677 | hz: 1 / TICK, | |
| 678 | frameMs: painted ? frameAverage : undefined, | |
| 679 | }); | |
| 680 | ||
| 551 | 681 | const show = (element: Panel, on: boolean): void => { |
| 552 | 682 | element.hidden = !on; |
| 553 | 683 | }; |
| @@ -910,14 +1040,12 @@ export function boot(host: Host): Frame { | ||
| 910 | 1040 | const level = levelOfChoice(choice, choice === "generated" ? seedFrom(nextMatch()) : 0); |
| 911 | 1041 | // Device pixels, like the HUD's canvas: the CSS box is a fixed square and |
| 912 | 1042 | // this is what the browser is actually giving us of it. |
| 913 | const dpr = Math.min(win.devicePixelRatio || 1, 2); | |
| 914 | const w = Math.round(planCanvas.clientWidth * dpr); | |
| 915 | const h = Math.round(planCanvas.clientHeight * dpr); | |
| 1043 | const [w, h] = backing(planCanvas.clientWidth, planCanvas.clientHeight, win.devicePixelRatio); | |
| 916 | 1044 | if (planCanvas.width !== w || planCanvas.height !== h) { |
| 917 | 1045 | planCanvas.width = w; |
| 918 | 1046 | planCanvas.height = h; |
| 919 | 1047 | } |
| 920 | drawPreview(planCtx, level, w, h); | |
| 1048 | drawPreview(planCtx, level, w, h, win.devicePixelRatio); | |
| 921 | 1049 | planAlt.textContent = previewText(level); |
| 922 | 1050 | } |
| 923 | 1051 | |
| @@ -1064,7 +1192,7 @@ export function boot(host: Host): Frame { | ||
| 1064 | 1192 | show(summaryPanel, false); |
| 1065 | 1193 | show(touchPanel, win.matchMedia("(pointer: coarse)").matches); |
| 1066 | 1194 | audio.resume(); |
| 1067 | void sceneCanvas.requestPointerLock(); | |
| 1195 | void takePointer(); | |
| 1068 | 1196 | host.storage.setItem("cs.name", name); |
| 1069 | 1197 | } |
| 1070 | 1198 | |
| @@ -1111,7 +1239,10 @@ export function boot(host: Host): Frame { | ||
| 1111 | 1239 | show(summaryPanel, false); |
| 1112 | 1240 | show(touchPanel, win.matchMedia("(pointer: coarse)").matches); |
| 1113 | 1241 | audio.resume(); |
| 1114 | void sceneCanvas.requestPointerLock(); | |
| 1242 | // The same ask as a match of your own: raw counts first, and the answer read | |
| 1243 | // — a room is exactly where a silently refused lock would be worst, because | |
| 1244 | // the round starts on the server whether this page took the mouse or not. | |
| 1245 | void takePointer(); | |
| 1115 | 1246 | } |
| 1116 | 1247 | |
| 1117 | 1248 | joinRoom.addEventListener("click", () => { |
| @@ -1162,9 +1293,10 @@ export function boot(host: Host): Frame { | ||
| 1162 | 1293 | // Resume is the gesture that puts back whatever the pause cost: the screen |
| 1163 | 1294 | // first, if a browser took it, and then the pointer. |
| 1164 | 1295 | resumeButton.addEventListener("click", () => { |
| 1165 | show(pause, false); | |
| 1166 | 1296 | restoreFull(); |
| 1167 | void sceneCanvas.requestPointerLock(); | |
| 1297 | // The panel goes when the pointer comes back and not before: see | |
| 1298 | // `takePointer`. A browser that refuses leaves the pause where it was. | |
| 1299 | void takePointer(); | |
| 1168 | 1300 | }); |
| 1169 | 1301 | /** |
| 1170 | 1302 | * Back to the page: the menu, nothing of the match left running, and the |
| @@ -1318,12 +1450,25 @@ export function boot(host: Host): Frame { | ||
| 1318 | 1450 | |
| 1319 | 1451 | function frame(elapsed: number): void { |
| 1320 | 1452 | frameAverage += (elapsed * 1000 - frameAverage) * 0.05; |
| 1453 | painted = true; | |
| 1321 | 1454 | // One layer of the atlas per frame, and the browser has the rest of it: the |
| 1322 | 1455 | // menu is clickable the whole way through. The last slice is what builds |
| 1323 | 1456 | // the renderer, before anything has been drawn with it. |
| 1324 | 1457 | if (renderer === null) { |
| 1325 | 1458 | if (!atlas.paint(TEX_SIZE)) return; |
| 1326 | renderer = build(atlas.bytes); | |
| 1459 | // A driver that refuses a shader throws here with its own info log — | |
| 1460 | // ANGLE on Direct3D rejects programs a desktop driver waves through, so | |
| 1461 | // this is the common way a Windows machine ends up with a black canvas. | |
| 1462 | // It is a sentence and a line to paste, not silence, and the rest of the | |
| 1463 | // page — the leaderboard, the map plan, the reference — is untouched. | |
| 1464 | try { | |
| 1465 | renderer = build(atlas.bytes); | |
| 1466 | } catch (failure) { | |
| 1467 | loop.stop(); | |
| 1468 | menu.innerHTML = `<h2>${NO_PICTURE}</h2><p>${NO_PICTURE_SAYS}</p>`; | |
| 1469 | saySupport(failure instanceof Error ? failure.message : String(failure)); | |
| 1470 | return; | |
| 1471 | } | |
| 1327 | 1472 | ready(); |
| 1328 | 1473 | return; |
| 1329 | 1474 | } |
| @@ -1423,14 +1568,16 @@ export function boot(host: Host): Frame { | ||
| 1423 | 1568 | |
| 1424 | 1569 | renderer.draw(buildScene(world, camera, frameScene, view), elapsed); |
| 1425 | 1570 | |
| 1426 | const dpr = Math.min(win.devicePixelRatio || 1, 2); | |
| 1427 | const w = Math.round(hudCanvas.clientWidth * dpr); | |
| 1428 | const h = Math.round(hudCanvas.clientHeight * dpr); | |
| 1571 | // The same backing store the renderer gives the scene canvas underneath — | |
| 1572 | // one function, so the HUD's pixels and the world's are the same pixels — | |
| 1573 | // and the ratio goes through to the HUD, which needs it to put the | |
| 1574 | // crosshair on the grid rather than across it. See `engine/pixels.ts`. | |
| 1575 | const [w, h] = backing(hudCanvas.clientWidth, hudCanvas.clientHeight, win.devicePixelRatio); | |
| 1429 | 1576 | if (hudCanvas.width !== w || hudCanvas.height !== h) { |
| 1430 | 1577 | hudCanvas.width = w; |
| 1431 | 1578 | hudCanvas.height = h; |
| 1432 | 1579 | } |
| 1433 | drawHud(hudCtx, world, w, h, scoreboard, view); | |
| 1580 | drawHud(hudCtx, world, w, h, scoreboard, view, win.devicePixelRatio); | |
| 1434 | 1581 | by("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`; |
| 1435 | 1582 | } |
| 1436 | 1583 | loop.start(frame); |
| @@ -1476,6 +1623,29 @@ export function gameMeshes(level: Level = DE_FORGE): Record<string, Mesh> { | ||
| 1476 | 1623 | * question and the answer — which is the only way the engine's `createRenderer` |
| 1477 | 1624 | * returns null, and why the line below says so rather than pretending. |
| 1478 | 1625 | */ |
| 1626 | /** | |
| 1627 | * What the WebGL2 driver calls itself — on Windows the ANGLE line naming the | |
| 1628 | * Direct3D device, which is the single most useful thing in a report from that | |
| 1629 | * platform — or null for a browser that will not say. | |
| 1630 | * | |
| 1631 | * Every step of it is optional and every step is guarded: the extension is a | |
| 1632 | * browser's to refuse, a context stub in a test has no `getExtension` at all, | |
| 1633 | * and a driver that throws inside one is still a driver whose game must boot. | |
| 1634 | * A name nobody can read is worth less than a page that starts. | |
| 1635 | */ | |
| 1636 | function driverName(surface: HTMLCanvasElement): string | null { | |
| 1637 | try { | |
| 1638 | const gl = surface.getContext("webgl2"); | |
| 1639 | if (gl === null || typeof gl.getExtension !== "function") return null; | |
| 1640 | const info = gl.getExtension("WEBGL_debug_renderer_info"); | |
| 1641 | if (info === null) return null; | |
| 1642 | const name = gl.getParameter(info.UNMASKED_RENDERER_WEBGL); | |
| 1643 | return typeof name === "string" && name !== "" ? name : null; | |
| 1644 | } catch { | |
| 1645 | return null; | |
| 1646 | } | |
| 1647 | } | |
| 1648 | ||
| 1479 | 1649 | const browser = (): Host => ({ |
| 1480 | 1650 | document, |
| 1481 | 1651 | window, |
| @@ -1500,6 +1670,23 @@ const browser = (): Host => ({ | ||
| 1500 | 1670 | // asked for: a browser without it has no such member, and the copy control |
| 1501 | 1671 | // stays the plain link it is rendered as. |
| 1502 | 1672 | copy: navigator.clipboard === undefined ? undefined : (text) => navigator.clipboard.writeText(text), |
| 1673 | // Everything a browser will say about itself, asked for once. The driver's | |
| 1674 | // own name is behind `WEBGL_debug_renderer_info`, which a browser is entitled | |
| 1675 | // to refuse — Firefox does, in some configurations — and a refusal is null | |
| 1676 | // here and a plain "driver not named" in the line. | |
| 1677 | facts: (canvas) => { | |
| 1678 | const surface = canvas as unknown as HTMLCanvasElement; | |
| 1679 | return { | |
| 1680 | userAgent: navigator.userAgent, | |
| 1681 | renderer: driverName(surface), | |
| 1682 | stage: [surface.clientWidth, surface.clientHeight], | |
| 1683 | dpr: window.devicePixelRatio, | |
| 1684 | // What the server said it built this page from: `<meta name=build>`, which | |
| 1685 | // `pages/shell.ts` writes beside the commit in the header. A local run has | |
| 1686 | // no commit and so no meta, and the line simply does not mention one. | |
| 1687 | commit: document.querySelector('meta[name="build"]')?.getAttribute("content") ?? undefined, | |
| 1688 | }; | |
| 1689 | }, | |
| 1503 | 1690 | createRenderer: (canvas) => { |
| 1504 | 1691 | const surface = canvas as unknown as HTMLCanvasElement; |
| 1505 | 1692 | if (surface.getContext("webgl2") === null) return null; |
src/client/preview.ts +8 −2
| @@ -96,13 +96,19 @@ function drawSpawns(ui: Overlay, at: Projection, points: readonly Vec3[], half: | ||
| 96 | 96 | * It clears first, because it is drawn once per choice rather than once a frame |
| 97 | 97 | * and the map before it was a different shape. |
| 98 | 98 | */ |
| 99 | export function drawPreview(ctx: Paint, level: Level, width: number, height: number): void { | |
| 99 | export function drawPreview( | |
| 100 | ctx: Paint, | |
| 101 | level: Level, | |
| 102 | width: number, | |
| 103 | height: number, | |
| 104 | dpr = 1, | |
| 105 | ): void { | |
| 100 | 106 | ctx.clearRect(0, 0, width, height); |
| 101 | 107 | const side = Math.min(width, height); |
| 102 | 108 | if (side <= 0) return; |
| 103 | 109 | const x = Math.round((width - side) / 2); |
| 104 | 110 | const y = Math.round((height - side) / 2); |
| 105 | const ui = overlay(ctx, side, side); | |
| 111 | const ui = overlay(ctx, side, side, dpr); | |
| 106 | 112 | const at = drawPlan(ui, level, x, y, side); |
| 107 | 113 | // One cell of this map, in pixels of this square, and never under a pixel. |
| 108 | 114 | const half = Math.max(1, Math.round(side / Math.max(level.grid.width, level.grid.height) / 2)); |
src/client/stage.test-helper.ts +26 −1
| @@ -102,6 +102,16 @@ export type StageCanvas = FakeCanvas & { paint: FakePaint; gl: GlRecorder | null | ||
| 102 | 102 | /** Where this stub page is, for the whole URL a copied link has to be. */ |
| 103 | 103 | export const ORIGIN = "https://cs.example"; |
| 104 | 104 | |
| 105 | /** | |
| 106 | * The browser this stub page says it is, and the driver behind its picture: | |
| 107 | * Edge on Windows over ANGLE's Direct3D, because that is the machine #307 was | |
| 108 | * reported from and the one the support line exists for. | |
| 109 | */ | |
| 110 | export const USER_AGENT = | |
| 111 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + | |
| 112 | "Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0"; | |
| 113 | export const DRIVER = "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)"; | |
| 114 | ||
| 105 | 115 | /** |
| 106 | 116 | * What this page's wall clock says until a test moves it: noon UTC on an |
| 107 | 117 | * ordinary day, so `src/daily.ts` has a day to derive the match of the day |
| @@ -210,7 +220,7 @@ export type FakeStage = { | ||
| 210 | 220 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 211 | 221 | * served page against this same list rather than a second copy of it. |
| 212 | 222 | */ |
| 213 | export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "joinroom", "makeroom", "roomsaid", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full"]; | |
| 223 | export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "joinroom", "makeroom", "roomsaid", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"]; | |
| 214 | 224 | |
| 215 | 225 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 216 | 226 | const HIDDEN = ["pause", "summary", "buy", "touch", "full"]; |
| @@ -251,6 +261,14 @@ export function fakeStage( | ||
| 251 | 261 | hold?: boolean; |
| 252 | 262 | /** Leave the `#weights` link off the page entirely: a page that named nothing. */ |
| 253 | 263 | unlinked?: boolean; |
| 264 | /** | |
| 265 | * A driver that takes the context and then refuses the shaders, which is | |
| 266 | * what ANGLE on Direct3D does with GLSL a desktop driver waves through: the | |
| 267 | * build throws with the driver's own info log, as `createRenderer` does. | |
| 268 | */ | |
| 269 | refuseShaders?: string; | |
| 270 | /** What `WEBGL_debug_renderer_info` names, and null for a browser that refuses it. */ | |
| 271 | driver?: string | null; | |
| 254 | 272 | /** |
| 255 | 273 | * The endpoint meta a deployment with a TypeSafe key renders: where the page |
| 256 | 274 | * asks for a round to be planned. Absent is every other deployment, which |
| @@ -357,10 +375,17 @@ export function fakeStage( | ||
| 357 | 375 | // The wall clock, which is only ever asked which day it is: a test sets |
| 358 | 376 | // `stage.wall` and the page is on that day, without waiting for one. |
| 359 | 377 | now: () => stub.wall, |
| 378 | facts: (_canvas: Stage) => ({ | |
| 379 | userAgent: USER_AGENT, | |
| 380 | renderer: options.driver === undefined ? DRIVER : options.driver, | |
| 381 | stage: [960, 540], | |
| 382 | dpr: 1, | |
| 383 | }), | |
| 360 | 384 | createRenderer: (_canvas: Stage): BuildRenderer | null => |
| 361 | 385 | options.webgl === false |
| 362 | 386 | ? null |
| 363 | 387 | : (bytes) => { |
| 388 | if (options.refuseShaders !== undefined) throw new Error(options.refuseShaders); | |
| 364 | 389 | // Every atlas the frame ever built a renderer out of, kept whole: |
| 365 | 390 | // a test says there was one of them and that it was finished. |
| 366 | 391 | atlases.push(bytes); |
src/client/support.test.ts added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { browserOf, NO_PICTURE, NO_PICTURE_SAYS, supportLine } from "./support.ts"; | |
| 3 | ||
| 4 | const EDGE = | |
| 5 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + | |
| 6 | "Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0"; | |
| 7 | const SAFARI = | |
| 8 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) " + | |
| 9 | "Version/17.5 Safari/605.1.15"; | |
| 10 | const ANGLE = "ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)"; | |
| 11 | ||
| 12 | const FACTS = { userAgent: EDGE, renderer: ANGLE, stage: [960, 540] as [number, number], dpr: 1.25 }; | |
| 13 | ||
| 14 | test("the browser is named the short way, and Edge is not Chrome", () => { | |
| 15 | // The order matters: Edge says Chrome too, and says it first. | |
| 16 | expect(browserOf(EDGE)).toBe("Edge 128 on Windows"); | |
| 17 | expect(browserOf(EDGE.replace(" Edg/128.0.0.0", ""))).toBe("Chrome 128 on Windows"); | |
| 18 | expect(browserOf(SAFARI)).toBe("Safari 17 on macOS"); | |
| 19 | expect(browserOf("Mozilla/5.0 (X11; Linux x86_64) Firefox/130.0")).toBe("Firefox 130 on Linux"); | |
| 20 | }); | |
| 21 | ||
| 22 | test("a user agent it cannot read is handed back rather than guessed at", () => { | |
| 23 | // The honest answer to a string with none of the tokens in it is the string: | |
| 24 | // a report from a browser this file has never heard of is still a report. | |
| 25 | expect(browserOf("curl/8.6.0")).toBe("curl/8.6.0"); | |
| 26 | expect(browserOf("")).toBe("an unnamed browser"); | |
| 27 | expect(browserOf(" ")).toBe("an unnamed browser"); | |
| 28 | }); | |
| 29 | ||
| 30 | test("the line is one line: the browser, the driver, the stage and the ratio", () => { | |
| 31 | expect(supportLine(FACTS)).toBe( | |
| 32 | "Edge 128 on Windows · ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11) · " + | |
| 33 | "960×540 at 1.25× dpr", | |
| 34 | ); | |
| 35 | // A browser that refuses `WEBGL_debug_renderer_info` says so rather than | |
| 36 | // printing the word undefined at somebody who is about to file a bug. | |
| 37 | expect(supportLine({ ...FACTS, renderer: null })).toContain("driver not named"); | |
| 38 | // And the ratio is written as a person writes it. | |
| 39 | expect(supportLine({ ...FACTS, dpr: 1.5 })).toContain("at 1.5× dpr"); | |
| 40 | expect(supportLine({ ...FACTS, dpr: Number.NaN })).toContain("at 1× dpr"); | |
| 41 | expect(supportLine({ ...FACTS, dpr: 0 })).toContain("at 1× dpr"); | |
| 42 | expect(supportLine({ ...FACTS, stage: [960.4, 540.6] })).toContain("960×541"); | |
| 43 | }); | |
| 44 | ||
| 45 | test("the second half is what the game is doing, and only what it was told", () => { | |
| 46 | // Nothing about the run is nothing printed: the example has no pointer to | |
| 47 | // lock and no keyboard lock to be granted, and a line for it says neither. | |
| 48 | expect(supportLine(FACTS).split(" · ")).toHaveLength(3); | |
| 49 | const line = supportLine( | |
| 50 | { ...FACTS, commit: "ced16d8" }, | |
| 51 | { pointer: "raw", keys: true, fullscreen: true, hz: 64, frameMs: 16.66 }, | |
| 52 | ); | |
| 53 | expect(line).toBe( | |
| 54 | "Edge 128 on Windows · ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11) · " + | |
| 55 | "960×540 at 1.25× dpr · pointer raw · Esc held · full screen · 64 Hz · 16.7 ms frame · build ced16d8", | |
| 56 | ); | |
| 57 | // The three the report of #307 turns on: a pointer Windows adjusted, a Play | |
| 58 | // that locked nothing, and a browser that never had the keyboard lock at all. | |
| 59 | expect(supportLine(FACTS, { pointer: "adjusted" })).toContain("pointer adjusted"); | |
| 60 | expect(supportLine(FACTS, { pointer: null })).toContain("pointer free"); | |
| 61 | expect(supportLine(FACTS, { pointer: "unknown" })).toContain("pointer unknown"); | |
| 62 | expect(supportLine(FACTS, { keys: false })).toContain("Esc the browser's"); | |
| 63 | expect(supportLine(FACTS, { fullscreen: false })).toContain("no full screen"); | |
| 64 | // A number that is not one is left out rather than printed as NaN. | |
| 65 | expect(supportLine(FACTS, { hz: Number.NaN, frameMs: Number.POSITIVE_INFINITY })) | |
| 66 | .toBe(supportLine(FACTS)); | |
| 67 | // And a build nobody named — a local run, where the server has no commit. | |
| 68 | expect(supportLine({ ...FACTS, commit: "" })).not.toContain("build"); | |
| 69 | }); | |
| 70 | ||
| 71 | test("what the driver said is the end of the line, and it is one line", () => { | |
| 72 | const log = "ERROR: 0:14: 'texture' : no matching overloaded function found\n\nfragment shader"; | |
| 73 | const line = supportLine(FACTS, { failure: log }); | |
| 74 | expect(line).toContain("no matching overloaded function found"); | |
| 75 | expect(line.split("\n")).toHaveLength(1); | |
| 76 | // A middle dot inside a driver's own string would read as another field, so | |
| 77 | // it is not one. Neither is a control character. | |
| 78 | expect(supportLine(FACTS, { failure: "a · b\u0007c" }).endsWith("a b c")).toBe(true); | |
| 79 | // Nothing to say is nothing said: no trailing separator. | |
| 80 | expect(supportLine(FACTS, { failure: " " })).toBe(supportLine(FACTS)); | |
| 81 | expect(supportLine(FACTS, { failure: "" })).toBe(supportLine(FACTS)); | |
| 82 | }); | |
| 83 | ||
| 84 | test("every piece is cut to a length, because every piece comes from outside", () => { | |
| 85 | const long = "x".repeat(400); | |
| 86 | const line = supportLine({ ...FACTS, renderer: long, commit: long }, { failure: long }); | |
| 87 | for (const piece of line.split(" · ")) expect(piece.length).toBeLessThanOrEqual(120); | |
| 88 | expect(line).toContain("…"); | |
| 89 | }); | |
| 90 | ||
| 91 | test("the sentence a refused shader gets says what still works", () => { | |
| 92 | expect(NO_PICTURE).toBe("The picture could not be built"); | |
| 93 | expect(NO_PICTURE_SAYS).toContain("Nothing else on the page is affected"); | |
| 94 | expect(NO_PICTURE_SAYS).toContain("paste it into a bug report"); | |
| 95 | }); |
src/client/support.ts added +175
| @@ -0,0 +1,175 @@ | ||
| 1 | /** | |
| 2 | * One line of what this browser is doing, for a person who is about to say the | |
| 3 | * game is buggy. | |
| 4 | * | |
| 5 | * "Buggy on Windows in Edge" is a true sentence and an unanswerable one: a | |
| 6 | * refused shader, a driver that paints nothing, a pointer that was never | |
| 7 | * granted and a display at 125% all read the same from the outside. This is the | |
| 8 | * line that tells them apart — the browser, the platform, the driver behind the | |
| 9 | * WebGL2 context, the size and the pixel ratio of the screen it is drawing at, | |
| 10 | * and then what the game is doing in it: how the pointer was granted, whether | |
| 11 | * Escape is the page's, whether full screen is there at all, the tick rate, what | |
| 12 | * a frame costs and the build it is all running on — short enough to paste into | |
| 13 | * an issue and boring enough that pasting one costs nothing. | |
| 14 | * | |
| 15 | * It is a pure function of plain facts: `main.ts` gathers them from the | |
| 16 | * browser, this builds the sentence, and a test holds the sentence. Every piece | |
| 17 | * of it comes from outside the page — a driver's own string, a user agent — so | |
| 18 | * every piece is trimmed, stripped of control characters and cut to a length | |
| 19 | * before it goes anywhere near the DOM. | |
| 20 | */ | |
| 21 | ||
| 22 | /** What the page can find out about the browser it is running in. */ | |
| 23 | export type Facts = { | |
| 24 | /** `navigator.userAgent`. */ | |
| 25 | userAgent: string; | |
| 26 | /** | |
| 27 | * What the WebGL2 driver calls itself — `WEBGL_debug_renderer_info`'s | |
| 28 | * unmasked renderer, which on Windows is the ANGLE line naming the D3D11 | |
| 29 | * device. Null where the extension is refused, which is a browser's right. | |
| 30 | */ | |
| 31 | renderer: string | null; | |
| 32 | /** The stage's size in CSS pixels, as the page laid it out. */ | |
| 33 | stage: [number, number]; | |
| 34 | /** `window.devicePixelRatio`: 1 on a plain screen, 1.25 and 1.5 on Windows. */ | |
| 35 | dpr: number; | |
| 36 | /** | |
| 37 | * The commit this deployment was built from, server-rendered into the page — | |
| 38 | * a report about a preview and a report about production are different | |
| 39 | * reports. Left out where the server did not say, which is a local run. | |
| 40 | */ | |
| 41 | commit?: string; | |
| 42 | }; | |
| 43 | ||
| 44 | /** | |
| 45 | * What the game is doing *now*, as against what the browser is: the half of the | |
| 46 | * line that is only true while a match is running, gathered at the moment | |
| 47 | * somebody opens the panel rather than kept up to date behind their back. | |
| 48 | * | |
| 49 | * Every member is optional because every one of them is a different game's, and | |
| 50 | * the example has no scoreboard, no keyboard lock and no pointer to report on. | |
| 51 | */ | |
| 52 | export type Running = { | |
| 53 | /** | |
| 54 | * How the locked pointer is sending counts — the engine's `PointerMode` — or | |
| 55 | * null while the game has no pointer. This is the field that separates "the | |
| 56 | * mouse feels wrong on Windows" from "Play did nothing": `adjusted` is the | |
| 57 | * first, `null` after pressing Play is the second. | |
| 58 | */ | |
| 59 | pointer?: "raw" | "adjusted" | "unknown" | null; | |
| 60 | /** | |
| 61 | * Whether the Keyboard Lock API handed Escape over, false where it exists and | |
| 62 | * has not, and left out on a browser that has no such API at all. | |
| 63 | */ | |
| 64 | keys?: boolean; | |
| 65 | /** Whether this browser will put the stage on the whole screen. */ | |
| 66 | fullscreen?: boolean; | |
| 67 | /** The fixed step this game simulates at, in Hz. */ | |
| 68 | hz?: number; | |
| 69 | /** The smoothed cost of a frame, in milliseconds. */ | |
| 70 | frameMs?: number; | |
| 71 | /** What the picture died of, when it died: a driver's own shader log. */ | |
| 72 | failure?: string; | |
| 73 | }; | |
| 74 | ||
| 75 | /** The longest any one piece of this may be before it is cut short. */ | |
| 76 | const PIECE = 120; | |
| 77 | ||
| 78 | /** | |
| 79 | * One piece of the line: trimmed, control characters and separators removed — | |
| 80 | * a driver string with a newline in it must not become two lines, and a middle | |
| 81 | * dot in it must not read as another field — and cut to `PIECE` characters, or | |
| 82 | * to fewer where the piece carries a word of this file's own in front of it. | |
| 83 | */ | |
| 84 | function piece(text: string, most = PIECE): string { | |
| 85 | const flat = text.replace(/[\u0000-\u001f\u007f·]+/g, " ").replace(/\s+/g, " ").trim(); | |
| 86 | return flat.length > most ? `${flat.slice(0, most - 1)}…` : flat; | |
| 87 | } | |
| 88 | ||
| 89 | /** A piece with a label: `build abc1234`, and the whole of it inside `PIECE`. */ | |
| 90 | const labelled = (label: string, text: string): string => | |
| 91 | `${label} ${piece(text, PIECE - label.length - 1)}`; | |
| 92 | ||
| 93 | /** | |
| 94 | * The browser and the platform, named the short way, out of a user agent. | |
| 95 | * | |
| 96 | * A user agent is a lie by design and this does not pretend otherwise: it looks | |
| 97 | * for the handful of tokens that actually distinguish the cases this game | |
| 98 | * behaves differently in — Edge and Chrome are both Chromium and say so in that | |
| 99 | * order, Windows is where the pointer and the shortcuts differ — and falls back | |
| 100 | * to the raw string, which is the honest answer when the tokens are not there. | |
| 101 | */ | |
| 102 | export function browserOf(userAgent: string): string { | |
| 103 | const ua = piece(userAgent); | |
| 104 | const name = | |
| 105 | /\bEdg\//.test(ua) ? "Edge" | |
| 106 | : /\bOPR\//.test(ua) ? "Opera" | |
| 107 | : /\bFirefox\//.test(ua) ? "Firefox" | |
| 108 | : /\bChrome\//.test(ua) ? "Chrome" | |
| 109 | : /\bSafari\//.test(ua) ? "Safari" | |
| 110 | : null; | |
| 111 | const os = | |
| 112 | /Windows NT/.test(ua) ? "Windows" | |
| 113 | : /Mac OS X/.test(ua) ? "macOS" | |
| 114 | : /Android/.test(ua) ? "Android" | |
| 115 | : /(iPhone|iPad|iPod)/.test(ua) ? "iOS" | |
| 116 | : /Linux/.test(ua) ? "Linux" | |
| 117 | : null; | |
| 118 | if (name === null || os === null) return ua === "" ? "an unnamed browser" : ua; | |
| 119 | // Each browser keeps its own version under its own token — and Safari keeps | |
| 120 | // it under `Version/`, because `Safari/605` is the engine's build and has not | |
| 121 | // moved in years. | |
| 122 | const token = name === "Edge" ? "Edg" : name === "Safari" ? "Version" : name; | |
| 123 | const version = new RegExp(`${token}/(\\d+)`).exec(ua)?.[1]; | |
| 124 | return `${name}${version === undefined ? "" : ` ${version}`} on ${os}`; | |
| 125 | } | |
| 126 | ||
| 127 | /** | |
| 128 | * The line itself: what the browser is, then what the game is doing in it, then | |
| 129 | * what went wrong if something did. | |
| 130 | * | |
| 131 | * `run.failure` is what the boot threw, when it threw, and it goes last because | |
| 132 | * it is the longest: a refused shader's info log is the single most useful thing | |
| 133 | * in a report from a Windows machine, because ANGLE on D3D11 rejects programs a | |
| 134 | * desktop driver takes. Anything a caller leaves out of `run` is left out of the | |
| 135 | * line rather than printed as a guess — a game with no pointer to lock should | |
| 136 | * not claim one, and "not knowing" is not a field worth a person's eye. | |
| 137 | */ | |
| 138 | export function supportLine(facts: Facts, run: Running = {}): string { | |
| 139 | const parts = [ | |
| 140 | browserOf(facts.userAgent), | |
| 141 | facts.renderer === null ? "driver not named" : piece(facts.renderer), | |
| 142 | `${Math.round(facts.stage[0])}×${Math.round(facts.stage[1])} at ${dprOf(facts.dpr)}× dpr`, | |
| 143 | ]; | |
| 144 | if (run.pointer !== undefined) { | |
| 145 | parts.push(run.pointer === null ? "pointer free" : `pointer ${run.pointer}`); | |
| 146 | } | |
| 147 | if (run.keys !== undefined) parts.push(run.keys ? "Esc held" : "Esc the browser's"); | |
| 148 | if (run.fullscreen !== undefined) parts.push(run.fullscreen ? "full screen" : "no full screen"); | |
| 149 | if (run.hz !== undefined && Number.isFinite(run.hz)) parts.push(`${Math.round(run.hz)} Hz`); | |
| 150 | if (run.frameMs !== undefined && Number.isFinite(run.frameMs)) { | |
| 151 | parts.push(`${run.frameMs.toFixed(1)} ms frame`); | |
| 152 | } | |
| 153 | if (facts.commit !== undefined && piece(facts.commit) !== "") { | |
| 154 | parts.push(labelled("build", facts.commit)); | |
| 155 | } | |
| 156 | if (run.failure !== undefined && piece(run.failure) !== "") parts.push(piece(run.failure)); | |
| 157 | return parts.join(" · "); | |
| 158 | } | |
| 159 | ||
| 160 | /** The pixel ratio as it is written: 1, 1.25, 2 — never 1.2500000000000002. */ | |
| 161 | const dprOf = (dpr: number): string => | |
| 162 | Number.isFinite(dpr) && dpr > 0 ? String(Math.round(dpr * 1000) / 1000) : "1"; | |
| 163 | ||
| 164 | /** What the menu says when the picture could not be built at all. */ | |
| 165 | export const NO_PICTURE = "The picture could not be built"; | |
| 166 | ||
| 167 | /** | |
| 168 | * And the paragraph under that heading: what happened, what still works, and | |
| 169 | * what to do with the line below it. Text, escaped where it is rendered. | |
| 170 | */ | |
| 171 | export const NO_PICTURE_SAYS = | |
| 172 | "This browser has WebGL2 and then refused the game’s shaders — which is what a driver " + | |
| 173 | "does when it is asked for something it will not compile, and is far more common on Windows, " + | |
| 174 | "where the browser draws through Direct3D. Nothing else on the page is affected. The line " + | |
| 175 | "below says which browser and which driver: paste it into a bug report."; |
src/engine/GUIDE.md +80 −12
| @@ -150,7 +150,7 @@ const SPEED = 4, TURN = 120, EYE = 1.6, REACH = 40; | ||
| 150 | 150 | |
| 151 | 151 | ## What is behind the door |
| 152 | 152 | |
| 153 | **Weight**: 36.1 kB raw, 14.7 kB gzipped — every group at once, which is the most any game can pay. | |
| 153 | **Weight**: 41.1 kB raw, 16.6 kB gzipped — every group at once, which is the most any game can pay. | |
| 154 | 154 | |
| 155 | 155 | Every section below opens with what that group costs a bundle, because "what |
| 156 | 156 | does it cost to use?" is a question a page can answer with a number. The number |
| @@ -413,21 +413,42 @@ number the test moves, which is how a second of play takes a millisecond. | ||
| 413 | 413 | |
| 414 | 414 | ### controls |
| 415 | 415 | |
| 416 | **Weight**: 2.7 kB raw, 1.1 kB gzipped — including math, which comes with it. | |
| 416 | **Weight**: 3.9 kB raw, 1.7 kB gzipped — including math, which comes with it. | |
| 417 | 417 | |
| 418 | 418 | `createControls` takes the host and `Options` — the key table, the mouse table, |
| 419 | the keys whose default the page must not take, the look scales, the sensitivity, | |
| 420 | the thumb-stick region and whether the mouse is ignored until the pointer is | |
| 421 | locked — and returns `Controls`: the live held set, the `Stick`, a | |
| 419 | the wheel table, the keys whose default the page must not take, the look scales, | |
| 420 | the sensitivity, the thumb-stick region and whether the mouse is ignored until | |
| 421 | the pointer is locked — and returns `Controls`: the live held set, the `Stick`, a | |
| 422 | 422 | `Controls.takeLook` that hands over the turn asked for since you last asked and |
| 423 | then forgets it, a `Controls.locked` flag, the `Controls.onAction` and | |
| 423 | then forgets it, a `Controls.locked` flag, a `Controls.pointer` saying which | |
| 424 | counts the locked mouse is sending — a `PointerMode`: *raw* where the ask for the | |
| 425 | unadjusted movement dictionary was taken, *adjusted* where the platform's own | |
| 426 | pointer speed is in the movement a mouse event carries, *unknown* where the | |
| 427 | browser said yes without saying which, and null while there is no pointer at all, | |
| 428 | which is what a page prints in the line a bug report pastes — the | |
| 429 | `Controls.onAction` and | |
| 424 | 430 | `Controls.onUnlock` callbacks, a `Controls.setAction` for pressing an action by |
| 425 | 431 | name, and a `Controls.detach` for when the game is over. `attachButtons` wires |
| 426 | 432 | every element carrying a `data-action` attribute to `Controls.setAction`, with |
| 427 | 433 | `ButtonOptions` saying which of them are a tap rather than a hold, so a thumb |
| 428 | 434 | reaches what a keyboard does. The event shapes it reads are exported too — |
| 429 | `KeyEventish`, `MouseEventish`, `TouchEventish`, `Touchish`, `Button` — because | |
| 430 | a test drives the handlers with plain objects and no browser is in the room. | |
| 435 | `KeyEventish`, `MouseEventish`, `WheelEventish`, `TouchEventish`, `Touchish`, | |
| 436 | `Button` — because a test drives the handlers with plain objects and no browser | |
| 437 | is in the room. | |
| 438 | ||
| 439 | The mouse is the whole mouse. `Options.mouse` is keyed by `MouseEvent.button`, | |
| 440 | so 1 is the wheel pressed and 3 and 4 are the two under the thumb — which are | |
| 441 | Back and Forward to a browser on Windows, and a page that does not take their | |
| 442 | default loses the match to a stray thumb. Every button a game binds has its | |
| 443 | `mousedown` and its `auxclick` prevented while the game has the input, and every | |
| 444 | button it does not bind is left exactly as the browser had it. `Options.wheel` | |
| 445 | binds the two directions by name — `{ up: "jump", down: "jump" }` — and a turn | |
| 446 | is counted in notches rather than in the delta the browser happened to send: one | |
| 447 | click of a wheel is one press whether it arrives as a hundred pixels, as three | |
| 448 | lines or as a page, a violent flick is still one press, and a trackpad's stream | |
| 449 | of small deltas has to add up to a click's worth of travel before it is one. | |
| 450 | A notch presses the action and releases it `Options.wheelMs` later, so a polled | |
| 451 | frame sees it, the way `attachButtons` treats a tap. | |
| 431 | 452 | |
| 432 | 453 | What a game does with all that can be one line — read the held set every tick |
| 433 | 454 | and be done — and `attachButtons` is a second line whenever there are on-screen |
| @@ -493,9 +514,42 @@ that: close a panel, pause the match, let the pointer go. What this does not do | ||
| 493 | 514 | put anything back — a browser that took the screen took it inside its own gesture, |
| 494 | 515 | and only another gesture can ask again, which is your Resume button. |
| 495 | 516 | |
| 517 | ### pixels | |
| 518 | ||
| 519 | **Weight**: 0.6 kB raw, 0.3 kB gzipped — this module alone. | |
| 520 | ||
| 521 | The device pixel grid, which is two questions a game asks on every resize and | |
| 522 | every frame, and which nobody gets right by remembering to. | |
| 523 | ||
| 524 | `backing` — `backing(clientWidth, clientHeight, dpr)` — is how large a canvas's backing store | |
| 525 | should be for the box the layout gave it: whole pixels, never none, and the | |
| 526 | ratio capped at `MAX_RATIO`, which is 2 — past that the pixels cost memory and | |
| 527 | fill rate and buy nothing an eye finds at arm's length. `ratioOf` is the cap and | |
| 528 | the guard on its own, for a game that needs the number rather than the size. | |
| 529 | The renderer's own `Renderer.resize` uses it, and so should whatever 2D canvas | |
| 530 | you lay over the scene, so the two are the same grid. | |
| 531 | ||
| 532 | `px(css, ratio)` turns a length you wrote in CSS pixels into whole device | |
| 533 | pixels, never fewer than one, so a thing is the same size on the glass at 1, | |
| 534 | 1.25, 1.5 and 2. `bar` — `bar(centre, thickness)` — is the other half: where a bar that | |
| 535 | thick, centred there, has to start so that *both* of its edges land on a pixel | |
| 536 | — and how thick it ends up, because a bar of even thickness fits a centre on a | |
| 537 | pixel boundary and one of odd thickness fits a centre in the middle of a pixel, | |
| 538 | so the thickness bends by one rather than the centre moving by a half. | |
| 539 | ||
| 540 | This exists because Windows ships display scaling at 125% and 150% far more | |
| 541 | often than anything else does. At 1.25 a canvas 1281 CSS pixels wide has a | |
| 542 | 1601-pixel backing store, its centre is 800.5, and a two-pixel crosshair arm | |
| 543 | *stroked* through that centre covers three columns of pixels at two thirds of | |
| 544 | its colour each: a smeared, dim cross, on the one thing a player looks at for an | |
| 545 | hour. On a 1.0 display it is pixel-perfect, which is why it can live in a game | |
| 546 | for a long time. The rule the engine draws by is in `overlay` below: device | |
| 547 | pixels, no context transform, and everything with an edge filled on whole | |
| 548 | pixels. | |
| 549 | ||
| 496 | 550 | ### overlay |
| 497 | 551 | |
| 498 | **Weight**: 1.2 kB raw, 0.7 kB gzipped — including math, which comes with it. | |
| 552 | **Weight**: 1.8 kB raw, 1.0 kB gzipped — including pixels and math, which come with it. | |
| 499 | 553 | |
| 500 | 554 | The 2D a game draws over its scene. `overlay` takes the surface, the width and |
| 501 | 555 | the height and hands back an `Overlay`: `Overlay.scale`, the canvas width over |
| @@ -507,6 +561,17 @@ that is always put back, `Overlay.crosshair` for four arms around a centre, | ||
| 507 | 561 | right-aligned figures and `Overlay.clock` for seconds as `m:ss`. Build it on a |
| 508 | 562 | resize, not in a frame: the tools it returns allocate nothing per call. |
| 509 | 563 | |
| 564 | The width and the height it is built with are **device** pixels — a canvas's | |
| 565 | backing store — and the fourth argument is the device pixel ratio, 1 when it is | |
| 566 | left out. It draws with no context transform, because a transform is how a 1.25 | |
| 567 | display resamples everything drawn through it, and `Overlay.crosshair` fills | |
| 568 | four rectangles on whole pixels rather than stroking four lines through a | |
| 569 | centre that may be half of one. `Overlay.px` is `px` bound to that ratio, for a | |
| 570 | size you wrote in CSS pixels; `Overlay.ratio` is the ratio it settled on; and | |
| 571 | `Overlay.scale` is now two questions multiplied — how wide the canvas is *in CSS | |
| 572 | pixels* against 1280, clamped to 0.7–1.4, times that ratio — so the furniture is | |
| 573 | the same size on the glass on a dense screen instead of shrinking to 70% of it. | |
| 574 | ||
| 510 | 575 | What it draws onto is a `Paint` — the seventeen members of a 2D context these |
| 511 | 576 | tools touch, and not a whole context. A browser's is one; so is a recorder in a |
| 512 | 577 | test, which is how every pixel decision in `overlay.test.ts` is checked with no |
| @@ -623,7 +688,7 @@ in. A game reads the first two of those and leaves the ring arithmetic here. | ||
| 623 | 688 | |
| 624 | 689 | ### renderer |
| 625 | 690 | |
| 626 | **Weight**: 9.0 kB raw, 3.6 kB gzipped — including math, which comes with it. | |
| 691 | **Weight**: 9.1 kB raw, 3.7 kB gzipped — including math and pixels, which come with it. | |
| 627 | 692 | |
| 628 | 693 | The 3D under the scene, and the largest thing behind the door. `createRenderer` |
| 629 | 694 | takes the canvas and the assets and hands back a `Renderer` — or nothing at all, |
| @@ -674,7 +739,7 @@ game's. One line a frame draws it, and `src/client/main.ts` and | ||
| 674 | 739 | |
| 675 | 740 | ### page |
| 676 | 741 | |
| 677 | **Weight**: 3.1 kB raw, 1.6 kB gzipped — this module alone. | |
| 742 | **Weight**: 3.5 kB raw, 1.7 kB gzipped — this module alone. | |
| 678 | 743 | |
| 679 | 744 | How you boot your entry in a test with no browser. `fakePage` hands back a |
| 680 | 745 | `FakePage`: a `FakePage.surface`, a `FakePage.window` and a `FakePage.document` |
| @@ -691,7 +756,10 @@ and `PageOptions.actions` is one `[data-action]` element per name. | ||
| 691 | 756 | is a phone, `fullscreen: false` is a browser that will not put an element on the |
| 692 | 757 | whole screen, and `keyboardLock: false` is one with no Keyboard Lock API — which |
| 693 | 758 | takes both methods off `FakePage.keyboard`, the `FakeKeyboard` that records what |
| 694 | is locked, how often it was asked and whether it is refusing. Then `FakePage.step(ms)` runs the oldest frame that was asked for, | |
| 759 | is locked, how often it was asked and whether it is refusing. `PageOptions.metas` | |
| 760 | is the `<meta name=…>` elements the server rendered into the page, by name, which | |
| 761 | `FakeDocument.querySelector` answers for — `meta[name="build"]` is the commit a | |
| 762 | game reads back to say which deployment a bug report came from. Then `FakePage.step(ms)` runs the oldest frame that was asked for, | |
| 695 | 763 | that many milliseconds after the last one — that is the whole clock, and there |
| 696 | 764 | is no timer anywhere in the file. |
| 697 | 765 |
src/engine/controls.test.ts +281 −8
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { attachButtons, createControls, type Host } from "./controls.ts"; | |
| 2 | import { attachButtons, createControls, MAX_MOVEMENT, type Host } from "./controls.ts"; | |
| 3 | 3 | |
| 4 | 4 | // `(event: never)` is the one type every listener can be stored as: a handler |
| 5 | 5 | // written for a key event and one written for a touch both fit here, which is |
| @@ -44,7 +44,18 @@ type Stub = ReturnType<typeof stubTarget>; | ||
| 44 | 44 | const KEYS = { KeyW: "forward", KeyA: "left", Space: "jump", Tab: "scoreboard" }; |
| 45 | 45 | |
| 46 | 46 | function harness(options: Partial<Parameters<typeof createControls>[1]> = {}) { |
| 47 | const surface = Object.assign(stubTarget(), { clientWidth: 1000 }); | |
| 47 | // The browser's half of pointer lock: what each ask was given, and what the | |
| 48 | // browser answers with — nothing, by default, which is what a browser with no | |
| 49 | // promise in its `requestPointerLock` has always said. | |
| 50 | const asks: unknown[] = []; | |
| 51 | let answer: (options: unknown) => unknown = () => undefined; | |
| 52 | const surface = Object.assign(stubTarget(), { | |
| 53 | clientWidth: 1000, | |
| 54 | requestPointerLock(options?: unknown): unknown { | |
| 55 | asks.push(options); | |
| 56 | return answer(options); | |
| 57 | }, | |
| 58 | }); | |
| 48 | 59 | const win = stubTarget(); |
| 49 | 60 | const doc = Object.assign(stubTarget(), { pointerLockElement: null as unknown }); |
| 50 | 61 | const host: Host = { surface, window: win, document: doc }; |
| @@ -53,7 +64,10 @@ function harness(options: Partial<Parameters<typeof createControls>[1]> = {}) { | ||
| 53 | 64 | doc.pointerLockElement = on ? surface : null; |
| 54 | 65 | doc.emit("pointerlockchange", {}); |
| 55 | 66 | }; |
| 56 | return { controls, surface: surface as Stub & { clientWidth: number }, win, doc, lock }; | |
| 67 | const answers = (reply: (options: unknown) => unknown): void => { | |
| 68 | answer = reply; | |
| 69 | }; | |
| 70 | return { controls, surface: surface as Stub & { clientWidth: number }, win, doc, lock, asks, answers }; | |
| 57 | 71 | } |
| 58 | 72 | |
| 59 | 73 | const key = (code: string, extra: Record<string, unknown> = {}) => ({ |
| @@ -106,17 +120,64 @@ test("a repeating keydown does not fire the action again", () => { | ||
| 106 | 120 | expect([...controls.held]).toEqual(["forward"]); |
| 107 | 121 | }); |
| 108 | 122 | |
| 109 | test("Tab and Space keep their default off the page, W does not need to", () => { | |
| 110 | const { win } = harness(); | |
| 123 | const prevented = (event: unknown): boolean | undefined => | |
| 124 | (event as { prevented?: boolean }).prevented; | |
| 125 | ||
| 126 | test("Tab and Space keep their default off the game, W does not need to", () => { | |
| 127 | const { win, lock } = harness(); | |
| 128 | lock(true); | |
| 111 | 129 | const tab = key("Tab"); |
| 112 | 130 | const space = key("Space"); |
| 113 | 131 | const w = key("KeyW"); |
| 114 | 132 | win.emit("keydown", tab); |
| 115 | 133 | win.emit("keydown", space); |
| 116 | 134 | win.emit("keydown", w); |
| 117 | expect((tab as { prevented?: boolean }).prevented).toBe(true); | |
| 118 | expect((space as { prevented?: boolean }).prevented).toBe(true); | |
| 119 | expect((w as { prevented?: boolean }).prevented).toBeUndefined(); | |
| 135 | expect(prevented(tab)).toBe(true); | |
| 136 | expect(prevented(space)).toBe(true); | |
| 137 | expect(prevented(w)).toBeUndefined(); | |
| 138 | }); | |
| 139 | ||
| 140 | test("out of the lock the page keeps its keys: Tab walks the menu, Space presses", () => { | |
| 141 | const { win } = harness(); | |
| 142 | const tab = key("Tab"); | |
| 143 | const space = key("Space"); | |
| 144 | win.emit("keydown", tab); | |
| 145 | win.emit("keydown", space); | |
| 146 | expect(prevented(tab)).toBeUndefined(); | |
| 147 | expect(prevented(space)).toBeUndefined(); | |
| 148 | }); | |
| 149 | ||
| 150 | test("a game that never locks takes its keys anyway", () => { | |
| 151 | const { win } = harness({ requireLock: false }); | |
| 152 | const space = key("Space"); | |
| 153 | win.emit("keydown", space); | |
| 154 | expect(prevented(space)).toBe(true); | |
| 155 | }); | |
| 156 | ||
| 157 | test("the arrow keys scroll the page and steer the locked game", () => { | |
| 158 | const { win, lock } = harness({ keys: { ...KEYS, ArrowUp: "forward" } }); | |
| 159 | const loose = key("ArrowUp"); | |
| 160 | win.emit("keydown", loose); | |
| 161 | expect(prevented(loose)).toBeUndefined(); | |
| 162 | lock(true); | |
| 163 | const held = key("ArrowUp"); | |
| 164 | win.emit("keydown", held); | |
| 165 | expect(prevented(held)).toBe(true); | |
| 166 | }); | |
| 167 | ||
| 168 | test("a key typed into a field is the field's: no action, and no swallowed space", () => { | |
| 169 | const { controls, win, lock } = harness(); | |
| 170 | lock(true); | |
| 171 | const field = { tagName: "input" }; | |
| 172 | const space = key("Space", { target: field }); | |
| 173 | win.emit("keydown", key("KeyW", { target: field })); | |
| 174 | win.emit("keydown", space); | |
| 175 | win.emit("keydown", key("KeyA", { target: { isContentEditable: true } })); | |
| 176 | expect(controls.held.size).toBe(0); | |
| 177 | expect(prevented(space)).toBeUndefined(); | |
| 178 | // The stage is not a field, and the same key on it is the game's. | |
| 179 | win.emit("keydown", key("KeyW", { target: { tagName: "canvas" } })); | |
| 180 | expect([...controls.held]).toEqual(["forward"]); | |
| 120 | 181 | }); |
| 121 | 182 | |
| 122 | 183 | test("mouse movement accumulates and takeLook drains it exactly once", () => { |
| @@ -130,6 +191,84 @@ test("mouse movement accumulates and takeLook drains it exactly once", () => { | ||
| 130 | 191 | expect(controls.takeLook()).toEqual([0, 0]); |
| 131 | 192 | }); |
| 132 | 193 | |
| 194 | test("the pointer is asked for raw first: no operating system acceleration on the look", async () => { | |
| 195 | const { controls, asks } = harness(); | |
| 196 | expect(await controls.lock()).toBe(true); | |
| 197 | expect(asks).toEqual([{ unadjustedMovement: true }]); | |
| 198 | }); | |
| 199 | ||
| 200 | test("a browser that will not take the dictionary is asked again plainly", async () => { | |
| 201 | const { controls, asks, answers } = harness(); | |
| 202 | answers((options) => | |
| 203 | options === undefined ? Promise.resolve() : Promise.reject(new Error("unadjustedMovement"))); | |
| 204 | expect(await controls.lock()).toBe(true); | |
| 205 | expect(asks).toEqual([{ unadjustedMovement: true }, undefined]); | |
| 206 | }); | |
| 207 | ||
| 208 | test("which mouse the player got is readable, and not knowing is not a guess", async () => { | |
| 209 | // Chromium: a promise that resolves on the ask that carried the dictionary, | |
| 210 | // which is the only way a page is told it has raw counts. | |
| 211 | const raw = harness(); | |
| 212 | raw.answers(() => Promise.resolve()); | |
| 213 | expect(raw.controls.pointer).toBe(null); // nothing is locked yet | |
| 214 | expect(await raw.controls.lock()).toBe(true); | |
| 215 | raw.lock(true); | |
| 216 | expect(raw.controls.pointer).toBe("raw"); | |
| 217 | // Escape, and there is no pointer to describe any more. | |
| 218 | raw.lock(false); | |
| 219 | expect(raw.controls.pointer).toBe(null); | |
| 220 | ||
| 221 | // A browser that refuses the dictionary and takes the plain ask: the platform's | |
| 222 | // own pointer speed is in `movementX`, which is the Windows half of #307 and | |
| 223 | // the thing a bug report has to be able to say. | |
| 224 | const adjusted = harness(); | |
| 225 | adjusted.answers((options) => | |
| 226 | options === undefined ? Promise.resolve() : Promise.reject(new Error("unadjustedMovement"))); | |
| 227 | expect(await adjusted.controls.lock()).toBe(true); | |
| 228 | adjusted.lock(true); | |
| 229 | expect(adjusted.controls.pointer).toBe("adjusted"); | |
| 230 | ||
| 231 | // And a pre-promise browser, which says yes by saying nothing: it has not said | |
| 232 | // whether it read the dictionary, so neither does this. | |
| 233 | const quiet = harness(); | |
| 234 | expect(await quiet.controls.lock()).toBe(true); | |
| 235 | quiet.lock(true); | |
| 236 | expect(quiet.controls.pointer).toBe("unknown"); | |
| 237 | }); | |
| 238 | ||
| 239 | test("a refused lock is an answer, not a throw: both ways of refusing say false", async () => { | |
| 240 | const rejects = harness(); | |
| 241 | rejects.answers(() => Promise.reject(new Error("exited the lock with Escape"))); | |
| 242 | expect(await rejects.controls.lock()).toBe(false); | |
| 243 | ||
| 244 | const throws = harness(); | |
| 245 | throws.answers(() => { | |
| 246 | throw new Error("no pointer lock in this document"); | |
| 247 | }); | |
| 248 | expect(await throws.controls.lock()).toBe(false); | |
| 249 | }); | |
| 250 | ||
| 251 | test("a browser with no pointer to lock says so instead of crashing the game", async () => { | |
| 252 | const surface = Object.assign(stubTarget(), { clientWidth: 1000 }); | |
| 253 | const doc = Object.assign(stubTarget(), { pointerLockElement: null as unknown }); | |
| 254 | const controls = createControls({ surface, window: stubTarget(), document: doc }, { keys: KEYS }); | |
| 255 | expect(await controls.lock()).toBe(false); | |
| 256 | }); | |
| 257 | ||
| 258 | test("one absurd pointer event cannot fling the view", () => { | |
| 259 | const { controls, win, lock } = harness({ sensitivity: 2.2 }); | |
| 260 | lock(true); | |
| 261 | // What a browser hands a page when its own delta is wrong: a whole screen of | |
| 262 | // desk in one event, and a value that is not a number at all. | |
| 263 | win.emit("mousemove", { movementX: 900_000, movementY: Number.NaN }); | |
| 264 | const [dx, dy] = controls.takeLook(); | |
| 265 | expect(dx).toBeCloseTo(MAX_MOVEMENT * 2.2 * 0.022, 10); | |
| 266 | expect(dy).toBe(0); | |
| 267 | // A flick under the ceiling keeps every count of itself. | |
| 268 | win.emit("mousemove", { movementX: -300, movementY: 40 }); | |
| 269 | expect(controls.takeLook()[0]).toBeCloseTo(-300 * 2.2 * 0.022, 10); | |
| 270 | }); | |
| 271 | ||
| 133 | 272 | test("the mouse says nothing until the pointer is locked", () => { |
| 134 | 273 | const { controls, win, surface } = harness(); |
| 135 | 274 | win.emit("mousemove", { movementX: 100, movementY: 100 }); |
| @@ -147,6 +286,140 @@ test("the mouse buttons are bindings too", () => { | ||
| 147 | 286 | expect(controls.held.size).toBe(0); |
| 148 | 287 | }); |
| 149 | 288 | |
| 289 | test("the buttons under the thumb are the game's, not the browser's Back and Forward", () => { | |
| 290 | const { controls, win, surface, lock } = harness({ mouse: { 0: "fire", 3: "use", 4: "grenade" } }); | |
| 291 | lock(true); | |
| 292 | // Buttons 3 and 4 on Windows: Edge navigates on them unless the press is | |
| 293 | // taken, and a page that navigates mid-round is a match thrown away. | |
| 294 | const back = { button: 3, preventDefault() {}, prevented: false }; | |
| 295 | back.preventDefault = () => void (back.prevented = true); | |
| 296 | surface.emit("mousedown", back); | |
| 297 | expect(back.prevented).toBe(true); | |
| 298 | expect([...controls.held]).toEqual(["use"]); | |
| 299 | win.emit("mouseup", { button: 3, preventDefault() {} }); | |
| 300 | ||
| 301 | // The click a non-primary button fires afterwards is taken too. | |
| 302 | const aux = { button: 4, preventDefault() {}, prevented: false }; | |
| 303 | aux.preventDefault = () => void (aux.prevented = true); | |
| 304 | surface.emit("auxclick", aux); | |
| 305 | expect(aux.prevented).toBe(true); | |
| 306 | ||
| 307 | // A button this game never asked for keeps whatever the browser does with it: | |
| 308 | // a game that has not bound mouse3 does not get to break the middle click. | |
| 309 | const wheelPress = { button: 1, preventDefault() {}, prevented: false }; | |
| 310 | wheelPress.preventDefault = () => void (wheelPress.prevented = true); | |
| 311 | surface.emit("mousedown", wheelPress); | |
| 312 | const wheelClick = { button: 1, preventDefault() {}, prevented: false }; | |
| 313 | wheelClick.preventDefault = () => void (wheelClick.prevented = true); | |
| 314 | surface.emit("auxclick", wheelClick); | |
| 315 | expect(wheelPress.prevented).toBe(false); | |
| 316 | expect(wheelClick.prevented).toBe(false); | |
| 317 | expect(controls.held.size).toBe(0); | |
| 318 | }); | |
| 319 | ||
| 320 | test("out of the game the page keeps its own mouse: Back goes back", () => { | |
| 321 | const { surface } = harness({ mouse: { 3: "use" } }); | |
| 322 | const back = { button: 3, preventDefault() {}, prevented: false }; | |
| 323 | back.preventDefault = () => void (back.prevented = true); | |
| 324 | const aux = { button: 3, preventDefault() {}, prevented: false }; | |
| 325 | aux.preventDefault = () => void (aux.prevented = true); | |
| 326 | surface.emit("mousedown", back); | |
| 327 | surface.emit("auxclick", aux); | |
| 328 | expect(back.prevented).toBe(false); | |
| 329 | expect(aux.prevented).toBe(false); | |
| 330 | }); | |
| 331 | ||
| 332 | test("the wheel is a binding: a notch presses its action and lets go by itself", async () => { | |
| 333 | const { controls, win, lock } = harness({ wheel: { up: "jump", down: "jump" }, wheelMs: 5 }); | |
| 334 | lock(true); | |
| 335 | const up = { deltaY: -120, preventDefault() {}, prevented: false }; | |
| 336 | up.preventDefault = () => void (up.prevented = true); | |
| 337 | win.emit("wheel", up); | |
| 338 | // The page behind a locked game must not scroll under it. | |
| 339 | expect(up.prevented).toBe(true); | |
| 340 | expect([...controls.held]).toEqual(["jump"]); | |
| 341 | // Held long enough for a polled frame to see, and then released with no | |
| 342 | // second event: a wheel has no "up". | |
| 343 | await Bun.sleep(20); | |
| 344 | expect(controls.held.size).toBe(0); | |
| 345 | ||
| 346 | // The other way round is the same bind, and a notch of no size is no notch. | |
| 347 | win.emit("wheel", { deltaY: 120, preventDefault() {} }); | |
| 348 | expect([...controls.held]).toEqual(["jump"]); | |
| 349 | await Bun.sleep(20); | |
| 350 | win.emit("wheel", { deltaY: 0, preventDefault() {} }); | |
| 351 | expect(controls.held.size).toBe(0); | |
| 352 | }); | |
| 353 | ||
| 354 | test("a notch is a notch in pixels, in lines and in pages, and a fling is still one", async () => { | |
| 355 | const { controls, win, lock } = harness({ wheel: { down: "jump" }, wheelMs: 5 }); | |
| 356 | lock(true); | |
| 357 | let pressed = 0; | |
| 358 | controls.onAction = (_action, down) => void (down && (pressed += 1)); | |
| 359 | // The same click of the same wheel, as three browsers describe it: Chromium's | |
| 360 | // hundred pixels, Firefox's three lines, and a page. | |
| 361 | win.emit("wheel", { deltaY: 100, deltaMode: 0, preventDefault() {} }); | |
| 362 | win.emit("wheel", { deltaY: 3, deltaMode: 1, preventDefault() {} }); | |
| 363 | win.emit("wheel", { deltaY: 1, deltaMode: 2, preventDefault() {} }); | |
| 364 | expect(pressed).toBe(3); | |
| 365 | // And one violent flick of it, which some drivers send as one huge delta: one | |
| 366 | // press, not seven. | |
| 367 | win.emit("wheel", { deltaY: 700, deltaMode: 0, preventDefault() {} }); | |
| 368 | expect(pressed).toBe(4); | |
| 369 | await Bun.sleep(20); | |
| 370 | expect(controls.held.size).toBe(0); | |
| 371 | }); | |
| 372 | ||
| 373 | test("a trackpad is not twenty jumps: the small deltas add up to a notch and no more", () => { | |
| 374 | const { controls, win, lock } = harness({ wheel: { down: "jump", up: "jump" } }); | |
| 375 | lock(true); | |
| 376 | let pressed = 0; | |
| 377 | controls.onAction = (_action, down) => void (down && (pressed += 1)); | |
| 378 | // Twenty events of ten pixels is what a finger dragged down a trackpad sends. | |
| 379 | // It is two wheel clicks of travel, so it is two jumps. | |
| 380 | for (let i = 0; i < 20; i++) win.emit("wheel", { deltaY: 10, deltaMode: 0, preventDefault() {} }); | |
| 381 | expect(pressed).toBe(2); | |
| 382 | // Turning back the other way starts the count again rather than cancelling | |
| 383 | // out: half a notch down and then half a notch up is neither. | |
| 384 | pressed = 0; | |
| 385 | win.emit("wheel", { deltaY: 50, deltaMode: 0, preventDefault() {} }); | |
| 386 | win.emit("wheel", { deltaY: -50, deltaMode: 0, preventDefault() {} }); | |
| 387 | expect(pressed).toBe(0); | |
| 388 | win.emit("wheel", { deltaY: -60, deltaMode: 0, preventDefault() {} }); | |
| 389 | expect(pressed).toBe(1); | |
| 390 | }); | |
| 391 | ||
| 392 | test("the wheel outside the game scrolls the page, as a wheel does", () => { | |
| 393 | const { controls, win } = harness({ wheel: { up: "jump", down: "jump" } }); | |
| 394 | const notch = { deltaY: -120, preventDefault() {}, prevented: false }; | |
| 395 | notch.preventDefault = () => void (notch.prevented = true); | |
| 396 | win.emit("wheel", notch); | |
| 397 | expect(notch.prevented).toBe(false); | |
| 398 | expect(controls.held.size).toBe(0); | |
| 399 | expect(win.optionsFor("wheel")).toEqual({ passive: false }); | |
| 400 | }); | |
| 401 | ||
| 402 | test("a direction the game never bound is the page's scroll, locked or not", () => { | |
| 403 | const { controls, win, lock } = harness({ wheel: { down: "jump" } }); | |
| 404 | lock(true); | |
| 405 | const up = { deltaY: -400, preventDefault() {}, prevented: false }; | |
| 406 | up.preventDefault = () => void (up.prevented = true); | |
| 407 | win.emit("wheel", up); | |
| 408 | expect(up.prevented).toBe(false); | |
| 409 | expect(controls.held.size).toBe(0); | |
| 410 | }); | |
| 411 | ||
| 412 | test("detaching with a wheel notch in flight lets nothing go off afterwards", async () => { | |
| 413 | const { controls, win, lock } = harness({ wheel: { down: "jump" }, wheelMs: 5 }); | |
| 414 | lock(true); | |
| 415 | const seen: [string, boolean][] = []; | |
| 416 | win.emit("wheel", { deltaY: 120, preventDefault() {} }); | |
| 417 | controls.onAction = (action, down) => seen.push([action, down]); | |
| 418 | controls.detach(); | |
| 419 | await Bun.sleep(20); | |
| 420 | expect(seen).toEqual([]); | |
| 421 | }); | |
| 422 | ||
| 150 | 423 | test("the stick clamps to ±1 and returns to zero when the touch ends", () => { |
| 151 | 424 | const { controls, surface } = harness(); |
| 152 | 425 | surface.emit("touchstart", touchEvent([{ identifier: 1, clientX: 100, clientY: 400 }])); |
src/engine/controls.ts +266 −10
| @@ -6,6 +6,12 @@ | ||
| 6 | 6 | * half; this module only says which actions are down, how far the stick is |
| 7 | 7 | * pushed, and how much the view was asked to turn since the last frame. |
| 8 | 8 | * |
| 9 | * The pointer is asked for here as well as read here — `lock()` — because the | |
| 10 | * module that knows whether the mouse is the game's is the one that should ask | |
| 11 | * for it, and because asking properly is four lines of browser lore (raw | |
| 12 | * counts, a fallback, a refusal that is an answer rather than an exception) | |
| 13 | * that no game should be writing twice. | |
| 14 | * | |
| 9 | 15 | * Every target it listens on is a parameter, typed by the two methods it |
| 10 | 16 | * actually calls, so a stub with `addEventListener`/`removeEventListener` |
| 11 | 17 | * drives the whole thing headlessly. Every listener it adds is remembered, so |
| @@ -27,21 +33,51 @@ export type Listens = { | ||
| 27 | 33 | removeEventListener<E>(type: string, handler: (event: E) => void): void; |
| 28 | 34 | }; |
| 29 | 35 | |
| 30 | /** The element the pointer locks to and the fingers land on. */ | |
| 31 | export type Surface = Listens & { readonly clientWidth: number }; | |
| 36 | /** | |
| 37 | * The element the pointer locks to and the fingers land on. | |
| 38 | * | |
| 39 | * `requestPointerLock` is optional because a browser without one is a real | |
| 40 | * browser — an iPhone has no pointer to lock — and calling a method that is not | |
| 41 | * there is a thrown `TypeError` in the middle of starting a game. `lock()` | |
| 42 | * below is the only thing that calls it, and a browser that has not got it gets | |
| 43 | * `false` rather than an exception. | |
| 44 | * | |
| 45 | * The options dictionary is Chromium's `unadjustedMovement`: see `lock()`. | |
| 46 | */ | |
| 47 | export type Surface = Listens & { | |
| 48 | readonly clientWidth: number; | |
| 49 | requestPointerLock?(options?: { unadjustedMovement?: boolean }): unknown; | |
| 50 | }; | |
| 32 | 51 | |
| 33 | 52 | /** Whatever owns pointer lock — a `Document` fits. */ |
| 34 | 53 | export type LockOwner = Listens & { readonly pointerLockElement: unknown }; |
| 35 | 54 | |
| 36 | 55 | export type Host = { surface: Surface; window: Listens; document: LockOwner }; |
| 37 | 56 | |
| 38 | export type KeyEventish = { code: string; repeat?: boolean; preventDefault(): void }; | |
| 57 | /** | |
| 58 | * A key, as far as this module reads one. `target` is where the browser aimed | |
| 59 | * it — a text field, usually not — and it is `unknown` because the only | |
| 60 | * question asked of it is whether somebody is typing into it, which `typing` | |
| 61 | * below answers from two properties and no DOM. | |
| 62 | */ | |
| 63 | export type KeyEventish = { | |
| 64 | code: string; | |
| 65 | repeat?: boolean; | |
| 66 | target?: unknown; | |
| 67 | preventDefault(): void; | |
| 68 | }; | |
| 39 | 69 | export type MouseEventish = { |
| 40 | 70 | button?: number; |
| 41 | 71 | movementX?: number; |
| 42 | 72 | movementY?: number; |
| 43 | 73 | preventDefault(): void; |
| 44 | 74 | }; |
| 75 | /** | |
| 76 | * A wheel turn, as far as this module reads one: which way, how far, and in | |
| 77 | * whose units — `deltaMode` is 0 for pixels, 1 for lines and 2 for pages, and a | |
| 78 | * browser picks whichever it likes for the same physical notch. See `NOTCH`. | |
| 79 | */ | |
| 80 | export type WheelEventish = { deltaY?: number; deltaMode?: number; preventDefault(): void }; | |
| 45 | 81 | export type Touchish = { identifier: number; clientX: number; clientY: number }; |
| 46 | 82 | export type TouchEventish = { changedTouches: ArrayLike<Touchish>; preventDefault(): void }; |
| 47 | 83 | |
| @@ -50,12 +86,27 @@ export type Stick = { readonly x: number; readonly y: number }; | ||
| 50 | 86 | export type Options = { |
| 51 | 87 | /** `KeyboardEvent.code` to action name. Anything unbound is ignored. */ |
| 52 | 88 | keys: Record<string, string>; |
| 53 | /** `MouseEvent.button` to action name; 0 is left, 2 is right. */ | |
| 89 | /** | |
| 90 | * `MouseEvent.button` to action name; 0 is left, 1 the wheel pressed, 2 the | |
| 91 | * right one, and 3 and 4 the two under the thumb — which are Back and Forward | |
| 92 | * to a browser on Windows, and are bound here so that they are the game's. | |
| 93 | */ | |
| 54 | 94 | mouse?: Record<number, string>; |
| 95 | /** | |
| 96 | * The wheel, by direction: `{ up: "jump", down: "jump" }` is the bind half of | |
| 97 | * the world plays with. A notch is an edge and not a hold, so the action is | |
| 98 | * pressed and released `wheelMs` later — long enough for a frame to see it. | |
| 99 | * A direction nothing is bound to is left to the browser, scroll and all. | |
| 100 | */ | |
| 101 | wheel?: Record<string, string>; | |
| 102 | /** How long a wheel notch holds its action down, in milliseconds. */ | |
| 103 | wheelMs?: number; | |
| 55 | 104 | /** Codes whose default the page must not take — Tab moves focus, Space scrolls. */ |
| 56 | 105 | preventKeys?: readonly string[]; |
| 57 | 106 | /** Degrees of turn per unit of pointer movement, scaled by `sensitivity`. */ |
| 58 | 107 | lookScale?: number; |
| 108 | /** Most counts one pointer event may be worth. See `MAX_MOVEMENT`. */ | |
| 109 | maxMove?: number; | |
| 59 | 110 | /** The same, for a finger dragged across the surface. */ |
| 60 | 111 | touchLookScale?: number; |
| 61 | 112 | sensitivity?: number; |
| @@ -67,6 +118,20 @@ export type Options = { | ||
| 67 | 118 | requireLock?: boolean; |
| 68 | 119 | }; |
| 69 | 120 | |
| 121 | /** | |
| 122 | * Which counts a locked pointer is sending. | |
| 123 | * | |
| 124 | * - `"raw"` — the ask for `unadjustedMovement` was answered with a promise and | |
| 125 | * that promise resolved, which is Chromium saying it took the dictionary. | |
| 126 | * - `"adjusted"` — that ask failed and the plain one was the one taken, so | |
| 127 | * `movementX` arrives with the platform's pointer speed and acceleration in it. | |
| 128 | * - `"unknown"` — the ask returned nothing at all, which is what a | |
| 129 | * pre-promise `requestPointerLock` does for everything: it has said yes and | |
| 130 | * has not said whether it read the dictionary. Not knowing is reported as not | |
| 131 | * knowing rather than guessed at. | |
| 132 | */ | |
| 133 | export type PointerMode = "raw" | "adjusted" | "unknown"; | |
| 134 | ||
| 70 | 135 | export type Controls = { |
| 71 | 136 | /** The actions held right now. Live: read it every frame. */ |
| 72 | 137 | readonly held: ReadonlySet<string>; |
| @@ -76,6 +141,20 @@ export type Controls = { | ||
| 76 | 141 | takeLook(): [number, number]; |
| 77 | 142 | sensitivity: number; |
| 78 | 143 | readonly locked: boolean; |
| 144 | /** | |
| 145 | * How the pointer the game has now was granted, and null while it has none. | |
| 146 | * Live, like `locked`: a page can say which mouse the player is actually | |
| 147 | * aiming with, which is the difference between a Windows machine that plays | |
| 148 | * like a Mac and one that does not. See `PointerMode`. | |
| 149 | */ | |
| 150 | readonly pointer: PointerMode | null; | |
| 151 | /** | |
| 152 | * Ask for the pointer, inside the gesture that wants it. True when the | |
| 153 | * browser took the request, false when it refused or has no pointer lock at | |
| 154 | * all — a refusal a caller can say something about instead of a match that | |
| 155 | * runs with a dead mouse. See `lock` in `createControls` for what is asked. | |
| 156 | */ | |
| 157 | lock(): Promise<boolean>; | |
| 79 | 158 | /** Press or release an action by name — how an on-screen button speaks. */ |
| 80 | 159 | setAction(action: string, down: boolean): void; |
| 81 | 160 | /** Every press and release, however it arrived. */ |
| @@ -85,7 +164,66 @@ export type Controls = { | ||
| 85 | 164 | detach(): void; |
| 86 | 165 | }; |
| 87 | 166 | |
| 88 | const PREVENT_KEYS = ["Tab", "Space"] as const; | |
| 167 | /** | |
| 168 | * The bound keys whose browser meaning would fight the game: Tab moves the | |
| 169 | * focus, Space and the arrows scroll the page. Every one of them is a key these | |
| 170 | * games bind to something, and the default is only ever taken while the game | |
| 171 | * actually has the input — see `engaged` below — so a person reading the page | |
| 172 | * around the stage still scrolls it with the keyboard. | |
| 173 | */ | |
| 174 | const PREVENT_KEYS = ["Tab", "Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"] as const; | |
| 175 | ||
| 176 | /** | |
| 177 | * Most counts one pointer event may be worth, per axis. | |
| 178 | * | |
| 179 | * A hand does not move a mouse four thousand counts between two events: at | |
| 180 | * 1600 dpi that is a quarter of a metre of desk inside one frame, and at this | |
| 181 | * package's default of 0.022°/count and a sensitivity of 2.2 it is already a | |
| 182 | * half turn. A number past it is the platform's rather than the player's — | |
| 183 | * Windows adds its own pointer ballistics to `movementX` unless a page asks for | |
| 184 | * raw counts, and a lock that has just engaged can carry the whole distance | |
| 185 | * from wherever the cursor was — and one of those must not fling the view | |
| 186 | * across the map. Clamped rather than dropped: a real fast flick keeps every | |
| 187 | * degree up to the ceiling. | |
| 188 | */ | |
| 189 | export const MAX_MOVEMENT = 4000; | |
| 190 | ||
| 191 | /** | |
| 192 | * How much of a wheel's own unit is one notch, by `deltaMode`: pixels, lines, | |
| 193 | * pages. | |
| 194 | * | |
| 195 | * One click of a wheel is one jump, and a browser is free to describe that | |
| 196 | * click as 100 pixels (Chromium), as three lines (Firefox on Windows) or as a | |
| 197 | * page. A trackpad is the other half of the reason this table exists: it sends | |
| 198 | * a stream of small pixel deltas, and a finger dragged down it would be twenty | |
| 199 | * jumps if every event were a notch. So the deltas are summed in notches and an | |
| 200 | * action is pressed each time the sum passes one — a click is a click whatever | |
| 201 | * it is measured in, a flick of the wheel worth three notches is still one | |
| 202 | * press, and a trackpad has to travel a wheel click's distance to be one. | |
| 203 | */ | |
| 204 | const NOTCH = [100, 3, 1] as const; | |
| 205 | ||
| 206 | /** The unit one notch is in, for a `deltaMode` this module has not heard of. */ | |
| 207 | const notchOf = (mode: number | undefined): number => NOTCH[mode ?? 0] ?? NOTCH[0]; | |
| 208 | ||
| 209 | /** | |
| 210 | * Whether a key belongs to whatever it was aimed at rather than to the game. A | |
| 211 | * name being typed into the menu is not a player running west: the games here | |
| 212 | * listen on the window, so without this every letter of a name is also a | |
| 213 | * binding, `preventDefault` eats the space bar between two words, and Tab | |
| 214 | * cannot leave the field it is in. | |
| 215 | * | |
| 216 | * Read off the two properties every browser puts on such an element and nothing | |
| 217 | * else — no `instanceof`, no DOM — so a plain object in a test is a text field | |
| 218 | * by saying it is one. | |
| 219 | */ | |
| 220 | function typing(target: unknown): boolean { | |
| 221 | if (typeof target !== "object" || target === null) return false; | |
| 222 | const node = target as { tagName?: unknown; isContentEditable?: unknown }; | |
| 223 | if (node.isContentEditable === true) return true; | |
| 224 | const tag = typeof node.tagName === "string" ? node.tagName.toUpperCase() : ""; | |
| 225 | return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; | |
| 226 | } | |
| 89 | 227 | |
| 90 | 228 | export function createControls(host: Host, options: Options): Controls { |
| 91 | 229 | const keys = options.keys; |
| @@ -96,12 +234,21 @@ export function createControls(host: Host, options: Options): Controls { | ||
| 96 | 234 | const stickRegion = options.stickRegion ?? 0.38; |
| 97 | 235 | const stickRadius = options.stickRadius ?? 60; |
| 98 | 236 | const requireLock = options.requireLock ?? true; |
| 237 | const maxMove = options.maxMove ?? MAX_MOVEMENT; | |
| 238 | const wheel = options.wheel ?? {}; | |
| 239 | const wheelMs = options.wheelMs ?? 120; | |
| 99 | 240 | |
| 100 | 241 | const held = new Set<string>(); |
| 242 | /** The release a wheel notch has pending, by action: one per action, restarted. */ | |
| 243 | const wheelTimers = new Map<string, ReturnType<typeof setTimeout>>(); | |
| 244 | /** How far the wheel has turned since the last notch, in notches. See `NOTCH`. */ | |
| 245 | let wheelTurn = 0; | |
| 101 | 246 | const stick = { x: 0, y: 0 }; |
| 102 | 247 | let lookX = 0; |
| 103 | 248 | let lookY = 0; |
| 104 | 249 | let locked = false; |
| 250 | /** How the last successful ask for the pointer was answered. See `PointerMode`. */ | |
| 251 | let granted: PointerMode | null = null; | |
| 105 | 252 | |
| 106 | 253 | // Every listener, so detach can undo exactly what attach did. |
| 107 | 254 | const added: [Listens, string, (event: never) => void][] = []; |
| @@ -128,6 +275,10 @@ export function createControls(host: Host, options: Options): Controls { | ||
| 128 | 275 | get locked() { |
| 129 | 276 | return locked; |
| 130 | 277 | }, |
| 278 | get pointer() { | |
| 279 | return locked ? granted : null; | |
| 280 | }, | |
| 281 | lock, | |
| 131 | 282 | setAction(action, down) { |
| 132 | 283 | if (down) held.add(action); |
| 133 | 284 | else held.delete(action); |
| @@ -138,6 +289,10 @@ export function createControls(host: Host, options: Options): Controls { | ||
| 138 | 289 | detach() { |
| 139 | 290 | for (const [target, type, handler] of added) target.removeEventListener(type, handler); |
| 140 | 291 | added.length = 0; |
| 292 | // A wheel notch has a release in flight; a detached controls must not | |
| 293 | // fire it at a game that has stopped listening. | |
| 294 | for (const timer of wheelTimers.values()) clearTimeout(timer); | |
| 295 | wheelTimers.clear(); | |
| 141 | 296 | }, |
| 142 | 297 | }; |
| 143 | 298 | |
| @@ -145,10 +300,19 @@ export function createControls(host: Host, options: Options): Controls { | ||
| 145 | 300 | on(host.window, "keydown", (event: KeyEventish) => { |
| 146 | 301 | const action = keys[event.code]; |
| 147 | 302 | if (action === undefined) return; |
| 148 | if (preventKeys.has(event.code)) event.preventDefault(); | |
| 303 | // Somebody is typing. The key is theirs, and the game hears nothing: not | |
| 304 | // the action, and not the `preventDefault` that would swallow a space. | |
| 305 | if (typing(event.target)) return; | |
| 306 | // The browser's meaning, taken only while the game has the input. Out of | |
| 307 | // the lock the page is a page: Tab walks the menu, Space presses what it | |
| 308 | // walked to, and the arrows scroll the words under the stage. | |
| 309 | if (engaged() && preventKeys.has(event.code)) event.preventDefault(); | |
| 149 | 310 | if (event.repeat === true) return; // a held key repeats; the action is already down |
| 150 | 311 | controls.setAction(action, true); |
| 151 | 312 | }); |
| 313 | // A release is heard wherever it lands, typed into or not: a key that went | |
| 314 | // down on the stage and came up somewhere else must not stay down. It is the | |
| 315 | // safe direction — releasing what was never pressed costs nothing. | |
| 152 | 316 | on(host.window, "keyup", (event: KeyEventish) => { |
| 153 | 317 | const action = keys[event.code]; |
| 154 | 318 | if (action !== undefined) controls.setAction(action, false); |
| @@ -163,9 +327,22 @@ export function createControls(host: Host, options: Options): Controls { | ||
| 163 | 327 | }); |
| 164 | 328 | on(host.surface, "mousedown", (event: MouseEventish) => { |
| 165 | 329 | if (requireLock && !locked) return; |
| 166 | event.preventDefault(); | |
| 167 | 330 | const action = mouse[event.button ?? 0]; |
| 168 | if (action !== undefined) controls.setAction(action, true); | |
| 331 | if (action === undefined) return; // an unbound button is still the browser's | |
| 332 | // A bound button belongs to the game, and buttons 3 and 4 are Back and | |
| 333 | // Forward to a browser on Windows: a thumb that finds one mid-round would | |
| 334 | // otherwise leave the page, taking the match, the round and the score with | |
| 335 | // it. This is what stops the navigation; the table is what gives the button | |
| 336 | // something to do instead. | |
| 337 | event.preventDefault(); | |
| 338 | controls.setAction(action, true); | |
| 339 | }); | |
| 340 | // The click a non-primary button fires after its press. Chromium navigates on | |
| 341 | // it as well as on the press, and the wheel pressed is where a page gets its | |
| 342 | // scroll cursor — so for the buttons this game asked for, neither happens | |
| 343 | // while it has the mouse. A button it did not ask for keeps both. | |
| 344 | on(host.surface, "auxclick", (event: MouseEventish) => { | |
| 345 | if (engaged() && mouse[event.button ?? 0] !== undefined) event.preventDefault(); | |
| 169 | 346 | }); |
| 170 | 347 | on(host.window, "mouseup", (event: MouseEventish) => { |
| 171 | 348 | const action = mouse[event.button ?? 0]; |
| @@ -174,9 +351,39 @@ export function createControls(host: Host, options: Options): Controls { | ||
| 174 | 351 | on(host.surface, "contextmenu", (event: MouseEventish) => event.preventDefault()); |
| 175 | 352 | on(host.window, "mousemove", (event: MouseEventish) => { |
| 176 | 353 | if (requireLock && !locked) return; |
| 177 | lookX += (event.movementX ?? 0) * controls.sensitivity * lookScale; | |
| 178 | lookY += (event.movementY ?? 0) * controls.sensitivity * lookScale; | |
| 354 | // Counts first, clamped, and degrees after: the ceiling is the mouse's own | |
| 355 | // unit, so it means the same thing at every sensitivity. See `MAX_MOVEMENT`. | |
| 356 | lookX += counts(event.movementX) * controls.sensitivity * lookScale; | |
| 357 | lookY += counts(event.movementY) * controls.sensitivity * lookScale; | |
| 179 | 358 | }); |
| 359 | // The wheel, which is how a lot of people jump. Not passive: a page that | |
| 360 | // scrolls behind a locked game is a page that has moved when the game gives | |
| 361 | // the pointer back, so a bound turn is the game's and the scroll with it. | |
| 362 | on(host.window, "wheel", (event: WheelEventish) => { | |
| 363 | if (!engaged()) return; | |
| 364 | const delta = event.deltaY ?? 0; | |
| 365 | if (delta === 0) return; | |
| 366 | const action = wheel[delta < 0 ? "up" : "down"]; | |
| 367 | if (action === undefined) return; // an unbound direction still scrolls | |
| 368 | event.preventDefault(); | |
| 369 | // Turning the other way is a new gesture, not the rest of this one. | |
| 370 | if (wheelTurn !== 0 && (wheelTurn < 0) !== (delta < 0)) wheelTurn = 0; | |
| 371 | wheelTurn += delta / notchOf(event.deltaMode); | |
| 372 | // The slack is floating point and nothing else: ten events of a tenth of a | |
| 373 | // notch sum to 0.9999999999999999, and a player who has turned the wheel a | |
| 374 | // whole click must not be told they have not. | |
| 375 | if (Math.abs(wheelTurn) + 1e-9 < 1) return; | |
| 376 | wheelTurn = 0; | |
| 377 | // A notch is an edge and a frame is polled, so the action is held long | |
| 378 | // enough for one to see it — the same shape `attachButtons` gives a tap. | |
| 379 | controls.setAction(action, true); | |
| 380 | const timer = wheelTimers.get(action); | |
| 381 | if (timer !== undefined) clearTimeout(timer); | |
| 382 | wheelTimers.set(action, setTimeout(() => { | |
| 383 | wheelTimers.delete(action); | |
| 384 | controls.setAction(action, false); | |
| 385 | }, wheelMs)); | |
| 386 | }, false); | |
| 180 | 387 | on(host.window, "blur", clearHeld); |
| 181 | 388 | |
| 182 | 389 | // --- touch: the left of the surface is a stick, the rest of it looks around |
| @@ -226,6 +433,55 @@ export function createControls(host: Host, options: Options): Controls { | ||
| 226 | 433 | for (const action of Array.from(held)) controls.setAction(action, false); |
| 227 | 434 | } |
| 228 | 435 | |
| 436 | /** Whether the keyboard is the game's right now rather than the page's. */ | |
| 437 | function engaged(): boolean { | |
| 438 | return locked || !requireLock; | |
| 439 | } | |
| 440 | ||
| 441 | /** One event's movement on one axis: a finite number of counts, clamped. */ | |
| 442 | function counts(movement: number | undefined): number { | |
| 443 | const raw = movement ?? 0; | |
| 444 | return Number.isFinite(raw) ? clamp(raw, -maxMove, maxMove) : 0; | |
| 445 | } | |
| 446 | ||
| 447 | /** | |
| 448 | * Ask the browser for the pointer, the way a shooter wants it. | |
| 449 | * | |
| 450 | * `unadjustedMovement` is the first ask and the whole point of asking twice: | |
| 451 | * without it Chromium on Windows hands a page `movementX` with the operating | |
| 452 | * system's pointer acceleration already applied, so the same flick of the | |
| 453 | * same mouse is worth a different angle depending on how fast it was flicked | |
| 454 | * and the sensitivity in the menu is not the sensitivity you get. With it the | |
| 455 | * page is given raw counts. Chromium takes the dictionary; a browser that | |
| 456 | * does not either ignores it or rejects the request, so a refusal is asked | |
| 457 | * again plainly rather than left as the answer. | |
| 458 | * | |
| 459 | * Every failure is a `false`, never a throw and never an unhandled rejection: | |
| 460 | * a browser with no pointer lock, one that will not give it (Chromium refuses | |
| 461 | * for about a second after Escape let it go, which is exactly the moment a | |
| 462 | * paused game asks for it back), one that throws where it should reject. | |
| 463 | */ | |
| 464 | async function lock(): Promise<boolean> { | |
| 465 | const surface = host.surface; | |
| 466 | if (typeof surface.requestPointerLock !== "function") return false; | |
| 467 | const asked = async (options?: { unadjustedMovement: boolean }): Promise<boolean> => { | |
| 468 | try { | |
| 469 | const answer = surface.requestPointerLock?.(options); | |
| 470 | // A promise is Chromium's answer and its rejection is a refusal; a | |
| 471 | // browser that answers with nothing has said yes, which is all a | |
| 472 | // pre-promise `requestPointerLock` ever said — and all it says about the | |
| 473 | // dictionary either, which is why that case is `"unknown"`. | |
| 474 | const promised = answer instanceof Promise; | |
| 475 | if (promised) await answer; | |
| 476 | granted = options === undefined ? "adjusted" : promised ? "raw" : "unknown"; | |
| 477 | return true; | |
| 478 | } catch { | |
| 479 | return false; | |
| 480 | } | |
| 481 | }; | |
| 482 | return (await asked({ unadjustedMovement: true })) || (await asked()); | |
| 483 | } | |
| 484 | ||
| 229 | 485 | return controls; |
| 230 | 486 | } |
| 231 | 487 |
src/engine/escape.ts +19 −4
| @@ -5,8 +5,8 @@ | ||
| 5 | 5 | * a player sees as their window being thrown away mid-round. |
| 6 | 6 | * |
| 7 | 7 | * There is exactly one way to ask for the key rather than the meaning, and this |
| 8 | * module is the three lines of it: the Keyboard Lock API. `keyboard.lock(["Escape"])` | |
| 9 | * says "deliver that key to the page while it is full screen"; the browser then | |
| 8 | * module is the three lines of it: the Keyboard Lock API. `keyboard.lock(codes)` | |
| 9 | * says "deliver those keys to the page while it is full screen"; the browser then | |
| 10 | 10 | * only leaves full screen when Escape is *held*, which is the behaviour the |
| 11 | 11 | * platform intends and the one a game wants. It is Chrome's and Edge's; Safari |
| 12 | 12 | * and Firefox do not have it, so `supported` says so and a game that asks gets |
| @@ -73,7 +73,22 @@ export type Escape = { | ||
| 73 | 73 | detach(): void; |
| 74 | 74 | }; |
| 75 | 75 | |
| 76 | export function createEscape(host: EscapeHost, screen: Screen): Escape { | |
| 76 | /** | |
| 77 | * The keys this page asks for, Escape and whatever else a game names, with no | |
| 78 | * key twice. A game names the rest because on Windows the browser's own | |
| 79 | * shortcuts are built out of Ctrl and a letter — and Ctrl is crouch: Ctrl+R | |
| 80 | * reloads a match away, Ctrl+W closes the tab it was in, Ctrl+1 changes tab. | |
| 81 | * (On a Mac those are Command, which no game binds, which is why this is a | |
| 82 | * Windows bug and not one anybody saw here.) A locked key is delivered to the | |
| 83 | * page instead, but only while the page is full screen, which is the only place | |
| 84 | * a browser will hand its own shortcuts over. | |
| 85 | */ | |
| 86 | export function createEscape( | |
| 87 | host: EscapeHost, | |
| 88 | screen: Screen, | |
| 89 | also: readonly string[] = [], | |
| 90 | ): Escape { | |
| 91 | const codes = [...new Set([ESCAPE, ...also])]; | |
| 77 | 92 | const keyboard = host.keyboard; |
| 78 | 93 | const supported = typeof keyboard?.lock === "function" && typeof keyboard?.unlock === "function"; |
| 79 | 94 | // Whether the last request was granted. `held` is this *and* the screen: the |
| @@ -101,7 +116,7 @@ export function createEscape(host: EscapeHost, screen: Screen): Escape { | ||
| 101 | 116 | try { |
| 102 | 117 | // A promise nobody awaits still rejects loudly, and a refusal here is not |
| 103 | 118 | // news: it is a browser saying the key stays its own. |
| 104 | const asking = keyboard?.lock?.([ESCAPE]); | |
| 119 | const asking = keyboard?.lock?.(codes); | |
| 105 | 120 | // Only the current request may claim the key: a stale one says nothing at |
| 106 | 121 | // all, rather than answering for the lock that replaced it. |
| 107 | 122 | if (asking instanceof Promise) asking.then(() => void (mine === asked && (granted = true)), () => {}); |
src/engine/guide.test.ts +2 −1
| @@ -64,8 +64,9 @@ const NOT_THE_ENGINES = new Set([ | ||
| 64 | 64 | "AudioContext", "DynamicsCompressorNode", "Float32Array", "Float64Array", "GainNode", |
| 65 | 65 | "Int32Array", "StereoPannerNode", |
| 66 | 66 | "TEXTURE_2D_ARRAY", |
| 67 | "addEventListener", | |
| 67 | "addEventListener", "auxclick", | |
| 68 | 68 | "cancelAnimationFrame", "createDynamicsCompressor", "createStereoPanner", "de_forge", |
| 69 | "mousedown", | |
| 69 | 70 | "fullscreenElement", |
| 70 | 71 | "pointerLockElement", |
| 71 | 72 | "removeEventListener", "requestAnimationFrame", "uniformMatrix4fv", |
src/engine/index.ts +8 −1
| @@ -59,7 +59,7 @@ export type { Frames, Loop, LoopOptions } from "./loop.ts"; | ||
| 59 | 59 | export { attachButtons, createControls } from "./controls.ts"; |
| 60 | 60 | export type { |
| 61 | 61 | Button, ButtonOptions, Controls, Host, KeyEventish, Listens, LockOwner, MouseEventish, |
| 62 | Options, Stick, Surface, TouchEventish, Touchish, | |
| 62 | Options, PointerMode, Stick, Surface, TouchEventish, Touchish, WheelEventish, | |
| 63 | 63 | } from "./controls.ts"; |
| 64 | 64 | |
| 65 | 65 | /** The stage on the whole screen: whether this browser will, whether it is, and the way in and out. */ |
| @@ -77,6 +77,13 @@ export type { Escape, EscapeHost, KeyboardLock, Screen } from "./escape.ts"; | ||
| 77 | 77 | export { overlay } from "./overlay.ts"; |
| 78 | 78 | export type { Overlay, Paint } from "./overlay.ts"; |
| 79 | 79 | |
| 80 | /** | |
| 81 | * The device pixel grid: how big a canvas's backing store is for its box, and | |
| 82 | * where a bar of pixels starts so both its edges land on one. Windows at 125% | |
| 83 | * is the reason this is arithmetic and not a rounding somebody remembered. | |
| 84 | */ | |
| 85 | export { backing, bar, MAX_RATIO, px, ratioOf } from "./pixels.ts"; | |
| 86 | ||
| 80 | 87 | /** A frame as data, in a pool: made once at a capacity, refilled, never grown. */ |
| 81 | 88 | export { createScene, nextInstance, pushLine, pushShadow, resetScene } from "./scene.ts"; |
| 82 | 89 | export type { Camera, Instance, Scene, SceneCapacity, Shadow } from "./scene.ts"; |
src/engine/overlay.test.ts +63 −14
| @@ -38,9 +38,9 @@ function recorder(): Paint & { calls: Call[]; texts: string[]; points: Call[] } | ||
| 38 | 38 | } |
| 39 | 39 | |
| 40 | 40 | /** A tool set on a recorder, since every test below wants both. */ |
| 41 | const tools = (width = 1280, height = 720) => { | |
| 41 | const tools = (width = 1280, height = 720, dpr = 1) => { | |
| 42 | 42 | const paint = recorder(); |
| 43 | return { paint, o: overlay(paint, width, height) }; | |
| 43 | return { paint, o: overlay(paint, width, height, dpr) }; | |
| 44 | 44 | }; |
| 45 | 45 | |
| 46 | 46 | const rects = (paint: ReturnType<typeof recorder>): Call[] => |
| @@ -116,26 +116,75 @@ test("a faded line leaves the context solid, even when it draws nothing", () => | ||
| 116 | 116 | expect(paint.globalAlpha).toBe(1); |
| 117 | 117 | }); |
| 118 | 118 | |
| 119 | test("the crosshair strokes four arms, each between the gap and the gap plus the arm", () => { | |
| 119 | test("the crosshair fills four arms, each between the gap and the gap plus the arm", () => { | |
| 120 | 120 | const { paint, o } = tools(); |
| 121 | 121 | o.crosshair(200, 100, 5, 7, "#8ea"); |
| 122 | expect(paint.strokeStyle).toBe("#8ea"); | |
| 123 | expect(paint.calls.filter((c) => c[0] === "beginPath").length).toBe(1); | |
| 124 | expect(paint.calls.filter((c) => c[0] === "stroke").length).toBe(1); | |
| 125 | expect(paint.points).toEqual([ | |
| 126 | ["moveTo", 212, 100], ["lineTo", 205, 100], | |
| 127 | ["moveTo", 188, 100], ["lineTo", 195, 100], | |
| 128 | ["moveTo", 200, 112], ["lineTo", 200, 105], | |
| 129 | ["moveTo", 200, 88], ["lineTo", 200, 95], | |
| 122 | // Filled, not stroked: a stroke is centred on its line and a line through a | |
| 123 | // fractional centre is grey on both sides of where it should be. | |
| 124 | expect(paint.fillStyle).toBe("#8ea"); | |
| 125 | expect(paint.calls.some((c) => c[0] === "stroke")).toBe(false); | |
| 126 | expect(rects(paint)).toEqual([ | |
| 127 | ["fillRect", 205, 99, 7, 2], // right: from the gap out, two pixels thick | |
| 128 | ["fillRect", 188, 99, 7, 2], // left, the same distance the other way | |
| 129 | ["fillRect", 199, 105, 2, 7], // down | |
| 130 | ["fillRect", 199, 88, 2, 7], // up | |
| 130 | 131 | ]); |
| 131 | 132 | }); |
| 132 | 133 | |
| 133 | 134 | test("no gap is a plain cross: the four arms meet at the centre", () => { |
| 134 | 135 | const { paint, o } = tools(); |
| 135 | 136 | o.crosshair(100, 100, 0, 10, "#fff"); |
| 136 | expect(paint.points.filter((c) => c[1] === 100 && c[2] === 100).length).toBe(4); | |
| 137 | expect(paint.points).toContainEqual(["moveTo", 100, 90]); | |
| 138 | expect(paint.points).toContainEqual(["moveTo", 110, 100]); | |
| 137 | expect(rects(paint)).toEqual([ | |
| 138 | ["fillRect", 100, 99, 10, 2], | |
| 139 | ["fillRect", 90, 99, 10, 2], | |
| 140 | ["fillRect", 99, 100, 2, 10], | |
| 141 | ["fillRect", 99, 90, 2, 10], | |
| 142 | ]); | |
| 143 | }); | |
| 144 | ||
| 145 | test("the crosshair is whole device pixels and centred, at every ratio", () => { | |
| 146 | // The five ratios that matter: a plain screen, Windows at 125% and 150%, a | |
| 147 | // retina laptop, and a phone — which the backing store caps at 2 anyway. | |
| 148 | for (const dpr of [1, 1.25, 1.5, 2, 3]) { | |
| 149 | // Both an even canvas, whose centre is a pixel boundary, and an odd one, | |
| 150 | // whose centre is the middle of the middle pixel. | |
| 151 | for (const width of [Math.round(1280 * dpr), Math.round(1280 * dpr) + 1]) { | |
| 152 | const height = width - 1; | |
| 153 | const { paint, o } = tools(width, height, dpr); | |
| 154 | o.crosshair(width / 2, height / 2, o.px(4), o.px(7), "#fff"); | |
| 155 | const drawn = rects(paint); | |
| 156 | expect(drawn.length).toBe(4); | |
| 157 | for (const [, x, y, w, h] of drawn) { | |
| 158 | for (const n of [x, y, w, h]) expect(Number.isInteger(n)).toBe(true); | |
| 159 | expect(w as number).toBeGreaterThan(0); | |
| 160 | expect(h as number).toBeGreaterThan(0); | |
| 161 | } | |
| 162 | // Symmetry: the right arm's near edge and the left arm's far edge are the | |
| 163 | // same distance from the true centre, and so are the down and up arms. | |
| 164 | const [right, left, down, up] = drawn as [Call, Call, Call, Call]; | |
| 165 | const cx = width / 2, cy = height / 2; | |
| 166 | expect((right[1] as number) - cx).toBe(cx - ((left[1] as number) + (left[3] as number))); | |
| 167 | expect((down[2] as number) - cy).toBe(cy - ((up[2] as number) + (up[4] as number))); | |
| 168 | // And the arms straddle the centre: as much of the bar on one side as the | |
| 169 | // other, so the cross does not lean at 1.25 the way a stroke did. | |
| 170 | expect((right[2] as number) + (right[4] as number) / 2).toBe(cy); | |
| 171 | expect((down[1] as number) + (down[3] as number) / 2).toBe(cx); | |
| 172 | } | |
| 173 | } | |
| 174 | }); | |
| 175 | ||
| 176 | test("a crosshair is the same size on the glass whatever the screen is made of", () => { | |
| 177 | // Four CSS pixels of arm are four device pixels at 1 and eight at 2: the same | |
| 178 | // length of glass. Before this they were four pixels either way, so the whole | |
| 179 | // cross was half the size on the screen where each pixel is half as wide. | |
| 180 | expect(tools(1280, 720, 1).o.px(4)).toBe(4); | |
| 181 | expect(tools(1600, 900, 1.25).o.px(4)).toBe(5); | |
| 182 | expect(tools(1920, 1080, 1.5).o.px(4)).toBe(6); | |
| 183 | expect(tools(2560, 1440, 2).o.px(4)).toBe(8); | |
| 184 | // And the arms are thicker with the pixels: two CSS pixels, not two of them. | |
| 185 | const { paint, o } = tools(2560, 1440, 2); | |
| 186 | o.crosshair(o.width / 2, o.height / 2, 0, 8, "#fff"); | |
| 187 | expect(rects(paint)[0]).toEqual(["fillRect", 1280, 718, 8, 4]); | |
| 139 | 188 | }); |
| 140 | 189 | |
| 141 | 190 | test("a bar fills the fraction of its width, and only ever a fraction of it", () => { |
src/engine/overlay.ts +66 −17
| @@ -12,12 +12,20 @@ | ||
| 12 | 12 | * than `string` because a real context's `fillStyle` is a string *or* a |
| 13 | 13 | * gradient *or* a pattern, and a narrower type here would force a cast there. |
| 14 | 14 | * |
| 15 | * `overlay(paint, width, height)` is built once — on a resize, not in a frame — | |
| 16 | * and the tools it hands back allocate nothing per call. It holds no colour a | |
| 17 | * game chose beyond one neutral scrim, and no word from either game. | |
| 15 | * `overlay(paint, width, height, ratio)` is built once — on a resize, not in a | |
| 16 | * frame — and the tools it hands back allocate nothing per call. It holds no | |
| 17 | * colour a game chose beyond one neutral scrim, and no word from either game. | |
| 18 | * | |
| 19 | * `width` and `height` are **device** pixels, which is what a canvas's backing | |
| 20 | * store is measured in, and `ratio` says how many of those go to a CSS pixel. | |
| 21 | * There is no context transform anywhere here: a transform is how a 1.25 | |
| 22 | * display resamples everything drawn through it. See `./pixels.ts` for the | |
| 23 | * grid and the rule — it is why a crosshair arm is a filled rectangle of whole | |
| 24 | * pixels and not a stroked line through a fractional centre. | |
| 18 | 25 | */ |
| 19 | 26 | |
| 20 | 27 | import { clamp } from "./math.ts"; |
| 28 | import { bar as pixelBar, px as pixelsOf, ratioOf } from "./pixels.ts"; | |
| 21 | 29 | |
| 22 | 30 | /** Everything these tools ask of the thing they draw on. Nothing else. */ |
| 23 | 31 | export type Paint = { |
| @@ -64,6 +72,9 @@ const LEAD = 18; | ||
| 64 | 72 | /** The four ways a crosshair arm points, made once rather than once a frame. */ |
| 65 | 73 | const ARMS: readonly (readonly [number, number])[] = [[1, 0], [-1, 0], [0, 1], [0, -1]]; |
| 66 | 74 | |
| 75 | /** How thick a crosshair's arms are, in CSS pixels, when the caller says nothing. */ | |
| 76 | const ARM_THICKNESS = 2; | |
| 77 | ||
| 67 | 78 | /** What `text` is given when it is given nothing, so the default allocates nothing. */ |
| 68 | 79 | const PLAIN: TextOptions = {}; |
| 69 | 80 | |
| @@ -85,21 +96,39 @@ export type ColumnsAt = { x: number; y: number; lead?: number }; | ||
| 85 | 96 | export type Overlay = { |
| 86 | 97 | readonly width: number; |
| 87 | 98 | readonly height: number; |
| 88 | /** `width / 1280`, clamped to 0.7..1.4: the number every size here is scaled by. */ | |
| 99 | /** Device pixels to the CSS pixel: 1 on a plain screen, 1.25 on Windows at 125%. */ | |
| 100 | readonly ratio: number; | |
| 101 | /** | |
| 102 | * The number every size here is scaled by: how wide the canvas is *in CSS | |
| 103 | * pixels* against 1280, clamped to 0.7..1.4, times the device pixel ratio. | |
| 104 | * | |
| 105 | * The two halves are two different questions and were one number before. | |
| 106 | * How wide the window is decides how big the furniture should look — a phone | |
| 107 | * gets smaller text because it has less room, not because of its screen. How | |
| 108 | * dense the screen is decides how many pixels that look costs. Multiplied | |
| 109 | * rather than clamped together, a 1280-wide window renders the same physical | |
| 110 | * HUD at 1, 1.25 and 2, which it did not before: at 2 the clamp bit and the | |
| 111 | * whole HUD came out at 70% of its size. | |
| 112 | */ | |
| 89 | 113 | readonly scale: number; |
| 90 | 114 | /** The margin from the edge of the canvas that everything is laid out inside. */ |
| 91 | 115 | readonly pad: number; |
| 92 | 116 | /** `font(30)` is `"30px system-ui, sans-serif"` on a 1280-wide canvas, and less on a phone. */ |
| 93 | 117 | font(px: number): string; |
| 118 | /** As many whole device pixels as `css` CSS pixels are here. Never fewer than one. */ | |
| 119 | px(css: number): number; | |
| 94 | 120 | /** A scrim behind something else: a filled rectangle, in `fill` or the neutral one. */ |
| 95 | 121 | panel(x: number, y: number, w: number, h: number, fill?: string): void; |
| 96 | 122 | /** One string, and `globalAlpha` back at 1 whatever it was drawn at. */ |
| 97 | 123 | text(s: string, x: number, y: number, options?: TextOptions): void; |
| 98 | 124 | /** |
| 99 | * Four arms around a centre, each stroked from its outer end in to the gap, | |
| 100 | * so a gap of zero is a plain cross and a wide one is an open sight. | |
| 125 | * Four arms around a centre, each reaching from the gap out to `gap + arm`, | |
| 126 | * so a gap of zero is a plain cross and a wide one is an open sight. `gap`, | |
| 127 | * `arm` and `thickness` are device pixels — `px()` turns a CSS size into | |
| 128 | * them — and every arm is filled on whole pixels, so the cross is exact at | |
| 129 | * 1.25 and 1.5 as well as at 1 and 2. `thickness` defaults to two CSS pixels. | |
| 101 | 130 | */ |
| 102 | crosshair(cx: number, cy: number, gap: number, arm: number, color: string): void; | |
| 131 | crosshair(cx: number, cy: number, gap: number, arm: number, color: string, thickness?: number): void; | |
| 103 | 132 | /** The filled part of a bar: `fraction` of `w`, with the fraction clamped to 0..1. */ |
| 104 | 133 | bar(x: number, y: number, w: number, h: number, fraction: number, fill: string): void; |
| 105 | 134 | /** |
| @@ -113,17 +142,20 @@ export type Overlay = { | ||
| 113 | 142 | clock(seconds: number): string; |
| 114 | 143 | }; |
| 115 | 144 | |
| 116 | export function overlay(paint: Paint, width: number, height: number): Overlay { | |
| 117 | const scale = clamp(width / REFERENCE, MIN_SCALE, MAX_SCALE); | |
| 118 | const pad = Math.max(MIN_PAD, Math.round(width * PAD_FRACTION)); | |
| 145 | export function overlay(paint: Paint, width: number, height: number, dpr = 1): Overlay { | |
| 146 | const ratio = ratioOf(dpr); | |
| 147 | const scale = clamp(width / ratio / REFERENCE, MIN_SCALE, MAX_SCALE) * ratio; | |
| 148 | const pad = Math.round(Math.max(MIN_PAD * ratio, width * PAD_FRACTION)); | |
| 119 | 149 | const lead = Math.round(LEAD * scale); |
| 120 | 150 | |
| 121 | 151 | return { |
| 122 | 152 | width, |
| 123 | 153 | height, |
| 154 | ratio, | |
| 124 | 155 | scale, |
| 125 | 156 | pad, |
| 126 | 157 | font: (px) => `${Math.round(px * scale)}px ${FAMILY}`, |
| 158 | px: (css) => pixelsOf(css, ratio), | |
| 127 | 159 | panel(x, y, w, h, fill = SCRIM) { |
| 128 | 160 | paint.fillStyle = fill; |
| 129 | 161 | paint.fillRect(x, y, w, h); |
| @@ -141,15 +173,32 @@ export function overlay(paint: Paint, width: number, height: number): Overlay { | ||
| 141 | 173 | // line that leaves the context faded is a bug in every later line. |
| 142 | 174 | paint.globalAlpha = 1; |
| 143 | 175 | }, |
| 144 | crosshair(cx, cy, gap, arm, color) { | |
| 145 | paint.strokeStyle = color; | |
| 146 | paint.lineWidth = 2; | |
| 147 | paint.beginPath(); | |
| 176 | crosshair(cx, cy, gap, arm, color, thickness) { | |
| 177 | // Four filled rectangles and not four strokes: see `./pixels.ts`. The | |
| 178 | // thickness bends by a pixel where it has to so the centre does not, and | |
| 179 | // the gap and the arm are whole pixels so an arm's outer end is an edge | |
| 180 | // and not a fade. `bar` is asked once per axis, not once per arm, because | |
| 181 | // the four arms share two centre lines. | |
| 182 | const thick = Math.max(1, Math.round(thickness ?? pixelsOf(ARM_THICKNESS, ratio))); | |
| 183 | const inner = Math.max(0, Math.round(gap)); | |
| 184 | const length = Math.max(1, Math.round(arm)); | |
| 185 | const [left, wide] = pixelBar(cx, thick); | |
| 186 | const [top, tall] = pixelBar(cy, thick); | |
| 187 | // Where an arm starts along its own axis. A centre in the middle of a | |
| 188 | // pixel — an odd canvas — pushes both ends out by that half pixel, which | |
| 189 | // keeps the two arms of a pair the same distance from the centre and | |
| 190 | // both of their ends on a boundary. | |
| 191 | const near = (centre: number, sign: number): number => { | |
| 192 | const fraction = centre - Math.floor(centre); | |
| 193 | const half = fraction === 0.5 ? 0.5 : 0; | |
| 194 | const base = half === 0.5 ? centre : Math.round(centre); | |
| 195 | return sign > 0 ? base + inner + half : base - inner - half - length; | |
| 196 | }; | |
| 197 | paint.fillStyle = color; | |
| 148 | 198 | for (const [dx, dy] of ARMS) { |
| 149 | paint.moveTo(cx + dx * (gap + arm), cy + dy * (gap + arm)); | |
| 150 | paint.lineTo(cx + dx * gap, cy + dy * gap); | |
| 199 | if (dx !== 0) paint.fillRect(near(cx, dx), top, length, tall); | |
| 200 | else paint.fillRect(left, near(cy, dy), wide, length); | |
| 151 | 201 | } |
| 152 | paint.stroke(); | |
| 153 | 202 | }, |
| 154 | 203 | bar(x, y, w, h, fraction, fill) { |
| 155 | 204 | paint.fillStyle = fill; |
src/engine/page.test.ts +3 −1
| @@ -234,7 +234,9 @@ test("the recorder is a Paint, so the engine's overlay draws onto one", () => { | ||
| 234 | 234 | draw.crosshair(400, 225, 4, 10, "#fff"); |
| 235 | 235 | expect(paint.texts).toEqual(["tick 1"]); |
| 236 | 236 | expect(paint.calls[0]).toEqual(["clearRect", 0, 0, 800, 450]); |
| 237 | expect(paint.calls.some((call) => call[0] === "stroke")).toBe(true); | |
| 237 | // The crosshair is four filled rectangles, so this is what a recorder has to | |
| 238 | // have taken: the fills, in the order the arms are drawn. | |
| 239 | expect(paint.calls.filter((call) => call[0] === "fillRect").length).toBe(4); | |
| 238 | 240 | }); |
| 239 | 241 | |
| 240 | 242 | test("a browser with no 2D context hands back null rather than a recorder", () => { |
src/engine/page.ts +51 −2
| @@ -225,8 +225,27 @@ export type FakeCanvas = FakeElement & { | ||
| 225 | 225 | gl: unknown; |
| 226 | 226 | /** How many times something asked for the pointer. */ |
| 227 | 227 | locks: number; |
| 228 | /** | |
| 229 | * What each of those asks carried, oldest first: Chromium's | |
| 230 | * `{ unadjustedMovement: true }` for a game that wants raw counts, and | |
| 231 | * `undefined` for one that asked plainly. | |
| 232 | */ | |
| 233 | lockOptions: unknown[]; | |
| 234 | /** | |
| 235 | * Set to have the next ask refused, the way Chromium refuses one made within | |
| 236 | * about a second of Escape letting the pointer go — the exact moment a paused | |
| 237 | * game asks for it back. The promise rejects; `pointerLockElement` stays | |
| 238 | * where it was, because this canvas never sets it: a test says when a lock | |
| 239 | * arrived. | |
| 240 | */ | |
| 241 | refuseLock: boolean; | |
| 228 | 242 | getContext(kind: string): unknown; |
| 229 | requestPointerLock(): void; | |
| 243 | /** | |
| 244 | * Chromium's shape: a promise, resolved when the ask was taken. A browser | |
| 245 | * older than that returns undefined, and `createControls` reads both — this | |
| 246 | * is the newer one because Chrome and Edge are where the refusal happens. | |
| 247 | */ | |
| 248 | requestPointerLock(options?: unknown): Promise<void>; | |
| 230 | 249 | }; |
| 231 | 250 | |
| 232 | 251 | function canvasElement(id: string, paint: boolean): FakeCanvas { |
| @@ -236,12 +255,18 @@ function canvasElement(id: string, paint: boolean): FakeCanvas { | ||
| 236 | 255 | paint: paint ? paintRecorder() : null, |
| 237 | 256 | gl: null, |
| 238 | 257 | locks: 0, |
| 258 | lockOptions: [], | |
| 259 | refuseLock: false, | |
| 239 | 260 | getContext(kind) { |
| 240 | 261 | if (kind === "2d") return this.paint; |
| 241 | 262 | return kind === "webgl2" ? this.gl : null; |
| 242 | 263 | }, |
| 243 | requestPointerLock() { | |
| 264 | requestPointerLock(options?: unknown) { | |
| 244 | 265 | this.locks += 1; |
| 266 | this.lockOptions.push(options); | |
| 267 | return this.refuseLock | |
| 268 | ? Promise.reject(new Error("the pointer was asked for too soon after it was let go")) | |
| 269 | : Promise.resolve(); | |
| 245 | 270 | }, |
| 246 | 271 | }; |
| 247 | 272 | } |
| @@ -264,6 +289,12 @@ export type FakeDocument = FakeTarget & { | ||
| 264 | 289 | /** The element that is full screen right now, or null. The browser owns this. */ |
| 265 | 290 | fullscreenElement: unknown; |
| 266 | 291 | getElementById(id: string): FakeElement | null; |
| 292 | /** | |
| 293 | * One element by selector, and the one selector it understands is a meta by | |
| 294 | * name — `meta[name="build"]`, which is how a game reads what the server said | |
| 295 | * it built the page from. Anything else is null: this is a page, not a DOM. | |
| 296 | */ | |
| 297 | querySelector(selector: string): FakeElement | null; | |
| 267 | 298 | /** The `[data-action]` buttons, whatever the selector says: there is one list. */ |
| 268 | 299 | querySelectorAll(selector: string): FakeElement[]; |
| 269 | 300 | createElement(tag: string): FakeElement; |
| @@ -301,6 +332,13 @@ export type PageOptions = { | ||
| 301 | 332 | paint?: boolean; |
| 302 | 333 | /** What `matchMedia` answers. True is a phone. */ |
| 303 | 334 | coarse?: boolean; |
| 335 | /** | |
| 336 | * The `<meta name=…>` elements this page was served with, by name — the | |
| 337 | * server's own words about the deployment, which `pages/shell.ts` writes and | |
| 338 | * a game reads back. Left out is a page with none, which is what a local run | |
| 339 | * with no commit looks like. | |
| 340 | */ | |
| 341 | metas?: Readonly<Record<string, string>>; | |
| 304 | 342 | /** |
| 305 | 343 | * Whether this browser can put an element full screen. Default true: asking |
| 306 | 344 | * sets `document.fullscreenElement` and fires `fullscreenchange` at once. |
| @@ -363,6 +401,16 @@ export function fakePage(options: PageOptions = {}): FakePage { | ||
| 363 | 401 | .map((id) => elements.get(id)) |
| 364 | 402 | .filter((found): found is FakeCanvas => found !== undefined); |
| 365 | 403 | const buttons = (options.actions ?? []).map((action) => element({ action })); |
| 404 | // The metas, keyed by the selector that finds one, because that is the only | |
| 405 | // question anything asks of them: `meta[name="build"]` and its content. | |
| 406 | const metas = new Map<string, FakeElement>( | |
| 407 | Object.entries(options.metas ?? {}).map(([name, content]) => { | |
| 408 | const meta = element({ tag: "meta" }); | |
| 409 | meta.setAttribute("name", name); | |
| 410 | meta.setAttribute("content", content); | |
| 411 | return [`meta[name="${name}"]`, meta]; | |
| 412 | }), | |
| 413 | ); | |
| 366 | 414 | const frames: ((now: number) => void)[] = []; |
| 367 | 415 | let clock = START; |
| 368 | 416 | |
| @@ -371,6 +419,7 @@ export function fakePage(options: PageOptions = {}): FakePage { | ||
| 371 | 419 | readyState: "complete", |
| 372 | 420 | pointerLockElement: null as unknown, |
| 373 | 421 | getElementById: (id) => elements.get(id) ?? null, |
| 422 | querySelector: (selector) => metas.get(selector) ?? null, | |
| 374 | 423 | querySelectorAll: (_selector) => buttons, |
| 375 | 424 | createElement: (tag) => element({ tag }), |
| 376 | 425 | fullscreenElement: null as unknown, |
src/engine/pixels.test.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** | |
| 2 | * The grid the overlay draws on. Every number here is a device pixel, and the | |
| 3 | * point of every test is the same: whatever the ratio, an edge lands on a pixel. | |
| 4 | */ | |
| 5 | ||
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { backing, bar, MAX_RATIO, px, ratioOf } from "./pixels.ts"; | |
| 8 | ||
| 9 | /** The ratios this has to be exact at: a Mac, Windows at 125% and 150%, and up. */ | |
| 10 | const RATIOS = [1, 1.25, 1.5, 2, 3]; | |
| 11 | ||
| 12 | test("a ratio is a positive finite number, capped, and 1 when the browser is odd", () => { | |
| 13 | expect(ratioOf(1)).toBe(1); | |
| 14 | expect(ratioOf(1.25)).toBe(1.25); | |
| 15 | expect(ratioOf(3)).toBe(MAX_RATIO); | |
| 16 | expect(ratioOf(undefined)).toBe(1); | |
| 17 | expect(ratioOf(0)).toBe(1); | |
| 18 | expect(ratioOf(-2)).toBe(1); | |
| 19 | expect(ratioOf(Number.NaN)).toBe(1); | |
| 20 | expect(ratioOf(Number.POSITIVE_INFINITY)).toBe(1); | |
| 21 | }); | |
| 22 | ||
| 23 | test("a backing store is whole pixels, never none, and never more than the cap", () => { | |
| 24 | expect(backing(1280, 720, 1)).toEqual([1280, 720]); | |
| 25 | expect(backing(1280, 720, 1.25)).toEqual([1600, 900]); | |
| 26 | expect(backing(1281, 721, 1.25)).toEqual([1601, 901]); | |
| 27 | expect(backing(1280, 720, 1.5)).toEqual([1920, 1080]); | |
| 28 | // Past the cap the box is drawn at the cap, not at the ratio. | |
| 29 | expect(backing(400, 300, 3)).toEqual([800, 600]); | |
| 30 | // A canvas the layout has not measured yet is one pixel, not zero: a zero | |
| 31 | // viewport is a WebGL error and a zero-wide 2D canvas throws on `getContext`. | |
| 32 | expect(backing(0, 0, 2)).toEqual([1, 1]); | |
| 33 | expect(backing(Number.NaN, 10, 1)).toEqual([1, 10]); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a CSS length becomes whole device pixels, and the smallest is one", () => { | |
| 37 | expect(px(2, 1)).toBe(2); | |
| 38 | expect(px(2, 1.25)).toBe(3); | |
| 39 | expect(px(2, 1.5)).toBe(3); | |
| 40 | expect(px(2, 2)).toBe(4); | |
| 41 | // A hairline is a pixel wherever it is drawn: nothing here ever rounds away. | |
| 42 | expect(px(0.4, 1)).toBe(1); | |
| 43 | expect(px(0, 2)).toBe(1); | |
| 44 | expect(px(Number.NaN, 2)).toBe(1); | |
| 45 | }); | |
| 46 | ||
| 47 | test("a bar's two edges are whole pixels at every ratio, centred on an even canvas", () => { | |
| 48 | for (const ratio of RATIOS) { | |
| 49 | const width = Math.round(1280 * ratio); // even at all five | |
| 50 | const [at, size] = bar(width / 2, px(2, ratio)); | |
| 51 | expect(Number.isInteger(at)).toBe(true); | |
| 52 | expect(Number.isInteger(size)).toBe(true); | |
| 53 | // The centre did not move: as much of the bar is left of it as right. | |
| 54 | expect(at + size / 2).toBe(width / 2); | |
| 55 | } | |
| 56 | }); | |
| 57 | ||
| 58 | test("an odd canvas keeps its centre too, by thickening the bar rather than blurring it", () => { | |
| 59 | // 1601 device pixels: the centre is 800.5, the middle of pixel 800, which is | |
| 60 | // where an *odd* bar belongs. A two-pixel ask becomes three rather than a | |
| 61 | // two-pixel smear across three columns. | |
| 62 | const [at, size] = bar(1601 / 2, 2); | |
| 63 | expect([at, size]).toEqual([799, 3]); | |
| 64 | expect(at + size / 2).toBe(800.5); | |
| 65 | // And a three-pixel ask on an even canvas becomes four, the same way round. | |
| 66 | expect(bar(800, 3)).toEqual([798, 4]); | |
| 67 | // An ask that already fits is left alone in both cases. | |
| 68 | expect(bar(800.5, 3)).toEqual([799, 3]); | |
| 69 | expect(bar(800, 2)).toEqual([799, 2]); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a bar anywhere else is snapped to the grid its thickness wants", () => { | |
| 73 | // A marker chasing a moving thing: no thickness fits, so the centre moves by | |
| 74 | // at most half a pixel and the edges stay exact. | |
| 75 | for (const centre of [100.2, 100.37, 99.8, 0.1, -3.4]) { | |
| 76 | for (const thickness of [1, 2, 3, 4]) { | |
| 77 | const [at, size] = bar(centre, thickness); | |
| 78 | expect(Number.isInteger(at)).toBe(true); | |
| 79 | expect(size).toBe(thickness); | |
| 80 | expect(Math.abs(at + size / 2 - centre)).toBeLessThanOrEqual(0.5); | |
| 81 | } | |
| 82 | } | |
| 83 | }); | |
| 84 | ||
| 85 | test("a bar is at least one pixel thick and survives a number that is not one", () => { | |
| 86 | // Nothing rounds away to nothing: the least a bar can be asked for is one | |
| 87 | // pixel, and on a boundary that one is widened to two by the rule above | |
| 88 | // rather than being drawn half in each of the pixels either side of it. | |
| 89 | expect(bar(10, 0)).toEqual([9, 2]); | |
| 90 | expect(bar(10, -4)).toEqual([9, 2]); | |
| 91 | expect(bar(10, Number.NaN)).toEqual([9, 2]); | |
| 92 | expect(bar(10.5, 0)).toEqual([10, 1]); | |
| 93 | expect(bar(Number.NaN, 2)).toEqual([-1, 2]); | |
| 94 | }); |
src/engine/pixels.ts added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | /** | |
| 2 | * The device pixel grid: how large a canvas's backing store is for the box it | |
| 3 | * was laid out in, and where a bar of pixels has to start so that both of its | |
| 4 | * edges land on a pixel rather than a quarter of the way through one. | |
| 5 | * | |
| 6 | * Both were arithmetic each game had its own copy of — the renderer's `resize`, | |
| 7 | * the shooter's HUD and its map plan, the range's two canvases, the example's — | |
| 8 | * and the second one nobody had written at all. | |
| 9 | * | |
| 10 | * **Why it matters, and why it never showed here.** Windows ships display | |
| 11 | * scaling at 125% and 150% far more often than anything else does, so | |
| 12 | * `devicePixelRatio` on the machine #307 was reported from is 1.25 or 1.5 and | |
| 13 | * not the 1 or 2 a Mac laptop hands out. At 1.25 a canvas 1281 CSS pixels wide | |
| 14 | * has a 1601-pixel backing store; its centre is 800.5; and a two-pixel | |
| 15 | * crosshair arm *stroked* through that centre covers three columns of pixels at | |
| 16 | * two thirds of its colour each. It is a smeared, dim cross that will not sit | |
| 17 | * still — "buggy on Windows" — and on a 1.0 display it is pixel-perfect, which | |
| 18 | * is why it survived this long. | |
| 19 | * | |
| 20 | * **The rule chosen.** The overlay draws in device pixels with no context | |
| 21 | * transform, so nothing it draws is ever resampled, and everything with an edge | |
| 22 | * is an axis-aligned rectangle filled on whole device pixels. A fill of whole | |
| 23 | * pixels is exact at every ratio; a stroke has to be offset by half a line | |
| 24 | * width, and whether that offset is a half or a zero depends on the parity of a | |
| 25 | * number the browser chose. Sizes are given in CSS pixels and turned into whole | |
| 26 | * device pixels here, so what is drawn is the same size on the glass at 1, | |
| 27 | * 1.25, 1.5, 2 and 3. | |
| 28 | */ | |
| 29 | ||
| 30 | /** | |
| 31 | * The ratio a backing store is capped at. Past two, the pixels cost memory and | |
| 32 | * fill rate and buy nothing an eye can find at arm's length — and a phone at 3 | |
| 33 | * is exactly the machine that cannot afford them. | |
| 34 | */ | |
| 35 | export const MAX_RATIO = 2; | |
| 36 | ||
| 37 | /** A usable pixel ratio out of whatever a browser's `devicePixelRatio` is. */ | |
| 38 | export function ratioOf(dpr: number | undefined): number { | |
| 39 | if (dpr === undefined || !Number.isFinite(dpr) || dpr <= 0) return 1; | |
| 40 | return Math.min(dpr, MAX_RATIO); | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * The backing store for a box `clientWidth` × `clientHeight` CSS pixels at | |
| 45 | * `dpr`: whole pixels, at least one of each, and the ratio capped. | |
| 46 | * | |
| 47 | * The CSS size is never touched — the canvas keeps the box the layout gave it, | |
| 48 | * so there is no shift and no stretch beyond the half pixel `Math.round` can | |
| 49 | * cost on an odd width, which the browser resolves in the compositor and no | |
| 50 | * arithmetic here can do better. | |
| 51 | */ | |
| 52 | export function backing(clientWidth: number, clientHeight: number, dpr: number | undefined): [number, number] { | |
| 53 | const ratio = ratioOf(dpr); | |
| 54 | return [whole(clientWidth * ratio), whole(clientHeight * ratio)]; | |
| 55 | } | |
| 56 | ||
| 57 | /** One dimension of a backing store: a whole number of pixels, and never none. */ | |
| 58 | const whole = (value: number): number => | |
| 59 | Math.max(1, Math.round(Number.isFinite(value) ? value : 0)); | |
| 60 | ||
| 61 | /** As many whole device pixels as `css` CSS pixels are at `ratio`, and never none. */ | |
| 62 | export const px = (css: number, ratio: number): number => | |
| 63 | Math.max(1, Math.round((Number.isFinite(css) ? css : 0) * ratio)); | |
| 64 | ||
| 65 | /** | |
| 66 | * Where a bar `thickness` device pixels thick, centred on `centre`, starts — | |
| 67 | * and how thick it ends up. Both numbers come back whole, so a `fillRect` of | |
| 68 | * them covers exactly that many pixels and no fraction of any other. | |
| 69 | * | |
| 70 | * A bar of **even** thickness has both edges on pixel boundaries when its | |
| 71 | * centre is on one; a bar of **odd** thickness when its centre is the middle of | |
| 72 | * a pixel. A crosshair's centre is always one or the other — a canvas with an | |
| 73 | * even width puts it on a boundary and an odd one in the middle of the middle | |
| 74 | * pixel — so there the thickness bends by a pixel to meet the centre and the | |
| 75 | * centre itself never moves. One device pixel of width is not something an eye | |
| 76 | * finds; half a pixel of blur, on the one thing a player stares at for an hour, | |
| 77 | * is. | |
| 78 | * | |
| 79 | * Anywhere else — a marker following a moving thing, which the example's plan | |
| 80 | * draws — no thickness fits, so the centre is snapped to the nearest grid this | |
| 81 | * thickness wants, which moves it by at most half a device pixel and leaves | |
| 82 | * every edge exact. | |
| 83 | */ | |
| 84 | export function bar(centre: number, thickness: number): [number, number] { | |
| 85 | const want = Math.max(1, Math.round(Number.isFinite(thickness) ? thickness : 1)); | |
| 86 | const middle = Number.isFinite(centre) ? centre : 0; | |
| 87 | const fraction = middle - Math.floor(middle); | |
| 88 | if (fraction === 0 || fraction === 0.5) { | |
| 89 | const odd = want % 2 === 1; | |
| 90 | const size = (fraction === 0.5) === odd ? want : want + 1; | |
| 91 | return [middle - size / 2, size]; | |
| 92 | } | |
| 93 | const grid = want % 2 === 0 ? Math.round(middle) : Math.floor(middle) + 0.5; | |
| 94 | return [grid - want / 2, want]; | |
| 95 | } |
src/engine/renderer.ts +8 −6
| @@ -25,6 +25,7 @@ | ||
| 25 | 25 | |
| 26 | 26 | import { multiply, perspective, viewMatrix, modelMatrix, type Vec3 } from "./math.ts"; |
| 27 | 27 | import type { Mesh } from "./mesh.ts"; |
| 28 | import { backing } from "./pixels.ts"; | |
| 28 | 29 | import type { Scene, Tint } from "./scene.ts"; |
| 29 | 30 | |
| 30 | 31 | const SCENE_VS = `#version 300 es |
| @@ -180,9 +181,6 @@ type Buffers = { vao: WebGLVertexArrayObject; count: number; buffers: WebGLBuffe | ||
| 180 | 181 | /** The bytes an atlas of this shape must be: RGBA8, every layer, tightly packed. */ |
| 181 | 182 | const atlasBytes = (size: number, layers: number): number => size * size * 4 * layers; |
| 182 | 183 | |
| 183 | /** Past this the pixels cost more than they show, on a phone especially. */ | |
| 184 | const MAX_PIXEL_RATIO = 2; | |
| 185 | ||
| 186 | 184 | /** The clip planes a game that says nothing gets: Source units, the shooter's. */ |
| 187 | 185 | const NEAR = 6, FAR = 12000; |
| 188 | 186 | |
| @@ -352,9 +350,13 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets | ||
| 352 | 350 | const far = assets.far ?? FAR; |
| 353 | 351 | |
| 354 | 352 | const resize = (): void => { |
| 355 | const dpr = Math.min((pixelRatio === undefined ? 1 : pixelRatio()) || 1, MAX_PIXEL_RATIO); | |
| 356 | const width = Math.round(canvas.clientWidth * dpr); | |
| 357 | const height = Math.round(canvas.clientHeight * dpr); | |
| 353 | // One place says how large a backing store is, so the scene canvas and the | |
| 354 | // 2D canvas over it are the same grid: `./pixels.ts`. | |
| 355 | const [width, height] = backing( | |
| 356 | canvas.clientWidth, | |
| 357 | canvas.clientHeight, | |
| 358 | pixelRatio === undefined ? 1 : pixelRatio(), | |
| 359 | ); | |
| 358 | 360 | if (canvas.width !== width || canvas.height !== height) { |
| 359 | 361 | canvas.width = width; |
| 360 | 362 | canvas.height = height; |
src/engine/surface.json +8 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | "version": "0.3.0", | |
| 2 | "version": "0.6.0", | |
| 3 | 3 | "names": [ |
| 4 | 4 | "Activations", |
| 5 | 5 | "Adam", |
| @@ -47,6 +47,7 @@ | ||
| 47 | 47 | "LockOwner", |
| 48 | 48 | "Loop", |
| 49 | 49 | "LoopOptions", |
| 50 | "MAX_RATIO", | |
| 50 | 51 | "Mat4", |
| 51 | 52 | "Mesh", |
| 52 | 53 | "MouseEventish", |
| @@ -58,6 +59,7 @@ | ||
| 58 | 59 | "Paint", |
| 59 | 60 | "Painter", |
| 60 | 61 | "Pin", |
| 62 | "PointerMode", | |
| 61 | 63 | "Policy", |
| 62 | 64 | "QUANTUM", |
| 63 | 65 | "Renderer", |
| @@ -77,6 +79,7 @@ | ||
| 77 | 79 | "Touchish", |
| 78 | 80 | "UpdateStats", |
| 79 | 81 | "Vec3", |
| 82 | "WheelEventish", | |
| 80 | 83 | "Wish", |
| 81 | 84 | "accelerate", |
| 82 | 85 | "add", |
| @@ -87,7 +90,9 @@ | ||
| 87 | 90 | "applyGrads", |
| 88 | 91 | "attachButtons", |
| 89 | 92 | "attenuate", |
| 93 | "backing", | |
| 90 | 94 | "backward", |
| 95 | "bar", | |
| 91 | 96 | "builder", |
| 92 | 97 | "cellAt", |
| 93 | 98 | "clamp", |
| @@ -151,9 +156,11 @@ | ||
| 151 | 156 | "pushEffect", |
| 152 | 157 | "pushLine", |
| 153 | 158 | "pushShadow", |
| 159 | "px", | |
| 154 | 160 | "quadMesh", |
| 155 | 161 | "quantise", |
| 156 | 162 | "rad", |
| 163 | "ratioOf", | |
| 157 | 164 | "rayBox", |
| 158 | 165 | "region", |
| 159 | 166 | "resetEffects", |
src/example/boot.test.ts +11 −4
| @@ -65,10 +65,15 @@ function drew(p: ExamplePage, ms: number): Call[] { | ||
| 65 | 65 | const aim = (calls: Call[]): string => |
| 66 | 66 | calls.filter((c) => c[0] === "fillRect" && c[3] === 2 && c[4] === 2).map((c) => `${c[1]},${c[2]}`).join(" "); |
| 67 | 67 | |
| 68 | /** Where the player's crosshair was centred: the one path this page strokes. */ | |
| 68 | /** | |
| 69 | * Where the player's crosshair is, read off its first arm: the crosshair is | |
| 70 | * four filled rectangles of whole device pixels rather than a stroked path — | |
| 71 | * see `engine/pixels.ts` — and the first of them is the one reaching right, | |
| 72 | * eight pixels long and two thick, so its corner moves with the centre. | |
| 73 | */ | |
| 69 | 74 | const marker = (calls: Call[]): [number, number] => { |
| 70 | const move = calls.find((c) => c[0] === "moveTo") as Call | undefined; | |
| 71 | return [Number(move?.[1] ?? 0), Number(move?.[2] ?? 0)]; | |
| 75 | const arm = calls.find((c) => c[0] === "fillRect" && c[3] === 8 && c[4] === 2) as Call | undefined; | |
| 76 | return [Number(arm?.[1] ?? 0), Number(arm?.[2] ?? 0)]; | |
| 72 | 77 | }; |
| 73 | 78 | |
| 74 | 79 | test("the table it binds is the game's own, plus the two keys the page adds", () => { |
| @@ -124,7 +129,9 @@ test("it boots, asks for a frame, and draws the readout over the room", () => { | ||
| 124 | 129 | // and the crosshair is the one thing the readout draws into the middle. |
| 125 | 130 | expect(first.calls[0]).toEqual(["clearRect", 0, 0, 800, 450]); |
| 126 | 131 | expect(first.calls.some((c) => c[0] === "fillRect" && c[3] === 800)).toBe(false); |
| 127 | expect(first.calls.filter((c) => c[0] === "moveTo").length).toBe(4); | |
| 132 | expect(first.calls.filter((c) => c[0] === "fillRect" && (c[3] === 2 || c[4] === 2)).length) | |
| 133 | .toBeGreaterThanOrEqual(4); | |
| 134 | expect(first.calls.some((c) => c[0] === "stroke")).toBe(false); | |
| 128 | 135 | // A frame was asked for before this one ran, so the loop keeps going. |
| 129 | 136 | expect(p.frames.length).toBe(1); |
| 130 | 137 | } finally { |
src/example/main.ts +5 −7
| @@ -23,7 +23,7 @@ | ||
| 23 | 23 | |
| 24 | 24 | import { KEYS, TICK, assets, createExampleScene, createWorld, look, step, type World } from "../engine/example.ts"; |
| 25 | 25 | import { |
| 26 | attachButtons, createControls, createFullscreen, createLoop, createRenderer, type Paint, | |
| 26 | attachButtons, backing, createControls, createFullscreen, createLoop, createRenderer, type Paint, | |
| 27 | 27 | } from "../engine/index.ts"; |
| 28 | 28 | import { drawPlan, drawReadout, type Phase } from "./plan.ts"; |
| 29 | 29 | |
| @@ -110,12 +110,10 @@ export function boot(): void { | ||
| 110 | 110 | let starting = false; |
| 111 | 111 | let frameMs = 16.7; |
| 112 | 112 | |
| 113 | /** Match a canvas's backing store to its box, at most twice the CSS pixels. | |
| 113 | /** Match a canvas's backing store to its box, the way `engine/pixels.ts` says. | |
| 114 | 114 | * The renderer does this for the scene canvas itself; the HUD is the page's. */ |
| 115 | 115 | const size = (canvas: HTMLCanvasElement): [number, number] => { |
| 116 | const dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| 117 | const width = Math.max(1, Math.round(canvas.clientWidth * dpr)); | |
| 118 | const height = Math.max(1, Math.round(canvas.clientHeight * dpr)); | |
| 116 | const [width, height] = backing(canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio); | |
| 119 | 117 | if (canvas.width !== width) canvas.width = width; |
| 120 | 118 | if (canvas.height !== height) canvas.height = height; |
| 121 | 119 | return [width, height]; |
| @@ -154,10 +152,10 @@ export function boot(): void { | ||
| 154 | 152 | if (renderer !== null) renderer.draw(look(world, frameScene), elapsed); |
| 155 | 153 | else if (plan !== null) { |
| 156 | 154 | const [width, height] = size(scene); |
| 157 | drawPlan(plan, width, height, frame); | |
| 155 | drawPlan(plan, width, height, frame, window.devicePixelRatio); | |
| 158 | 156 | } |
| 159 | 157 | const [width, height] = size(hud); |
| 160 | drawReadout(readout, width, height, frame); | |
| 158 | drawReadout(readout, width, height, frame, window.devicePixelRatio); | |
| 161 | 159 | $("fps").textContent = `${(1000 / Math.max(1, frameMs)).toFixed(0)} fps · ${frameMs.toFixed(1)} ms`; |
| 162 | 160 | }); |
| 163 | 161 | } |
src/example/plan.test.ts +12 −7
| @@ -56,9 +56,11 @@ test("the plan is one background, one room, one plate and the aim", () => { | ||
| 56 | 56 | expect(room[1] + room[3]).toBeLessThanOrEqual(HEIGHT); |
| 57 | 57 | // The readout is the other canvas's: the plan itself says nothing. |
| 58 | 58 | expect(paint.texts).toEqual([]); |
| 59 | // The player is a crosshair: four arms, one path, one stroke. | |
| 60 | expect(paint.calls.filter((c) => c[0] === "moveTo").length).toBe(4); | |
| 61 | expect(paint.calls.filter((c) => c[0] === "stroke").length).toBe(1); | |
| 59 | // The player is a crosshair: four arms, each a filled rectangle of whole | |
| 60 | // device pixels — two pixels thick and eight long — and nothing stroked. | |
| 61 | const arms = all.filter((r) => (r[2] === 2 && r[3] === 8) || (r[2] === 8 && r[3] === 2)); | |
| 62 | expect(arms.length).toBe(4); | |
| 63 | expect(paint.calls.filter((c) => c[0] === "stroke").length).toBe(0); | |
| 62 | 64 | }); |
| 63 | 65 | |
| 64 | 66 | test("the aim walks out of the eye and stops at the wall", () => { |
| @@ -100,10 +102,13 @@ test("the readout says the score, the clock and the one line of help", () => { | ||
| 100 | 102 | // The crosshair is the middle of the room, and only when there is a room: |
| 101 | 103 | // on the plan the player is a crosshair where they stand instead. |
| 102 | 104 | const middle = read({ drawn: true }); |
| 103 | expect(middle.calls.filter((c) => c[0] === "moveTo").length).toBe(4); | |
| 104 | expect(middle.calls.filter((c) => c[0] === "moveTo").map((c) => Number(c[1]))) | |
| 105 | .toContain(WIDTH / 2); | |
| 106 | expect(read({ drawn: false }).calls.filter((c) => c[0] === "moveTo").length).toBe(0); | |
| 105 | // Four arms, filled: the two vertical ones are two pixels wide and straddle | |
| 106 | // the middle of the canvas, which is what "centred" has to mean in pixels. | |
| 107 | const arms = rects(middle).filter((r) => (r[2] === 2 && r[3] === 10) || (r[2] === 10 && r[3] === 2)); | |
| 108 | expect(arms.length).toBe(4); | |
| 109 | const upright = arms.filter((r) => r[2] === 2); | |
| 110 | for (const arm of upright) expect(arm[0] + arm[2] / 2).toBe(WIDTH / 2); | |
| 111 | expect(rects(read({ drawn: false })).filter((r) => r[2] === 2 && r[3] === 10).length).toBe(0); | |
| 107 | 112 | }); |
| 108 | 113 | |
| 109 | 114 | test("the clock reads the overlay's m:ss, and the last seconds are said differently", () => { |
src/example/plan.ts +18 −6
| @@ -59,8 +59,14 @@ const lineOf = (o: Overlay): number => o.pad + Math.round(30 * o.scale); | ||
| 59 | 59 | * cleared and no background is painted — this canvas sits over the scene, and |
| 60 | 60 | * in WebGL2 the scene is the room itself, so the crosshair is drawn here too. |
| 61 | 61 | */ |
| 62 | export function drawReadout(paint: Paint, width: number, height: number, frame: Frame): void { | |
| 63 | const o = overlay(paint, width, height); | |
| 62 | export function drawReadout( | |
| 63 | paint: Paint, | |
| 64 | width: number, | |
| 65 | height: number, | |
| 66 | frame: Frame, | |
| 67 | dpr = 1, | |
| 68 | ): void { | |
| 69 | const o = overlay(paint, width, height, dpr); | |
| 64 | 70 | const { world, clock } = frame; |
| 65 | 71 | const late = clock <= HURRY; |
| 66 | 72 | const line = lineOf(o); |
| @@ -72,13 +78,19 @@ export function drawReadout(paint: Paint, width: number, height: number, frame: | ||
| 72 | 78 | frame.fraction, late ? WARN : AIM); |
| 73 | 79 | // Where the shot goes, when the shot goes into a room you are standing in. |
| 74 | 80 | // On the plan below it is the dots instead, which is what a plan can say. |
| 75 | if (frame.drawn) o.crosshair(width / 2, height / 2, Math.round(4 * o.scale), Math.round(10 * o.scale), AIM); | |
| 81 | if (frame.drawn) o.crosshair(width / 2, height / 2, o.px(4), o.px(10), AIM); | |
| 76 | 82 | o.text(SAY[frame.phase], width / 2, height - o.pad, { align: "center", font: o.font(18), fill: DIM }); |
| 77 | 83 | } |
| 78 | 84 | |
| 79 | 85 | /** The room from above: the whole frame, for a browser that cannot draw it. */ |
| 80 | export function drawPlan(paint: Paint, width: number, height: number, frame: Frame): void { | |
| 81 | const o = overlay(paint, width, height); | |
| 86 | export function drawPlan( | |
| 87 | paint: Paint, | |
| 88 | width: number, | |
| 89 | height: number, | |
| 90 | frame: Frame, | |
| 91 | dpr = 1, | |
| 92 | ): void { | |
| 93 | const o = overlay(paint, width, height, dpr); | |
| 82 | 94 | const { world } = frame; |
| 83 | 95 | o.panel(0, 0, width, height, BACK); |
| 84 | 96 | |
| @@ -109,5 +121,5 @@ export function drawPlan(paint: Paint, width: number, height: number, frame: Fra | ||
| 109 | 121 | if (x < ROOM.min[0] || x > ROOM.max[0] || y < ROOM.min[1] || y > ROOM.max[1]) break; |
| 110 | 122 | o.panel(sx(x) - dot / 2, sy(y) - dot / 2, dot, dot, AIM); |
| 111 | 123 | } |
| 112 | o.crosshair(sx(world.pos[0]), sy(world.pos[1]), 0, Math.round(8 * o.scale), TEXT); | |
| 124 | o.crosshair(sx(world.pos[0]), sy(world.pos[1]), 0, o.px(8), TEXT); | |
| 113 | 125 | } |
src/games.ts +38 −11
| @@ -103,8 +103,17 @@ export const GAMES: readonly Game[] = [ | ||
| 103 | 103 | * ends read. 115,156 raw and 44,864 gzipped, measured by |
| 104 | 104 | * `bun run scripts/build.ts` — 1.8 kB of gzip for a round five people can |
| 105 | 105 | * play together, and the rooms' own server half ships in no bundle at all. |
| 106 | * | |
| 107 | * Raised again by #307's merge of that work with the Windows one — the engine's | |
| 108 | * pixel grid, the raw pointer ask and the keyboard lock, and the line a bug | |
| 109 | * report pastes — and with #279's contact shadows, which every game gets from | |
| 110 | * the renderer whether it casts one or not: 123,024 raw and 47,926 gzipped | |
| 111 | * measured together by `bun run scripts/build.ts`. Each of those pulls left | |
| 112 | * its own number a few hundred bytes clear, and three of them in a row is how | |
| 113 | * a ceiling ends up tighter than the rule above, so this one is measured plus | |
| 114 | * seven per cent again rather than a hair over today. | |
| 106 | 115 | */ |
| 107 | ceiling: { raw: 122_000, gzip: 47_500 }, | |
| 116 | ceiling: { raw: 132_000, gzip: 51_500 }, | |
| 108 | 117 | assets: [ |
| 109 | 118 | { |
| 110 | 119 | name: "weights", |
| @@ -124,15 +133,27 @@ export const GAMES: readonly Game[] = [ | ||
| 124 | 133 | label: "range", |
| 125 | 134 | entry: "src/range/main.ts", |
| 126 | 135 | /** |
| 127 | * Raised by #279, which gave the engine contact shadows and this game one | |
| 128 | * under every plate: a third program with its own two shaders, the blended | |
| 129 | * pass that draws it, and the pool and the height arithmetic behind it. | |
| 130 | * 38,690 raw and 16,050 gzipped, measured by `bun run scripts/build.ts` — | |
| 131 | * 2,631 and 881 bytes more than the ceiling #63 set, of which most is the | |
| 132 | * renderer's and arrives whether a game casts a shadow or not. Worth it | |
| 133 | * here: a plate hanging over the lane now says how far over it is hanging. | |
| 136 | * Raised by #63, which gave the range a person to be: its tuning, its hull | |
| 137 | * and the engine's solver. Raised again by #308 and #309, which put the | |
| 138 | * pointer lock, its fallback and the whole mouse — every button, and the | |
| 139 | * wheel counted in notches — inside the engine's controls: 37,663 raw and | |
| 140 | * 15,815 gzipped, measured by `bun run scripts/build.ts`. The range is the | |
| 141 | * smallest game here, so the engine's floor is the largest share of it. | |
| 142 | * | |
| 143 | * Raised again by #279, which gave the engine contact shadows and this game | |
| 144 | * one under every plate: a third program with its own two shaders, the | |
| 145 | * blended pass that draws it, and the pool and the height arithmetic behind | |
| 146 | * it — most of which is the renderer's and arrives whether a game casts a | |
| 147 | * shadow or not. Worth it here: a plate hanging over the lane now says how | |
| 148 | * far over it is hanging. | |
| 149 | * | |
| 150 | * And again by #311, whose `src/engine/pixels.ts` every canvas in every game | |
| 151 | * now sizes itself through, and by the pointer mode the same pull's line | |
| 152 | * reports. Measured with all of it together: 40,816 raw and 16,927 gzipped by | |
| 153 | * `bun run scripts/build.ts`. The range is the smallest game here, so nearly | |
| 154 | * all of that is the engine's floor and not the drills. | |
| 134 | 155 | */ |
| 135 | ceiling: { raw: 41_500, gzip: 17_200 }, | |
| 156 | ceiling: { raw: 44_000, gzip: 18_200 }, | |
| 136 | 157 | assets: [], |
| 137 | 158 | blurb: |
| 138 | 159 | "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.", |
| @@ -144,8 +165,14 @@ export const GAMES: readonly Game[] = [ | ||
| 144 | 165 | href: "/example", |
| 145 | 166 | label: "example", |
| 146 | 167 | entry: "src/example/main.ts", |
| 147 | /** Raised by #216, which gave the example the WebGL2 renderer it advertises — a tool set ships whole. */ | |
| 148 | ceiling: { raw: 23_500, gzip: 10_200 }, | |
| 168 | /** | |
| 169 | * Raised by #216, which gave the example the WebGL2 renderer it advertises — | |
| 170 | * a tool set ships whole. Raised again by #311, whose pixel grid the | |
| 171 | * example's two canvases size themselves through like everything else here, | |
| 172 | * and again by #279's contact shadows, which arrive with the renderer: | |
| 173 | * 25,004 raw and 10,631 gzipped, measured by `bun run scripts/build.ts`. | |
| 174 | */ | |
| 175 | ceiling: { raw: 26_800, gzip: 11_400 }, | |
| 149 | 176 | assets: [], |
| 150 | 177 | blurb: |
| 151 | 178 | "the guide's own game, served: one room, one plate, thirty seconds and a score, drawn in WebGL2 through the engine's own renderer, with the plan from above for a browser that has none — the price tag on a third game.", |
src/pages/actions.test.ts +13 −4
| @@ -36,12 +36,12 @@ import { expect, test } from "bun:test"; | ||
| 36 | 36 | import { mkdtempSync } from "node:fs"; |
| 37 | 37 | import { tmpdir } from "node:os"; |
| 38 | 38 | import { join } from "node:path"; |
| 39 | import { CS_KEYS, CS_MOUSE } from "../client/binding.ts"; | |
| 39 | import { CS_KEYS, CS_MOUSE, CS_WHEEL } from "../client/binding.ts"; | |
| 40 | 40 | import type { Cost } from "../cost.ts"; |
| 41 | 41 | import { openDatabase } from "../db.ts"; |
| 42 | 42 | import { page as headless } from "../example/stage.test-helper.ts"; |
| 43 | 43 | import { GAMES } from "../games.ts"; |
| 44 | import { RANGE_KEYS, RANGE_MOUSE } from "../range/binding.ts"; | |
| 44 | import { RANGE_KEYS, RANGE_MOUSE, RANGE_WHEEL } from "../range/binding.ts"; | |
| 45 | 45 | import { examplePage } from "./example.ts"; |
| 46 | 46 | import { gamePage } from "./game.ts"; |
| 47 | 47 | import { rangePage } from "./range.ts"; |
| @@ -146,7 +146,7 @@ const SURFACES: readonly Surface[] = [ | ||
| 146 | 146 | { |
| 147 | 147 | game: "app", |
| 148 | 148 | html: gamePage(db, { ...SHELL, who: { role: "anon" } }), |
| 149 | bound: [...Object.values(CS_KEYS), ...Object.values(CS_MOUSE)], | |
| 149 | bound: [...Object.values(CS_KEYS), ...Object.values(CS_MOUSE), ...Object.values(CS_WHEEL)], | |
| 150 | 150 | elsewhere: [ |
| 151 | 151 | { |
| 152 | 152 | why: "the left of the stage is a thumb stick, and it is analogue: `wishSpeed` in " + |
| @@ -174,6 +174,13 @@ const SURFACES: readonly Surface[] = [ | ||
| 174 | 174 | "phone reaches every one of them without a key.", |
| 175 | 175 | actions: ["slot1", "slot2", "slot3", "slot4", "slot5", "slot6", "slot7", "slot8", "slot9", "slot10", "slot11"], |
| 176 | 176 | }, |
| 177 | { | |
| 178 | why: "the wheel pressed, which is where every other shooter keeps the knife. A phone reaches " + | |
| 179 | "the same pocket the same way it reaches the others — `src/client/economy.ts` equips what " + | |
| 180 | "was bought — and a knife button beside the six on the pad is the trade nobody has made " + | |
| 181 | "for `slot3` either.", | |
| 182 | actions: ["knife"], | |
| 183 | }, | |
| 177 | 184 | { |
| 178 | 185 | why: "the buy panel draws its own Rebuy control beside the tiles (`src/client/buy.ts`), which " + |
| 179 | 186 | "is the button a thumb presses; X is that same call from the keyboard, for a hand that is " + |
| @@ -196,7 +203,9 @@ const SURFACES: readonly Surface[] = [ | ||
| 196 | 203 | { |
| 197 | 204 | game: "range", |
| 198 | 205 | html: rangePage(db, SHELL), |
| 199 | bound: [...Object.values(RANGE_KEYS), ...Object.values(RANGE_MOUSE)], | |
| 206 | bound: [ | |
| 207 | ...Object.values(RANGE_KEYS), ...Object.values(RANGE_MOUSE), ...Object.values(RANGE_WHEEL), | |
| 208 | ], | |
| 200 | 209 | elsewhere: [ |
| 201 | 210 | { |
| 202 | 211 | why: "the same thumb stick, and the page says so in words above the board: on a touch screen " + |
src/pages/game.ts +15
| @@ -393,6 +393,11 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => ` | ||
| 393 | 393 | number row buys the tile it is printed on and <kbd>X</kbd> buys last round's loadout again · |
| 394 | 394 | <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen · |
| 395 | 395 | <kbd>Esc</kbd> or <kbd>P</kbd> pause</p> |
| 396 | <p class="hint">The whole mouse is bound, not just its two big buttons: the wheel jumps | |
| 397 | either way it is turned, the wheel pressed pulls the knife, and the two under your thumb | |
| 398 | plant-or-defuse and throw an HE — which on Windows are the browser's Back and Forward, and | |
| 399 | while the game has the mouse they are the game's, so a thumb mid-round cannot leave the | |
| 400 | page.</p> | |
| 396 | 401 | <p class="hint">Full screen keeps the screen through a pause where a browser lets it: |
| 397 | 402 | Chrome and Edge hand the game the <kbd>Esc</kbd> key, and hold it down to leave. Safari and |
| 398 | 403 | Firefox leave full screen on <kbd>Esc</kbd> whatever the page asks, so Resume puts it back — |
| @@ -402,6 +407,16 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => ` | ||
| 402 | 407 | <p class="hint">Every weapon's numbers — what it costs, what it fires and what one shot of it |
| 403 | 408 | does to a person — are on <a href="${WEAPONS_HREF}">the weapons page</a>, rendered from the |
| 404 | 409 | table the fight is tuned by. It is a page of its own so it is a link you can send.</p> |
| 410 | <details id="support"> | |
| 411 | <summary>What this browser is doing</summary> | |
| 412 | <p class="hint">One line for a bug report: the browser, the graphics driver behind the | |
| 413 | picture and the size it is drawn at. "Buggy on Windows" and this line are two different | |
| 414 | reports, and only one of them can be answered. Nothing is sent anywhere — it is text on | |
| 415 | this page until you copy it.</p> | |
| 416 | <p class="line" id="supportline">This line is filled in by the game, so a browser with | |
| 417 | scripts off has nothing to paste.</p> | |
| 418 | <button type="button" id="copysupport" hidden>Copy this line</button> | |
| 419 | </details> | |
| 405 | 420 | `; |
| 406 | 421 | |
| 407 | 422 | /** |
src/pages/range.ts +3 −1
| @@ -112,7 +112,9 @@ const NOTE = ` | ||
| 112 | 112 | <div class="row"><button type="button" class="primary" id="look">Capture the mouse</button></div> |
| 113 | 113 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · |
| 114 | 114 | <kbd>Shift</kbd> walk · <kbd>F</kbd> or the mouse fires · <kbd>R</kbd> reload · |
| 115 | <kbd>Enter</kbd> start · <kbd>T</kbd> the other drill · <kbd>G</kbd> full screen, which is | |
| 115 | <kbd>Enter</kbd> start · <kbd>T</kbd> the other drill · the wheel jumps and the two buttons | |
| 116 | under your thumb reload and switch drill, which on Windows are the browser's Back and | |
| 117 | Forward and are this page's while it has the mouse · <kbd>G</kbd> full screen, which is | |
| 116 | 118 | G here because F is the trigger · <kbd>Esc</kbd> gives the mouse back, and keeps the screen |
| 117 | 119 | where a browser lets the page hold the key — Chrome and Edge do, and Safari and Firefox |
| 118 | 120 | leave full screen with it, so Capture the mouse puts that back too. On a touch screen the left of the view is a stick, the right looks around, and the |
src/pages/shell.test.ts +8 −3
| @@ -109,9 +109,14 @@ test("text that carries markup comes out inert, wherever it enters", () => { | ||
| 109 | 109 | expect(html.match(/<script/g)?.length).toBe(1); |
| 110 | 110 | expect(html).not.toContain("<script>alert(1)</script>"); |
| 111 | 111 | expect(html).not.toContain("</script><script>"); |
| 112 | // Ten, not eight: the heading and the environment are each printed twice, | |
| 113 | // once in the header and once in the footer. | |
| 114 | expect(html.split("</script><script>alert(1)</script>").length - 1).toBe(10); | |
| 112 | // Eleven, not eight: the heading and the environment are each printed twice, | |
| 113 | // once in the header and once in the footer, and the commit is printed twice | |
| 114 | // too — the header a person reads and the `<meta name=build>` the game reads | |
| 115 | // for the line a bug report pastes. | |
| 116 | expect(html.split("</script><script>alert(1)</script>").length - 1).toBe(11); | |
| 117 | // Including in that meta, whose content is an attribute and is not a way out | |
| 118 | // of one. | |
| 119 | expect(html).toContain(`<meta name="build" content="</script>`); | |
| 115 | 120 | }); |
| 116 | 121 | |
| 117 | 122 | test("markup handed in as markup is written through, not escaped again", () => { |
src/pages/shell.ts +7
| @@ -281,6 +281,12 @@ export type PageOptions = { | ||
| 281 | 281 | nav: NavEntry[]; |
| 282 | 282 | env: string; |
| 283 | 283 | pull: string | undefined; |
| 284 | /** | |
| 285 | * The commit this deployment was built from. It is in the header for a person | |
| 286 | * to read and in a `<meta name=build>` for the game to read, because the line | |
| 287 | * a bug report pastes has to say whether it came from production or from a | |
| 288 | * preview — the same string, escaped in both places, and empty is neither. | |
| 289 | */ | |
| 284 | 290 | commit: string; |
| 285 | 291 | /** |
| 286 | 292 | * The URL of the one module script. Nothing else runs — and a page that runs |
| @@ -331,6 +337,7 @@ export function page(options: PageOptions): string { | ||
| 331 | 337 | <link rel="icon" href="${FAVICON}"> |
| 332 | 338 | ${preloads(options.assets)}${endpoints(options.endpoints)}<title>${escape(options.title)}</title> |
| 333 | 339 | <meta name="description" content="${escape(options.description)}"> |
| 340 | ${options.commit ? `<meta name="build" content="${escape(options.commit)}">\n` : ""} | |
| 334 | 341 | <style>${styleFor(options.extraStyle)}</style> |
| 335 | 342 | <header> |
| 336 | 343 | <h1>${escape(options.heading)}</h1> |
src/range/binding.test.ts +10 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { |
| 3 | heldActions, NO_INTENT, RANGE_ACTIONS, RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, readIntent, | |
| 3 | heldActions, NO_INTENT, RANGE_ACTIONS, RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, RANGE_WHEEL, readIntent, | |
| 4 | 4 | } from "./binding.ts"; |
| 5 | 5 | |
| 6 | 6 | const NONE = { x: 0, y: 0 }; |
| @@ -24,6 +24,15 @@ test("the range's bindings are its own, not the shooter's", () => { | ||
| 24 | 24 | expect(RANGE_KEYS.KeyF).toBe("fire"); |
| 25 | 25 | expect(RANGE_MOUSE[0]).toBe("fire"); |
| 26 | 26 | expect(RANGE_MOUSE[2]).toBeUndefined(); |
| 27 | // The two under the thumb are bound because they are Back and Forward to a | |
| 28 | // browser on Windows, and the wheel presses nothing: this game has nothing | |
| 29 | // left worth a button, and an unbound button stays the browser's. | |
| 30 | expect(RANGE_MOUSE[3]).toBe("reload"); | |
| 31 | expect(RANGE_MOUSE[4]).toBe("drill"); | |
| 32 | expect(RANGE_MOUSE[1]).toBeUndefined(); | |
| 33 | expect(RANGE_WHEEL.up).toBe("jump"); | |
| 34 | expect(RANGE_WHEEL.down).toBe("jump"); | |
| 35 | for (const action of Object.values(RANGE_WHEEL)) expect<readonly string[]>(RANGE_ACTIONS).toContain(action); | |
| 27 | 36 | expect([...RANGE_TAP]).toEqual(["jump", "reload", "start", "drill"]); |
| 28 | 37 | // Nothing cs-shaped: no buy, no scoreboard, no weapon slots. |
| 29 | 38 | for (const code of ["KeyB", "Tab", "Digit1", "Digit2", "Digit3"]) |
src/range/binding.ts +12 −2
| @@ -21,8 +21,18 @@ export const RANGE_KEYS: Record<string, string> = { | ||
| 21 | 21 | KeyG: "fullscreen", |
| 22 | 22 | }; |
| 23 | 23 | |
| 24 | /** One weapon, one button: the left one. There is nothing to scope here. */ | |
| 25 | export const RANGE_MOUSE: Record<number, string> = { 0: "fire" }; | |
| 24 | /** | |
| 25 | * One weapon, one trigger: the left button. The two under the thumb reload and | |
| 26 | * switch drill — not because a range needs them, but because they are Back and | |
| 27 | * Forward to a browser on Windows, and a thumb that finds one mid-run would | |
| 28 | * otherwise leave the page. Bound here, their default is taken by the controls; | |
| 29 | * the wheel pressed is bound to nothing, because this game has nothing left | |
| 30 | * worth a button and an unbound button stays the browser's. | |
| 31 | */ | |
| 32 | export const RANGE_MOUSE: Record<number, string> = { 0: "fire", 3: "reload", 4: "drill" }; | |
| 33 | ||
| 34 | /** The wheel jumps, whichever way it is turned. */ | |
| 35 | export const RANGE_WHEEL: Record<string, string> = { up: "jump", down: "jump" }; | |
| 26 | 36 | |
| 27 | 37 | /** On-screen buttons that are a tap, not a hold. */ |
| 28 | 38 | export const RANGE_TAP = ["jump", "reload", "start", "drill"] as const; |
src/range/boot.test.ts +38 −2
| @@ -35,6 +35,14 @@ function booted( | ||
| 35 | 35 | |
| 36 | 36 | const hudTexts = (p: RangePage): string[] => p.hud.paint?.texts ?? []; |
| 37 | 37 | |
| 38 | /** | |
| 39 | * Let the browser's answer to a pointer lock come back. Asking is a promise | |
| 40 | * now — a browser refuses one asked for too soon, and this page reads the | |
| 41 | * refusal rather than pretending the mouse arrived — so a click on Capture is | |
| 42 | * answered a turn of the loop later, not inside the click. | |
| 43 | */ | |
| 44 | const settled = (): Promise<void> => Bun.sleep(0); | |
| 45 | ||
| 38 | 46 | test("G and the button put the lane on the whole screen, and a browser that will not is not lied about", () => { |
| 39 | 47 | const { p, undo } = booted(); |
| 40 | 48 | try { |
| @@ -59,7 +67,7 @@ test("G and the button put the lane on the whole screen, and a browser that will | ||
| 59 | 67 | } |
| 60 | 68 | }); |
| 61 | 69 | |
| 62 | test("Escape gives the mouse back, and the screen a browser took comes back with Capture", () => { | |
| 70 | test("Escape gives the mouse back, and the screen a browser took comes back with Capture", async () => { | |
| 63 | 71 | // Bun has no Keyboard Lock API, so this is the browser where Escape keeps its |
| 64 | 72 | // own meaning — Safari on a Mac — and the way back is the one this page owns. |
| 65 | 73 | const { p, undo } = booted(); |
| @@ -70,6 +78,7 @@ test("Escape gives the mouse back, and the screen a browser took comes back with | ||
| 70 | 78 | p.by("look").fire("click"); |
| 71 | 79 | p.document.pointerLockElement = p.scene; |
| 72 | 80 | p.document.fire("pointerlockchange"); |
| 81 | await settled(); | |
| 73 | 82 | expect(p.document.fullscreenElement).toBe(stage); |
| 74 | 83 | expect(p.by("note").hidden).toBe(true); |
| 75 | 84 | |
| @@ -190,7 +199,7 @@ test("a phone gets there too: the left of the stage is a stick, the right looks | ||
| 190 | 199 | } |
| 191 | 200 | }); |
| 192 | 201 | |
| 193 | test("the mouse turns the view once it is locked, and not before", () => { | |
| 202 | test("the mouse turns the view once it is locked, and not before", async () => { | |
| 194 | 203 | const { p, undo } = booted(); |
| 195 | 204 | try { |
| 196 | 205 | p.window.fire("mousemove", { movementX: 10, movementY: 0 }); |
| @@ -198,8 +207,12 @@ test("the mouse turns the view once it is locked, and not before", () => { | ||
| 198 | 207 | expect(hudTexts(p)).toContain("yaw 0.0° · pitch 0.0°"); |
| 199 | 208 | |
| 200 | 209 | p.by("look").fire("click"); |
| 210 | await settled(); | |
| 201 | 211 | expect(p.by("note").hidden).toBe(true); |
| 202 | 212 | expect(p.scene.locks).toBe(1); |
| 213 | // Raw counts, asked for by name: without this Windows hands the page its | |
| 214 | // own pointer acceleration and the same flick is worth a different angle. | |
| 215 | expect(p.scene.lockOptions).toEqual([{ unadjustedMovement: true }]); | |
| 203 | 216 | p.document.pointerLockElement = p.scene; |
| 204 | 217 | p.document.fire("pointerlockchange"); |
| 205 | 218 | p.window.fire("mousemove", { movementX: 10, movementY: 10 }); |
| @@ -221,6 +234,29 @@ test("the mouse turns the view once it is locked, and not before", () => { | ||
| 221 | 234 | } |
| 222 | 235 | }); |
| 223 | 236 | |
| 237 | test("a refused pointer leaves the note up: the page never says captured when it is not", async () => { | |
| 238 | const { p, undo } = booted(); | |
| 239 | try { | |
| 240 | // Chromium refusing a lock asked for within a second of the last one being | |
| 241 | // released — press Escape, press Capture, and this is what comes back. | |
| 242 | p.scene.refuseLock = true; | |
| 243 | p.by("look").fire("click"); | |
| 244 | await settled(); | |
| 245 | // Asked twice: once for raw counts and once plainly, in case the dictionary | |
| 246 | // was what this browser objected to. Both refused, so the note stays. | |
| 247 | expect(p.scene.locks).toBe(2); | |
| 248 | expect(p.by("note").hidden).toBe(false); | |
| 249 | ||
| 250 | // And pressing it again once the browser has calmed down works. | |
| 251 | p.scene.refuseLock = false; | |
| 252 | p.by("look").fire("click"); | |
| 253 | await settled(); | |
| 254 | expect(p.by("note").hidden).toBe(true); | |
| 255 | } finally { | |
| 256 | undo(); | |
| 257 | } | |
| 258 | }); | |
| 259 | ||
| 224 | 260 | test("a canvas with no context is a sentence on the page, not an exception", () => { |
| 225 | 261 | const { p, undo } = booted({ has2d: false }); |
| 226 | 262 | try { |
src/range/main.ts +28 −16
| @@ -46,7 +46,7 @@ | ||
| 46 | 46 | */ |
| 47 | 47 | |
| 48 | 48 | import { createRangeAudio } from "./audio.ts"; |
| 49 | import { RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, heldActions, readIntent } from "./binding.ts"; | |
| 49 | import { RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, RANGE_WHEEL, heldActions, readIntent } from "./binding.ts"; | |
| 50 | 50 | import { RANGE_ROOM } from "./level.ts"; |
| 51 | 51 | import { |
| 52 | 52 | RANGE_HULL, RANGE_MOVE, createRangeBody, createRangeWish, rangeEye, rangeWish, |
| @@ -59,8 +59,8 @@ import { | ||
| 59 | 59 | type DrillName, type Target, |
| 60 | 60 | } from "./targets.ts"; |
| 61 | 61 | import { |
| 62 | angleVectors, attachButtons, clamp, createControls, createEscape, createFullscreen, createLoop, | |
| 63 | createRenderer, move, type KeyboardLock, type Vec3, | |
| 62 | angleVectors, attachButtons, backing, clamp, createControls, createEscape, createFullscreen, | |
| 63 | createLoop, createRenderer, move, type KeyboardLock, type Vec3, | |
| 64 | 64 | } from "../engine/index.ts"; |
| 65 | 65 | |
| 66 | 66 | /** The range's own step: 50 Hz, deliberately not the shooter's 64. */ |
| @@ -123,7 +123,7 @@ export function boot(): void { | ||
| 123 | 123 | |
| 124 | 124 | const controls = createControls( |
| 125 | 125 | { surface: scene, window, document }, |
| 126 | { keys: RANGE_KEYS, mouse: RANGE_MOUSE }, | |
| 126 | { keys: RANGE_KEYS, mouse: RANGE_MOUSE, wheel: RANGE_WHEEL }, | |
| 127 | 127 | ); |
| 128 | 128 | attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: RANGE_TAP }); |
| 129 | 129 | // The range's own recipes over the engine's device. Nothing is opened here: |
| @@ -199,17 +199,20 @@ export function boot(): void { | ||
| 199 | 199 | // A phone has no pointer to lock, and says so by throwing or by rejecting: |
| 200 | 200 | // either way the note gets out of the way and the touch look still works. |
| 201 | 201 | $("look").addEventListener("click", () => { |
| 202 | note.hidden = true; | |
| 203 | 202 | // The click is a gesture, so it is also the way back into a screen a browser |
| 204 | 203 | // took: on a Mac, Escape hands the window back whether the page wanted it or |
| 205 | 204 | // not, and `wantsFull` below is how this page remembers that it did. |
| 206 | 205 | restoreFull(); |
| 207 | try { | |
| 208 | const asked: unknown = scene.requestPointerLock?.(); | |
| 209 | if (asked instanceof Promise) asked.catch(() => {}); | |
| 210 | } catch { | |
| 211 | // No pointer lock here. Nothing to do about it, and nothing to report. | |
| 212 | } | |
| 206 | // The note goes when the pointer actually arrives, and not before. A | |
| 207 | // refusal is a real answer here — Chromium will not grant a lock for about | |
| 208 | // a second after Escape released one, which is exactly how fast a person | |
| 209 | // presses Capture again — and the old code hid the note either way, leaving | |
| 210 | // a page that looked captured and did not turn. A phone is the other case: | |
| 211 | // no pointer to lock, the refusal is honest, and the finger still looks | |
| 212 | // around, so the note goes. | |
| 213 | void controls.lock().then((got) => { | |
| 214 | note.hidden = got || matchMedia("(pointer: coarse)").matches; | |
| 215 | }); | |
| 213 | 216 | }); |
| 214 | 217 | controls.onUnlock = () => { |
| 215 | 218 | note.hidden = false; |
| @@ -255,17 +258,23 @@ export function boot(): void { | ||
| 255 | 258 | const escape = createEscape( |
| 256 | 259 | { window, document, keyboard: (navigator as { keyboard?: KeyboardLock }).keyboard }, |
| 257 | 260 | full, |
| 261 | // Every key this page binds, asked for by name while it has the screen: on | |
| 262 | // Windows the browser's own shortcuts are Ctrl and a letter, and Ctrl here | |
| 263 | // is crouch — Ctrl+W is close the tab, Ctrl+R is reload the run away. | |
| 264 | Object.keys(RANGE_KEYS), | |
| 258 | 265 | ); |
| 259 | 266 | escape.onPress = () => { |
| 260 | 267 | if (controls.locked) document.exitPointerLock(); |
| 261 | 268 | }; |
| 262 | 269 | touch.hidden = !matchMedia("(pointer: coarse)").matches; |
| 263 | 270 | |
| 264 | /** Match a canvas's backing store to its box, at most twice the CSS pixels. */ | |
| 271 | /** | |
| 272 | * Match a canvas's backing store to its box. The arithmetic — and the cap on | |
| 273 | * the ratio — is `engine/pixels.ts`, which is also where the overlay learns | |
| 274 | * to put an edge on a pixel at 125%. | |
| 275 | */ | |
| 265 | 276 | const size = (canvas: HTMLCanvasElement): [number, number] => { |
| 266 | const dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| 267 | const width = Math.max(1, Math.round(canvas.clientWidth * dpr)); | |
| 268 | const height = Math.max(1, Math.round(canvas.clientHeight * dpr)); | |
| 277 | const [width, height] = backing(canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio); | |
| 269 | 278 | if (canvas.width !== width || canvas.height !== height) { |
| 270 | 279 | canvas.width = width; |
| 271 | 280 | canvas.height = height; |
| @@ -372,7 +381,9 @@ export function boot(): void { | ||
| 372 | 381 | renderer.draw(buildRangeScene(run, view, trail, time, frameScene), elapsed); |
| 373 | 382 | } else if (sceneCtx !== null) { |
| 374 | 383 | const [sw, sh] = size(scene); |
| 375 | drawHorizon(sceneCtx, view, sw, sh, { eye: view.eye, targets: run.targets }); | |
| 384 | drawHorizon( | |
| 385 | sceneCtx, view, sw, sh, { eye: view.eye, targets: run.targets }, window.devicePixelRatio, | |
| 386 | ); | |
| 376 | 387 | } |
| 377 | 388 | // No third branch: the guard above returned when both were null. |
| 378 | 389 | const [hw, hh] = size(hud); |
| @@ -401,6 +412,7 @@ export function boot(): void { | ||
| 401 | 412 | }, |
| 402 | 413 | hw, |
| 403 | 414 | hh, |
| 415 | window.devicePixelRatio, | |
| 404 | 416 | ); |
| 405 | 417 | $("fps").textContent = `${(1000 / Math.max(1, frameMs)).toFixed(0)} fps · ${frameMs.toFixed(1)} ms`; |
| 406 | 418 | } |
src/range/readout.test.ts +35 −2
| @@ -109,8 +109,14 @@ test("the HUD is cleared and every line is drawn once", () => { | ||
| 109 | 109 | drawReadout(paint, READOUT, 960, 540); |
| 110 | 110 | expect(paint.calls[0]?.[0]).toBe("clearRect"); |
| 111 | 111 | expect(paint.texts).toEqual(readoutLines(READOUT)); |
| 112 | // The crosshair: two strokes crossing at the centre. | |
| 113 | expect(paint.lines).toContainEqual(["moveTo", 480, 270 - Math.max(6, Math.round(960 / 90))]); | |
| 112 | // The crosshair: four filled arms of whole device pixels, meeting at the | |
| 113 | // centre of the canvas — 480, 270 — with no gap between them. | |
| 114 | const arm = Math.max(6, Math.round(960 / 90)); | |
| 115 | const rects = paint.calls.filter((c) => c[0] === "fillRect"); | |
| 116 | expect(rects).toContainEqual(["fillRect", 480, 269, arm, 2]); | |
| 117 | expect(rects).toContainEqual(["fillRect", 480 - arm, 269, arm, 2]); | |
| 118 | expect(rects).toContainEqual(["fillRect", 479, 270, 2, arm]); | |
| 119 | expect(rects).toContainEqual(["fillRect", 479, 270 - arm, 2, arm]); | |
| 114 | 120 | }); |
| 115 | 121 | |
| 116 | 122 | test("the horizon follows the pitch and the compass follows the yaw", () => { |
| @@ -137,6 +143,33 @@ test("the horizon follows the pitch and the compass follows the yaw", () => { | ||
| 137 | 143 | expect(turned.texts).not.toContain("0"); |
| 138 | 144 | }); |
| 139 | 145 | |
| 146 | test("the fallback scene is the same size on the glass at every device ratio", () => { | |
| 147 | // This is the picture a machine whose driver refused the shaders gets — see | |
| 148 | // `client/support.ts` — so at 150% its lines cannot be two thirds of a pixel | |
| 149 | // and its compass numbers cannot be eight pixels tall. | |
| 150 | const tickHeight = (paint: ReturnType<typeof recorder>): number => { | |
| 151 | // The compass's first tick: the second moveTo/lineTo pair, after the horizon. | |
| 152 | const [, , up, down] = paint.lines; | |
| 153 | return Number(down?.[2]) - Number(up?.[2]); | |
| 154 | }; | |
| 155 | for (const [dpr, ratio] of [[1, 1], [1.25, 1.25], [1.5, 1.5], [2, 2], [3, 2]] as const) { | |
| 156 | const paint = recorder(); | |
| 157 | const side = Math.round(900 * ratio); | |
| 158 | drawHorizon(paint, { yaw: 0, pitch: 0 }, side, Math.round(450 * ratio), EMPTY, dpr); | |
| 159 | // Eight CSS pixels above the horizon and eight below, whatever a pixel is. | |
| 160 | expect(tickHeight(paint)).toBe(2 * Math.round(8 * ratio)); | |
| 161 | // The last font and line width set are the compass's: twelve CSS pixels of | |
| 162 | // text and one of tick, in device pixels, and never rounded away to none. | |
| 163 | expect(paint.font).toBe(`${Math.round(12 * ratio)}px system-ui, sans-serif`); | |
| 164 | expect(paint.lineWidth).toBe(Math.max(1, Math.round(ratio))); | |
| 165 | // A 3× phone is drawn at the cap the backing store is capped at, not at 3. | |
| 166 | if (dpr === 3) expect(paint.font).toBe("24px system-ui, sans-serif"); | |
| 167 | } | |
| 168 | }); | |
| 169 | ||
| 170 | /** No plates: the scene a browser with no WebGL2 draws before a run starts. */ | |
| 171 | const EMPTY: Sight = { eye: EYE, targets: [] }; | |
| 172 | ||
| 140 | 173 | /** A plate at a place, so the projection below is arithmetic and not a seed. */ |
| 141 | 174 | const plate = (at: [number, number, number], half = 16, left = 1): Target => ({ |
| 142 | 175 | id: 1, |
src/range/readout.ts +34 −11
| @@ -17,7 +17,9 @@ | ||
| 17 | 17 | * changes nothing. |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | import { angleDelta, clamp, deg, overlay, type Paint, type Vec3 } from "../engine/index.ts"; | |
| 20 | import { | |
| 21 | angleDelta, clamp, deg, overlay, px as pixelsOf, ratioOf, type Paint, type Vec3, | |
| 22 | } from "../engine/index.ts"; | |
| 21 | 23 | import { EYE, type Report, type Target } from "./targets.ts"; |
| 22 | 24 | |
| 23 | 25 | /** The 2D calls this module makes: the engine's surface, re-exported so `main.ts` can name it. */ |
| @@ -177,6 +179,14 @@ export function blips(view: View, sight: Sight, width: number, height: number): | ||
| 177 | 179 | * Every part of it comes from the same two angles the panel prints, so what you |
| 178 | 180 | * see and what it says cannot disagree — and a browser with no WebGL2 can still |
| 179 | 181 | * find a plate, shoot it and read the score. |
| 182 | * | |
| 183 | * `width` and `height` are device pixels, and `dpr` says how many go to a CSS | |
| 184 | * pixel, so the horizon's line and the compass's ticks and numbers are the same | |
| 185 | * size on the glass at 125% and on a retina screen as they are at 100% — this is | |
| 186 | * the picture a machine whose driver refused the shaders actually gets, and a | |
| 187 | * half-pixel hairline with six-pixel labels is not a picture. What it does *not* | |
| 188 | * snap is the horizon or a blip: both move with the view, and a moving line | |
| 189 | * snapped to the grid steps rather than slides. | |
| 180 | 190 | */ |
| 181 | 191 | export function drawHorizon( |
| 182 | 192 | paint: Paint, |
| @@ -184,8 +194,12 @@ export function drawHorizon( | ||
| 184 | 194 | width: number, |
| 185 | 195 | height: number, |
| 186 | 196 | sight: Sight = EMPTY_SIGHT, |
| 197 | dpr = 1, | |
| 187 | 198 | ): void { |
| 188 | 199 | const px = perDegree(height); |
| 200 | const ratio = ratioOf(dpr); | |
| 201 | /** A length written in CSS pixels, in the device pixels this canvas is in. */ | |
| 202 | const size = (css: number): number => pixelsOf(css, ratio); | |
| 189 | 203 | // Pitch is positive downwards, so looking down lifts the horizon up the screen. |
| 190 | 204 | const horizon = clamp(height / 2 - view.pitch * px, -height, height * 2); |
| 191 | 205 | paint.fillStyle = SKY; |
| @@ -195,26 +209,27 @@ export function drawHorizon( | ||
| 195 | 209 | paint.fillRect(0, top, width, height - top); |
| 196 | 210 | |
| 197 | 211 | paint.strokeStyle = LINE; |
| 198 | paint.lineWidth = 2; | |
| 212 | paint.lineWidth = size(2); | |
| 199 | 213 | paint.beginPath(); |
| 200 | 214 | paint.moveTo(0, horizon); |
| 201 | 215 | paint.lineTo(width, horizon); |
| 202 | 216 | paint.stroke(); |
| 203 | 217 | |
| 204 | 218 | // One tick per 15°, only the ones that fall on screen. |
| 219 | const tick = size(8), margin = size(8); | |
| 205 | 220 | paint.strokeStyle = DIM; |
| 206 | paint.lineWidth = 1; | |
| 207 | paint.font = "12px system-ui, sans-serif"; | |
| 221 | paint.lineWidth = size(1); | |
| 222 | paint.font = `${size(12)}px system-ui, sans-serif`; | |
| 208 | 223 | paint.textBaseline = "top"; |
| 209 | 224 | for (let angle = 0; angle < 360; angle += 15) { |
| 210 | 225 | const x = width / 2 + angleDelta(angle, view.yaw) * px; |
| 211 | if (x < 8 || x > width - 8) continue; | |
| 226 | if (x < margin || x > width - margin) continue; | |
| 212 | 227 | paint.beginPath(); |
| 213 | paint.moveTo(x, horizon - 8); | |
| 214 | paint.lineTo(x, horizon + 8); | |
| 228 | paint.moveTo(x, horizon - tick); | |
| 229 | paint.lineTo(x, horizon + tick); | |
| 215 | 230 | paint.stroke(); |
| 216 | 231 | paint.fillStyle = DIM; |
| 217 | paint.fillText(`${angle}`, x + 4, horizon + 10); | |
| 232 | paint.fillText(`${angle}`, x + size(4), horizon + tick + size(2)); | |
| 218 | 233 | } |
| 219 | 234 | |
| 220 | 235 | // The plates. A square the size the angle says, dimmed once it is most of |
| @@ -226,12 +241,18 @@ export function drawHorizon( | ||
| 226 | 241 | } |
| 227 | 242 | |
| 228 | 243 | /** The HUD: the panel of numbers, and the crosshair the view turns around. */ |
| 229 | export function drawReadout(paint: Paint, r: Readout, width: number, height: number): void { | |
| 244 | export function drawReadout( | |
| 245 | paint: Paint, | |
| 246 | r: Readout, | |
| 247 | width: number, | |
| 248 | height: number, | |
| 249 | dpr = 1, | |
| 250 | ): void { | |
| 230 | 251 | paint.clearRect(0, 0, width, height); |
| 231 | 252 | |
| 232 | 253 | // The tools are built here rather than kept, because this is the one place |
| 233 | 254 | // the range knows its size: a frame is a few dozen calls, not a few thousand. |
| 234 | const o = overlay(paint, width, height); | |
| 255 | const o = overlay(paint, width, height, dpr); | |
| 235 | 256 | // The engine's margin: a fiftieth of the width, as this always was, and never |
| 236 | 257 | // under 12 px rather than under 10 — which is the same number above 600 px |
| 237 | 258 | // wide and two pixels kinder to a phone below it. |
| @@ -251,5 +272,7 @@ export function drawReadout(paint: Paint, r: Readout, width: number, height: num | ||
| 251 | 272 | |
| 252 | 273 | // The crosshair marks the centre the angles are measured from: no gap, so the |
| 253 | 274 | // four arms meet and it reads as the plain cross it always was. |
| 254 | o.crosshair(Math.round(width / 2), Math.round(height / 2), 0, Math.max(6, Math.round(width / 90)), "#8ea"); | |
| 275 | // The centre of the canvas and not a rounded one: `crosshair` puts the arms | |
| 276 | // on whole pixels around it, which a rounded centre would only move off. | |
| 277 | o.crosshair(width / 2, height / 2, 0, Math.max(o.px(6), Math.round(width / 90)), "#8ea"); | |
| 255 | 278 | } |
new above
offline