fable/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine

Merge pull request #15 from wren/14-the-renderer-is-pixel-bound-on-a-retina

The renderer is pixel-bound on a retina screen: cap the internal resolution and batch the wall slices
benjivers committed 8cd8fa4 merge Browse files
23 files changed +1557 −88
README.md +26 −4
@@ -73,7 +73,24 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
7373 - The view is a raycaster: walls are one tile tall and flat-shaded, there is
7474 no looking up or down, and everything that is not a wall — bodies, the
7575 spike, ability zones — is a flat billboard scaled by distance and clipped
76 against the walls.
76 against the walls. It is drawn into an offscreen canvas 960 columns wide
77 (`static/resolution.js`, never more than the canvas has pixels), one ray
78 every other column, and scaled onto the visible one, so the frame costs the
79 same on a retina screen as on any other; the crosshair, the HUD and the
80 minimap are drawn on top at full device resolution. Columns that would draw
81 the same rectangle are batched into one fill (`static/slices.js`), the
82 map's tiles are drawn once a size and blitted after that, and if the frame
83 still runs over 20ms the render width drops a rung until it fits
84 (`static/budget.js`). Fidelity is cheap here and frame rate is not.
85 All of it is presentation: the simulation and the snapshots are untouched.
86 - A body you have only just been shown is faded and grown into sight over a
87 tenth of a second, and slid in from the direction it came if it was seen
88 near there before; where somebody was last seen alive leaves an outline
89 that dims away over a second (`static/presence.js`). The server still sends
90 only what you can see — that is the rule, and nothing here bends it. This
91 is a drawing about information you already had: an enemy who steps into a
92 sightline has no history, and without it they appear mid-stride rather than
93 walking into the room.
7794 - One map, one match at a time, ten seats. There is no matchmaking, no
7895 ranking and no account: your seat belongs to a session cookie, and a
7996 playing seat is capped one per client address so a cookie — free to mint
@@ -99,8 +116,10 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
99116 Click the view to capture the mouse, then WASD move and the mouse looks;
100117 **Esc** gives the pointer back. Click fires, **R** reloads, **F** plants and
101118 defuses (hold it), **C / Q / E / X** cast, **B** opens the buy, **Tab**
102 holds the big map open, **V** swaps to the top-down view and **H** or **?**
103 brings back the how-to-play card. On a phone it stays top-down: the left
119 holds the big map open, **V** swaps to the top-down view, **H** or **?**
120 brings back the how-to-play card, and **F3** or **`** prints the frame
121 readout: frame rate, mean frame, snapshots a second, the interpolation delay
122 in force and the width the world is being drawn at. On a phone it stays top-down: the left
104123 thumb walks and the right thumb aims and fires.
105124
106125 ```sh
@@ -125,7 +144,10 @@ says how.
125144 no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one
126145 live match and its sockets · `src/server.ts` the door · `static/` the browser client —
127146 `val.js` wires the socket, the keys and the HUD, `raycast.js` is the ray
128 maths, `scene.js` the first-person view, `topdown.js` the minimap,
147 maths, `scene.js` the first-person view, `slices.js` batches its wall
148 columns, `resolution.js` and `budget.js` decide how wide to draw it,
149 `presence.js` fades bodies into sight and marks where they left it,
150 `topdown.js` the minimap,
129151 `gun.js`, `beep.js`, `steer.js`, `roster.js`, `yaw.js`, `interp.js` and `prompt.js` the rest; the server bundles
130152 them into one content-named file at start-up. The rules are pure functions and the tests play whole matches
131153 headlessly.
src/budget.test.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * The frame governor, fed frames by hand. A second of slow frames should
3 * cost one rung and not four; a second of fast ones should not immediately
4 * give it back, because a resolution that flaps is worse than a low one.
5 */
6
7 import { expect, test } from "bun:test";
8 import { FAST, makeBudget, measure, PATIENCE, SLOW, WINDOW } from "../static/budget.js";
9
10 /** `seconds` of frames, each `ms` long, and how many times the level moved. */
11 function run(budget: ReturnType<typeof makeBudget>, ms: number, seconds: number, from = 0) {
12 let at = from;
13 let moves = 0;
14 const end = from + seconds;
15 while (at < end) {
16 at += ms / 1000;
17 if (measure(budget, ms, at)) moves++;
18 }
19 return { moves, at };
20 }
21
22 test("a second over budget costs exactly one rung, not four", () => {
23 const budget = makeBudget(0);
24 const { moves } = run(budget, SLOW + 8, WINDOW * 1.5);
25 expect(moves).toBe(1);
26 expect(budget.level).toBe(1);
27 });
28
29 test("frames inside the budget leave the level alone", () => {
30 const budget = makeBudget(2);
31 run(budget, (SLOW + FAST) / 2, 10);
32 expect(budget.level).toBe(2);
33 });
34
35 test("headroom gives a rung back, but not before two windows of it", () => {
36 const budget = makeBudget(0);
37 let at = run(budget, SLOW + 8, WINDOW * 1.2).at;
38 expect(budget.level).toBe(1);
39 at = run(budget, 8, WINDOW * 1.2, at).at;
40 expect(budget.level).toBe(1); // one good window is not enough
41 run(budget, 8, WINDOW * 1.2, at);
42 expect(budget.level).toBe(0);
43 });
44
45 test("it never refines past the rung the window started on", () => {
46 const budget = makeBudget(2);
47 run(budget, 4, 20);
48 expect(budget.level).toBe(2);
49 expect(budget.good).toBeLessThanOrEqual(PATIENCE);
50 });
51
52 test("it stops at the coarsest rung rather than walking off the ladder", () => {
53 const budget = makeBudget(0, 3);
54 run(budget, 60, 40);
55 expect(budget.level).toBe(3);
56 });
57
58 test("one slow window, then one fast, does not oscillate", () => {
59 const budget = makeBudget(0);
60 let at = 0;
61 const levels: number[] = [];
62 for (let i = 0; i < 8; i++) {
63 at = run(budget, i % 2 === 0 ? SLOW + 8 : 6, WINDOW * 1.05, at).at;
64 levels.push(budget.level);
65 }
66 // Alternating windows never hand a rung back — it takes two good ones —
67 // so the level climbs slowly and stays put rather than flickering.
68 expect(levels.filter((l, i) => i > 0 && l < levels[i - 1]!).length).toBe(0);
69 });
70
71 test("the window reports the frame rate and the mean it decided on", () => {
72 const budget = makeBudget(0);
73 run(budget, 20, WINDOW * 1.1);
74 expect(budget.mean).toBeCloseTo(20, 1);
75 expect(budget.fps).toBeGreaterThan(45);
76 expect(budget.fps).toBeLessThan(55);
77 });
src/client.test.ts +65 −8
@@ -112,7 +112,7 @@ const nodes = new Map<string, Record<string, unknown>>();
112112 for (const id of [
113113 "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle",
114114 "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage",
115 "log", "atkboard", "defboard", "helpagents", "roster", "feed",
115 "log", "atkboard", "defboard", "helpagents", "roster", "feed", "perf",
116116 ])
117117 nodes.set(id, element(id));
118118
@@ -232,22 +232,39 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
232232 expect(row).toContain(" ▸ ");
233233 expect(row).not.toMatch(/\d/);
234234
235 // A frame of the first-person view actually draws walls: a slice a column,
236 // far more rectangles than the map has tiles.
235 // A frame of the first-person view draws the world into its own buffer and
236 // blits it, with the minimap's tiles blitted too. It is a picture, but it
237 // is not one fill a screen column any more, nor one a map tile: the map has
238 // 570 tiles and the canvas 900 columns, and the frame used to pay for both.
239 const tally = () => ({
240 fills: drawn.filter((op) => op.startsWith("fillRect")).length,
241 blits: drawn.filter((op) => op.startsWith("drawImage")).length,
242 });
243 runFrame(); // whatever was cached at this size is cached before we count
237244 drawn.length = 0;
238245 runFrame();
239 expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800);
246 const fp = tally();
247 expect(fp.blits).toBeGreaterThanOrEqual(2); // the world, and the map's tiles
248 expect(fp.fills).toBeGreaterThan(40);
249 expect(fp.fills).toBeLessThan(570);
240250
241 // Tab swaps that frame for the whole map — many fewer rectangles.
251 // Tab swaps that frame for the whole map. Its tiles are drawn once into a
252 // canvas of their own and blitted from then on: the first such frame pays
253 // for all 570 of them, and no frame after it pays for any.
242254 for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop });
243255 drawn.length = 0;
244256 runFrame();
245 expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(800);
257 const built = tally();
258 drawn.length = 0;
259 runFrame();
260 const top = tally();
261 expect(built.fills).toBeGreaterThan(400);
262 expect(top.blits).toBe(1);
263 expect(top.fills).toBeLessThan(100);
246264 for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" });
247265 drawn.length = 0;
248266 runFrame();
249 expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800);
250
267 expect(tally().blits).toBeGreaterThanOrEqual(2);
251268 // Holding W reaches the server as a direction, and it is the way we face.
252269 const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!;
253270 press("KeyW");
@@ -380,6 +397,46 @@ test("a snapshot with no seat in it draws the watching view instead of throwing"
380397 runFrame();
381398 expect(String(nodes.get("prompt")!.textContent)).toContain("cookie");
382399
400 // Back to the first-person view for the last of it, and a frame of it to
401 // measure: the one before is the one that rebuilds anything cached.
402 press("KeyV");
403 runFrame();
404 drawn.length = 0;
405 runFrame();
406 const onePixel = tally().fills;
407 expect(onePixel).toBeGreaterThan(40);
408
409 // The frame is not the screen's pixel count. On a retina display the same
410 // window is twice the canvas, and the world is still drawn at a capped
411 // internal width and blitted up: the work does not double with the device
412 // pixel ratio, which is what made this choppy on a retina Mac.
413 (globalThis as { devicePixelRatio: number }).devicePixelRatio = 2;
414 for (const fn of listeners.get("resize") ?? []) fn({});
415 expect(nodes.get("view")!.width).toBe(1800);
416 runFrame(); // the first frame at a new size redraws the minimap's tiles
417 drawn.length = 0;
418 runFrame();
419 expect(drawn.some((op) => op.startsWith("drawImage"))).toBe(true);
420 expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(onePixel * 1.3);
421 (globalThis as { devicePixelRatio: number }).devicePixelRatio = 1;
422 for (const fn of listeners.get("resize") ?? []) fn({});
423
424 // ` prints the numbers, so "it feels choppy" and "it delivers 30Hz" can be
425 // told apart: the frame rate, the mean frame, the snapshot rate, the
426 // interpolation delay, and the width the world is really drawn at.
427 const perf = nodes.get("perf")!;
428 expect((perf.style as Record<string, string>).display).not.toBe("block");
429 press("Backquote");
430 expect((perf.style as Record<string, string>).display).toBe("block");
431 const readout = String(perf.textContent);
432 expect(readout).toContain("fps");
433 expect(readout).toContain("ms/frame");
434 expect(readout).toContain("Hz in");
435 expect(readout).toContain("ms behind");
436 expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas
437 press("Backquote");
438 expect((perf.style as Record<string, string>).display).toBe("none");
439
383440 // This test's loopback address is shared with every other test in the
384441 // run; close the socket so the address is free for the next real
385442 // connection rather than left holding a seat for the rest of the suite.
src/pages/game.ts +10 −2
@@ -76,6 +76,10 @@ export function gamePage(opts: {
7676 #aimhint { position: absolute; inset: auto 0 45% 0; display: none; place-items: center;
7777 font-size: .85rem; letter-spacing: .08em; text-transform: uppercase;
7878 text-shadow: 0 1px 4px #000; pointer-events: none; }
79 #perf { position: absolute; left: .5rem; bottom: 2.6rem; display: none; pointer-events: none;
80 padding: .2rem .45rem; border-radius: .3rem; background: #0b0d11cc; border: 1px solid #2f3541;
81 font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap;
82 max-width: calc(100% - 1rem); overflow: hidden; text-overflow: ellipsis; }
7983 #help { position: fixed; inset: 0; z-index: 10; display: none; place-items: center;
8084 background: #080a0ef2; padding: .8rem; overflow: auto; }
8185 #help > div { max-width: 40rem; }
@@ -156,6 +160,7 @@ export function gamePage(opts: {
156160 <div id="aimhint">click to aim · esc releases</div>
157161 <div id="dead">waiting for the next round</div>
158162 <div id="feed"></div>
163 <div id="perf" aria-live="off"></div>
159164 <div id="prompt">Click the map to aim, then press <b>H</b> for how to play.</div>
160165 <div id="help" role="dialog" aria-label="How to play val"><div>
161166 <h2>How to play</h2>
@@ -169,7 +174,8 @@ export function gamePage(opts: {
169174 click the view to capture the mouse and look, <kbd>Esc</kbd> releases ·
170175 click to fire · <kbd>R</kbd> reload · hold <kbd>F</kbd> to plant, defuse or pick up ·
171176 <kbd>Tab</kbd> holds the big map open · <kbd>V</kbd> swaps to the top-down view ·
172 <kbd>B</kbd> opens the buy · <kbd>H</kbd> brings this card back.</p>
177 <kbd>B</kbd> opens the buy · <kbd>H</kbd> brings this card back ·
178 <kbd>F3</kbd> or <kbd>\`</kbd> shows the frame readout.</p>
173179 <p><strong>Abilities.</strong> <kbd>C</kbd> and <kbd>Q</kbd> are bought in the buy phase and
174180 last the round, <kbd>E</kbd> is free and comes back on a cooldown, and <kbd>X</kbd> is
175181 the ultimate, paid for in ult points you earn by playing.</p>
@@ -238,7 +244,9 @@ export function gamePage(opts: {
238244 <p><strong>Controls.</strong> WASD move · click the view to capture the mouse, move to look,
239245 Esc releases · click fires · R reloads · <strong>F</strong> plants and defuses (hold it) ·
240246 C / Q / E / X cast · Tab holds the map open · V swaps to the top-down view ·
241 B opens the buy · H shows how to play.
247 B opens the buy · H shows how to play · F3 or \` prints the frame numbers:
248 frame rate, mean frame, snapshots a second, the interpolation delay and how
249 wide the world is being drawn before it is scaled onto the canvas.
242250 On a phone it stays top-down: left thumb walks, right thumb aims and fires.</p>
243251 <p>Five attackers carry the spike, five defenders stop them. 100s round, 4s plant, 45s fuse,
244252 7s defuse with a checkpoint at half. First to 13, sides swap at 12. Bots fill every seat
src/presence.test.ts added +163
@@ -0,0 +1,163 @@
1 /**
2 * Coming into sight and going out of it, as arithmetic. The case that
3 * matters is a bot crossing a doorway: it should arrive over a tenth of a
4 * second from the side it came from, and leave a mark where it went.
5 */
6
7 import { expect, test } from "bun:test";
8 import {
9 ENTRY,
10 FADE,
11 forget,
12 GHOST,
13 ghosts,
14 makePresence,
15 REACH,
16 shown,
17 sighted,
18 slide,
19 SMALL,
20 swell,
21 } from "../static/presence.js";
22
23 const body = (over: Record<string, unknown> = {}) => ({
24 id: "them",
25 x: 400,
26 y: 200,
27 angle: 0,
28 alive: true,
29 mate: false,
30 agent: "sable",
31 ...over,
32 });
33
34 const here = (x: number, y: number) => ({ x, y, angle: 0 });
35
36 test("a body just in sight is drawn in over a tenth of a second", () => {
37 const p = makePresence();
38 sighted(p, [body()], 10);
39 expect(shown(p, "them", 10)).toBe(0);
40 expect(shown(p, "them", 10 + FADE / 2)).toBeCloseTo(0.5, 5);
41 expect(shown(p, "them", 10 + FADE * 1.01)).toBe(1);
42 expect(shown(p, "them", 30)).toBe(1);
43 // It starts small and reaches its own size, never past it.
44 expect(swell(0)).toBe(SMALL);
45 expect(swell(1)).toBe(1);
46 expect(swell(4)).toBe(1);
47 });
48
49 test("a body already in sight is not faded in again by the next snapshot", () => {
50 const p = makePresence();
51 sighted(p, [body()], 10);
52 sighted(p, [body({ x: 420 })], 10.03);
53 sighted(p, [body({ x: 440 })], 10.06);
54 expect(shown(p, "them", 10.06)).toBeCloseTo(0.5, 5);
55 expect(shown(p, "them", 10.2)).toBe(1);
56 });
57
58 test("somebody nobody has ever seen is solid, not invisible", () => {
59 const p = makePresence();
60 expect(shown(p, "nobody", 10)).toBe(1);
61 expect(slide(p, "nobody", here(1, 2), 10, here(0, 0))).toEqual(here(1, 2));
62 });
63
64 test("a body that steps back into view slides in from the way it came", () => {
65 const p = makePresence();
66 sighted(p, [body({ x: 400, y: 200 })], 10); // seen at the door
67 sighted(p, [], 10.1); // and gone behind the wall
68 sighted(p, [body({ x: 500, y: 200 })], 11); // back, further along
69
70 const out = here(0, 0);
71 // It begins a stride back down the line it walked, not at its destination.
72 const start = slide(p, "them", here(500, 200), 11, out);
73 expect(start.x).toBeCloseTo(500 - ENTRY, 5);
74 expect(start.y).toBeCloseTo(200, 5);
75 // Half way through the fade it is half way in.
76 const half = slide(p, "them", here(500, 200), 11 + FADE / 2, out);
77 expect(half.x).toBeCloseTo(500 - ENTRY / 2, 5);
78 // And once it is in, the drawing is the interpolation's, untouched.
79 const done = slide(p, "them", here(500, 200), 11 + FADE * 1.01, out);
80 expect(done).toEqual(here(500, 200));
81 });
82
83 test("a body that turns up across the map does not slide across it", () => {
84 const p = makePresence();
85 sighted(p, [body({ x: 100, y: 100 })], 10);
86 sighted(p, [], 10.1);
87 sighted(p, [body({ x: 100 + REACH + 10, y: 100 })], 12);
88 const out = here(0, 0);
89 const at = here(100 + REACH + 10, 100);
90 expect(slide(p, "them", at, 12, out)).toEqual(at);
91 // It still fades in: an arrival, just not a walk from anywhere.
92 expect(shown(p, "them", 12)).toBe(0);
93 });
94
95 test("a body that has not moved since has no direction to come from", () => {
96 const p = makePresence();
97 sighted(p, [body({ x: 300, y: 300 })], 10);
98 sighted(p, [], 10.1);
99 sighted(p, [body({ x: 300.5, y: 300 })], 11);
100 const at = here(300.5, 300);
101 expect(slide(p, "them", at, 11, here(0, 0))).toEqual(at);
102 });
103
104 test("where somebody was last seen is marked, and the mark dims away", () => {
105 const p = makePresence();
106 sighted(p, [body({ x: 640, y: 480, agent: "veil" })], 10);
107 expect(ghosts(p, 10)).toEqual([]);
108
109 sighted(p, [], 10.5);
110 const fresh = ghosts(p, 10.5);
111 expect(fresh.length).toBe(1);
112 expect(fresh[0]).toMatchObject({ id: "them", x: 640, y: 480, agent: "veil", fade: 1 });
113 expect(ghosts(p, 10.5 + GHOST / 2)[0]!.fade).toBeCloseTo(0.5, 5);
114 expect(ghosts(p, 10.5 + GHOST)).toEqual([]);
115 // And it is forgotten, not kept for ever.
116 expect(p.last.size).toBe(0);
117 });
118
119 test("a body last seen dead leaves no mark: a corpse is not a question", () => {
120 const p = makePresence();
121 sighted(p, [body({ alive: false })], 10);
122 sighted(p, [], 10.2);
123 expect(ghosts(p, 10.2)).toEqual([]);
124 });
125
126 test("coming back into sight takes the mark away at once", () => {
127 const p = makePresence();
128 sighted(p, [body({ x: 400 })], 10);
129 sighted(p, [], 10.2);
130 expect(ghosts(p, 10.3).length).toBe(1);
131 sighted(p, [body({ x: 430 })], 10.4);
132 expect(ghosts(p, 10.4)).toEqual([]);
133 });
134
135 test("a body in sight the whole time is never marked and never re-faded", () => {
136 const p = makePresence();
137 for (let i = 0; i < 20; i++) sighted(p, [body({ x: 400 + i * 5 })], 10 + i * 0.033);
138 expect(ghosts(p, 10.7)).toEqual([]);
139 expect(shown(p, "them", 10.7)).toBe(1);
140 });
141
142 test("a new round forgets everybody", () => {
143 const p = makePresence();
144 sighted(p, [body()], 10);
145 sighted(p, [], 10.2);
146 forget(p);
147 expect(ghosts(p, 10.3)).toEqual([]);
148 expect(p.live.size).toBe(0);
149 // And the next sighting is a first sighting, with nothing to slide from.
150 sighted(p, [body({ x: 900 })], 11);
151 expect(shown(p, "them", 11)).toBe(0);
152 const at = here(900, 200);
153 expect(slide(p, "them", at, 11, here(0, 0))).toEqual(at);
154 });
155
156 test("two bodies are tracked apart", () => {
157 const p = makePresence();
158 sighted(p, [body({ id: "a" }), body({ id: "b", x: 800 })], 10);
159 sighted(p, [body({ id: "a" })], 10.2);
160 expect(shown(p, "a", 10.2)).toBe(1);
161 const marks = ghosts(p, 10.2);
162 expect(marks.map((m) => m.id)).toEqual(["b"]);
163 });
src/resolution.test.ts added +52
@@ -0,0 +1,52 @@
1 /**
2 * The internal render size, as arithmetic. A retina window is the case that
3 * started this: 1512 CSS pixels across is a 3024px canvas, and the renderer
4 * was marching a ray for every other one of them.
5 */
6
7 import { expect, test } from "bun:test";
8 import { renderSize, WIDTHS } from "../static/resolution.js";
9
10 test("a retina window draws the world at a fixed width, not at its pixels", () => {
11 const size = renderSize(3024, 1915, 0);
12 expect(size.width).toBe(960);
13 // The picture keeps the canvas's shape, so the blit only ever scales.
14 expect(size.height / size.width).toBeCloseTo(1915 / 3024, 3);
15 // Which is what the frame costs: rays come out of the width, and this is
16 // three times fewer of them.
17 expect(3024 / size.width).toBeGreaterThan(3);
18 });
19
20 test("every window starts at the same width: the canvas size is not the knob", () => {
21 expect(renderSize(1400, 887, 0).width).toBe(960);
22 expect(renderSize(2400, 1520, 0).width).toBe(960);
23 expect(renderSize(5120, 3240, 0).width).toBe(960);
24 });
25
26 test("the world is never drawn wider than the canvas has pixels for", () => {
27 // A phone: 360 CSS pixels at dpr 3 is a 1080px canvas, and a level whose
28 // rung is wider than that would be paying for columns nobody can see.
29 expect(renderSize(1080, 684, 0).width).toBe(960);
30 expect(renderSize(720, 456, 0).width).toBe(720);
31 expect(renderSize(600, 380, 0).width).toBe(600);
32 expect(renderSize(320, 203, 0).width).toBe(320);
33 });
34
35 test("every rung is coarser than the one before, and the ladder ends", () => {
36 const widths = WIDTHS.map((_, i) => renderSize(4000, 2500, i).width);
37 expect(widths).toEqual(WIDTHS);
38 for (let i = 1; i < widths.length; i++) expect(widths[i]!).toBeLessThan(widths[i - 1]!);
39 // Past the end it holds at the coarsest rung rather than vanishing.
40 expect(renderSize(4000, 2500, 99).width).toBe(WIDTHS[WIDTHS.length - 1]);
41 expect(renderSize(4000, 2500, -3).width).toBe(WIDTHS[0]);
42 });
43
44 test("a size is always drawable: no zero, no NaN, whatever it is handed", () => {
45 for (const [w, h] of [[0, 0], [NaN, 100], [-10, -10], [10, 0]] as [number, number][]) {
46 const size = renderSize(w, h, 0);
47 expect(Number.isFinite(size.width)).toBe(true);
48 expect(Number.isFinite(size.height)).toBe(true);
49 expect(size.width).toBeGreaterThan(0);
50 expect(size.height).toBeGreaterThan(0);
51 }
52 });
src/scene.test.ts +122 −5
@@ -6,11 +6,12 @@
66
77 import { expect, test } from "bun:test";
88 import { drawGun, makeGun } from "../static/gun.js";
9 import { GHOST, ghosts, makePresence, shown, sighted } from "../static/presence.js";
910 import { drawScene, makeScene } from "../static/scene.js";
1011 import { drawTopdown } from "../static/topdown.js";
1112 import { gridRows, TILE } from "./map.ts";
1213
13 type Op = { op: string; args: unknown[] };
14 type Op = { op: string; args: unknown[]; alpha: number };
1415
1516 /** A canvas that records. Every call the renderer makes has to exist here. */
1617 function fakeContext() {
@@ -18,7 +19,9 @@ function fakeContext() {
1819 const note =
1920 (op: string) =>
2021 (...args: unknown[]) => {
21 ops.push({ op, args });
22 // The opacity in force when the call was made: how faded a body
23 // arriving into sight is, and how far a last-seen mark has dimmed.
24 ops.push({ op, args, alpha: ctx.globalAlpha });
2225 };
2326 const ctx = {
2427 ops,
@@ -88,12 +91,27 @@ const body = (over: Record<string, unknown> = {}) => ({
8891 const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p };
8992 const eye = { x: 120, y: 200, angle: 0 };
9093
91 test("a frame draws walls: one filled column per screen column, and the horizon", () => {
94 test("a frame draws walls in runs: the whole width covered, far fewer fills", () => {
9295 const ctx = fakeContext();
9396 drawScene(ctx as never, makeScene(), world, eye, snapshot([]), view);
9497 const rects = ctx.ops.filter((o) => o.op === "fillRect");
95 // The two bands and the horizon, then a slice for every column of the wall.
96 expect(rects.length).toBeGreaterThan(800);
98 // The bands are the full width; the rest are wall runs.
99 const walls = rects
100 .map((o) => ({ x: Number(o.args[0]), w: Number(o.args[2]) }))
101 .filter((r) => r.w < view.width)
102 .sort((a, b) => a.x - b.x);
103 // This room is closed, so every column of the picture is wall: the runs
104 // start at the left edge, meet end to end and reach the right one. That is
105 // the same picture the old one-fill-a-column loop drew.
106 expect(walls[0]!.x).toBe(0);
107 let reach = 0;
108 for (const rect of walls) {
109 expect(rect.x).toBeLessThanOrEqual(reach);
110 reach = Math.max(reach, rect.x + rect.w);
111 }
112 expect(reach).toBeGreaterThanOrEqual(view.width);
113 // And it costs a fraction of the fills: one a column would be 900.
114 expect(walls.length).toBeLessThan(view.width / 2);
97115 });
98116
99117 test("a body in the open is drawn; the same body behind the pillar is not", () => {
@@ -229,3 +247,102 @@ test("the gun and the crosshair draw, and the hit marker only when there is one"
229247 drawGun(loud as never, gun, 900, 570, snapshot([]));
230248 expect(loud.ops.length).toBeGreaterThan(quiet);
231249 });
250
251 // ------------------------------------------- coming into sight, and leaving
252
253 test("a body arriving is drawn faded and small, and solid once it is in", () => {
254 const edgeOf = (ctx: ReturnType<typeof fakeContext>) => ctx.ops.find((o) => o.op === "strokeRect")!;
255
256 const arriving = fakeContext();
257 drawScene(arriving as never, makeScene(), world, eye, snapshot([body()]), { ...view, shown: () => 0.4 });
258 const solid = fakeContext();
259 drawScene(solid as never, makeScene(), world, eye, snapshot([body()]), { ...view, shown: () => 1 });
260
261 expect(edgeOf(arriving).alpha).toBeCloseTo(0.4, 5);
262 expect(edgeOf(solid).alpha).toBe(1);
263 // And smaller: it grows from its feet as it comes in.
264 expect(Number(edgeOf(arriving).args[2])).toBeLessThan(Number(edgeOf(solid).args[2]));
265 expect(Number(edgeOf(arriving).args[2])).toBeGreaterThan(Number(edgeOf(solid).args[2]) * 0.7);
266 });
267
268 test("a body the presence store has only just seen is the one being faded", () => {
269 const seen = makePresence();
270 const them = body();
271 sighted(seen, [them], 100);
272 const draw = (at: number) => {
273 const ctx = fakeContext();
274 drawScene(ctx as never, makeScene(), world, eye, snapshot([them]), {
275 ...view,
276 shown: (p: { id: string }) => shown(seen, p.id, at),
277 });
278 return ctx.ops.find((o) => o.op === "strokeRect")!;
279 };
280 expect(draw(100.03).alpha).toBeGreaterThan(0);
281 expect(draw(100.03).alpha).toBeLessThan(1);
282 expect(draw(100.5).alpha).toBe(1);
283 });
284
285 test("a last-seen mark is an outline, dimming, and never a body", () => {
286 const marks = [{ id: "them", x: 280, y: 200, angle: 0, mate: false, fade: 0.6 }];
287 const ctx = fakeContext();
288 drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks });
289 const edge = ctx.ops.find((o) => o.op === "strokeRect");
290 expect(edge).toBeDefined();
291 expect(edge!.alpha).toBeCloseTo(0.6, 5);
292 // No head, no chevron, no name: a memory of a sighting, not a sighting.
293 expect(ctx.ops.some((o) => o.op === "arc")).toBe(false);
294 expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false);
295 expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false);
296 expect(GHOST).toBeGreaterThan(0);
297 });
298
299 test("a body that steps out of sight leaves the outline the store hands over", () => {
300 const seen = makePresence();
301 const them = body();
302 sighted(seen, [them], 100); // in sight, in the open
303 sighted(seen, [], 100.1); // and gone behind the corner
304 const ctx = fakeContext();
305 drawScene(ctx as never, makeScene(), world, eye, snapshot([]), {
306 ...view,
307 marks: ghosts(seen, 100.1),
308 });
309 // Nobody is in the snapshot at all, and there is still something there.
310 const edge = ctx.ops.find((o) => o.op === "strokeRect");
311 expect(edge).toBeDefined();
312 expect(edge!.alpha).toBeCloseTo(1, 2);
313
314 // A second later the store hands over nothing, and nothing is drawn.
315 const after = fakeContext();
316 drawScene(after as never, makeScene(), world, eye, snapshot([]), {
317 ...view,
318 marks: ghosts(seen, 100.1 + GHOST),
319 });
320 expect(after.ops.some((o) => o.op === "strokeRect")).toBe(false);
321 });
322
323 test("a mark where the wall now stands between you and it is not drawn", () => {
324 const marks = [{ id: "them", x: 700, y: 200, angle: 0, mate: false, fade: 1 }];
325 const ctx = fakeContext();
326 drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks });
327 expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(false);
328 });
329
330 test("on the map a last-seen ring is hollow, and dims with the mark", () => {
331 const map = { rows: gridRows(), tile: TILE };
332 const box = { x: 0, y: 0, w: 900, h: 570 };
333 const look = (p: never) => p;
334 const marks = [{ id: "them", x: 400, y: 400, angle: 0, mate: false, fade: 0.5 }];
335
336 const plain = fakeContext();
337 drawTopdown(plain as never, map, snapshot([]), look, box);
338 const marked = fakeContext();
339 drawTopdown(marked as never, map, snapshot([]), look, box, { marks });
340
341 const count = (ctx: ReturnType<typeof fakeContext>, op: string) => ctx.ops.filter((o) => o.op === op).length;
342 // Hollow: the ring adds strokes and not one fill.
343 expect(count(marked, "fill")).toBe(count(plain, "fill"));
344 expect(count(marked, "stroke")).toBeGreaterThan(count(plain, "stroke"));
345 const ring = marked.ops.find((o) => o.op === "arc")!;
346 expect(ring).toBeDefined();
347 expect(ring.alpha).toBeCloseTo(0.4, 5);
348 });
src/slices.test.ts added +116
@@ -0,0 +1,116 @@
1 /**
2 * The wall batching, as arithmetic: columns in, runs out. What matters is
3 * that the runs say the same thing the columns did — every column covered
4 * exactly once, in order — while there are far fewer of them.
5 */
6
7 import { expect, test } from "bun:test";
8 import { covered, GEOM, hazeStep, makeColumns, SKY, wallRuns } from "../static/slices.js";
9
10 type Col = { level: number; side?: number; top?: number; high?: number; haze?: number };
11
12 /** Columns from a list, so a case reads as the picture it describes. */
13 function columns(list: Col[]) {
14 const cols = makeColumns(list.length);
15 list.forEach((c, i) => {
16 cols.level[i] = c.level;
17 cols.side[i] = c.side ?? 0;
18 cols.top[i] = c.top ?? 100;
19 cols.high[i] = c.high ?? 200;
20 cols.haze[i] = c.haze ?? 0;
21 });
22 return cols;
23 }
24
25 const flat = (n: number, over: Col = { level: 7 }) => columns(Array.from({ length: n }, () => over));
26
27 test("a run of equal levels is one rectangle, however long it is", () => {
28 const out: never[] = [];
29 const used = wallRuns(flat(1280), out);
30 expect(used).toBe(1);
31 expect(out[0]).toMatchObject({ from: 0, to: 1279, level: 7 });
32 });
33
34 test("the picture's column count is unchanged: every column covered once", () => {
35 const cols = columns([
36 { level: 3 },
37 { level: 3 },
38 { level: 4 },
39 { level: 4 },
40 { level: 4 },
41 { level: 9, side: 1 },
42 ]);
43 const out: { from: number; to: number }[] = [];
44 const used = wallRuns(cols, out);
45 expect(used).toBe(3);
46 expect(covered(out, used)).toBe(6);
47 // In order, end to end, no gaps and no overlap.
48 expect(out.slice(0, used).map((r) => [r.from, r.to])).toEqual([
49 [0, 1],
50 [2, 4],
51 [5, 5],
52 ]);
53 });
54
55 test("each column its own level is one rectangle each: no picture is lost", () => {
56 const cols = columns(Array.from({ length: 40 }, (_, i) => ({ level: i })));
57 const out: never[] = [];
58 const used = wallRuns(cols, out);
59 expect(used).toBe(40);
60 expect(covered(out, used)).toBe(40);
61 });
62
63 test("the same level on a different face is a different run", () => {
64 const cols = columns([{ level: 5, side: 0 }, { level: 5, side: 1 }, { level: 5, side: 1 }]);
65 const out: { side: number }[] = [];
66 const used = wallRuns(cols, out);
67 expect(used).toBe(2);
68 expect(out[1]!.side).toBe(1);
69 });
70
71 test("sky ends a run and is covered by nothing", () => {
72 const cols = columns([{ level: 2 }, { level: SKY }, { level: 2 }, { level: 2 }]);
73 const out: { from: number; to: number }[] = [];
74 const used = wallRuns(cols, out);
75 expect(used).toBe(2);
76 expect(covered(out, used)).toBe(3);
77 expect(out[1]).toMatchObject({ from: 2, to: 3 });
78 });
79
80 test("slices that differ in height by more than half a pixel do not merge", () => {
81 const near = columns([
82 { level: 6, top: 100, high: 200 },
83 { level: 6, top: 100 + GEOM / 2, high: 200 },
84 ]);
85 const far = columns([
86 { level: 6, top: 100, high: 200 },
87 { level: 6, top: 100, high: 200 + GEOM * 4 },
88 ]);
89 expect(wallRuns(near, [])).toBe(1);
90 expect(wallRuns(far, [])).toBe(2);
91 });
92
93 test("haze joins the run's identity, quantised, so fog is batched too", () => {
94 expect(hazeStep(0)).toBe(0);
95 expect(hazeStep(1)).toBe(16);
96 expect(hazeStep(2)).toBe(16); // clamped: nothing is fogged past opaque
97 const cols = columns([
98 { level: 6, haze: 0.5 },
99 { level: 6, haze: 0.51 }, // the same step: one rectangle
100 { level: 6, haze: 0.9 },
101 ]);
102 const out: { haze: number }[] = [];
103 const used = wallRuns(cols, out);
104 expect(used).toBe(2);
105 expect(out[0]!.haze).toBeCloseTo(0.5, 5);
106 });
107
108 test("the run buffer is reused: a smaller frame allocates nothing new", () => {
109 const out: unknown[] = [];
110 wallRuns(columns(Array.from({ length: 30 }, (_, i) => ({ level: i }))), out);
111 const grown = out.length;
112 expect(grown).toBe(30);
113 const used = wallRuns(flat(30), out);
114 expect(used).toBe(1);
115 expect(out.length).toBe(grown);
116 });
static/budget.js added +66
@@ -0,0 +1,66 @@
1 /**
2 * The frame budget: what the last second of frames cost, and one knob.
3 *
4 * The renderer's price is the number of columns it draws, and the right
5 * number is not knowable from here — it depends on the machine, the window,
6 * the battery and whatever else the browser is doing. So it is measured: a
7 * second of frames at a time, and if the mean frame is over the budget the
8 * world is drawn a rung coarser (see resolution.js), and a rung finer again
9 * when there is room.
10 *
11 * It must not flap, because a resolution changing twice a second is more
12 * visible than a resolution that is simply low. Three things stop it:
13 * coarsening and refining have different thresholds, refining needs two
14 * windows of headroom in a row, and it never refines past the rung the
15 * window started on.
16 *
17 * Measurement only. Nothing here is sent anywhere or told to the simulation.
18 */
19
20 /** Seconds of frames behind one decision. */
21 export const WINDOW = 1;
22 /** Mean milliseconds a frame: over this is over budget, under this has room. */
23 export const SLOW = 20;
24 export const FAST = 13;
25 /** Windows of headroom before a rung is given back. */
26 export const PATIENCE = 2;
27
28 export function makeBudget(base = 0, max = 6) {
29 return { base, max, level: base, frames: 0, total: 0, since: 0, good: 0, fps: 0, mean: 0 };
30 }
31
32 /**
33 * One frame, `ms` long, drawn at `now` seconds. Returns true when the level
34 * moved — the caller resizes what it draws into — and updates `fps` and
35 * `mean` at the end of every window, for the readout to print.
36 */
37 export function measure(budget, ms, now) {
38 budget.frames++;
39 budget.total += ms;
40 if (budget.since === 0) {
41 budget.since = now;
42 return false;
43 }
44 const span = now - budget.since;
45 if (span < WINDOW) return false;
46 budget.mean = budget.total / budget.frames;
47 budget.fps = budget.frames / span;
48 budget.frames = 0;
49 budget.total = 0;
50 budget.since = now;
51 if (budget.mean > SLOW) {
52 budget.good = 0;
53 if (budget.level >= budget.max) return false;
54 budget.level++;
55 return true;
56 }
57 if (budget.mean < FAST && budget.level > budget.base) {
58 budget.good++;
59 if (budget.good < PATIENCE) return false;
60 budget.good = 0;
61 budget.level--;
62 return true;
63 }
64 budget.good = 0;
65 return false;
66 }
static/presence.js added +137
@@ -0,0 +1,137 @@
1 /**
2 * Who came into sight, and where the last one went.
3 *
4 * The server only sends bodies you can actually see, and that rule is not
5 * negotiable — but it means a body stepping into a sightline arrives with no
6 * history at all, and the interpolation makes it worse: a body that was out
7 * of sight starts fresh at its exact position, a render delay further along
8 * than where it entered view. So enemies do not walk into the room, they
9 * materialise mid-stride, and it reads as a broken game rather than a fast
10 * one.
11 *
12 * This is the fix, and it is entirely in the drawing:
13 *
14 * - A body that has just appeared is drawn faded and slightly small for a
15 * tenth of a second, so the eye reads an arrival rather than a pop.
16 * - If it was seen before and not far from where it is now, the fade starts
17 * a stride back along the line it came in on, so it slides into view from
18 * the edge instead of appearing at its destination.
19 * - Where somebody was last seen alive leaves a mark that dims away over a
20 * second. It answers "where did they go", and it is information you had
21 * already: nothing here is ever sent anywhere, and nothing is drawn that
22 * the server did not already show you.
23 *
24 * Plain data in and out. Time is seconds, positions are world units.
25 */
26
27 /** Seconds from first sight to a solid body. */
28 export const FADE = 0.12;
29 /** How small a body starts, as a fraction of itself. */
30 export const SMALL = 0.82;
31 /** Seconds a last-seen mark lingers. */
32 export const GHOST = 1;
33 /** How far back along its path a body slides in from, in world units. */
34 export const ENTRY = 42;
35 /** Last seen further away than this and it is a new sighting, not a return. */
36 export const REACH = 320;
37
38 const clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n);
39
40 export const makePresence = () => ({ live: new Map(), last: new Map(), seq: 0 });
41
42 /**
43 * A snapshot landed at `at`, carrying exactly these bodies. Anybody in it is
44 * in sight; anybody who was and is not has just gone out of it.
45 */
46 export function sighted(presence, bodies, at) {
47 const seq = ++presence.seq;
48 for (const body of bodies) {
49 const have = presence.live.get(body.id);
50 if (have) {
51 remember(have, body, seq);
52 continue;
53 }
54 const before = presence.last.get(body.id);
55 const fresh = { since: at, seq, from: before ? entryPoint(before, body) : null };
56 remember(fresh, body, seq);
57 presence.live.set(body.id, fresh);
58 presence.last.delete(body.id);
59 }
60 for (const [id, it] of presence.live) {
61 if (it.seq === seq) continue;
62 presence.live.delete(id);
63 // A body last seen dead is a corpse where you left it, not a question
64 // about where somebody went: no mark.
65 if (it.alive) presence.last.set(id, { ...it, gone: at });
66 else presence.last.delete(id);
67 }
68 }
69
70 function remember(it, body, seq) {
71 it.seq = seq;
72 it.x = body.x;
73 it.y = body.y;
74 it.angle = body.angle;
75 it.alive = body.alive;
76 it.mate = body.mate;
77 it.agent = body.agent;
78 }
79
80 /**
81 * Where a body's arrival begins: a stride back down the line from where it
82 * was last seen. Somebody who turns up across the map is not the same walk,
83 * and somebody who has not moved has no direction to come from, so neither
84 * slides.
85 */
86 function entryPoint(before, body) {
87 const dx = body.x - before.x;
88 const dy = body.y - before.y;
89 const span = Math.hypot(dx, dy);
90 if (span < 2 || span > REACH) return null;
91 const back = Math.min(ENTRY, span) / span;
92 return { x: body.x - dx * back, y: body.y - dy * back };
93 }
94
95 /** How far in a body is: 0 the moment it appears, 1 once it is solid. */
96 export function shown(presence, id, now) {
97 const it = presence.live.get(id);
98 return it ? clamp01((now - it.since) / FADE) : 1;
99 }
100
101 /** The size a body is drawn at while it arrives: small, then itself. */
102 export const swell = (t) => SMALL + (1 - SMALL) * clamp01(t);
103
104 /**
105 * The position to draw a body at: the interpolated one, or a point on the
106 * way there from where it came in, while it is still arriving.
107 */
108 export function slide(presence, id, at, now, out) {
109 const it = presence.live.get(id);
110 if (!it || !it.from) return at;
111 const t = clamp01((now - it.since) / FADE);
112 if (t >= 1) return at;
113 out.x = it.from.x + (at.x - it.from.x) * t;
114 out.y = it.from.y + (at.y - it.from.y) * t;
115 out.angle = at.angle;
116 return out;
117 }
118
119 /** The last-seen marks, freshest first in nothing in particular: ≤ a team. */
120 export function ghosts(presence, now) {
121 const out = [];
122 for (const [id, it] of presence.last) {
123 const left = 1 - (now - it.gone) / GHOST;
124 if (left <= 0) {
125 presence.last.delete(id);
126 continue;
127 }
128 out.push({ id, x: it.x, y: it.y, angle: it.angle, agent: it.agent, mate: it.mate, fade: left });
129 }
130 return out;
131 }
132
133 /** A new round: nobody has been seen, and nobody is anywhere in particular. */
134 export function forget(presence) {
135 presence.live.clear();
136 presence.last.clear();
137 }
static/resolution.js added +41
@@ -0,0 +1,41 @@
1 /**
2 * How big the world is drawn, before it is scaled onto the screen.
3 *
4 * A raycaster does not need device pixels. Every column is a ray marched
5 * through the grid, so the cost of the frame is the width of the canvas, and
6 * a retina window asks for three thousand of them — 1512 rays and as many
7 * fills at every frame, sixty times a second, for a picture made of flat
8 * shaded columns that upscales almost invisibly.
9 *
10 * So the world is drawn into an offscreen canvas 960 columns wide however
11 * big the window is, and blitted up; the HUD, the crosshair and the minimap
12 * stay on the visible canvas at full resolution, where sharpness actually
13 * shows. Fidelity is cheap here and frame rate is not: this game can be
14 * stick figures if that is what runs.
15 *
16 * The ladder is the knob the frame governor turns: a rung coarser is about a
17 * fifth fewer rays, and the rungs are in order of what they cost, so going
18 * down one can never cost more. Nothing here touches what is sent, predicted
19 * or simulated — it is a count of columns out of a canvas size and a level.
20 */
21
22 /** Internal widths, finest first. The first rung is what every window gets. */
23 export const WIDTHS = [960, 800, 672, 544, 448];
24
25 const clamp = (n, low, high) => (n < low ? low : n > high ? high : n);
26
27 /**
28 * The size to draw the world at: never wider than the canvas actually has
29 * pixels for — upscaling a 448px render onto a 360px phone would be paying
30 * for columns nobody can see — and never so small it stops being a picture.
31 * The aspect is the canvas's own, so the blit only ever scales.
32 */
33 export function renderSize(deviceWidth, deviceHeight, level) {
34 const want = WIDTHS[clamp(level | 0, 0, WIDTHS.length - 1)];
35 const width = Math.max(240, Math.min(want, Math.round(deviceWidth) || 240));
36 const height = Math.max(
37 1,
38 Math.round(width * (deviceHeight > 0 && deviceWidth > 0 ? deviceHeight / deviceWidth : 0.6)),
39 );
40 return { width, height };
41 }
static/scene.js +86 −22
@@ -7,11 +7,16 @@
77 * drawn at all. Smoke is measured along each ray, not painted as a disc:
88 * cross one and everything past it fogs.
99 *
10 * The ray loop allocates nothing. Colours come out of a table built once,
11 * the depth and fog buffers are kept between frames, and the sprite list is
12 * sorted in a buffer the scene owns.
10 * The ray loop allocates nothing and draws nothing: it writes a shade level,
11 * a face and a slice into the column arrays, and `slices.js` collapses those
12 * into runs so a flat wall costs one `fillRect` and not one a column.
13 * Colours come out of a table built once, the depth and column buffers are
14 * kept between frames, and the sprite list is sorted in a buffer the scene
15 * owns.
1316 */
1417
18 import { swell } from "./presence.js";
19 import { makeColumns, SKY, wallRuns } from "./slices.js";
1520 import {
1621 castRay,
1722 columnOffset,
@@ -55,7 +60,8 @@ const FOG = "rgb(198,203,212)";
5560 export function makeScene() {
5661 return {
5762 depth: new Float32Array(0),
58 fog: new Float32Array(0),
63 cols: makeColumns(0),
64 runs: [],
5965 hit: hitResult(),
6066 proj: projectResult(),
6167 order: [],
@@ -65,7 +71,31 @@ export function makeScene() {
6571 };
6672 }
6773
68 const shadeOf = (light) => SHADES[Math.min(LEVELS - 1, Math.max(0, (light * LEVELS) | 0))];
74 const levelOf = (light) => Math.min(LEVELS - 1, Math.max(0, (light * LEVELS) | 0));
75
76 /**
77 * The walls, a rectangle a run rather than a rectangle a column. The runs
78 * are stretches where the shade level, the face, the fog and the geometry
79 * all agree — see `slices.js` — so this is the same picture the per-column
80 * loop drew, in a fraction of the calls.
81 */
82 function drawWalls(ctx, scene, step) {
83 const runs = scene.runs;
84 const used = wallRuns(scene.cols, runs);
85 for (let r = 0; r < used; r++) {
86 const run = runs[r];
87 const x = run.from * step;
88 const wide = (run.to - run.from + 1) * step + 1;
89 ctx.fillStyle = SHADES[run.level];
90 ctx.fillRect(x, run.top, wide, run.high);
91 if (run.haze > 0.01) {
92 ctx.globalAlpha = run.haze;
93 ctx.fillStyle = FOG;
94 ctx.fillRect(x, run.top, wide, run.high);
95 ctx.globalAlpha = 1;
96 }
97 }
98 }
6999
70100 /**
71101 * Draw one frame.
@@ -78,11 +108,15 @@ const shadeOf = (light) => SHADES[Math.min(LEVELS - 1, Math.max(0, (light * LEVE
78108 export function drawScene(ctx, scene, world, eye, snap, view) {
79109 const W = view.width;
80110 const H = view.height;
81 const step = W > 1100 ? 2 : 1;
111 // One ray every other column of the buffer, and the wall slice two columns
112 // wide: flat shading does not reward a ray a pixel, and the sprites are
113 // still drawn at the buffer's own resolution. Below that width a picture
114 // this coarse stops being one.
115 const step = W >= 640 ? 2 : 1;
82116 const columns = Math.ceil(W / step);
83117 if (scene.depth.length !== columns) {
84118 scene.depth = new Float32Array(columns);
85 scene.fog = new Float32Array(columns);
119 scene.cols = makeColumns(columns);
86120 }
87121 const proj = projection(W);
88122 const horizon = H / 2;
@@ -94,7 +128,8 @@ export function drawScene(ctx, scene, world, eye, snap, view) {
94128 for (const zone of snap.zones) if ((zone.k === "smoke" || zone.k === "wall") && zone.f <= 0) smokes.push(zone);
95129
96130 const depth = scene.depth;
97 const fog = scene.fog;
131 const cols = scene.cols;
132 const fog = cols.haze;
98133 const hit = scene.hit;
99134 for (let i = 0; i < columns; i++) {
100135 const offset = columnOffset(i * step + step / 2, W);
@@ -114,18 +149,17 @@ export function drawScene(ctx, scene, world, eye, snap, view) {
114149 haze = Math.min(1, haze);
115150 }
116151 fog[i] = haze;
117 if (!hit.wall) continue;
118 const h = Math.min(H * 6, sliceHeight(perp, proj, WALL));
119 const top = horizon - h / 2;
120 ctx.fillStyle = shadeOf(wallLight(perp, hit.side, world.tile));
121 ctx.fillRect(i * step, top, step + 1, h);
122 if (haze > 0.01) {
123 ctx.globalAlpha = haze;
124 ctx.fillStyle = FOG;
125 ctx.fillRect(i * step, top, step + 1, h);
126 ctx.globalAlpha = 1;
152 if (!hit.wall) {
153 cols.level[i] = SKY;
154 continue;
127155 }
156 const h = Math.min(H * 6, sliceHeight(perp, proj, WALL));
157 cols.level[i] = levelOf(wallLight(perp, hit.side, world.tile));
158 cols.side[i] = hit.side;
159 cols.top[i] = horizon - h / 2;
160 cols.high[i] = h;
128161 }
162 drawWalls(ctx, scene, step);
129163
130164 drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns);
131165 }
@@ -167,8 +201,14 @@ function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, col
167201 for (const player of snap.players) {
168202 if (player.id === snap.me.id) continue;
169203 const at = view.look(player);
170 things.push({ kind: "body", player, x: at.x, y: at.y, angle: at.angle });
204 // How far into sight this body is: 0 the moment it appears, 1 solid.
205 // See presence.js — a body that pops into existence reads as a bug.
206 const shown = view.shown ? view.shown(player) : 1;
207 things.push({ kind: "body", player, shown, x: at.x, y: at.y, angle: at.angle });
171208 }
209 // Where somebody was last seen, dimming away. It stands where they were,
210 // so a wall between you and that spot hides it like anything else.
211 if (view.marks) for (const mark of view.marks) things.push({ kind: "mark", mark, x: mark.x, y: mark.y });
172212
173213 const p = scene.proj;
174214 for (const thing of things) {
@@ -184,9 +224,11 @@ function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, col
184224 if (thing.behind) continue;
185225 const floorY = horizon + (EYE * proj) / thing.perp;
186226 const haze = hazeAt(scene, thing.column, step, columns);
187 ctx.globalAlpha = Math.max(0, 1 - haze);
227 const arriving = thing.kind === "body" ? thing.shown : thing.kind === "mark" ? thing.mark.fade : 1;
228 ctx.globalAlpha = Math.max(0, 1 - haze) * arriving;
188229 if (ctx.globalAlpha < 0.05) continue;
189230 if (thing.kind === "body") drawBody(ctx, scene, thing, floorY, step, columns);
231 else if (thing.kind === "mark") drawMark(ctx, scene, thing, floorY, step, columns);
190232 else if (thing.kind === "spike") drawSpike(ctx, scene, thing, floorY, snap, step, columns);
191233 else if (thing.kind === "orb") drawBlob(ctx, scene, thing, floorY, "#c9a0ff", 0.35, step, columns);
192234 else drawZone(ctx, scene, thing, floorY, proj, step, columns);
@@ -199,7 +241,7 @@ function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, col
199241
200242 const hazeAt = (scene, column, step, columns) => {
201243 const i = Math.max(0, Math.min(columns - 1, Math.round(column / step)));
202 return scene.fog[i] ?? 0;
244 return scene.cols.haze[i] ?? 0;
203245 };
204246
205247 /**
@@ -228,7 +270,9 @@ function withRuns(ctx, scene, left, right, perp, step, columns, draw) {
228270
229271 function drawBody(ctx, scene, thing, floorY, step, columns) {
230272 const p = thing.player;
231 const h = thing.size;
273 // A body arriving grows from its feet as it fades in, so it reads as
274 // somebody coming round the corner rather than one appearing there.
275 const h = thing.size * swell(thing.shown ?? 1);
232276 const w = h * 0.46;
233277 const cx = thing.column;
234278 const top = floorY - h;
@@ -287,6 +331,26 @@ function drawBody(ctx, scene, thing, floorY, step, columns) {
287331 });
288332 }
289333
334 /**
335 * A mark where somebody was last seen: an empty outline and a scuff on the
336 * floor, dimming away over a second. Deliberately not a body — nothing is
337 * filled, there is no head and no chevron — because it is a memory of a
338 * sighting and must never be mistaken for one.
339 */
340 function drawMark(ctx, scene, thing, floorY, step, columns) {
341 const h = thing.size * 0.9;
342 const w = h * 0.46;
343 const cx = thing.column;
344 const top = floorY - h;
345 withRuns(ctx, scene, cx - w * 1.2, cx + w * 1.2, thing.perp, step, columns, () => {
346 ctx.strokeStyle = thing.mark.mate ? "rgba(120,180,230,0.85)" : "rgba(235,150,120,0.9)";
347 ctx.lineWidth = Math.max(1, h * 0.035);
348 ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72);
349 ctx.fillStyle = "rgba(10,12,16,0.45)";
350 ctx.fillRect(cx - w * 0.7, floorY - h * 0.04, w * 1.4, h * 0.05);
351 });
352 }
353
290354 /** An enemy wears a chevron: a shape, not a hue, for anyone whose eyes read
291355 * red and blue the same way. */
292356 function chevron(ctx, cx, top, h) {
static/slices.js added +94
@@ -0,0 +1,94 @@
1 /**
2 * Wall columns, collapsed into runs.
3 *
4 * A raycaster produces one vertical slice per screen column, and the naive
5 * renderer pays one `fillRect` for each of them — a couple of thousand fills
6 * a frame on a wide canvas, most of them identical to their neighbour. The
7 * shading is quantised into levels already (`scene.js`), so a flat wall face
8 * is a stretch of columns asking for exactly the same rectangle, drawn one
9 * pixel at a time.
10 *
11 * This walks the columns once and hands back the runs: the stretches where
12 * the level, the face, the haze over it and the geometry all agree, so the
13 * caller draws one rectangle for the stretch. A corridor collapses hundreds
14 * of fills into a handful and the picture does not change.
15 *
16 * Pure data in, pure data out — no canvas here — and nothing is allocated
17 * per frame: the column arrays and the run objects are kept and rewritten.
18 */
19
20 /** A level of -1 is sky: no wall was struck, and no run covers it. */
21 export const SKY = -1;
22 /** How far apart two slices may sit and still be drawn as one rectangle. */
23 export const GEOM = 0.5;
24 /** Haze is quantised this finely before it joins the run's identity. */
25 export const HAZE_STEPS = 16;
26
27 /** The columns of one frame: what the ray loop writes, all of it flat. */
28 export function makeColumns(count) {
29 return {
30 count,
31 level: new Int16Array(count).fill(SKY),
32 side: new Int8Array(count),
33 top: new Float32Array(count),
34 high: new Float32Array(count),
35 haze: new Float32Array(count),
36 };
37 }
38
39 const run = () => ({ from: 0, to: 0, level: SKY, side: 0, top: 0, high: 0, step: 0, haze: 0 });
40
41 /** The quantised haze of a column: equal steps draw as one rectangle. */
42 export const hazeStep = (haze) => Math.round(Math.min(1, Math.max(0, haze)) * HAZE_STEPS);
43
44 /**
45 * Write the runs of `cols` into `out`, growing it as needed, and return how
46 * many of its entries are this frame's. `out[i].from` and `.to` are column
47 * indices, both inclusive.
48 */
49 export function wallRuns(cols, out) {
50 let used = 0;
51 let open = false; // is out[used - 1] still growing, or did sky end it?
52 for (let i = 0; i < cols.count; i++) {
53 const level = cols.level[i];
54 if (level === SKY) {
55 open = false;
56 continue;
57 }
58 const step = hazeStep(cols.haze[i]);
59 const top = cols.top[i];
60 const high = cols.high[i];
61 const last = used > 0 ? out[used - 1] : null;
62 if (
63 open &&
64 last.level === level &&
65 last.side === cols.side[i] &&
66 last.step === step &&
67 Math.abs(last.top - top) <= GEOM &&
68 Math.abs(last.high - high) <= GEOM
69 ) {
70 last.to = i;
71 continue;
72 }
73 if (out.length <= used) out.push(run());
74 const fresh = out[used];
75 fresh.from = i;
76 fresh.to = i;
77 fresh.level = level;
78 fresh.side = cols.side[i];
79 fresh.top = top;
80 fresh.high = high;
81 fresh.step = step;
82 fresh.haze = step / HAZE_STEPS;
83 used++;
84 open = true;
85 }
86 return used;
87 }
88
89 /** How many columns the runs cover: the picture's width, for a test to check. */
90 export function covered(out, used) {
91 let total = 0;
92 for (let i = 0; i < used; i++) total += out[i].to - out[i].from + 1;
93 return total;
94 }
static/topdown.js +44 −11
@@ -20,12 +20,20 @@ const TILE_COLOUR = {
2020 /**
2121 * Draw the whole map into `box` (canvas pixels). `look(player)` gives the
2222 * smoothed position; `opts.labels` draws site letters and names, which only
23 * fit on the big version.
23 * fit on the big version; `opts.marks` are the last-seen rings from
24 * `presence.js`, which answer "where did they go" without claiming anybody
25 * is there now; `opts.tiles: false` leaves out the map itself, for a caller
26 * that has already drawn it once and kept it — it is 570 rectangles of a
27 * map that never changes.
28 *
29 * Called with no snapshot it draws the tiles and stops, which is exactly
30 * that layer.
2431 */
2532 export function drawTopdown(ctx, map, snap, look, box, opts = {}) {
2633 const cols = map.rows[0].length;
2734 const s = box.w / (cols * map.tile);
2835 const labels = opts.labels !== false;
36 const tiles = opts.tiles !== false;
2937 ctx.save();
3038 ctx.beginPath();
3139 ctx.rect(box.x, box.y, box.w, box.h);
@@ -33,17 +41,18 @@ export function drawTopdown(ctx, map, snap, look, box, opts = {}) {
3341 ctx.translate(box.x, box.y);
3442
3543 const t = map.tile * s;
36 for (let row = 0; row < map.rows.length; row++)
37 for (let col = 0; col < map.rows[row].length; col++) {
38 const cell = map.rows[row][col];
39 ctx.fillStyle = TILE_COLOUR[cell] ?? "#171a21";
40 ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6);
41 if (labels && (cell === "A" || cell === "B")) {
42 ctx.fillStyle = "#6b5f3a";
43 ctx.font = `${Math.round(t * 0.5)}px system-ui, sans-serif`;
44 ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68);
44 if (tiles)
45 for (let row = 0; row < map.rows.length; row++)
46 for (let col = 0; col < map.rows[row].length; col++) {
47 const cell = map.rows[row][col];
48 ctx.fillStyle = TILE_COLOUR[cell] ?? "#171a21";
49 ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6);
50 if (labels && (cell === "A" || cell === "B")) {
51 ctx.fillStyle = "#6b5f3a";
52 ctx.font = `${Math.round(t * 0.5)}px system-ui, sans-serif`;
53 ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68);
54 }
4555 }
46 }
4756
4857 if (!snap) {
4958 ctx.restore();
@@ -79,6 +88,9 @@ export function drawTopdown(ctx, map, snap, look, box, opts = {}) {
7988 ctx.stroke();
8089 }
8190
91 // Under the living: a ring where somebody was last seen is never allowed
92 // to sit on top of a body that is actually in sight.
93 if (opts.marks) for (const mark of opts.marks) drawMark(ctx, mark, s);
8294 for (const player of snap.players) drawPlayer(ctx, player, look(player), s, snap.me, labels);
8395 // Smoke last: on this view it hides what is under it, as it should.
8496 for (const zone of snap.zones) if (zone.k === "smoke" && zone.f <= 0) drawZone(ctx, zone, s);
@@ -115,6 +127,27 @@ function drawZone(ctx, z, s) {
115127 }
116128 }
117129
130 /**
131 * Where somebody was last seen: a hollow ring and the way they were facing,
132 * dimming away. Hollow because a filled dot on this map means a body, and
133 * this is a memory of one.
134 */
135 function drawMark(ctx, m, s) {
136 const x = m.x * s;
137 const y = m.y * s;
138 const r = Math.max(2.5, 16 * s);
139 ctx.save();
140 ctx.globalAlpha = 0.8 * m.fade;
141 ctx.strokeStyle = m.mate ? "#4fb3d9" : "#e35d5b";
142 ctx.lineWidth = Math.max(1, 2 * s);
143 disc(ctx, x, y, r, false, true);
144 ctx.beginPath();
145 ctx.moveTo(x, y);
146 ctx.lineTo(x + Math.cos(m.angle) * r * 1.7, y + Math.sin(m.angle) * r * 1.7);
147 ctx.stroke();
148 ctx.restore();
149 }
150
118151 function disc(ctx, x, y, r, fill, stroke) {
119152 ctx.beginPath();
120153 ctx.arc(x, y, r, 0, 7);
static/val.js +120 −7
@@ -12,11 +12,14 @@
1212 "use strict";
1313
1414 import { makeBeeper } from "./beep.js";
15 import { arrive, bodyAt, feed, makeTimeline, renderTime } from "./interp.js";
15 import { makeBudget, measure } from "./budget.js";
16 import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "./interp.js";
1617 import { BODY_R, moveSpeed } from "../src/glide.ts";
1718 import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js";
1819 import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js";
20 import { forget, ghosts, makePresence, shown, sighted, slide } from "./presence.js";
1921 import { choosePrompt, REACH } from "./prompt.js";
22 import { renderSize, WIDTHS } from "./resolution.js";
2023 import { roster, rosterKey } from "./roster.js";
2124 import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js";
2225 import { moveVector } from "./steer.js";
@@ -27,6 +30,11 @@ import { aim as aimAt, makeYaw, settle, told, turn } from "./yaw.js";
2730 const $ = (id) => document.getElementById(id);
2831 const view = $("view");
2932 const ctx = view.getContext("2d");
33 // The world is drawn here, at a few hundred columns, and blitted onto the
34 // visible canvas scaled. The HUD, the crosshair and the minimap are drawn on
35 // `view` itself, at whatever the device pixel ratio really is.
36 const sceneCanvas = document.createElement("canvas");
37 const sceneCtx = sceneCanvas.getContext("2d");
3038
3139 const KEY_SLOT = { KeyC: "c", KeyQ: "q", KeyE: "e", KeyX: "x" };
3240 const COARSE = matchMedia("(pointer: coarse)").matches;
@@ -44,6 +52,13 @@ let agents = null;
4452 // drawing, a snapshot interval behind the present.
4553 const timeline = makeTimeline();
4654 let drawAt = 0;
55 // Who is in sight and who just left it — see presence.js. A body the server
56 // has only now started sending has no history at all, and drawing it solid
57 // at its exact position is what makes enemies appear to teleport into a room.
58 const presence = makePresence();
59 const slid = { x: 0, y: 0, angle: 0 };
60 let marks = [];
61 let nowSec = performance.now() / 1000;
4762 const keys = new Set();
4863 // Where this person is looking: the browser's between snapshots, and the
4964 // server's word taken only when it is saying something we did not. See
@@ -59,6 +74,13 @@ let aimTouch = null;
5974 let helpShown = false;
6075 let last = performance.now();
6176 let snapAt = last; // when the last snapshot landed: how stale the answer is
77 // How many columns the world is drawn at, and what the frame is costing.
78 // See resolution.js for the ladder and budget.js for the rung it settles on:
79 // the visible canvas is device pixels, this is not.
80 const budget = makeBudget(0, WIDTHS.length - 1);
81 let render = { width: 960, height: 600 };
82 let perfOn = false;
83 let perfAt = 0;
6284
6385 const walk = { x: 0, y: 0 };
6486 // What this person is doing right now, read once a frame for the drawing and
@@ -94,6 +116,10 @@ function connect() {
94116 // Timed samples for every body in it, ours included: the drawing walks
95117 // between them rather than chasing the newest one.
96118 arrive(timeline, snapAt / 1000);
119 // Between rounds everybody is teleported to a spawn, so no sighting
120 // before that says anything about where anybody is now.
121 if (data.phase !== "live") forget(presence);
122 else sighted(presence, data.players, snapAt / 1000);
97123 feed(timeline, data.me.id, data.me, snapAt / 1000);
98124 for (const player of data.players) feed(timeline, player.id, player, snapAt / 1000);
99125 // The server's angle is taken only when it is a correction, never as the
@@ -143,6 +169,11 @@ addEventListener("keydown", (event) => {
143169 event.preventDefault();
144170 return;
145171 }
172 if (event.code === "F3" || event.code === "Backquote") {
173 togglePerf();
174 event.preventDefault();
175 return;
176 }
146177 if (KEY_SLOT[event.code]) {
147178 send({ t: "i", c: KEY_SLOT[event.code] });
148179 event.preventDefault();
@@ -294,9 +325,17 @@ function resize() {
294325 const dpr = Math.min(devicePixelRatio || 1, 2);
295326 view.width = Math.round(box.width * dpr);
296327 view.height = Math.round(box.height * dpr);
328 sizeRender();
297329 }
298330 addEventListener("resize", resize);
299331
332 /** The offscreen the world is drawn into, at the level in force right now. */
333 function sizeRender() {
334 render = renderSize(view.width, view.height, budget.level);
335 if (sceneCanvas.width !== render.width) sceneCanvas.width = render.width;
336 if (sceneCanvas.height !== render.height) sceneCanvas.height = render.height;
337 }
338
300339 const scale = () => (map ? view.width / (map.rows[0].length * map.tile) : 1);
301340 const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() });
302341
@@ -306,7 +345,15 @@ const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() });
306345 * You are not: your own body is the one this browser can run forward
307346 * honestly, and drawing it late is what dragged the camera backwards.
308347 */
309 const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : bodyAt(timeline, p, drawAt));
348 const look = (p) => {
349 if (predicting() && p.id === snap.me.id) return drawnMe;
350 // A body still arriving is drawn part of the way in from where it came,
351 // rather than at the position it had already reached when we first saw it.
352 return slide(presence, p.id, bodyAt(timeline, p, drawAt), nowSec, slid);
353 };
354
355 /** How far into sight somebody is: 0 as they appear, 1 once they are solid. */
356 const shownAt = (p) => shown(presence, p.id, nowSec);
310357
311358 /**
312359 * Your own movement is only guessed at while the round is live and you are
@@ -350,8 +397,11 @@ function predict(dt, now) {
350397
351398 function frame(now) {
352399 requestAnimationFrame(frame);
400 const began = performance.now();
353401 const dt = Math.min(0.1, (now - last) / 1000);
354402 last = now;
403 nowSec = began / 1000;
404 marks = ghosts(presence, nowSec);
355405 drawAt = renderTime(timeline, now / 1000);
356406 // A watching connection draws the watching view and stops there: it has no
357407 // map to stand on and no body to put on it, and must invent neither.
@@ -374,15 +424,23 @@ function frame(now) {
374424 const facing = mode === "fp" ? head.angle : eye.angle;
375425
376426 if (mode === "fp" && !mapOpen) {
377 drawScene(ctx, scene, { solid, tile: map.tile }, { x: eye.x, y: eye.y, angle: facing }, snap, {
378 width: view.width,
379 height: view.height,
427 // The world at a few hundred columns, then scaled onto the canvas. The
428 // walls are flat shaded, so the upscale costs the picture almost
429 // nothing; the crosshair and the map below are drawn sharp on top.
430 drawScene(sceneCtx, scene, { solid, tile: map.tile }, { x: eye.x, y: eye.y, angle: facing }, snap, {
431 width: render.width,
432 height: render.height,
380433 look,
434 shown: shownAt,
435 marks,
381436 });
437 ctx.drawImage(sceneCanvas, 0, 0, view.width, view.height);
382438 drawGun(ctx, gun, view.width, view.height, snap);
383439 drawMinimap();
384440 } else {
385 drawTopdown(ctx, map, snap, look, { x: 0, y: 0, w: view.width, h: view.height });
441 const box = { x: 0, y: 0, w: view.width, h: view.height };
442 ctx.drawImage(tileLayer(view.width, view.height, true), 0, 0);
443 drawTopdown(ctx, map, snap, look, box, { marks, tiles: false });
386444 }
387445
388446 if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting");
@@ -391,6 +449,60 @@ function frame(now) {
391449 $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8);
392450 $("dead").style.display = snap.me.alive ? "none" : "grid";
393451 $("aimhint").style.display = mode === "fp" && !locked && !COARSE ? "grid" : "none";
452
453 // What this frame cost to draw, not how long the browser waited for the
454 // next one: a machine with headroom is capped at its refresh rate, and the
455 // renderer would never earn a rung back if that were the measure.
456 const ended = performance.now();
457 if (measure(budget, ended - began, ended / 1000)) sizeRender();
458 paintPerf(ended);
459 }
460
461 /** F3 or ` — the numbers, so "it feels choppy" has something to argue with. */
462 function togglePerf() {
463 perfOn = !perfOn;
464 const box = $("perf");
465 if (!box) return;
466 box.style.display = perfOn ? "block" : "none";
467 // Straight away, whatever the clock says: a readout you turn on and that
468 // stays blank for half a second reads as a readout that does not work.
469 if (perfOn) paintPerf(performance.now(), true);
470 }
471
472 /**
473 * The readout, rewritten twice a second: the frame rate and the mean frame
474 * the governor decided on, the rate snapshots are actually arriving at, the
475 * interpolation delay in force, and how wide the world is being drawn.
476 */
477 function paintPerf(nowMs, force) {
478 const box = $("perf");
479 if (!perfOn || !box || (!force && nowMs - perfAt < 500)) return;
480 perfAt = nowMs;
481 const hz = timeline.interval > 0 ? 1 / timeline.interval : 0;
482 box.textContent =
483 `${budget.fps.toFixed(1)} fps · ${budget.mean.toFixed(1)} ms/frame · ` +
484 `${hz.toFixed(1)} Hz in · ${(delay(timeline) * 1000).toFixed(0)} ms behind · ` +
485 `${render.width}×${render.height} of ${view.width}×${view.height}`;
486 }
487
488 /**
489 * The map's own tiles, drawn once a size and kept. There are 570 of them and
490 * they are the same 570 every frame — on the minimap that was more fills
491 * than the whole first-person view costs. Called with no snapshot,
492 * `drawTopdown` draws exactly this layer and nothing else.
493 */
494 const layers = new Map();
495 function tileLayer(w, h, labels) {
496 const key = `${w}x${h}${labels ? "L" : ""}`;
497 const had = layers.get(key);
498 if (had) return had;
499 const layer = document.createElement("canvas");
500 layer.width = w;
501 layer.height = h;
502 drawTopdown(layer.getContext("2d"), map, null, look, { x: 0, y: 0, w, h }, { labels });
503 if (layers.size > 3) layers.clear(); // a window being dragged, not a leak
504 layers.set(key, layer);
505 return layer;
394506 }
395507
396508 /** The same top-down drawing, small, in the corner, with you as an arrow. */
@@ -402,7 +514,8 @@ function drawMinimap() {
402514 ctx.globalAlpha = 0.85;
403515 ctx.fillStyle = "#0b0d11";
404516 ctx.fillRect(pad - 2, pad - 2, w + 4, h + 4);
405 drawTopdown(ctx, map, snap, look, { x: pad, y: pad, w, h }, { labels: false });
517 ctx.drawImage(tileLayer(Math.max(1, Math.round(w)), Math.max(1, Math.round(h)), false), pad, pad, w, h);
518 drawTopdown(ctx, map, snap, look, { x: pad, y: pad, w, h }, { labels: false, marks, tiles: false });
406519 ctx.restore();
407520 ctx.strokeStyle = "rgba(255,255,255,0.18)";
408521 ctx.lineWidth = 1;
README.md +4 −1
@@ -97,7 +97,10 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
9797 again — cannot buy a second one on the other team. A second machine on a
9898 second network is still a second person as far as the server can tell,
9999 the same as any anonymous game without accounts; a socket that does not
100 get a seat is sent a spectator view with no position in it, never a side.
100 get a seat is sent a spectator view with no position in it, never a side:
101 the page draws the score, the round, the clock and the kill feed, says
102 which of the two reasons it is watching for, and draws no map and no
103 bodies, because it is never told where anybody is.
101104 - No overtime — first to 13 ends it at 12–12 too.
102105 - Sight is a full circle rather than a view cone — you are drawn what is in
103106 front of you, but the server reveals anything you have line of sight to,
src/arena.ts +24 −7
@@ -174,12 +174,20 @@ export const seatCount = (): number => seats.size;
174174
175175 // A connection that never carried a session the page minted gets no side and
176176 // no seat: only the public state everyone already sees on the page itself.
177 const spectators = new Map<string, (data: string) => void>();
177 const spectators = new Map<string, { send: (data: string) => void; why: Why }>();
178178
179 export function spectatorSnapshot(): string {
179 /**
180 * Why this connection is watching. The client says so in words, and the two
181 * have different answers — open the tab that is playing, or let the page keep
182 * its cookie — so which one it is travels with the snapshot.
183 */
184 export type Why = "address" | "cookie" | "unsaid";
185
186 export function spectatorSnapshot(why: Why = "unsaid"): string {
180187 return JSON.stringify({
181188 t: "s",
182189 spectator: true,
190 why,
183191 phase: state.phase,
184192 clock: round2(Math.max(0, state.clock)),
185193 round: state.round,
@@ -187,12 +195,21 @@ export function spectatorSnapshot(): string {
187195 attackers: state.attackers,
188196 result: state.result,
189197 events: state.events.slice(0, 5).map((e) => e.text),
198 // Names and a gun, the same as a seat is sent, and for the same reason it
199 // is safe there: the feed says what happened, never where.
200 feed: feed.map((f) => ({
201 k: f.killer,
202 v: f.victim,
203 w: f.weapon ? WEAPONS[f.weapon].name : null,
204 h: f.headshot,
205 t: round2(Math.max(0, state.now - f.at)),
206 })),
190207 });
191208 }
192209
193 export function watch(token: string, send: (data: string) => void): void {
194 spectators.set(token, send);
195 send(spectatorSnapshot());
210 export function watch(token: string, send: (data: string) => void, why: Why = "unsaid"): void {
211 spectators.set(token, { send, why });
212 send(spectatorSnapshot(why));
196213 }
197214
198215 export function unwatch(token: string): void {
@@ -400,9 +417,9 @@ function step(): void {
400417 // handler gives the seat back.
401418 }
402419 }
403 for (const send of spectators.values()) {
420 for (const spectator of spectators.values()) {
404421 try {
405 send(spectatorSnapshot());
422 spectator.send(spectatorSnapshot(spectator.why));
406423 } catch {
407424 // Same story: the close handler drops it from the set.
408425 }
src/client.test.ts +33 −1
@@ -364,6 +364,38 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
364364 expect(seat.x).toBe(startedAt.x);
365365 expect(seat.y).toBe(startedAt.y);
366366 arena.startArena();
367 }, 15000);
368
369 test("a snapshot with no seat in it draws the watching view instead of throwing", async () => {
370 // The shape no other test covers: score, round, clock and feed, and no `me`
371 // and no players at all. The client used to reach straight into `snap.me`
372 // on the way in, so a second tab's first frame threw and the page stopped
373 // drawing. This is the server's own spectator snapshot, handed to the
374 // bundled client the way the socket hands it over.
375 const { spectatorSnapshot } = await import("./arena.ts");
376 const deliver = (why: "address" | "cookie") =>
377 openedSocket!.onmessage!({ data: spectatorSnapshot(why) } as MessageEvent);
378
379 drawn.length = 0;
380 deliver("address");
381 runFrame();
382
383 // It says what the match is doing, and why this connection is watching.
384 expect(String(nodes.get("phase")!.textContent)).toContain("round");
385 expect(String(nodes.get("hp")!.textContent)).toBe("watching");
386 expect(String(nodes.get("prompt")!.textContent)).toContain("address");
387 // Nothing on screen that would need a position to fill in.
388 expect((nodes.get("kit")!.children as unknown[]).length).toBe(0);
389 expect((nodes.get("roster")!.children as unknown[]).length).toBe(0);
390 expect((nodes.get("atkboard")!.children as unknown[]).length).toBe(0);
391 // And the canvas is words on a panel, not a map: one rectangle, no tiles.
392 expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBe(1);
393 expect(drawn.filter((op) => op.startsWith("fillText")).length).toBeGreaterThan(3);
394
395 // The other reason reads differently, and the frame after it still draws.
396 deliver("cookie");
397 runFrame();
398 expect(String(nodes.get("prompt")!.textContent)).toContain("cookie");
367399
368400 // Back to the first-person view for the last of it, and a frame of it to
369401 // measure: the one before is the one that rebuilds anything cached.
@@ -410,4 +442,4 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
410442 // connection rather than left holding a seat for the rest of the suite.
411443 openedSocket?.close();
412444 await Bun.sleep(50);
413 }, 15000);
445 });
src/server.test.ts +14 −1
@@ -281,11 +281,24 @@ test("a real second connection from one address gets a spectator view, positions
281281 wsTwo.onmessage = (event) => resolve(JSON.parse(String(event.data)));
282282 wsTwo.onerror = reject;
283283 });
284 expect(snapTwo).toMatchObject({ t: "s", spectator: true });
284 // It says why it has no seat, because the two reasons have different
285 // answers, and it carries the kill feed — names and a gun, never a place.
286 expect(snapTwo).toMatchObject({ t: "s", spectator: true, why: "address" });
287 expect(Array.isArray((snapTwo as { feed?: unknown[] }).feed)).toBe(true);
285288 // No coordinate anywhere, not under "me", not under a different key either.
286289 expect(JSON.stringify(snapTwo)).not.toMatch(/"x"|"y"/);
290
291 // And a connection carrying no session at all is told the other reason.
292 const wsThree = new WebSocket(wsUrl);
293 const snapThree = await new Promise<unknown>((resolve, reject) => {
294 wsThree.onmessage = (event) => resolve(JSON.parse(String(event.data)));
295 wsThree.onerror = reject;
296 });
297 expect(snapThree).toMatchObject({ spectator: true, why: "cookie" });
298
287299 wsOne.close();
288300 wsTwo.close();
301 wsThree.close();
289302 await Bun.sleep(50);
290303 });
291304
src/server.ts +11 −5
@@ -23,6 +23,7 @@ import {
2323 unwatch,
2424 watch,
2525 type Command,
26 type Why,
2627 } from "./arena.ts";
2728 import { recentResults } from "./db.ts";
2829 import { gridRows, TILE } from "./map.ts";
@@ -158,7 +159,7 @@ const cleanName = (raw: unknown): string => {
158159 const setCookie = (name: string, value: string, httpOnly: boolean): string =>
159160 `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=604800${httpOnly ? "; HttpOnly" : ""}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`;
160161
161 type SocketData = { session: string; playerId: string; address: string; token: string; spectator: boolean };
162 type SocketData = { session: string; playerId: string; address: string; token: string; spectator: boolean; why: Why };
162163
163164 // ---------------------------------------------------------------- routing
164165
@@ -201,13 +202,18 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
201202 if (!sameOrigin(request)) return new Response("forbidden", { status: 403, headers: SECURITY });
202203 const token = randomUUID();
203204 if (fresh || addressCapped(address, session)) {
204 const upgraded = self.upgrade(request, { data: { session, playerId: "", address, token, spectator: true } });
205 // Which of the two it is: the watching page says so in words, and the
206 // answer differs — open the tab that plays, or keep the cookie.
207 const why: Why = fresh ? "cookie" : "address";
208 const upgraded = self.upgrade(request, { data: { session, playerId: "", address, token, spectator: true, why } });
205209 return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY });
206210 }
207211 const name = cleanName(jar[NAME]) || `Guest-${session.slice(0, 4)}`;
208212 const player = takeSeat(address, session, name);
209213 if (!player) return new Response("no seats", { status: 503, headers: SECURITY });
210 const upgraded = self.upgrade(request, { data: { session, playerId: player.id, address, token, spectator: false } });
214 const upgraded = self.upgrade(request, {
215 data: { session, playerId: player.id, address, token, spectator: false, why: "unsaid" },
216 });
211217 return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY });
212218 }
213219
@@ -291,8 +297,8 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
291297 websocket: {
292298 maxPayloadLength: 4 * 1024,
293299 open(socket) {
294 const { session, playerId, address, token, spectator } = socket.data;
295 if (spectator) return void watch(token, (data) => socket.send(data));
300 const { session, playerId, address, token, spectator, why } = socket.data;
301 if (spectator) return void watch(token, (data) => socket.send(data), why);
296302 attach(session, playerId, address, token, (data) => socket.send(data));
297303 socket.send(snapshot(playerId));
298304 },
src/watch.test.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * The watching view: what a snapshot with no seat in it turns into on screen.
3 */
4
5 import { describe, expect, test } from "bun:test";
6 import { drawWatching, watchLines, watching, wrap } from "../static/watch.js";
7
8 const spectator = (over: Record<string, unknown> = {}) => ({
9 t: "s",
10 spectator: true,
11 why: "address",
12 phase: "live",
13 clock: 42.4,
14 round: 7,
15 score: { red: 3, blue: 3 },
16 attackers: "red",
17 result: null,
18 events: ["red won the round"],
19 feed: [],
20 ...over,
21 });
22
23 const seated = () => ({ t: "s", phase: "live", me: { id: "p1", alive: true }, players: [] });
24
25 describe("telling a seat from a watcher", () => {
26 test("a spectator snapshot is watching, a seat's is not", () => {
27 expect(watching(spectator())).toBe(true);
28 expect(watching(seated())).toBe(false);
29 });
30
31 test("a snapshot with no `me` is watching even if nobody flagged it", () => {
32 expect(watching({ t: "s", phase: "live" })).toBe(true);
33 expect(watching(null)).toBe(false);
34 expect(watching(undefined)).toBe(false);
35 });
36 });
37
38 describe("what the watching view says", () => {
39 test("the score, the round and the clock, from the snapshot it was given", () => {
40 const lines = watchLines(spectator());
41 expect(lines.score).toBe("red 3 — 3 blue");
42 expect(lines.clock).toBe("43s");
43 expect(lines.phase).toBe("round 7 · live · red attacking");
44 });
45
46 test("the reason is named: the seat is held at this address", () => {
47 expect(watchLines(spectator()).why).toContain("address");
48 expect(watchLines(spectator({ why: "cookie" })).why).toContain("cookie");
49 // A snapshot that does not say still gets a sentence, not `undefined`.
50 expect(watchLines(spectator({ why: undefined })).why).toContain("watching");
51 });
52
53 test("it never claims to know where anybody is", () => {
54 const lines = watchLines(spectator());
55 expect(lines.note).toContain("No map");
56 expect(JSON.stringify(lines)).not.toMatch(/"x"|"y"/);
57 });
58
59 test("a half-built snapshot is drawn, not thrown on", () => {
60 const lines = watchLines({ spectator: true });
61 expect(lines.score).toBe("red 0 — 0 blue");
62 expect(lines.clock).toBe("0s");
63 expect(lines.phase).toContain("round 1");
64 });
65 });
66
67 test("long sentences are broken to fit the canvas", () => {
68 const ctx = { measureText: (text: string) => ({ width: text.length * 10 }) };
69 const lines = wrap(ctx, "one two three four five six", 100);
70 expect(lines.length).toBeGreaterThan(1);
71 for (const line of lines) expect(line.length).toBeLessThanOrEqual(10);
72 expect(lines.join(" ")).toBe("one two three four five six");
73 });
74
75 test("drawing it fills a flat panel and writes the score, and draws no world", () => {
76 const ops: string[] = [];
77 const ctx = new Proxy(
78 { measureText: (text: string) => ({ width: text.length * 6 }) } as Record<string, unknown>,
79 {
80 get: (target, prop: string) =>
81 prop in target
82 ? target[prop]
83 : (...args: unknown[]) => ops.push(`${prop}(${args.map(String).join(",")})`),
84 set: () => true,
85 },
86 );
87 drawWatching(ctx as never, spectator(), 900, 570);
88 // One background rectangle and nothing else with an edge: no tiles, no bodies.
89 expect(ops.filter((op) => op.startsWith("fillRect")).length).toBe(1);
90 expect(ops.some((op) => op.startsWith("fillText(red 3 — 3 blue"))).toBe(true);
91 expect(ops.some((op) => op.startsWith("fillText(watching"))).toBe(true);
92 });
static/val.js +69 −14
@@ -24,6 +24,7 @@ import { roster, rosterKey } from "./roster.js";
2424 import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js";
2525 import { moveVector } from "./steer.js";
2626 import { drawTopdown } from "./topdown.js";
27 import { drawWatching, watching, watchLines } from "./watch.js";
2728 import { aim as aimAt, makeYaw, settle, told, turn } from "./yaw.js";
2829
2930 const $ = (id) => document.getElementById(id);
@@ -109,6 +110,9 @@ function connect() {
109110 if (data.t !== "s") return;
110111 snap = data;
111112 snapAt = performance.now();
113 // No seat, no `me`, no players. Everything below reads a body out of the
114 // snapshot, so a watching connection takes the other path entirely.
115 if (watching(data)) return void paintWatch(data);
112116 // Timed samples for every body in it, ours included: the drawing walks
113117 // between them rather than chasing the newest one.
114118 arrive(timeline, snapAt / 1000);
@@ -193,6 +197,7 @@ addEventListener("blur", () => {
193197 // look, Escape gives it back. The angle sent is the same angle as ever.
194198 view.addEventListener("click", () => {
195199 beeper.wake();
200 if (watching(snap)) return; // nothing to aim: capturing the mouse would be a lie
196201 if (mode !== "fp" || locked) return;
197202 if (!view.requestPointerLock) return setMode("top"); // no lock, no first person
198203 // Asked for too soon after the last release, a browser rejects this; that
@@ -306,7 +311,7 @@ function readIntent() {
306311 // Twenty times a second the server hears it. Intent only — a direction, an
307312 // angle, a held key — never a position: the guess below is for drawing.
308313 setInterval(() => {
309 if (!snap || !map) return;
314 if (!snap || !map || watching(snap)) return; // nothing to drive, nothing to send
310315 const now = readIntent();
311316 send({ t: "i", mx: now.mx, my: now.my, a: now.angle, f: now.fire, u: now.use, r: now.reload });
312317 // What we told it, so its echo is recognisable as ours when it comes back.
@@ -356,7 +361,8 @@ const shownAt = (p) => shown(presence, p.id, nowSec);
356361 * match, not of the map — and a guess that does not know it would spend the
357362 * phase being pulled back through the door.
358363 */
359 const predicting = () => snap !== null && world !== null && snap.phase !== "buy" && snap.me.alive;
364 const predicting = () =>
365 snap !== null && !watching(snap) && world !== null && snap.phase !== "buy" && snap.me.alive;
360366
361367 /** The server's answer, caught up to now, then this frame of your own keys. */
362368 function predict(dt, now) {
@@ -397,6 +403,9 @@ function frame(now) {
397403 nowSec = began / 1000;
398404 marks = ghosts(presence, nowSec);
399405 drawAt = renderTime(timeline, now / 1000);
406 // A watching connection draws the watching view and stops there: it has no
407 // map to stand on and no body to put on it, and must invent neither.
408 if (watching(snap)) return void drawWatch();
400409 if (!map) return;
401410 gunTick(gun, dt);
402411 ctx.clearRect(0, 0, view.width, view.height);
@@ -599,18 +608,7 @@ function paintHud(s) {
599608 paintBuy(s);
600609 paintRoster(s);
601610 paintFeed(s);
602
603 const log = $("log");
604 const lines = s.events.slice(0, 3).join("\n");
605 if (log.dataset.text !== lines) {
606 log.dataset.text = lines;
607 log.replaceChildren();
608 for (const line of s.events.slice(0, 3)) {
609 const div = document.createElement("div");
610 div.textContent = line;
611 log.append(div);
612 }
613 }
611 paintLog(s);
614612
615613 for (const side of ["atk", "def"]) {
616614 const body = $(side + "board");
@@ -638,6 +636,63 @@ function paintHud(s) {
638636 }
639637 }
640638
639 /** The last few things that happened, in words. Rebuilt only when they change. */
640 function paintLog(s) {
641 const log = $("log");
642 const lines = (s.events ?? []).slice(0, 3);
643 if (log.dataset.text === lines.join("\n")) return;
644 log.dataset.text = lines.join("\n");
645 log.replaceChildren();
646 for (const line of lines) {
647 const div = document.createElement("div");
648 div.textContent = line;
649 log.append(div);
650 }
651 }
652
653 /**
654 * The HUD for a connection with no seat. Everything a seat's HUD says about
655 * you — health, gun, credits, kit, your five — has no answer here, so it says
656 * so rather than leaving the page's first paint standing there going stale.
657 */
658 function paintWatch(s) {
659 const lines = watchLines(s);
660 $("clock").textContent = Math.ceil(Math.max(0, Number(s.clock) || 0));
661 document.querySelector("#scoreline .red").textContent = "red " + (s.score?.red ?? 0);
662 document.querySelector("#scoreline .blue").textContent = (s.score?.blue ?? 0) + " blue";
663 $("phase").textContent = lines.phase;
664 $("hp").textContent = "watching";
665 $("gun").textContent = "no seat";
666 $("creds").textContent = "—";
667 $("ultmeter").textContent = "—";
668 $("buy").classList.toggle("on", false);
669 if ($("prompt").textContent !== lines.why) $("prompt").textContent = lines.why;
670 paintFeed(s);
671 paintLog(s);
672 blank($("kit"));
673 blank($("roster"));
674 blank($("atkboard"));
675 blank($("defboard"));
676 if (KITS.shape !== "watching") KITS.shape = "watching";
677 }
678
679 /** Empty a panel whose answer this connection is not sent, once. */
680 function blank(panel) {
681 if (panel.dataset.key === "watching") return;
682 panel.dataset.key = "watching";
683 panel.dataset.text = "watching";
684 panel.replaceChildren();
685 }
686
687 /** The canvas, watching: no map, no bodies, and the overlays a seat owns off. */
688 function drawWatch() {
689 ctx.clearRect(0, 0, view.width, view.height);
690 drawWatching(ctx, snap, view.width, view.height);
691 $("flash").style.opacity = 0;
692 $("dead").style.display = "none";
693 $("aimhint").style.display = "none";
694 }
695
641696 /** Is this player standing where the spike arms? The grid says so. */
642697 function onSite(me) {
643698 if (!map) return false;
static/watch.js added +91
@@ -0,0 +1,91 @@
1 /**
2 * Watching.
3 *
4 * A connection the server gave no seat — one that carried no session the page
5 * minted, or a second client from an address that already holds a seat — is
6 * sent a snapshot with no `me` and no players: the score, the round, the
7 * clock and the kill feed, and nothing at all that says where anybody is.
8 * That is the whole point of it, so this view draws no map and no bodies. It
9 * says what the match is doing and why this connection is only watching.
10 */
11
12 /** Is this a snapshot for a connection that has no seat? */
13 export const watching = (snap) => Boolean(snap) && (snap.spectator === true || !snap.me);
14
15 // Why the server handed this connection no seat. It says which, because the
16 // two have different answers: open the tab that is playing, or let the page
17 // keep its cookie.
18 const WHY = {
19 address: "Somebody at this address is already playing — that tab holds the seat, and this one watches.",
20 cookie: "This browser did not send the session cookie the page sets, so there is no seat to give it.",
21 };
22 const WHY_UNSAID = "This connection has no seat, so it is watching the match.";
23
24 const PHASE = { buy: "buy phase", live: "live", end: "round over" };
25
26 /** What the watching view says, in order. Plain strings, nothing drawn. */
27 export function watchLines(snap) {
28 const score = snap.score ?? { red: 0, blue: 0 };
29 return {
30 title: "watching",
31 why: WHY[snap.why] ?? WHY_UNSAID,
32 phase: `round ${snap.round ?? 1} · ${PHASE[snap.phase] ?? "match over"} · ${snap.attackers ?? "—"} attacking${snap.result ? " · " + snap.result : ""}`,
33 score: `red ${score.red ?? 0} — ${score.blue ?? 0} blue`,
34 clock: `${Math.ceil(Math.max(0, Number(snap.clock) || 0))}s`,
35 note: "No map and no bodies: a watching connection is never told where anybody is.",
36 };
37 }
38
39 /** Break a line at the last space that still fits, measured by the canvas. */
40 export function wrap(ctx, text, maxWidth) {
41 const lines = [];
42 let line = "";
43 for (const word of String(text).split(" ")) {
44 const tried = line ? `${line} ${word}` : word;
45 if (line && ctx.measureText(tried).width > maxWidth) {
46 lines.push(line);
47 line = word;
48 } else line = tried;
49 }
50 if (line) lines.push(line);
51 return lines;
52 }
53
54 /**
55 * The view itself: the score, the round, the clock, and the sentence that
56 * says why this connection is watching. Centred on a flat panel, because
57 * there is no world here to stand it on.
58 */
59 export function drawWatching(ctx, snap, width, height) {
60 const lines = watchLines(snap);
61 const mid = width / 2;
62 const unit = Math.min(width / 900, height / 570);
63 ctx.fillStyle = "#0b0d11";
64 ctx.fillRect(0, 0, width, height);
65 ctx.textAlign = "center";
66
67 ctx.fillStyle = "#8b93a5";
68 ctx.font = `${Math.round(22 * unit)}px system-ui, sans-serif`;
69 ctx.fillText(lines.title, mid, height * 0.3);
70
71 ctx.fillStyle = "#e7e9ee";
72 ctx.font = `${Math.round(54 * unit)}px system-ui, sans-serif`;
73 ctx.fillText(lines.score, mid, height * 0.42);
74
75 ctx.font = `${Math.round(30 * unit)}px system-ui, sans-serif`;
76 ctx.fillText(lines.clock, mid, height * 0.52);
77
78 ctx.fillStyle = "#b9c0cf";
79 ctx.font = `${Math.round(19 * unit)}px system-ui, sans-serif`;
80 ctx.fillText(lines.phase, mid, height * 0.6);
81
82 ctx.fillStyle = "#8b93a5";
83 ctx.font = `${Math.round(17 * unit)}px system-ui, sans-serif`;
84 let y = height * 0.7;
85 for (const line of [...wrap(ctx, lines.why, width * 0.8), ...wrap(ctx, lines.note, width * 0.8)]) {
86 ctx.fillText(line, mid, y);
87 y += 24 * unit;
88 }
89 ctx.textAlign = "left";
90 return lines;
91 }
↑↓ navigate ↵ open esc close

Keyboard shortcuts