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

Merged opened this in answer to

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

Merged

vesper/cs-engine:vesper/307-buggy-on-windows-tried-playing-with-a-mo → main 7 commits fork

  • Merged. vesper/307-buggy-on-windows-tried-playing-with-a-mo was merged into main as be13c27.

Review

Sign in to review this change.
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
419419 put an element full screen — an iPhone will not — never shows that button rather
420420 than showing one that does nothing.
421421
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
422472 Escape is the one key the browser reads before the page does, and on a Mac what
423473 it means is *give the window back*: full screen drops with the whole animation,
424474 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
4949
5050 test("the entry table is what the app ships today, each with the size it may not pass", () => {
5151 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 } },
5555 ]);
5656 // And it is the registry projected, not a second list beside it: a game's
5757 // source and its ceiling are written down once, in `src/games.ts`.
src/client/binding.test.ts +20 −1
@@ -1,5 +1,5 @@
11 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";
33
44 const NONE = { x: 0, y: 0 };
55 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", () => {
2525 expect(CS_KEYS.Digit3).toBe("slot3");
2626 expect(CS_MOUSE[0]).toBe("fire");
2727 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);
2838 expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade", "flash", "smoke"]);
2939 // F is the page's, not the world's: `main.ts` hears it on `onAction` and asks
3040 // 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
6474 }
6575 });
6676
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
6786 test("nothing held is nothing asked for", () => {
6887 const { input, scoreboard, slotWanted } = intent([]);
6988 expect(input.forward).toBe(0);
src/client/binding.ts +25 −4
@@ -59,8 +59,27 @@ export const CS_KEYS: Record<string, string> = {
5959 KeyX: "rebuy",
6060 };
6161
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" };
6483
6584 /** On-screen buttons that are a tap, not a hold. */
6685 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
102121 scoreboard: down("scoreboard"),
103122 // Three pockets, and only three: the rest of the number row belongs to the
104123 // 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,
107128 };
108129 }
src/client/hud.test.ts +93 −23
@@ -83,12 +83,14 @@ const HEIGHT = 720;
8383 */
8484 function draw(
8585 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 } = {},
8789 ): Recorder {
8890 const paint = recorder();
8991 drawHud(
9092 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,
9294 );
9395 return paint;
9496 }
@@ -111,19 +113,28 @@ function line(paint: Recorder, text: string): Call {
111113 // --- the crosshair ---------------------------------------------------------
112114
113115 /**
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.
119121 */
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]) };
125130 }
126131
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
127138 /** How far the crosshair's first arm starts from the middle of the screen. */
128139 const gapOf = (paint: Recorder, width = WIDTH): number => armOf(paint, width).near;
129140
@@ -138,14 +149,17 @@ const spreadOf = (world: World, moving: number): number =>
138149 test("the crosshair opens exactly as far as the gun is inaccurate, and is gone when you are", () => {
139150 const still = scene();
140151 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));
143157 // 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);
145159
146160 // Running wide open: any speed at or over the walk cap counts as fully moving.
147161 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));
149163 expect(gapOf(draw(running))).toBeGreaterThan(gapOf(rest));
150164
151165 // 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
157171 // Dead players do not aim: no crosshair, no sight, nothing stroked at all.
158172 const dead = draw(scene((w) => { w.me.alive = false; }));
159173 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);
161222 });
162223
163224 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
165226 w.time = 10;
166227 w.effects = only({ kind: "hitmark", at: 9.9, headshot: false });
167228 }));
229 // One stroke, and it is the marker: the crosshair is filled, not stroked.
168230 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)");
171234
172235 const head = draw(scene((w) => {
173236 w.time = 10;
174237 w.effects = only({ kind: "hitmark", at: 9.9, headshot: true });
175238 }));
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)");
177240
178241 const old = draw(scene((w) => {
179242 w.time = 10;
180243 w.effects = only({ kind: "hitmark", at: 9.7, headshot: true });
181244 }));
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);
183249 });
184250
185251 // --- health, money and armour ----------------------------------------------
@@ -586,7 +652,9 @@ test("the same canvas turned sideways is drawn at the new size, not the one befo
586652 // The clock bar is 240 units wide at the scale the *new* width says. It is
587653 // the first filled rectangle that is not a full-screen wash — this world has
588654 // 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;
590658 expect(bar.args[2]).toBe(Math.round(240 * 0.7));
591659 });
592660
@@ -622,7 +690,9 @@ test("a flashed player gets white over the world, and the crosshair back before
622690 expect(whiteout(0.45)).toBeLessThan(0.5);
623691 const half = draw(scene((w) => { (w.me as Player).blind = 0.45; }));
624692 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);
626696 expect(crosshair).toBeGreaterThanOrEqual(0);
627697 expect(half.calls.indexOf(wash)).toBeGreaterThan(crosshair);
628698
src/client/hud.ts +24 −8
@@ -26,7 +26,7 @@
2626 */
2727
2828 import {
29 angleDelta, clamp, effectAt, overlay,
29 angleDelta, clamp, effectAt, overlay, ratioOf,
3030 type Overlay, type Paint as Surface, type Vec3,
3131 } from "../engine/index.ts";
3232 import { drawMinimap } from "./minimap.ts";
@@ -77,9 +77,12 @@ const NOTHING: readonly string[] = [];
7777 let tools: Overlay | null = null;
7878 let builtOn: Paint | null = null;
7979
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);
8386 builtOn = ctx;
8487 }
8588 return tools;
@@ -191,6 +194,11 @@ export const FOG = 0.9;
191194 * round, the feed and the scoreboard belong to the match and are the same from
192195 * anybody's eyes. Left out, the view is you, which is every frame of a round
193196 * 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.
194202 */
195203 export function drawHud(
196204 ctx: Paint,
@@ -199,8 +207,9 @@ export function drawHud(
199207 height: number,
200208 showScoreboard: boolean,
201209 view: Player = world.me,
210 dpr = 1,
202211 ): void {
203 const ui = toolsFor(ctx, width, height);
212 const ui = toolsFor(ctx, width, height, dpr);
204213 ctx.clearRect(0, 0, width, height);
205214 ctx.textBaseline = "alphabetic";
206215 const weapon = currentWeapon(view);
@@ -236,7 +245,11 @@ export function drawHud(
236245 ducking: view.duckAmount > 0.5,
237246 scoped: view.scoped,
238247 });
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);
240253 if (view.scoped) {
241254 ctx.fillStyle = "rgba(0,0,0,0.55)";
242255 ctx.beginPath();
@@ -254,8 +267,11 @@ export function drawHud(
254267 const age = world.time - effect.at;
255268 if (age > 0.25) continue;
256269 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);
259275 ctx.beginPath();
260276 const arms: [number, number][] = [[1, 1], [-1, -1], [1, -1], [-1, 1]];
261277 for (const [dx, dy] of arms) {
src/client/main.test.ts +131 −6
@@ -32,6 +32,7 @@ import { expect, test } from "bun:test";
3232 import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts";
3333 import type { Level } from "./level.ts";
3434 import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, type Frame } from "./main.ts";
35 import { NO_PICTURE } from "./support.ts";
3536 import { HINT } from "./buy.ts";
3637 import { DE_FORGE } from "./map.ts";
3738 import { endRound, SHORT } from "./match.ts";
@@ -45,7 +46,7 @@ import { WEAPONS } from "./weapons.ts";
4546 import { matchOf } from "../matchid.ts";
4647 import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts";
4748 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";
4950 import { parseSituation } from "../plan/situation.ts";
5051 import { createWorld, type World } from "./world.ts";
5152
@@ -180,6 +181,12 @@ test("clicking play starts a match: the menu goes, the pointer is asked for, the
180181 expect(stage.by("menu").hidden).toBe(true);
181182 expect(stage.by("pause").hidden).toBe(true);
182183 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 }]);
183190 expect(stage.ids.length).toBe(1);
184191 expect(stage.sounds).toContain("resume");
185192
@@ -198,6 +205,25 @@ test("clicking play starts a match: the menu goes, the pointer is asked for, the
198205 expect(stage.by("fps").textContent).toContain("ms");
199206 });
200207
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
201227 test("nothing is heard before a match, and then your own boots are heard as your own", () => {
202228 const stage = painted(start(fakeStage()));
203229 // 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
296322 expect(world.me.yaw).toBeCloseTo(plainFrom - 0.484, 9);
297323 });
298324
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
299348 test("B opens the buy overlay: it is drawn, it keeps the pointer and the shelf takes the focus", () => {
300349 const stage = playing();
301350 stage.lock(true);
@@ -447,14 +496,16 @@ test("the buy overlay open suppresses fire, but not movement", () => {
447496 expect(stage.sounds.some((s) => s.startsWith("shot:"))).toBe(true);
448497 });
449498
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 () => {
451500 const stage = playing();
452501 stage.lock(true);
453502 stage.lock(false);
454503 expect(stage.by("pause").hidden).toBe(false);
455504
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.
457507 stage.by("resume").fire("click");
508 await settled();
458509 expect(stage.by("pause").hidden).toBe(true);
459510 expect(stage.scene.locks).toBe(2);
460511
@@ -470,15 +521,47 @@ test("losing the pointer pauses a running match, buy panel open or not", () => {
470521 expect(stage.by("pause").hidden).toBe(false);
471522 });
472523
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
473548 test("Escape pauses and keeps the screen where the browser hands the key over", async () => {
474549 const stage = playing();
475550 stage.press("KeyF");
476551 stage.lock(true);
477552 const box = stage.by("stage");
478553 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);
482565 await Promise.resolve();
483566
484567 stage.window.fire("keydown", { code: "Escape" });
@@ -493,6 +576,7 @@ test("Escape pauses and keeps the screen where the browser hands the key over",
493576 // And Resume goes back into the match without asking for a screen it already
494577 // has: one pointer lock, no second `requestFullscreen`.
495578 stage.by("resume").fire("click");
579 await settled();
496580 expect(stage.by("pause").hidden).toBe(true);
497581 expect(box.fullscreens).toBe(1);
498582 expect(stage.scene.locks).toBe(2);
@@ -958,6 +1042,47 @@ test("no WebGL2 is a sentence on the page, not an exception", () => {
9581042 expect(stage.atlases.length).toBe(0);
9591043 });
9601044
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
9611086 test("every mesh name a scene of this game can place is handed in at construction", () => {
9621087 // The renderer keeps what it is given and resolves nothing, so a name this
9631088 // table misses is an exception on the frame a player first holds that gun.
src/client/main.ts +206 −19
@@ -67,10 +67,12 @@
6767 */
6868
6969 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";
7171 import { drawHud, type Paint } from "./hud.ts";
72 import { NO_PICTURE, NO_PICTURE_SAYS, supportLine, type Facts, type Running } from "./support.ts";
7273 import {
73 attachButtons, clamp, createControls, createEscape, createFullscreen, createLoop, createRenderer,
74 attachButtons, backing, clamp, createControls, createEscape, createFullscreen, createLoop,
75 createRenderer,
7476 effectAt,
7577 painter,
7678 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
155157 export const COPIED = "Copied";
156158 export const NOT_COPIED = "Copy failed — the link still works";
157159
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
158163 // --- the host: what a page has to be for this frame to run on it -----------
159164
160165 /** 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 };
196201 /** A button that says which of two states it is in — the full screen one does. */
197202 export type Toggle = Panel & { setAttribute(name: string, value: string): void };
198203
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 };
201212
202213 export type PageDocument = Listens & {
203214 readonly pointerLockElement: unknown;
@@ -289,6 +300,13 @@ export type Host = {
289300 now(): number;
290301 /** Null when this browser has no WebGL2: the menu says so and nothing starts. */
291302 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;
292310 createAudio(): Audio;
293311 /**
294312 * 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 {
417435 const againButton = by("again");
418436 const leaveButton = by("leave");
419437
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
420479 const maybeBuild = host.createRenderer(sceneCanvas);
421480 if (maybeBuild === null) {
422481 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 {
431490 const audio = host.createAudio();
432491 const controls = createControls(
433492 { 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 },
435494 );
436495 attachButtons(doc.querySelectorAll("[data-action]"), controls, { tap: CS_TAP });
437496
@@ -478,7 +537,62 @@ export function boot(host: Host): Frame {
478537 * gives back. `P` is the same press for a player who would rather not find out
479538 * which browser they are on.
480539 */
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 }
482596
483597 const loop = createLoop({ tick: TICK }, {
484598 requestAnimationFrame: (callback) => win.requestAnimationFrame(callback),
@@ -544,10 +658,26 @@ export function boot(host: Host): Frame {
544658 let roomBuy: Item | null = null;
545659 let lastSound = 0;
546660 let frameAverage = 16.7;
661 /** Whether a frame has actually been drawn, so the average above means something. */
662 let painted = false;
547663 let posted = -1;
548664 /** Whether the post that said this match is finished has been made. */
549665 let postedFinal = false;
550666
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
551681 const show = (element: Panel, on: boolean): void => {
552682 element.hidden = !on;
553683 };
@@ -910,14 +1040,12 @@ export function boot(host: Host): Frame {
9101040 const level = levelOfChoice(choice, choice === "generated" ? seedFrom(nextMatch()) : 0);
9111041 // Device pixels, like the HUD's canvas: the CSS box is a fixed square and
9121042 // 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);
9161044 if (planCanvas.width !== w || planCanvas.height !== h) {
9171045 planCanvas.width = w;
9181046 planCanvas.height = h;
9191047 }
920 drawPreview(planCtx, level, w, h);
1048 drawPreview(planCtx, level, w, h, win.devicePixelRatio);
9211049 planAlt.textContent = previewText(level);
9221050 }
9231051
@@ -1064,7 +1192,7 @@ export function boot(host: Host): Frame {
10641192 show(summaryPanel, false);
10651193 show(touchPanel, win.matchMedia("(pointer: coarse)").matches);
10661194 audio.resume();
1067 void sceneCanvas.requestPointerLock();
1195 void takePointer();
10681196 host.storage.setItem("cs.name", name);
10691197 }
10701198
@@ -1111,7 +1239,10 @@ export function boot(host: Host): Frame {
11111239 show(summaryPanel, false);
11121240 show(touchPanel, win.matchMedia("(pointer: coarse)").matches);
11131241 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();
11151246 }
11161247
11171248 joinRoom.addEventListener("click", () => {
@@ -1162,9 +1293,10 @@ export function boot(host: Host): Frame {
11621293 // Resume is the gesture that puts back whatever the pause cost: the screen
11631294 // first, if a browser took it, and then the pointer.
11641295 resumeButton.addEventListener("click", () => {
1165 show(pause, false);
11661296 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();
11681300 });
11691301 /**
11701302 * Back to the page: the menu, nothing of the match left running, and the
@@ -1318,12 +1450,25 @@ export function boot(host: Host): Frame {
13181450
13191451 function frame(elapsed: number): void {
13201452 frameAverage += (elapsed * 1000 - frameAverage) * 0.05;
1453 painted = true;
13211454 // One layer of the atlas per frame, and the browser has the rest of it: the
13221455 // menu is clickable the whole way through. The last slice is what builds
13231456 // the renderer, before anything has been drawn with it.
13241457 if (renderer === null) {
13251458 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 }
13271472 ready();
13281473 return;
13291474 }
@@ -1423,14 +1568,16 @@ export function boot(host: Host): Frame {
14231568
14241569 renderer.draw(buildScene(world, camera, frameScene, view), elapsed);
14251570
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);
14291576 if (hudCanvas.width !== w || hudCanvas.height !== h) {
14301577 hudCanvas.width = w;
14311578 hudCanvas.height = h;
14321579 }
1433 drawHud(hudCtx, world, w, h, scoreboard, view);
1580 drawHud(hudCtx, world, w, h, scoreboard, view, win.devicePixelRatio);
14341581 by("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`;
14351582 }
14361583 loop.start(frame);
@@ -1476,6 +1623,29 @@ export function gameMeshes(level: Level = DE_FORGE): Record<string, Mesh> {
14761623 * question and the answer — which is the only way the engine's `createRenderer`
14771624 * returns null, and why the line below says so rather than pretending.
14781625 */
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
14791649 const browser = (): Host => ({
14801650 document,
14811651 window,
@@ -1500,6 +1670,23 @@ const browser = (): Host => ({
15001670 // asked for: a browser without it has no such member, and the copy control
15011671 // stays the plain link it is rendered as.
15021672 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 },
15031690 createRenderer: (canvas) => {
15041691 const surface = canvas as unknown as HTMLCanvasElement;
15051692 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:
9696 * It clears first, because it is drawn once per choice rather than once a frame
9797 * and the map before it was a different shape.
9898 */
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 {
100106 ctx.clearRect(0, 0, width, height);
101107 const side = Math.min(width, height);
102108 if (side <= 0) return;
103109 const x = Math.round((width - side) / 2);
104110 const y = Math.round((height - side) / 2);
105 const ui = overlay(ctx, side, side);
111 const ui = overlay(ctx, side, side, dpr);
106112 const at = drawPlan(ui, level, x, y, side);
107113 // One cell of this map, in pixels of this square, and never under a pixel.
108114 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
102102 /** Where this stub page is, for the whole URL a copied link has to be. */
103103 export const ORIGIN = "https://cs.example";
104104
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
105115 /**
106116 * What this page's wall clock says until a test moves it: noon UTC on an
107117 * ordinary day, so `src/daily.ts` has a day to derive the match of the day
@@ -210,7 +220,7 @@ export type FakeStage = {
210220 * contract between the page and this file, so `src/server.test.ts` checks the
211221 * served page against this same list rather than a second copy of it.
212222 */
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"];
214224
215225 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
216226 const HIDDEN = ["pause", "summary", "buy", "touch", "full"];
@@ -251,6 +261,14 @@ export function fakeStage(
251261 hold?: boolean;
252262 /** Leave the `#weights` link off the page entirely: a page that named nothing. */
253263 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;
254272 /**
255273 * The endpoint meta a deployment with a TypeSafe key renders: where the page
256274 * asks for a round to be planned. Absent is every other deployment, which
@@ -357,10 +375,17 @@ export function fakeStage(
357375 // The wall clock, which is only ever asked which day it is: a test sets
358376 // `stage.wall` and the page is on that day, without waiting for one.
359377 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 }),
360384 createRenderer: (_canvas: Stage): BuildRenderer | null =>
361385 options.webgl === false
362386 ? null
363387 : (bytes) => {
388 if (options.refuseShaders !== undefined) throw new Error(options.refuseShaders);
364389 // Every atlas the frame ever built a renderer out of, kept whole:
365390 // a test says there was one of them and that it was finished.
366391 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;
150150
151151 ## What is behind the door
152152
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.
154154
155155 Every section below opens with what that group costs a bundle, because "what
156156 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.
413413
414414 ### controls
415415
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.
417417
418418 `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
422422 `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
424430 `Controls.onUnlock` callbacks, a `Controls.setAction` for pressing an action by
425431 name, and a `Controls.detach` for when the game is over. `attachButtons` wires
426432 every element carrying a `data-action` attribute to `Controls.setAction`, with
427433 `ButtonOptions` saying which of them are a tap rather than a hold, so a thumb
428434 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.
431452
432453 What a game does with all that can be one line — read the held set every tick
433454 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
493514 put anything back — a browser that took the screen took it inside its own gesture,
494515 and only another gesture can ask again, which is your Resume button.
495516
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
496550 ### overlay
497551
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.
499553
500554 The 2D a game draws over its scene. `overlay` takes the surface, the width and
501555 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,
507561 right-aligned figures and `Overlay.clock` for seconds as `m:ss`. Build it on a
508562 resize, not in a frame: the tools it returns allocate nothing per call.
509563
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
510575 What it draws onto is a `Paint` — the seventeen members of a 2D context these
511576 tools touch, and not a whole context. A browser's is one; so is a recorder in a
512577 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.
623688
624689 ### renderer
625690
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.
627692
628693 The 3D under the scene, and the largest thing behind the door. `createRenderer`
629694 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
674739
675740 ### page
676741
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.
678743
679744 How you boot your entry in a test with no browser. `fakePage` hands back a
680745 `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.
691756 is a phone, `fullscreen: false` is a browser that will not put an element on the
692757 whole screen, and `keyboardLock: false` is one with no Keyboard Lock API — which
693758 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,
695763 that many milliseconds after the last one — that is the whole clock, and there
696764 is no timer anywhere in the file.
697765
src/engine/controls.test.ts +281 −8
@@ -1,5 +1,5 @@
11 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";
33
44 // `(event: never)` is the one type every listener can be stored as: a handler
55 // 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>;
4444 const KEYS = { KeyW: "forward", KeyA: "left", Space: "jump", Tab: "scoreboard" };
4545
4646 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 });
4859 const win = stubTarget();
4960 const doc = Object.assign(stubTarget(), { pointerLockElement: null as unknown });
5061 const host: Host = { surface, window: win, document: doc };
@@ -53,7 +64,10 @@ function harness(options: Partial<Parameters<typeof createControls>[1]> = {}) {
5364 doc.pointerLockElement = on ? surface : null;
5465 doc.emit("pointerlockchange", {});
5566 };
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 };
5771 }
5872
5973 const key = (code: string, extra: Record<string, unknown> = {}) => ({
@@ -106,17 +120,64 @@ test("a repeating keydown does not fire the action again", () => {
106120 expect([...controls.held]).toEqual(["forward"]);
107121 });
108122
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);
111129 const tab = key("Tab");
112130 const space = key("Space");
113131 const w = key("KeyW");
114132 win.emit("keydown", tab);
115133 win.emit("keydown", space);
116134 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"]);
120181 });
121182
122183 test("mouse movement accumulates and takeLook drains it exactly once", () => {
@@ -130,6 +191,84 @@ test("mouse movement accumulates and takeLook drains it exactly once", () => {
130191 expect(controls.takeLook()).toEqual([0, 0]);
131192 });
132193
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
133272 test("the mouse says nothing until the pointer is locked", () => {
134273 const { controls, win, surface } = harness();
135274 win.emit("mousemove", { movementX: 100, movementY: 100 });
@@ -147,6 +286,140 @@ test("the mouse buttons are bindings too", () => {
147286 expect(controls.held.size).toBe(0);
148287 });
149288
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
150423 test("the stick clamps to ±1 and returns to zero when the touch ends", () => {
151424 const { controls, surface } = harness();
152425 surface.emit("touchstart", touchEvent([{ identifier: 1, clientX: 100, clientY: 400 }]));
src/engine/controls.ts +266 −10
@@ -6,6 +6,12 @@
66 * half; this module only says which actions are down, how far the stick is
77 * pushed, and how much the view was asked to turn since the last frame.
88 *
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 *
915 * Every target it listens on is a parameter, typed by the two methods it
1016 * actually calls, so a stub with `addEventListener`/`removeEventListener`
1117 * drives the whole thing headlessly. Every listener it adds is remembered, so
@@ -27,21 +33,51 @@ export type Listens = {
2733 removeEventListener<E>(type: string, handler: (event: E) => void): void;
2834 };
2935
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 };
3251
3352 /** Whatever owns pointer lock — a `Document` fits. */
3453 export type LockOwner = Listens & { readonly pointerLockElement: unknown };
3554
3655 export type Host = { surface: Surface; window: Listens; document: LockOwner };
3756
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 };
3969 export type MouseEventish = {
4070 button?: number;
4171 movementX?: number;
4272 movementY?: number;
4373 preventDefault(): void;
4474 };
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 };
4581 export type Touchish = { identifier: number; clientX: number; clientY: number };
4682 export type TouchEventish = { changedTouches: ArrayLike<Touchish>; preventDefault(): void };
4783
@@ -50,12 +86,27 @@ export type Stick = { readonly x: number; readonly y: number };
5086 export type Options = {
5187 /** `KeyboardEvent.code` to action name. Anything unbound is ignored. */
5288 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 */
5494 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;
55104 /** Codes whose default the page must not take — Tab moves focus, Space scrolls. */
56105 preventKeys?: readonly string[];
57106 /** Degrees of turn per unit of pointer movement, scaled by `sensitivity`. */
58107 lookScale?: number;
108 /** Most counts one pointer event may be worth. See `MAX_MOVEMENT`. */
109 maxMove?: number;
59110 /** The same, for a finger dragged across the surface. */
60111 touchLookScale?: number;
61112 sensitivity?: number;
@@ -67,6 +118,20 @@ export type Options = {
67118 requireLock?: boolean;
68119 };
69120
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
70135 export type Controls = {
71136 /** The actions held right now. Live: read it every frame. */
72137 readonly held: ReadonlySet<string>;
@@ -76,6 +141,20 @@ export type Controls = {
76141 takeLook(): [number, number];
77142 sensitivity: number;
78143 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>;
79158 /** Press or release an action by name — how an on-screen button speaks. */
80159 setAction(action: string, down: boolean): void;
81160 /** Every press and release, however it arrived. */
@@ -85,7 +164,66 @@ export type Controls = {
85164 detach(): void;
86165 };
87166
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 }
89227
90228 export function createControls(host: Host, options: Options): Controls {
91229 const keys = options.keys;
@@ -96,12 +234,21 @@ export function createControls(host: Host, options: Options): Controls {
96234 const stickRegion = options.stickRegion ?? 0.38;
97235 const stickRadius = options.stickRadius ?? 60;
98236 const requireLock = options.requireLock ?? true;
237 const maxMove = options.maxMove ?? MAX_MOVEMENT;
238 const wheel = options.wheel ?? {};
239 const wheelMs = options.wheelMs ?? 120;
99240
100241 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;
101246 const stick = { x: 0, y: 0 };
102247 let lookX = 0;
103248 let lookY = 0;
104249 let locked = false;
250 /** How the last successful ask for the pointer was answered. See `PointerMode`. */
251 let granted: PointerMode | null = null;
105252
106253 // Every listener, so detach can undo exactly what attach did.
107254 const added: [Listens, string, (event: never) => void][] = [];
@@ -128,6 +275,10 @@ export function createControls(host: Host, options: Options): Controls {
128275 get locked() {
129276 return locked;
130277 },
278 get pointer() {
279 return locked ? granted : null;
280 },
281 lock,
131282 setAction(action, down) {
132283 if (down) held.add(action);
133284 else held.delete(action);
@@ -138,6 +289,10 @@ export function createControls(host: Host, options: Options): Controls {
138289 detach() {
139290 for (const [target, type, handler] of added) target.removeEventListener(type, handler);
140291 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();
141296 },
142297 };
143298
@@ -145,10 +300,19 @@ export function createControls(host: Host, options: Options): Controls {
145300 on(host.window, "keydown", (event: KeyEventish) => {
146301 const action = keys[event.code];
147302 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();
149310 if (event.repeat === true) return; // a held key repeats; the action is already down
150311 controls.setAction(action, true);
151312 });
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.
152316 on(host.window, "keyup", (event: KeyEventish) => {
153317 const action = keys[event.code];
154318 if (action !== undefined) controls.setAction(action, false);
@@ -163,9 +327,22 @@ export function createControls(host: Host, options: Options): Controls {
163327 });
164328 on(host.surface, "mousedown", (event: MouseEventish) => {
165329 if (requireLock && !locked) return;
166 event.preventDefault();
167330 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();
169346 });
170347 on(host.window, "mouseup", (event: MouseEventish) => {
171348 const action = mouse[event.button ?? 0];
@@ -174,9 +351,39 @@ export function createControls(host: Host, options: Options): Controls {
174351 on(host.surface, "contextmenu", (event: MouseEventish) => event.preventDefault());
175352 on(host.window, "mousemove", (event: MouseEventish) => {
176353 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;
179358 });
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);
180387 on(host.window, "blur", clearHeld);
181388
182389 // --- 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 {
226433 for (const action of Array.from(held)) controls.setAction(action, false);
227434 }
228435
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
229485 return controls;
230486 }
231487
src/engine/escape.ts +19 −4
@@ -5,8 +5,8 @@
55 * a player sees as their window being thrown away mid-round.
66 *
77 * 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
1010 * only leaves full screen when Escape is *held*, which is the behaviour the
1111 * platform intends and the one a game wants. It is Chrome's and Edge's; Safari
1212 * and Firefox do not have it, so `supported` says so and a game that asks gets
@@ -73,7 +73,22 @@ export type Escape = {
7373 detach(): void;
7474 };
7575
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])];
7792 const keyboard = host.keyboard;
7893 const supported = typeof keyboard?.lock === "function" && typeof keyboard?.unlock === "function";
7994 // 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 {
101116 try {
102117 // A promise nobody awaits still rejects loudly, and a refusal here is not
103118 // news: it is a browser saying the key stays its own.
104 const asking = keyboard?.lock?.([ESCAPE]);
119 const asking = keyboard?.lock?.(codes);
105120 // Only the current request may claim the key: a stale one says nothing at
106121 // all, rather than answering for the lock that replaced it.
107122 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([
6464 "AudioContext", "DynamicsCompressorNode", "Float32Array", "Float64Array", "GainNode",
6565 "Int32Array", "StereoPannerNode",
6666 "TEXTURE_2D_ARRAY",
67 "addEventListener",
67 "addEventListener", "auxclick",
6868 "cancelAnimationFrame", "createDynamicsCompressor", "createStereoPanner", "de_forge",
69 "mousedown",
6970 "fullscreenElement",
7071 "pointerLockElement",
7172 "removeEventListener", "requestAnimationFrame", "uniformMatrix4fv",
src/engine/index.ts +8 −1
@@ -59,7 +59,7 @@ export type { Frames, Loop, LoopOptions } from "./loop.ts";
5959 export { attachButtons, createControls } from "./controls.ts";
6060 export type {
6161 Button, ButtonOptions, Controls, Host, KeyEventish, Listens, LockOwner, MouseEventish,
62 Options, Stick, Surface, TouchEventish, Touchish,
62 Options, PointerMode, Stick, Surface, TouchEventish, Touchish, WheelEventish,
6363 } from "./controls.ts";
6464
6565 /** 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";
7777 export { overlay } from "./overlay.ts";
7878 export type { Overlay, Paint } from "./overlay.ts";
7979
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
8087 /** A frame as data, in a pool: made once at a capacity, refilled, never grown. */
8188 export { createScene, nextInstance, pushLine, pushShadow, resetScene } from "./scene.ts";
8289 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[] }
3838 }
3939
4040 /** 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) => {
4242 const paint = recorder();
43 return { paint, o: overlay(paint, width, height) };
43 return { paint, o: overlay(paint, width, height, dpr) };
4444 };
4545
4646 const rects = (paint: ReturnType<typeof recorder>): Call[] =>
@@ -116,26 +116,75 @@ test("a faded line leaves the context solid, even when it draws nothing", () =>
116116 expect(paint.globalAlpha).toBe(1);
117117 });
118118
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", () => {
120120 const { paint, o } = tools();
121121 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
130131 ]);
131132 });
132133
133134 test("no gap is a plain cross: the four arms meet at the centre", () => {
134135 const { paint, o } = tools();
135136 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]);
139188 });
140189
141190 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 @@
1212 * than `string` because a real context's `fillStyle` is a string *or* a
1313 * gradient *or* a pattern, and a narrower type here would force a cast there.
1414 *
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.
1825 */
1926
2027 import { clamp } from "./math.ts";
28 import { bar as pixelBar, px as pixelsOf, ratioOf } from "./pixels.ts";
2129
2230 /** Everything these tools ask of the thing they draw on. Nothing else. */
2331 export type Paint = {
@@ -64,6 +72,9 @@ const LEAD = 18;
6472 /** The four ways a crosshair arm points, made once rather than once a frame. */
6573 const ARMS: readonly (readonly [number, number])[] = [[1, 0], [-1, 0], [0, 1], [0, -1]];
6674
75 /** How thick a crosshair's arms are, in CSS pixels, when the caller says nothing. */
76 const ARM_THICKNESS = 2;
77
6778 /** What `text` is given when it is given nothing, so the default allocates nothing. */
6879 const PLAIN: TextOptions = {};
6980
@@ -85,21 +96,39 @@ export type ColumnsAt = { x: number; y: number; lead?: number };
8596 export type Overlay = {
8697 readonly width: number;
8798 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 */
89113 readonly scale: number;
90114 /** The margin from the edge of the canvas that everything is laid out inside. */
91115 readonly pad: number;
92116 /** `font(30)` is `"30px system-ui, sans-serif"` on a 1280-wide canvas, and less on a phone. */
93117 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;
94120 /** A scrim behind something else: a filled rectangle, in `fill` or the neutral one. */
95121 panel(x: number, y: number, w: number, h: number, fill?: string): void;
96122 /** One string, and `globalAlpha` back at 1 whatever it was drawn at. */
97123 text(s: string, x: number, y: number, options?: TextOptions): void;
98124 /**
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.
101130 */
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;
103132 /** The filled part of a bar: `fraction` of `w`, with the fraction clamped to 0..1. */
104133 bar(x: number, y: number, w: number, h: number, fraction: number, fill: string): void;
105134 /**
@@ -113,17 +142,20 @@ export type Overlay = {
113142 clock(seconds: number): string;
114143 };
115144
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));
119149 const lead = Math.round(LEAD * scale);
120150
121151 return {
122152 width,
123153 height,
154 ratio,
124155 scale,
125156 pad,
126157 font: (px) => `${Math.round(px * scale)}px ${FAMILY}`,
158 px: (css) => pixelsOf(css, ratio),
127159 panel(x, y, w, h, fill = SCRIM) {
128160 paint.fillStyle = fill;
129161 paint.fillRect(x, y, w, h);
@@ -141,15 +173,32 @@ export function overlay(paint: Paint, width: number, height: number): Overlay {
141173 // line that leaves the context faded is a bug in every later line.
142174 paint.globalAlpha = 1;
143175 },
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;
148198 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);
151201 }
152 paint.stroke();
153202 },
154203 bar(x, y, w, h, fraction, fill) {
155204 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", () => {
234234 draw.crosshair(400, 225, 4, 10, "#fff");
235235 expect(paint.texts).toEqual(["tick 1"]);
236236 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);
238240 });
239241
240242 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 & {
225225 gl: unknown;
226226 /** How many times something asked for the pointer. */
227227 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;
228242 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>;
230249 };
231250
232251 function canvasElement(id: string, paint: boolean): FakeCanvas {
@@ -236,12 +255,18 @@ function canvasElement(id: string, paint: boolean): FakeCanvas {
236255 paint: paint ? paintRecorder() : null,
237256 gl: null,
238257 locks: 0,
258 lockOptions: [],
259 refuseLock: false,
239260 getContext(kind) {
240261 if (kind === "2d") return this.paint;
241262 return kind === "webgl2" ? this.gl : null;
242263 },
243 requestPointerLock() {
264 requestPointerLock(options?: unknown) {
244265 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();
245270 },
246271 };
247272 }
@@ -264,6 +289,12 @@ export type FakeDocument = FakeTarget & {
264289 /** The element that is full screen right now, or null. The browser owns this. */
265290 fullscreenElement: unknown;
266291 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;
267298 /** The `[data-action]` buttons, whatever the selector says: there is one list. */
268299 querySelectorAll(selector: string): FakeElement[];
269300 createElement(tag: string): FakeElement;
@@ -301,6 +332,13 @@ export type PageOptions = {
301332 paint?: boolean;
302333 /** What `matchMedia` answers. True is a phone. */
303334 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>>;
304342 /**
305343 * Whether this browser can put an element full screen. Default true: asking
306344 * sets `document.fullscreenElement` and fires `fullscreenchange` at once.
@@ -363,6 +401,16 @@ export function fakePage(options: PageOptions = {}): FakePage {
363401 .map((id) => elements.get(id))
364402 .filter((found): found is FakeCanvas => found !== undefined);
365403 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 );
366414 const frames: ((now: number) => void)[] = [];
367415 let clock = START;
368416
@@ -371,6 +419,7 @@ export function fakePage(options: PageOptions = {}): FakePage {
371419 readyState: "complete",
372420 pointerLockElement: null as unknown,
373421 getElementById: (id) => elements.get(id) ?? null,
422 querySelector: (selector) => metas.get(selector) ?? null,
374423 querySelectorAll: (_selector) => buttons,
375424 createElement: (tag) => element({ tag }),
376425 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 @@
2525
2626 import { multiply, perspective, viewMatrix, modelMatrix, type Vec3 } from "./math.ts";
2727 import type { Mesh } from "./mesh.ts";
28 import { backing } from "./pixels.ts";
2829 import type { Scene, Tint } from "./scene.ts";
2930
3031 const SCENE_VS = `#version 300 es
@@ -180,9 +181,6 @@ type Buffers = { vao: WebGLVertexArrayObject; count: number; buffers: WebGLBuffe
180181 /** The bytes an atlas of this shape must be: RGBA8, every layer, tightly packed. */
181182 const atlasBytes = (size: number, layers: number): number => size * size * 4 * layers;
182183
183 /** Past this the pixels cost more than they show, on a phone especially. */
184 const MAX_PIXEL_RATIO = 2;
185
186184 /** The clip planes a game that says nothing gets: Source units, the shooter's. */
187185 const NEAR = 6, FAR = 12000;
188186
@@ -352,9 +350,13 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets
352350 const far = assets.far ?? FAR;
353351
354352 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 );
358360 if (canvas.width !== width || canvas.height !== height) {
359361 canvas.width = width;
360362 canvas.height = height;
src/engine/surface.json +8 −1
@@ -1,5 +1,5 @@
11 {
2 "version": "0.3.0",
2 "version": "0.6.0",
33 "names": [
44 "Activations",
55 "Adam",
@@ -47,6 +47,7 @@
4747 "LockOwner",
4848 "Loop",
4949 "LoopOptions",
50 "MAX_RATIO",
5051 "Mat4",
5152 "Mesh",
5253 "MouseEventish",
@@ -58,6 +59,7 @@
5859 "Paint",
5960 "Painter",
6061 "Pin",
62 "PointerMode",
6163 "Policy",
6264 "QUANTUM",
6365 "Renderer",
@@ -77,6 +79,7 @@
7779 "Touchish",
7880 "UpdateStats",
7981 "Vec3",
82 "WheelEventish",
8083 "Wish",
8184 "accelerate",
8285 "add",
@@ -87,7 +90,9 @@
8790 "applyGrads",
8891 "attachButtons",
8992 "attenuate",
93 "backing",
9094 "backward",
95 "bar",
9196 "builder",
9297 "cellAt",
9398 "clamp",
@@ -151,9 +156,11 @@
151156 "pushEffect",
152157 "pushLine",
153158 "pushShadow",
159 "px",
154160 "quadMesh",
155161 "quantise",
156162 "rad",
163 "ratioOf",
157164 "rayBox",
158165 "region",
159166 "resetEffects",
src/example/boot.test.ts +11 −4
@@ -65,10 +65,15 @@ function drew(p: ExamplePage, ms: number): Call[] {
6565 const aim = (calls: Call[]): string =>
6666 calls.filter((c) => c[0] === "fillRect" && c[3] === 2 && c[4] === 2).map((c) => `${c[1]},${c[2]}`).join(" ");
6767
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 */
6974 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)];
7277 };
7378
7479 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", () => {
124129 // and the crosshair is the one thing the readout draws into the middle.
125130 expect(first.calls[0]).toEqual(["clearRect", 0, 0, 800, 450]);
126131 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);
128135 // A frame was asked for before this one ran, so the loop keeps going.
129136 expect(p.frames.length).toBe(1);
130137 } finally {
src/example/main.ts +5 −7
@@ -23,7 +23,7 @@
2323
2424 import { KEYS, TICK, assets, createExampleScene, createWorld, look, step, type World } from "../engine/example.ts";
2525 import {
26 attachButtons, createControls, createFullscreen, createLoop, createRenderer, type Paint,
26 attachButtons, backing, createControls, createFullscreen, createLoop, createRenderer, type Paint,
2727 } from "../engine/index.ts";
2828 import { drawPlan, drawReadout, type Phase } from "./plan.ts";
2929
@@ -110,12 +110,10 @@ export function boot(): void {
110110 let starting = false;
111111 let frameMs = 16.7;
112112
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.
114114 * The renderer does this for the scene canvas itself; the HUD is the page's. */
115115 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);
119117 if (canvas.width !== width) canvas.width = width;
120118 if (canvas.height !== height) canvas.height = height;
121119 return [width, height];
@@ -154,10 +152,10 @@ export function boot(): void {
154152 if (renderer !== null) renderer.draw(look(world, frameScene), elapsed);
155153 else if (plan !== null) {
156154 const [width, height] = size(scene);
157 drawPlan(plan, width, height, frame);
155 drawPlan(plan, width, height, frame, window.devicePixelRatio);
158156 }
159157 const [width, height] = size(hud);
160 drawReadout(readout, width, height, frame);
158 drawReadout(readout, width, height, frame, window.devicePixelRatio);
161159 $("fps").textContent = `${(1000 / Math.max(1, frameMs)).toFixed(0)} fps · ${frameMs.toFixed(1)} ms`;
162160 });
163161 }
src/example/plan.test.ts +12 −7
@@ -56,9 +56,11 @@ test("the plan is one background, one room, one plate and the aim", () => {
5656 expect(room[1] + room[3]).toBeLessThanOrEqual(HEIGHT);
5757 // The readout is the other canvas's: the plan itself says nothing.
5858 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);
6264 });
6365
6466 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", () => {
100102 // The crosshair is the middle of the room, and only when there is a room:
101103 // on the plan the player is a crosshair where they stand instead.
102104 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);
107112 });
108113
109114 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);
5959 * cleared and no background is painted — this canvas sits over the scene, and
6060 * in WebGL2 the scene is the room itself, so the crosshair is drawn here too.
6161 */
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);
6470 const { world, clock } = frame;
6571 const late = clock <= HURRY;
6672 const line = lineOf(o);
@@ -72,13 +78,19 @@ export function drawReadout(paint: Paint, width: number, height: number, frame:
7278 frame.fraction, late ? WARN : AIM);
7379 // Where the shot goes, when the shot goes into a room you are standing in.
7480 // 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);
7682 o.text(SAY[frame.phase], width / 2, height - o.pad, { align: "center", font: o.font(18), fill: DIM });
7783 }
7884
7985 /** 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);
8294 const { world } = frame;
8395 o.panel(0, 0, width, height, BACK);
8496
@@ -109,5 +121,5 @@ export function drawPlan(paint: Paint, width: number, height: number, frame: Fra
109121 if (x < ROOM.min[0] || x > ROOM.max[0] || y < ROOM.min[1] || y > ROOM.max[1]) break;
110122 o.panel(sx(x) - dot / 2, sy(y) - dot / 2, dot, dot, AIM);
111123 }
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);
113125 }
src/games.ts +38 −11
@@ -103,8 +103,17 @@ export const GAMES: readonly Game[] = [
103103 * ends read. 115,156 raw and 44,864 gzipped, measured by
104104 * `bun run scripts/build.ts` — 1.8 kB of gzip for a round five people can
105105 * 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.
106115 */
107 ceiling: { raw: 122_000, gzip: 47_500 },
116 ceiling: { raw: 132_000, gzip: 51_500 },
108117 assets: [
109118 {
110119 name: "weights",
@@ -124,15 +133,27 @@ export const GAMES: readonly Game[] = [
124133 label: "range",
125134 entry: "src/range/main.ts",
126135 /**
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.
134155 */
135 ceiling: { raw: 41_500, gzip: 17_200 },
156 ceiling: { raw: 44_000, gzip: 18_200 },
136157 assets: [],
137158 blurb:
138159 "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[] = [
144165 href: "/example",
145166 label: "example",
146167 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 },
149176 assets: [],
150177 blurb:
151178 "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";
3636 import { mkdtempSync } from "node:fs";
3737 import { tmpdir } from "node:os";
3838 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";
4040 import type { Cost } from "../cost.ts";
4141 import { openDatabase } from "../db.ts";
4242 import { page as headless } from "../example/stage.test-helper.ts";
4343 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";
4545 import { examplePage } from "./example.ts";
4646 import { gamePage } from "./game.ts";
4747 import { rangePage } from "./range.ts";
@@ -146,7 +146,7 @@ const SURFACES: readonly Surface[] = [
146146 {
147147 game: "app",
148148 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)],
150150 elsewhere: [
151151 {
152152 why: "the left of the stage is a thumb stick, and it is analogue: `wishSpeed` in " +
@@ -174,6 +174,13 @@ const SURFACES: readonly Surface[] = [
174174 "phone reaches every one of them without a key.",
175175 actions: ["slot1", "slot2", "slot3", "slot4", "slot5", "slot6", "slot7", "slot8", "slot9", "slot10", "slot11"],
176176 },
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 },
177184 {
178185 why: "the buy panel draws its own Rebuy control beside the tiles (`src/client/buy.ts`), which " +
179186 "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[] = [
196203 {
197204 game: "range",
198205 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 ],
200209 elsewhere: [
201210 {
202211 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 => `
393393 number row buys the tile it is printed on and <kbd>X</kbd> buys last round's loadout again ·
394394 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen ·
395395 <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>
396401 <p class="hint">Full screen keeps the screen through a pause where a browser lets it:
397402 Chrome and Edge hand the game the <kbd>Esc</kbd> key, and hold it down to leave. Safari and
398403 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 => `
402407 <p class="hint">Every weapon's numbers — what it costs, what it fires and what one shot of it
403408 does to a person — are on <a href="${WEAPONS_HREF}">the weapons page</a>, rendered from the
404409 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>
405420 `;
406421
407422 /**
src/pages/range.ts +3 −1
@@ -112,7 +112,9 @@ const NOTE = `
112112 <div class="row"><button type="button" class="primary" id="look">Capture the mouse</button></div>
113113 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch ·
114114 <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
116118 G here because F is the trigger · <kbd>Esc</kbd> gives the mouse back, and keeps the screen
117119 where a browser lets the page hold the key — Chrome and Edge do, and Safari and Firefox
118120 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", () => {
109109 expect(html.match(/<script/g)?.length).toBe(1);
110110 expect(html).not.toContain("<script>alert(1)</script>");
111111 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("&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;").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("&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;").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="&lt;/script&gt;`);
115120 });
116121
117122 test("markup handed in as markup is written through, not escaped again", () => {
src/pages/shell.ts +7
@@ -281,6 +281,12 @@ export type PageOptions = {
281281 nav: NavEntry[];
282282 env: string;
283283 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 */
284290 commit: string;
285291 /**
286292 * 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 {
331337 <link rel="icon" href="${FAVICON}">
332338 ${preloads(options.assets)}${endpoints(options.endpoints)}<title>${escape(options.title)}</title>
333339 <meta name="description" content="${escape(options.description)}">
340 ${options.commit ? `<meta name="build" content="${escape(options.commit)}">\n` : ""}
334341 <style>${styleFor(options.extraStyle)}</style>
335342 <header>
336343 <h1>${escape(options.heading)}</h1>
src/range/binding.test.ts +10 −1
@@ -1,6 +1,6 @@
11 import { expect, test } from "bun:test";
22 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,
44 } from "./binding.ts";
55
66 const NONE = { x: 0, y: 0 };
@@ -24,6 +24,15 @@ test("the range's bindings are its own, not the shooter's", () => {
2424 expect(RANGE_KEYS.KeyF).toBe("fire");
2525 expect(RANGE_MOUSE[0]).toBe("fire");
2626 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);
2736 expect([...RANGE_TAP]).toEqual(["jump", "reload", "start", "drill"]);
2837 // Nothing cs-shaped: no buy, no scoreboard, no weapon slots.
2938 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> = {
2121 KeyG: "fullscreen",
2222 };
2323
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" };
2636
2737 /** On-screen buttons that are a tap, not a hold. */
2838 export const RANGE_TAP = ["jump", "reload", "start", "drill"] as const;
src/range/boot.test.ts +38 −2
@@ -35,6 +35,14 @@ function booted(
3535
3636 const hudTexts = (p: RangePage): string[] => p.hud.paint?.texts ?? [];
3737
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
3846 test("G and the button put the lane on the whole screen, and a browser that will not is not lied about", () => {
3947 const { p, undo } = booted();
4048 try {
@@ -59,7 +67,7 @@ test("G and the button put the lane on the whole screen, and a browser that will
5967 }
6068 });
6169
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 () => {
6371 // Bun has no Keyboard Lock API, so this is the browser where Escape keeps its
6472 // own meaning — Safari on a Mac — and the way back is the one this page owns.
6573 const { p, undo } = booted();
@@ -70,6 +78,7 @@ test("Escape gives the mouse back, and the screen a browser took comes back with
7078 p.by("look").fire("click");
7179 p.document.pointerLockElement = p.scene;
7280 p.document.fire("pointerlockchange");
81 await settled();
7382 expect(p.document.fullscreenElement).toBe(stage);
7483 expect(p.by("note").hidden).toBe(true);
7584
@@ -190,7 +199,7 @@ test("a phone gets there too: the left of the stage is a stick, the right looks
190199 }
191200 });
192201
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 () => {
194203 const { p, undo } = booted();
195204 try {
196205 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", () => {
198207 expect(hudTexts(p)).toContain("yaw 0.0° · pitch 0.0°");
199208
200209 p.by("look").fire("click");
210 await settled();
201211 expect(p.by("note").hidden).toBe(true);
202212 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 }]);
203216 p.document.pointerLockElement = p.scene;
204217 p.document.fire("pointerlockchange");
205218 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", () => {
221234 }
222235 });
223236
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
224260 test("a canvas with no context is a sentence on the page, not an exception", () => {
225261 const { p, undo } = booted({ has2d: false });
226262 try {
src/range/main.ts +28 −16
@@ -46,7 +46,7 @@
4646 */
4747
4848 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";
5050 import { RANGE_ROOM } from "./level.ts";
5151 import {
5252 RANGE_HULL, RANGE_MOVE, createRangeBody, createRangeWish, rangeEye, rangeWish,
@@ -59,8 +59,8 @@ import {
5959 type DrillName, type Target,
6060 } from "./targets.ts";
6161 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,
6464 } from "../engine/index.ts";
6565
6666 /** The range's own step: 50 Hz, deliberately not the shooter's 64. */
@@ -123,7 +123,7 @@ export function boot(): void {
123123
124124 const controls = createControls(
125125 { surface: scene, window, document },
126 { keys: RANGE_KEYS, mouse: RANGE_MOUSE },
126 { keys: RANGE_KEYS, mouse: RANGE_MOUSE, wheel: RANGE_WHEEL },
127127 );
128128 attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: RANGE_TAP });
129129 // The range's own recipes over the engine's device. Nothing is opened here:
@@ -199,17 +199,20 @@ export function boot(): void {
199199 // A phone has no pointer to lock, and says so by throwing or by rejecting:
200200 // either way the note gets out of the way and the touch look still works.
201201 $("look").addEventListener("click", () => {
202 note.hidden = true;
203202 // The click is a gesture, so it is also the way back into a screen a browser
204203 // took: on a Mac, Escape hands the window back whether the page wanted it or
205204 // not, and `wantsFull` below is how this page remembers that it did.
206205 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 });
213216 });
214217 controls.onUnlock = () => {
215218 note.hidden = false;
@@ -255,17 +258,23 @@ export function boot(): void {
255258 const escape = createEscape(
256259 { window, document, keyboard: (navigator as { keyboard?: KeyboardLock }).keyboard },
257260 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),
258265 );
259266 escape.onPress = () => {
260267 if (controls.locked) document.exitPointerLock();
261268 };
262269 touch.hidden = !matchMedia("(pointer: coarse)").matches;
263270
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 */
265276 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);
269278 if (canvas.width !== width || canvas.height !== height) {
270279 canvas.width = width;
271280 canvas.height = height;
@@ -372,7 +381,9 @@ export function boot(): void {
372381 renderer.draw(buildRangeScene(run, view, trail, time, frameScene), elapsed);
373382 } else if (sceneCtx !== null) {
374383 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 );
376387 }
377388 // No third branch: the guard above returned when both were null.
378389 const [hw, hh] = size(hud);
@@ -401,6 +412,7 @@ export function boot(): void {
401412 },
402413 hw,
403414 hh,
415 window.devicePixelRatio,
404416 );
405417 $("fps").textContent = `${(1000 / Math.max(1, frameMs)).toFixed(0)} fps · ${frameMs.toFixed(1)} ms`;
406418 }
src/range/readout.test.ts +35 −2
@@ -109,8 +109,14 @@ test("the HUD is cleared and every line is drawn once", () => {
109109 drawReadout(paint, READOUT, 960, 540);
110110 expect(paint.calls[0]?.[0]).toBe("clearRect");
111111 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]);
114120 });
115121
116122 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", () => {
137143 expect(turned.texts).not.toContain("0");
138144 });
139145
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
140173 /** A plate at a place, so the projection below is arithmetic and not a seed. */
141174 const plate = (at: [number, number, number], half = 16, left = 1): Target => ({
142175 id: 1,
src/range/readout.ts +34 −11
@@ -17,7 +17,9 @@
1717 * changes nothing.
1818 */
1919
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";
2123 import { EYE, type Report, type Target } from "./targets.ts";
2224
2325 /** 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):
177179 * Every part of it comes from the same two angles the panel prints, so what you
178180 * see and what it says cannot disagree — and a browser with no WebGL2 can still
179181 * 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.
180190 */
181191 export function drawHorizon(
182192 paint: Paint,
@@ -184,8 +194,12 @@ export function drawHorizon(
184194 width: number,
185195 height: number,
186196 sight: Sight = EMPTY_SIGHT,
197 dpr = 1,
187198 ): void {
188199 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);
189203 // Pitch is positive downwards, so looking down lifts the horizon up the screen.
190204 const horizon = clamp(height / 2 - view.pitch * px, -height, height * 2);
191205 paint.fillStyle = SKY;
@@ -195,26 +209,27 @@ export function drawHorizon(
195209 paint.fillRect(0, top, width, height - top);
196210
197211 paint.strokeStyle = LINE;
198 paint.lineWidth = 2;
212 paint.lineWidth = size(2);
199213 paint.beginPath();
200214 paint.moveTo(0, horizon);
201215 paint.lineTo(width, horizon);
202216 paint.stroke();
203217
204218 // One tick per 15°, only the ones that fall on screen.
219 const tick = size(8), margin = size(8);
205220 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`;
208223 paint.textBaseline = "top";
209224 for (let angle = 0; angle < 360; angle += 15) {
210225 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;
212227 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);
215230 paint.stroke();
216231 paint.fillStyle = DIM;
217 paint.fillText(`${angle}`, x + 4, horizon + 10);
232 paint.fillText(`${angle}`, x + size(4), horizon + tick + size(2));
218233 }
219234
220235 // The plates. A square the size the angle says, dimmed once it is most of
@@ -226,12 +241,18 @@ export function drawHorizon(
226241 }
227242
228243 /** 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 {
230251 paint.clearRect(0, 0, width, height);
231252
232253 // The tools are built here rather than kept, because this is the one place
233254 // 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);
235256 // The engine's margin: a fiftieth of the width, as this always was, and never
236257 // under 12 px rather than under 10 — which is the same number above 600 px
237258 // 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
251272
252273 // The crosshair marks the centre the angles are measured from: no gap, so the
253274 // 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");
255278 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs