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

forked from trevato/cs · proposes

The HUD is pinned before it moves: src/client/hud.test.ts says what a player sees today #71

Merged opened this

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

Merged

torque/cs-engine:torque/64-the-hud-is-pinned-before-it-moves-src-cl → main 1 commit fork

  • Merged. torque/64-the-hud-is-pinned-before-it-moves-src-cl was merged into main as a8747da.

Review

Sign in to review this change.
2 files changed +512 −1
README.md +7 −1
@@ -199,7 +199,13 @@ from the angle it subtends, and you turn on the spot.
199199 a match, one POST per round scored and a browser with no WebGL2 are all
200200 checked with no browser in the room. `hud` draws onto a `Paint` — the
201201 seventeen members of a 2D context it uses — which is how that stub can be a
202 recorder and the page can still hand it a canvas.
202 recorder and the page can still hand it a canvas. `hud.test.ts` drives it
203 with a recorder of its own that writes down the brush as well as the call, so
204 what is pinned is what a player reads: the health that goes red, the ammo, the
205 clock that stops between rounds and reddens once the bomb is down, the
206 crosshair that opens as far as the gun is inaccurate, the plant bar, the kill
207 feed fading out over eight seconds, the scoreboard's order, and the whole
208 readout fitting a 360px phone and a 1920px desk alike.
203209 - `src/range/` — the second game, at `/range`, and the proof that the engine is a
204210 package: a module in here may import the engine through its door, `src/pages/shell.ts`
205211 and its own siblings, and nothing else. **It is a game with no renderer under
src/client/hud.test.ts added +505
@@ -0,0 +1,505 @@
1 /**
2 * This test exists so #18 can move the HUD onto the engine's overlay and prove
3 * it drew the same thing.
4 *
5 * It says what a player reads off the screen — the number that goes red, the
6 * clock that stops, the crosshair that opens, the feed that fades — and not how
7 * `hud.ts` is arranged to draw it. Nothing here asserts a coordinate that only
8 * means something to the current layout: a panel that moves two pixels left is
9 * not a change a player sees, and a pin that fails on one is a pin someone
10 * deletes rather than uses.
11 *
12 * The `Paint` it draws onto is the HUD's own, not the engine's: `hud.ts` types
13 * its styles `string` where `src/engine/overlay.ts` types them `unknown`, so a
14 * recorder declared as the engine's cannot be handed to `drawHud` today. A
15 * recorder declared as the HUD's *can* be handed to `overlay()`, which is what
16 * the move needs. The recorder is this file's own because it writes down the
17 * brush as well as the call — the colour a line was drawn in is half of what is
18 * pinned here, and neither of the two recorders already in the repository
19 * (`stage.test-helper.ts`, `overlay.test.ts`) keeps it.
20 *
21 * No canvas, no document, no browser.
22 */
23
24 import { expect, test } from "bun:test";
25 import { drawHud, type Paint } from "./hud.ts";
26 import { SHORT } from "./match.ts";
27 import { inaccuracy, WEAPONS } from "./weapons.ts";
28 import { createWorld, currentWeapon, type Ammo, type Player, type World } from "./world.ts";
29
30 // --- a paint that remembers ------------------------------------------------
31
32 /** One call the HUD made, and the state of the brush that made it. */
33 type Call = {
34 op: string;
35 args: readonly unknown[];
36 fill: string;
37 stroke: string;
38 font: string;
39 align: string;
40 alpha: number;
41 };
42
43 type Recorder = Paint & { calls: Call[] };
44
45 function recorder(): Recorder {
46 const calls: Call[] = [];
47 const note = (op: string) => (...args: unknown[]): void => void calls.push({
48 op,
49 args,
50 fill: paint.fillStyle,
51 stroke: paint.strokeStyle,
52 font: paint.font,
53 align: paint.textAlign,
54 alpha: paint.globalAlpha,
55 });
56 const paint: Recorder = {
57 calls,
58 fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textAlign: "", textBaseline: "", globalAlpha: 1,
59 clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"),
60 beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), rect: note("rect"),
61 arc: note("arc"), fill: note("fill"), stroke: note("stroke"),
62 };
63 return paint;
64 }
65
66 // --- the world this suite draws --------------------------------------------
67
68 /**
69 * One match, built once for the whole file: `createWorld` lays out ten players,
70 * spawns, bots and a bomb, and doing that per test is seconds nobody needs.
71 * Every test draws a *copy* of it instead, so a test that spends a magazine or
72 * kills a bot does not reach the next one.
73 */
74 const BASE = createWorld(SHORT, "you", "ct", 5, 64);
75
76 const copyAmmo = (ammo: Record<string, Ammo>): Record<string, Ammo> =>
77 Object.fromEntries(Object.entries(ammo).map(([id, a]) => [id, { ...a }]));
78
79 /** A copy of the base world, changed the one way this test is about. */
80 function scene(change: (world: World) => void = () => {}): World {
81 const players = BASE.players.map((p) => ({ ...p, ammo: copyAmmo(p.ammo) }));
82 const world: World = {
83 ...BASE,
84 match: { ...BASE.match, score: { ...BASE.match.score }, losses: { ...BASE.match.losses } },
85 players,
86 me: players[0] as Player,
87 bomb: { ...BASE.bomb, defuser: null },
88 effects: [],
89 feed: [],
90 messages: [],
91 };
92 change(world);
93 return world;
94 }
95
96 const WIDTH = 1280;
97 const HEIGHT = 720;
98
99 /** Draw one frame of the HUD onto a fresh recorder. */
100 function draw(
101 world: World,
102 options: { width?: number; height?: number; scoreboard?: boolean } = {},
103 ): Recorder {
104 const paint = recorder();
105 drawHud(paint, world, options.width ?? WIDTH, options.height ?? HEIGHT, options.scoreboard ?? false);
106 return paint;
107 }
108
109 /** Every line on the screen a player could actually read: alpha zero is not on it. */
110 const readable = (paint: Recorder): Call[] =>
111 paint.calls.filter((c) => c.op === "fillText" && c.alpha > 0);
112
113 const words = (paint: Recorder): string[] => readable(paint).map((c) => String(c.args[0]));
114
115 const says = (paint: Recorder, text: string): boolean => words(paint).includes(text);
116
117 /** The one readable line that reads exactly this, and the brush that drew it. */
118 function line(paint: Recorder, text: string): Call {
119 const hits = readable(paint).filter((c) => c.args[0] === text);
120 expect(hits.length).toBe(1);
121 return hits[0] as Call;
122 }
123
124 // --- the crosshair ---------------------------------------------------------
125
126 /** How far the crosshair's first arm starts from the middle of the screen. */
127 const gapOf = (paint: Recorder, width = WIDTH): number => {
128 const first = paint.calls.find((c) => c.op === "moveTo");
129 return Number(first?.args[0]) - width / 2;
130 };
131
132 const spreadOf = (world: World, moving: number): number =>
133 inaccuracy(currentWeapon(world.me), {
134 moving,
135 inAir: !world.me.onGround,
136 ducking: world.me.duckAmount > 0.5,
137 scoped: world.me.scoped,
138 });
139
140 test("the crosshair opens exactly as far as the gun is inaccurate, and is gone when you are", () => {
141 const still = scene();
142 const rest = draw(still);
143 // At a standstill it is the gun's own spread and nothing else.
144 expect(gapOf(rest)).toBeCloseTo(4 + spreadOf(still, 0) * 14, 10);
145 // Each arm is seven pixels long, from the gap outwards.
146 const arm = rest.calls.find((c) => c.op === "lineTo");
147 expect(Number(arm?.args[0]) - WIDTH / 2).toBeCloseTo(gapOf(rest) + 7, 10);
148
149 // Running wide open: any speed at or over the walk cap counts as fully moving.
150 const running = scene((w) => { w.me.vel = [1000, 0, 0]; });
151 expect(gapOf(draw(running))).toBeCloseTo(4 + spreadOf(running, 1) * 14, 10);
152 expect(gapOf(draw(running))).toBeGreaterThan(gapOf(rest));
153
154 // Scoped it draws tight, and darkens everything outside the sight.
155 const scoped = draw(scene((w) => { w.me.scoped = true; }));
156 expect(gapOf(scoped)).toBeLessThan(gapOf(rest));
157 expect(scoped.calls.some((c) => c.op === "arc")).toBe(true);
158 expect(scoped.calls.find((c) => c.op === "fill")?.args[0]).toBe("evenodd");
159
160 // Dead players do not aim: no crosshair, no sight, nothing stroked at all.
161 const dead = draw(scene((w) => { w.me.alive = false; }));
162 expect(dead.calls.some((c) => c.op === "stroke")).toBe(false);
163 expect(dead.calls.some((c) => c.op === "moveTo")).toBe(false);
164 });
165
166 test("a hit is marked white, a headshot red, and both are gone a quarter second later", () => {
167 const hit = draw(scene((w) => {
168 w.time = 10;
169 w.effects = [{ kind: "hitmark", at: 9.9, headshot: false }];
170 }));
171 const body = hit.calls.filter((c) => c.op === "stroke");
172 expect(body.length).toBe(2); // the crosshair, then the marker
173 expect(body[1]?.stroke).toBe("rgba(255,255,255,0.9)");
174
175 const head = draw(scene((w) => {
176 w.time = 10;
177 w.effects = [{ kind: "hitmark", at: 9.9, headshot: true }];
178 }));
179 expect(head.calls.filter((c) => c.op === "stroke")[1]?.stroke).toBe("rgba(255,90,90,0.95)");
180
181 const old = draw(scene((w) => {
182 w.time = 10;
183 w.effects = [{ kind: "hitmark", at: 9.7, headshot: true }];
184 }));
185 expect(old.calls.filter((c) => c.op === "stroke").length).toBe(1);
186 });
187
188 // --- health, money and armour ----------------------------------------------
189
190 test("health reads white while you are standing and red when you are nearly gone", () => {
191 expect(line(draw(scene()), "100").fill).toBe("#f3f5f8");
192 expect(line(draw(scene((w) => { w.me.health = 31; })), "31").fill).toBe("#f3f5f8");
193 // Thirty itself is already the red one: the HUD asks `health > 30`.
194 expect(line(draw(scene((w) => { w.me.health = 30; })), "30").fill).toBe("#ff6b5b");
195 expect(line(draw(scene((w) => { w.me.health = 9; })), "9").fill).toBe("#ff6b5b");
196
197 // It is a whole number, and never a negative one.
198 expect(says(draw(scene((w) => { w.me.health = 45.6; })), "46")).toBe(true);
199 expect(says(draw(scene((w) => { w.me.health = -12; })), "0")).toBe(true);
200 });
201
202 test("the wallet, the armour and the kit say what you bought", () => {
203 const poor = draw(scene());
204 expect(says(poor, "$800")).toBe(true);
205 expect(says(poor, "no armour")).toBe(true);
206 expect(says(poor, "defuse kit")).toBe(false);
207
208 const kitted = draw(scene((w) => {
209 w.me.money = 4250;
210 w.me.armor = 100;
211 w.me.helmet = true;
212 w.me.defuseKit = true;
213 }));
214 expect(says(kitted, "$4250")).toBe(true);
215 expect(says(kitted, "armour 100 + helmet")).toBe(true);
216 expect(says(kitted, "defuse kit")).toBe(true);
217
218 // A terrorist with a kit in the inventory is still a terrorist: no kit line.
219 const t = draw(scene((w) => { w.me.team = "t"; w.me.defuseKit = true; }));
220 expect(says(t, "defuse kit")).toBe(false);
221 });
222
223 // --- ammo ------------------------------------------------------------------
224
225 test("ammo reads mag / reserve, the knife reads a dash, and reloading says so", () => {
226 const loaded = draw(scene((w) => {
227 w.me.weapon = "usp";
228 w.me.ammo.usp = { mag: 5, reserve: 20 };
229 }));
230 expect(says(loaded, "5 / 20")).toBe(true);
231 expect(says(loaded, WEAPONS.usp.name)).toBe(true);
232 expect(says(loaded, "reloading")).toBe(false);
233
234 const knife = draw(scene((w) => { w.me.weapon = "knife"; }));
235 expect(says(knife, "—")).toBe(true);
236 expect(says(knife, WEAPONS.knife.name)).toBe(true);
237
238 // Mid-reload the magazine still reads empty, and the word is on the screen.
239 const reloading = draw(scene((w) => {
240 w.time = 10;
241 w.me.reloadUntil = 10.5;
242 w.me.ammo.usp = { mag: 0, reserve: 24 };
243 }));
244 expect(says(reloading, "0 / 24")).toBe(true);
245 expect(line(reloading, "reloading").fill).toBe("#f0c060");
246
247 // The instant it finishes the word goes.
248 expect(says(draw(scene((w) => { w.time = 10; w.me.reloadUntil = 10; })), "reloading")).toBe(false);
249
250 // Carrying the bomb is written beside the gun's name.
251 const carrier = draw(scene((w) => { w.me.team = "t"; w.me.hasBomb = true; }));
252 expect(says(carrier, `${WEAPONS.usp.name} · bomb`)).toBe(true);
253 });
254
255 // --- the clock and the score -----------------------------------------------
256
257 test("the clock counts the phase down, goes red when the bomb is down, and stops between rounds", () => {
258 const freeze = draw(scene((w) => { w.match.phase = "freeze"; w.match.timer = 8; }));
259 expect(line(freeze, "0:08").fill).toBe("#f3f5f8");
260
261 const live = draw(scene((w) => { w.match.phase = "live"; w.match.timer = 95; }));
262 expect(line(live, "1:35").fill).toBe("#f3f5f8");
263
264 const planted = draw(scene((w) => { w.match.phase = "planted"; w.match.timer = 40; }));
265 expect(line(planted, "0:40").fill).toBe("#ff6b5b");
266
267 // Nothing is being counted between rounds or after the match: no clock to read.
268 for (const phase of ["over", "match_over"] as const) {
269 const paint = draw(scene((w) => { w.match.phase = phase; w.match.timer = 5; }));
270 expect(says(paint, "--:--")).toBe(true);
271 expect(says(paint, "0:05")).toBe(false);
272 }
273 });
274
275 test("the score sits either side of the clock, each in its team's colour, and so do the survivors", () => {
276 const paint = draw(scene((w) => {
277 w.match.score = { t: 7, ct: 11 };
278 for (const p of w.players) if (p.team === "t" && p !== w.me) p.alive = false;
279 }));
280 expect(line(paint, "7").fill).toBe("#e2b04a");
281 expect(line(paint, "11").fill).toBe("#6fa8e8");
282 // Five terrorists, all of them down; five counter-terrorists, all still up.
283 expect(line(paint, "T 0").fill).toBe("#e2b04a");
284 expect(line(paint, "CT 5").fill).toBe("#6fa8e8");
285 });
286
287 // --- the kill feed ---------------------------------------------------------
288
289 const kill = (at: number, over: Partial<World["feed"][number]> = {}): World["feed"][number] => ({
290 at, killer: "Kestrel", victim: "Pike", weapon: "ak47", headshot: false, killerTeam: "t", ...over,
291 });
292
293 test("a kill is named with the gun that made it, fades for eight seconds and is then gone", () => {
294 const fresh = draw(scene((w) => { w.time = 100; w.feed = [kill(100)]; }));
295 const entry = line(fresh, `Kestrel ${WEAPONS.ak47.name} Pike`);
296 expect(entry.alpha).toBe(1);
297 expect(entry.fill).toBe("#e2b04a");
298
299 // Halfway out it is half there, and a CT's kill is the other colour.
300 const fading = draw(scene((w) => {
301 w.time = 100;
302 w.feed = [kill(92.5, { killerTeam: "ct", headshot: true })];
303 }));
304 const faded = line(fading, `Kestrel ${WEAPONS.ak47.name} ⌖ Pike`);
305 expect(faded.alpha).toBeCloseTo(0.5, 10);
306 expect(faded.fill).toBe("#6fa8e8");
307
308 // Eight seconds old is the end of it: nothing readable, and nothing at all after.
309 const spent = draw(scene((w) => { w.time = 100; w.feed = [kill(92)]; }));
310 expect(readable(spent).some((c) => String(c.args[0]).includes("Kestrel"))).toBe(false);
311 const gone = draw(scene((w) => { w.time = 100; w.feed = [kill(91.9)]; }));
312 expect(gone.calls.some((c) => c.op === "fillText" && String(c.args[0]).includes("Kestrel"))).toBe(false);
313
314 // The feed shows the last five kills, newest last, oldest dropped.
315 const busy = draw(scene((w) => {
316 w.time = 100;
317 w.feed = [1, 2, 3, 4, 5, 6].map((n) => kill(99, { victim: `Down ${n}` }));
318 }));
319 expect(words(busy).filter((s) => s.includes("Down")).length).toBe(5);
320 expect(words(busy).some((s) => s.includes("Down 1"))).toBe(false);
321 expect(words(busy).some((s) => s.includes("Down 6"))).toBe(true);
322 });
323
324 test("the round's last word is on the screen for four seconds", () => {
325 const said = draw(scene((w) => {
326 w.time = 100;
327 w.messages = [{ at: 99.9, text: "The bomb has been planted at A." }];
328 }));
329 expect(says(said, "The bomb has been planted at A.")).toBe(true);
330
331 const stale = draw(scene((w) => {
332 w.time = 100;
333 w.messages = [{ at: 96, text: "The bomb has been planted at A." }];
334 }));
335 expect(says(stale, "The bomb has been planted at A.")).toBe(false);
336 });
337
338 // --- planting and defusing -------------------------------------------------
339
340 /** The filled fraction of the use bar: the bar's width over the panel behind it. */
341 function useBar(paint: Recorder): { fraction: number; fill: string } {
342 const rects = paint.calls.filter((c) => c.op === "fillRect");
343 const bar = rects[rects.length - 1] as Call;
344 const panel = rects[rects.length - 2] as Call;
345 return { fraction: Number(bar.args[2]) / (Number(panel.args[2]) - 4), fill: bar.fill };
346 }
347
348 test("the use bar fills as far as the plant or the defuse has got", () => {
349 const nothing = draw(scene());
350 expect(says(nothing, "Planting")).toBe(false);
351
352 const planting = draw(scene((w) => {
353 w.me.team = "t";
354 w.me.hasBomb = true;
355 w.bomb.plantProgress = SHORT.plantTime / 2;
356 }));
357 expect(says(planting, "Planting")).toBe(true);
358 expect(useBar(planting).fraction).toBeCloseTo(0.5, 10);
359 expect(useBar(planting).fill).toBe("#e2b04a");
360
361 const defusing = draw(scene((w) => {
362 w.bomb.state = "planted";
363 w.bomb.defuser = w.me;
364 w.bomb.defuseProgress = SHORT.defuseTime * 0.25;
365 }));
366 expect(says(defusing, "Defusing")).toBe(true);
367 expect(useBar(defusing).fraction).toBeCloseTo(0.25, 10);
368 expect(useBar(defusing).fill).toBe("#6fa8e8");
369
370 // A kit is faster, and says so, and the bar is full when the wire is cut.
371 const kit = draw(scene((w) => {
372 w.me.defuseKit = true;
373 w.bomb.state = "planted";
374 w.bomb.defuser = w.me;
375 w.bomb.defuseProgress = SHORT.defuseTimeWithKit;
376 }));
377 expect(says(kit, "Defusing (kit)")).toBe(true);
378 expect(useBar(kit).fraction).toBeCloseTo(1, 10);
379 });
380
381 // --- dead, and the scoreboard ----------------------------------------------
382
383 const WATCHING = "You are dead — watching the round out";
384
385 test("a dead player is told they are watching, until the match itself is over", () => {
386 expect(says(draw(scene()), WATCHING)).toBe(false);
387 expect(says(draw(scene((w) => { w.me.alive = false; })), WATCHING)).toBe(true);
388 expect(says(draw(scene((w) => {
389 w.me.alive = false;
390 w.match.phase = "match_over";
391 })), WATCHING)).toBe(false);
392 });
393
394 test("the scoreboard ranks by kills, then by damage, marks you, and dims the dead", () => {
395 const world = scene((w) => {
396 const [me, second, third, fourth] = w.players as Player[];
397 me.kills = 2; me.deaths = 1; me.headshots = 1; me.damage = 310.4; me.money = 3200;
398 second.kills = 5; second.damage = 700;
399 third.kills = 2; third.damage = 480; // same kills as me, more damage: above me
400 fourth.kills = 0; fourth.alive = false;
401 });
402 const paint = draw(world, { scoreboard: true });
403 const rows = readable(paint).filter((c) => c.align === "left" && String(c.args[0]).includes(" "));
404
405 const names = rows.map((c) => String(c.args[0]));
406 expect(names[0]).toContain(world.players[1]?.name);
407 expect(names[1]).toContain(world.players[2]?.name);
408 expect(names[2]).toContain("you");
409
410 // Your own row is the white one, and it is the only one that says so.
411 const mine = rows[2] as Call;
412 expect(mine.fill).toBe("#ffffff");
413 expect(names.filter((n) => n.includes("(you)")).length).toBe(1);
414 expect(rows.filter((r) => r.fill === "#ffffff").length).toBe(1);
415
416 // Every other row wears its team's colour, and a dead one is dimmed.
417 expect(rows.filter((r) => r.fill === "#e2b04a" || r.fill === "#6fa8e8").length).toBe(9);
418 const down = rows.find((r) => String(r.args[0]).includes(world.players[3]?.name ?? "")) as Call;
419 expect(down.alpha).toBeCloseTo(0.45, 10);
420 expect(mine.alpha).toBe(1);
421
422 // The columns are there, and your figures are under them, rounded.
423 expect(words(paint)).toContain("kills");
424 expect(words(paint)).toContain("damage");
425 const figures = readable(paint).filter((c) => c.align === "right");
426 expect(figures.map((c) => String(c.args[0]))).toContain("310");
427 expect(figures.map((c) => String(c.args[0]))).toContain("$3200");
428
429 // It is drawn over everything: its scrim comes after the health it covers.
430 const scrim = paint.calls.findIndex((c) => c.fill === "rgba(10, 12, 16, 0.88)");
431 const health = paint.calls.findIndex((c) => c.op === "fillText" && c.args[0] === "100");
432 expect(scrim).toBeGreaterThan(health);
433
434 // And nothing of it is on the screen when it is not being held open.
435 expect(says(draw(world), "kills")).toBe(false);
436 });
437
438 // --- both ends of the screen -----------------------------------------------
439
440 /** A frame with every panel on it at once, so a layout test has something to lay out. */
441 const busyWorld = (): World => scene((w) => {
442 w.time = 100;
443 w.match.phase = "planted";
444 w.match.timer = 37;
445 w.match.score = { t: 9, ct: 12 };
446 w.me.health = 24;
447 w.me.armor = 100;
448 w.me.helmet = true;
449 w.me.defuseKit = true;
450 w.me.money = 12450;
451 w.me.reloadUntil = 101;
452 w.bomb.state = "planted";
453 w.bomb.defuser = w.me;
454 w.bomb.defuseProgress = 1;
455 w.effects = [{ kind: "hitmark", at: 99.95, headshot: true }];
456 w.feed = [kill(99), kill(97, { killerTeam: "ct" }), kill(94, { headshot: true })];
457 w.messages = [{ at: 99.5, text: "The bomb has been planted at B." }];
458 for (const p of w.players) p.kills = p.id;
459 (w.players[4] as Player).alive = false;
460 });
461
462 /** Every call that put something over the edge of the canvas. */
463 function offCanvas(paint: Recorder, width: number, height: number): Call[] {
464 const on = (x: number, y: number): boolean => x >= 0 && y >= 0 && x <= width && y <= height;
465 return paint.calls.filter((c) => {
466 const [a, b, c3, d] = c.args.map(Number);
467 if (c.op === "fillText") return !on(Number(c.args[1]), Number(c.args[2]));
468 if (c.op === "moveTo" || c.op === "lineTo") return !on(a as number, b as number);
469 if (c.op === "arc") return !on((a as number) - (c3 as number), (b as number) - (c3 as number)) ||
470 !on((a as number) + (c3 as number), (b as number) + (c3 as number));
471 if (c.op === "fillRect" || c.op === "rect" || c.op === "clearRect")
472 return !on(a as number, b as number) || !on((a as number) + (c3 as number), (b as number) + (d as number));
473 return false;
474 });
475 }
476
477 const fontsUsed = (paint: Recorder): string[] =>
478 [...new Set(paint.calls.filter((c) => c.op === "fillText").map((c) => c.font))].sort();
479
480 test("the whole readout fits a phone and a desk, and says the same amount at both", () => {
481 const world = busyWorld();
482 const phone = draw(world, { width: 360, height: 640, scoreboard: true });
483 const desk = draw(world, { width: 1920, height: 1080, scoreboard: true });
484
485 expect(offCanvas(phone, 360, 640)).toEqual([]);
486 expect(offCanvas(desk, 1920, 1080)).toEqual([]);
487
488 // The scale is `width / 1280`, clamped to 0.7 .. 1.4: 21/13/11 px on a phone,
489 // 42/27/21 on a desk, and no other size on either.
490 expect(fontsUsed(phone)).toEqual([
491 "11px system-ui, sans-serif", "13px system-ui, sans-serif", "21px system-ui, sans-serif",
492 ]);
493 expect(fontsUsed(desk)).toEqual([
494 "21px system-ui, sans-serif", "27px system-ui, sans-serif", "42px system-ui, sans-serif",
495 ]);
496
497 // The same world reads the same on both: every line of it, in the same order.
498 expect(words(phone)).toEqual(words(desk));
499 expect(phone.calls.length).toBe(desk.calls.length);
500 expect(phone.calls.map((c) => c.op)).toEqual(desk.calls.map((c) => c.op));
501
502 // And the frame starts by clearing the one it is drawn on, whatever its size.
503 expect(phone.calls[0]?.op).toBe("clearRect");
504 expect(phone.calls[0]?.args).toEqual([0, 0, 360, 640]);
505 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs