rook/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
23 files
changed +1557
−88
README.md +26 −4
| @@ -73,7 +73,24 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 73 | 73 | - The view is a raycaster: walls are one tile tall and flat-shaded, there is |
| 74 | 74 | no looking up or down, and everything that is not a wall — bodies, the |
| 75 | 75 | 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. | |
| 77 | 94 | - One map, one match at a time, ten seats. There is no matchmaking, no |
| 78 | 95 | ranking and no account: your seat belongs to a session cookie, and a |
| 79 | 96 | 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. | ||
| 99 | 116 | Click the view to capture the mouse, then WASD move and the mouse looks; |
| 100 | 117 | **Esc** gives the pointer back. Click fires, **R** reloads, **F** plants and |
| 101 | 118 | 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 | |
| 104 | 123 | thumb walks and the right thumb aims and fires. |
| 105 | 124 | |
| 106 | 125 | sh |
| @@ -125,7 +144,10 @@ says how. | ||
| 125 | 144 | no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one |
| 126 | 145 | live match and its sockets · `src/server.ts` the door · `static/` the browser client — |
| 127 | 146 | `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, | |
| 129 | 151 | `gun.js`, `beep.js`, `steer.js`, `roster.js`, `yaw.js`, `interp.js` and `prompt.js` the rest; the server bundles |
| 130 | 152 | them into one content-named file at start-up. The rules are pure functions and the tests play whole matches |
| 131 | 153 | 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>>(); | ||
| 112 | 112 | for (const id of [ |
| 113 | 113 | "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle", |
| 114 | 114 | "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage", |
| 115 | "log", "atkboard", "defboard", "helpagents", "roster", "feed", | |
| 115 | "log", "atkboard", "defboard", "helpagents", "roster", "feed", "perf", | |
| 116 | 116 | ]) |
| 117 | 117 | nodes.set(id, element(id)); |
| 118 | 118 | |
| @@ -232,22 +232,39 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 232 | 232 | expect(row).toContain(" ▸ "); |
| 233 | 233 | expect(row).not.toMatch(/\d/); |
| 234 | 234 | |
| 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 | |
| 237 | 244 | drawn.length = 0; |
| 238 | 245 | 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); | |
| 240 | 250 | |
| 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. | |
| 242 | 254 | for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop }); |
| 243 | 255 | drawn.length = 0; |
| 244 | 256 | 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); | |
| 246 | 264 | for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" }); |
| 247 | 265 | drawn.length = 0; |
| 248 | 266 | runFrame(); |
| 249 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800); | |
| 250 | ||
| 267 | expect(tally().blits).toBeGreaterThanOrEqual(2); | |
| 251 | 268 | // Holding W reaches the server as a direction, and it is the way we face. |
| 252 | 269 | const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!; |
| 253 | 270 | press("KeyW"); |
| @@ -380,6 +397,46 @@ test("a snapshot with no seat in it draws the watching view instead of throwing" | ||
| 380 | 397 | runFrame(); |
| 381 | 398 | expect(String(nodes.get("prompt")!.textContent)).toContain("cookie"); |
| 382 | 399 | |
| 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 | ||
| 383 | 440 | // This test's loopback address is shared with every other test in the |
| 384 | 441 | // run; close the socket so the address is free for the next real |
| 385 | 442 | // 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: { | ||
| 76 | 76 | #aimhint { position: absolute; inset: auto 0 45% 0; display: none; place-items: center; |
| 77 | 77 | font-size: .85rem; letter-spacing: .08em; text-transform: uppercase; |
| 78 | 78 | 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; } | |
| 79 | 83 | #help { position: fixed; inset: 0; z-index: 10; display: none; place-items: center; |
| 80 | 84 | background: #080a0ef2; padding: .8rem; overflow: auto; } |
| 81 | 85 | #help > div { max-width: 40rem; } |
| @@ -156,6 +160,7 @@ export function gamePage(opts: { | ||
| 156 | 160 | <div id="aimhint">click to aim · esc releases</div> |
| 157 | 161 | <div id="dead">waiting for the next round</div> |
| 158 | 162 | <div id="feed"></div> |
| 163 | <div id="perf" aria-live="off"></div> | |
| 159 | 164 | <div id="prompt">Click the map to aim, then press <b>H</b> for how to play.</div> |
| 160 | 165 | <div id="help" role="dialog" aria-label="How to play val"><div> |
| 161 | 166 | <h2>How to play</h2> |
| @@ -169,7 +174,8 @@ export function gamePage(opts: { | ||
| 169 | 174 | click the view to capture the mouse and look, <kbd>Esc</kbd> releases · |
| 170 | 175 | click to fire · <kbd>R</kbd> reload · hold <kbd>F</kbd> to plant, defuse or pick up · |
| 171 | 176 | <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> | |
| 173 | 179 | <p><strong>Abilities.</strong> <kbd>C</kbd> and <kbd>Q</kbd> are bought in the buy phase and |
| 174 | 180 | last the round, <kbd>E</kbd> is free and comes back on a cooldown, and <kbd>X</kbd> is |
| 175 | 181 | the ultimate, paid for in ult points you earn by playing.</p> |
| @@ -238,7 +244,9 @@ export function gamePage(opts: { | ||
| 238 | 244 | <p><strong>Controls.</strong> WASD move · click the view to capture the mouse, move to look, |
| 239 | 245 | Esc releases · click fires · R reloads · <strong>F</strong> plants and defuses (hold it) · |
| 240 | 246 | 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. | |
| 242 | 250 | On a phone it stays top-down: left thumb walks, right thumb aims and fires.</p> |
| 243 | 251 | <p>Five attackers carry the spike, five defenders stop them. 100s round, 4s plant, 45s fuse, |
| 244 | 252 | 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 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { expect, test } from "bun:test"; |
| 8 | 8 | import { drawGun, makeGun } from "../static/gun.js"; |
| 9 | import { GHOST, ghosts, makePresence, shown, sighted } from "../static/presence.js"; | |
| 9 | 10 | import { drawScene, makeScene } from "../static/scene.js"; |
| 10 | 11 | import { drawTopdown } from "../static/topdown.js"; |
| 11 | 12 | import { gridRows, TILE } from "./map.ts"; |
| 12 | 13 | |
| 13 | type Op = { op: string; args: unknown[] }; | |
| 14 | type Op = { op: string; args: unknown[]; alpha: number }; | |
| 14 | 15 | |
| 15 | 16 | /** A canvas that records. Every call the renderer makes has to exist here. */ |
| 16 | 17 | function fakeContext() { |
| @@ -18,7 +19,9 @@ function fakeContext() { | ||
| 18 | 19 | const note = |
| 19 | 20 | (op: string) => |
| 20 | 21 | (...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 }); | |
| 22 | 25 | }; |
| 23 | 26 | const ctx = { |
| 24 | 27 | ops, |
| @@ -88,12 +91,27 @@ const body = (over: Record<string, unknown> = {}) => ({ | ||
| 88 | 91 | const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p }; |
| 89 | 92 | const eye = { x: 120, y: 200, angle: 0 }; |
| 90 | 93 | |
| 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", () => { | |
| 92 | 95 | const ctx = fakeContext(); |
| 93 | 96 | drawScene(ctx as never, makeScene(), world, eye, snapshot([]), view); |
| 94 | 97 | 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); | |
| 97 | 115 | }); |
| 98 | 116 | |
| 99 | 117 | 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" | ||
| 229 | 247 | drawGun(loud as never, gun, 900, 570, snapshot([])); |
| 230 | 248 | expect(loud.ops.length).toBeGreaterThan(quiet); |
| 231 | 249 | }); |
| 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 @@ | ||
| 7 | 7 | * drawn at all. Smoke is measured along each ray, not painted as a disc: |
| 8 | 8 | * cross one and everything past it fogs. |
| 9 | 9 | * |
| 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. | |
| 13 | 16 | */ |
| 14 | 17 | |
| 18 | import { swell } from "./presence.js"; | |
| 19 | import { makeColumns, SKY, wallRuns } from "./slices.js"; | |
| 15 | 20 | import { |
| 16 | 21 | castRay, |
| 17 | 22 | columnOffset, |
| @@ -55,7 +60,8 @@ const FOG = "rgb(198,203,212)"; | ||
| 55 | 60 | export function makeScene() { |
| 56 | 61 | return { |
| 57 | 62 | depth: new Float32Array(0), |
| 58 | fog: new Float32Array(0), | |
| 63 | cols: makeColumns(0), | |
| 64 | runs: [], | |
| 59 | 65 | hit: hitResult(), |
| 60 | 66 | proj: projectResult(), |
| 61 | 67 | order: [], |
| @@ -65,7 +71,31 @@ export function makeScene() { | ||
| 65 | 71 | }; |
| 66 | 72 | } |
| 67 | 73 | |
| 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 | } | |
| 69 | 99 | |
| 70 | 100 | /** |
| 71 | 101 | * Draw one frame. |
| @@ -78,11 +108,15 @@ const shadeOf = (light) => SHADES[Math.min(LEVELS - 1, Math.max(0, (light * LEVE | ||
| 78 | 108 | export function drawScene(ctx, scene, world, eye, snap, view) { |
| 79 | 109 | const W = view.width; |
| 80 | 110 | 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; | |
| 82 | 116 | const columns = Math.ceil(W / step); |
| 83 | 117 | if (scene.depth.length !== columns) { |
| 84 | 118 | scene.depth = new Float32Array(columns); |
| 85 | scene.fog = new Float32Array(columns); | |
| 119 | scene.cols = makeColumns(columns); | |
| 86 | 120 | } |
| 87 | 121 | const proj = projection(W); |
| 88 | 122 | const horizon = H / 2; |
| @@ -94,7 +128,8 @@ export function drawScene(ctx, scene, world, eye, snap, view) { | ||
| 94 | 128 | for (const zone of snap.zones) if ((zone.k === "smoke" || zone.k === "wall") && zone.f <= 0) smokes.push(zone); |
| 95 | 129 | |
| 96 | 130 | const depth = scene.depth; |
| 97 | const fog = scene.fog; | |
| 131 | const cols = scene.cols; | |
| 132 | const fog = cols.haze; | |
| 98 | 133 | const hit = scene.hit; |
| 99 | 134 | for (let i = 0; i < columns; i++) { |
| 100 | 135 | const offset = columnOffset(i * step + step / 2, W); |
| @@ -114,18 +149,17 @@ export function drawScene(ctx, scene, world, eye, snap, view) { | ||
| 114 | 149 | haze = Math.min(1, haze); |
| 115 | 150 | } |
| 116 | 151 | 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; | |
| 127 | 155 | } |
| 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; | |
| 128 | 161 | } |
| 162 | drawWalls(ctx, scene, step); | |
| 129 | 163 | |
| 130 | 164 | drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns); |
| 131 | 165 | } |
| @@ -167,8 +201,14 @@ function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, col | ||
| 167 | 201 | for (const player of snap.players) { |
| 168 | 202 | if (player.id === snap.me.id) continue; |
| 169 | 203 | 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 }); | |
| 171 | 208 | } |
| 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 }); | |
| 172 | 212 | |
| 173 | 213 | const p = scene.proj; |
| 174 | 214 | for (const thing of things) { |
| @@ -184,9 +224,11 @@ function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, col | ||
| 184 | 224 | if (thing.behind) continue; |
| 185 | 225 | const floorY = horizon + (EYE * proj) / thing.perp; |
| 186 | 226 | 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; | |
| 188 | 229 | if (ctx.globalAlpha < 0.05) continue; |
| 189 | 230 | if (thing.kind === "body") drawBody(ctx, scene, thing, floorY, step, columns); |
| 231 | else if (thing.kind === "mark") drawMark(ctx, scene, thing, floorY, step, columns); | |
| 190 | 232 | else if (thing.kind === "spike") drawSpike(ctx, scene, thing, floorY, snap, step, columns); |
| 191 | 233 | else if (thing.kind === "orb") drawBlob(ctx, scene, thing, floorY, "#c9a0ff", 0.35, step, columns); |
| 192 | 234 | 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 | ||
| 199 | 241 | |
| 200 | 242 | const hazeAt = (scene, column, step, columns) => { |
| 201 | 243 | 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; | |
| 203 | 245 | }; |
| 204 | 246 | |
| 205 | 247 | /** |
| @@ -228,7 +270,9 @@ function withRuns(ctx, scene, left, right, perp, step, columns, draw) { | ||
| 228 | 270 | |
| 229 | 271 | function drawBody(ctx, scene, thing, floorY, step, columns) { |
| 230 | 272 | 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); | |
| 232 | 276 | const w = h * 0.46; |
| 233 | 277 | const cx = thing.column; |
| 234 | 278 | const top = floorY - h; |
| @@ -287,6 +331,26 @@ function drawBody(ctx, scene, thing, floorY, step, columns) { | ||
| 287 | 331 | }); |
| 288 | 332 | } |
| 289 | 333 | |
| 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 | ||
| 290 | 354 | /** An enemy wears a chevron: a shape, not a hue, for anyone whose eyes read |
| 291 | 355 | * red and blue the same way. */ |
| 292 | 356 | 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 = { | ||
| 20 | 20 | /** |
| 21 | 21 | * Draw the whole map into `box` (canvas pixels). `look(player)` gives the |
| 22 | 22 | * 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. | |
| 24 | 31 | */ |
| 25 | 32 | export function drawTopdown(ctx, map, snap, look, box, opts = {}) { |
| 26 | 33 | const cols = map.rows[0].length; |
| 27 | 34 | const s = box.w / (cols * map.tile); |
| 28 | 35 | const labels = opts.labels !== false; |
| 36 | const tiles = opts.tiles !== false; | |
| 29 | 37 | ctx.save(); |
| 30 | 38 | ctx.beginPath(); |
| 31 | 39 | ctx.rect(box.x, box.y, box.w, box.h); |
| @@ -33,17 +41,18 @@ export function drawTopdown(ctx, map, snap, look, box, opts = {}) { | ||
| 33 | 41 | ctx.translate(box.x, box.y); |
| 34 | 42 | |
| 35 | 43 | 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 | } | |
| 45 | 55 | } |
| 46 | } | |
| 47 | 56 | |
| 48 | 57 | if (!snap) { |
| 49 | 58 | ctx.restore(); |
| @@ -79,6 +88,9 @@ export function drawTopdown(ctx, map, snap, look, box, opts = {}) { | ||
| 79 | 88 | ctx.stroke(); |
| 80 | 89 | } |
| 81 | 90 | |
| 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); | |
| 82 | 94 | for (const player of snap.players) drawPlayer(ctx, player, look(player), s, snap.me, labels); |
| 83 | 95 | // Smoke last: on this view it hides what is under it, as it should. |
| 84 | 96 | 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) { | ||
| 115 | 127 | } |
| 116 | 128 | } |
| 117 | 129 | |
| 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 | ||
| 118 | 151 | function disc(ctx, x, y, r, fill, stroke) { |
| 119 | 152 | ctx.beginPath(); |
| 120 | 153 | ctx.arc(x, y, r, 0, 7); |
static/val.js +120 −7
| @@ -12,11 +12,14 @@ | ||
| 12 | 12 | "use strict"; |
| 13 | 13 | |
| 14 | 14 | 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"; | |
| 16 | 17 | import { BODY_R, moveSpeed } from "../src/glide.ts"; |
| 17 | 18 | import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js"; |
| 18 | 19 | import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js"; |
| 20 | import { forget, ghosts, makePresence, shown, sighted, slide } from "./presence.js"; | |
| 19 | 21 | import { choosePrompt, REACH } from "./prompt.js"; |
| 22 | import { renderSize, WIDTHS } from "./resolution.js"; | |
| 20 | 23 | import { roster, rosterKey } from "./roster.js"; |
| 21 | 24 | import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js"; |
| 22 | 25 | import { moveVector } from "./steer.js"; |
| @@ -27,6 +30,11 @@ import { aim as aimAt, makeYaw, settle, told, turn } from "./yaw.js"; | ||
| 27 | 30 | const $ = (id) => document.getElementById(id); |
| 28 | 31 | const view = $("view"); |
| 29 | 32 | 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"); | |
| 30 | 38 | |
| 31 | 39 | const KEY_SLOT = { KeyC: "c", KeyQ: "q", KeyE: "e", KeyX: "x" }; |
| 32 | 40 | const COARSE = matchMedia("(pointer: coarse)").matches; |
| @@ -44,6 +52,13 @@ let agents = null; | ||
| 44 | 52 | // drawing, a snapshot interval behind the present. |
| 45 | 53 | const timeline = makeTimeline(); |
| 46 | 54 | 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; | |
| 47 | 62 | const keys = new Set(); |
| 48 | 63 | // Where this person is looking: the browser's between snapshots, and the |
| 49 | 64 | // server's word taken only when it is saying something we did not. See |
| @@ -59,6 +74,13 @@ let aimTouch = null; | ||
| 59 | 74 | let helpShown = false; |
| 60 | 75 | let last = performance.now(); |
| 61 | 76 | 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; | |
| 62 | 84 | |
| 63 | 85 | const walk = { x: 0, y: 0 }; |
| 64 | 86 | // What this person is doing right now, read once a frame for the drawing and |
| @@ -94,6 +116,10 @@ function connect() { | ||
| 94 | 116 | // Timed samples for every body in it, ours included: the drawing walks |
| 95 | 117 | // between them rather than chasing the newest one. |
| 96 | 118 | 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); | |
| 97 | 123 | feed(timeline, data.me.id, data.me, snapAt / 1000); |
| 98 | 124 | for (const player of data.players) feed(timeline, player.id, player, snapAt / 1000); |
| 99 | 125 | // The server's angle is taken only when it is a correction, never as the |
| @@ -143,6 +169,11 @@ addEventListener("keydown", (event) => { | ||
| 143 | 169 | event.preventDefault(); |
| 144 | 170 | return; |
| 145 | 171 | } |
| 172 | if (event.code === "F3" || event.code === "Backquote") { | |
| 173 | togglePerf(); | |
| 174 | event.preventDefault(); | |
| 175 | return; | |
| 176 | } | |
| 146 | 177 | if (KEY_SLOT[event.code]) { |
| 147 | 178 | send({ t: "i", c: KEY_SLOT[event.code] }); |
| 148 | 179 | event.preventDefault(); |
| @@ -294,9 +325,17 @@ function resize() { | ||
| 294 | 325 | const dpr = Math.min(devicePixelRatio || 1, 2); |
| 295 | 326 | view.width = Math.round(box.width * dpr); |
| 296 | 327 | view.height = Math.round(box.height * dpr); |
| 328 | sizeRender(); | |
| 297 | 329 | } |
| 298 | 330 | addEventListener("resize", resize); |
| 299 | 331 | |
| 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 | ||
| 300 | 339 | const scale = () => (map ? view.width / (map.rows[0].length * map.tile) : 1); |
| 301 | 340 | const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() }); |
| 302 | 341 | |
| @@ -306,7 +345,15 @@ const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() }); | ||
| 306 | 345 | * You are not: your own body is the one this browser can run forward |
| 307 | 346 | * honestly, and drawing it late is what dragged the camera backwards. |
| 308 | 347 | */ |
| 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); | |
| 310 | 357 | |
| 311 | 358 | /** |
| 312 | 359 | * Your own movement is only guessed at while the round is live and you are |
| @@ -350,8 +397,11 @@ function predict(dt, now) { | ||
| 350 | 397 | |
| 351 | 398 | function frame(now) { |
| 352 | 399 | requestAnimationFrame(frame); |
| 400 | const began = performance.now(); | |
| 353 | 401 | const dt = Math.min(0.1, (now - last) / 1000); |
| 354 | 402 | last = now; |
| 403 | nowSec = began / 1000; | |
| 404 | marks = ghosts(presence, nowSec); | |
| 355 | 405 | drawAt = renderTime(timeline, now / 1000); |
| 356 | 406 | // A watching connection draws the watching view and stops there: it has no |
| 357 | 407 | // map to stand on and no body to put on it, and must invent neither. |
| @@ -374,15 +424,23 @@ function frame(now) { | ||
| 374 | 424 | const facing = mode === "fp" ? head.angle : eye.angle; |
| 375 | 425 | |
| 376 | 426 | 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, | |
| 380 | 433 | look, |
| 434 | shown: shownAt, | |
| 435 | marks, | |
| 381 | 436 | }); |
| 437 | ctx.drawImage(sceneCanvas, 0, 0, view.width, view.height); | |
| 382 | 438 | drawGun(ctx, gun, view.width, view.height, snap); |
| 383 | 439 | drawMinimap(); |
| 384 | 440 | } 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 }); | |
| 386 | 444 | } |
| 387 | 445 | |
| 388 | 446 | if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting"); |
| @@ -391,6 +449,60 @@ function frame(now) { | ||
| 391 | 449 | $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8); |
| 392 | 450 | $("dead").style.display = snap.me.alive ? "none" : "grid"; |
| 393 | 451 | $("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; | |
| 394 | 506 | } |
| 395 | 507 | |
| 396 | 508 | /** The same top-down drawing, small, in the corner, with you as an arrow. */ |
| @@ -402,7 +514,8 @@ function drawMinimap() { | ||
| 402 | 514 | ctx.globalAlpha = 0.85; |
| 403 | 515 | ctx.fillStyle = "#0b0d11"; |
| 404 | 516 | 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 }); | |
| 406 | 519 | ctx.restore(); |
| 407 | 520 | ctx.strokeStyle = "rgba(255,255,255,0.18)"; |
| 408 | 521 | ctx.lineWidth = 1; |
README.md +4 −1
| @@ -97,7 +97,10 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 97 | 97 | again — cannot buy a second one on the other team. A second machine on a |
| 98 | 98 | second network is still a second person as far as the server can tell, |
| 99 | 99 | 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. | |
| 101 | 104 | - No overtime — first to 13 ends it at 12–12 too. |
| 102 | 105 | - Sight is a full circle rather than a view cone — you are drawn what is in |
| 103 | 106 | 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; | ||
| 174 | 174 | |
| 175 | 175 | // A connection that never carried a session the page minted gets no side and |
| 176 | 176 | // 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 }>(); | |
| 178 | 178 | |
| 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 { | |
| 180 | 187 | return JSON.stringify({ |
| 181 | 188 | t: "s", |
| 182 | 189 | spectator: true, |
| 190 | why, | |
| 183 | 191 | phase: state.phase, |
| 184 | 192 | clock: round2(Math.max(0, state.clock)), |
| 185 | 193 | round: state.round, |
| @@ -187,12 +195,21 @@ export function spectatorSnapshot(): string { | ||
| 187 | 195 | attackers: state.attackers, |
| 188 | 196 | result: state.result, |
| 189 | 197 | 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 | })), | |
| 190 | 207 | }); |
| 191 | 208 | } |
| 192 | 209 | |
| 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)); | |
| 196 | 213 | } |
| 197 | 214 | |
| 198 | 215 | export function unwatch(token: string): void { |
| @@ -400,9 +417,9 @@ function step(): void { | ||
| 400 | 417 | // handler gives the seat back. |
| 401 | 418 | } |
| 402 | 419 | } |
| 403 | for (const send of spectators.values()) { | |
| 420 | for (const spectator of spectators.values()) { | |
| 404 | 421 | try { |
| 405 | send(spectatorSnapshot()); | |
| 422 | spectator.send(spectatorSnapshot(spectator.why)); | |
| 406 | 423 | } catch { |
| 407 | 424 | // Same story: the close handler drops it from the set. |
| 408 | 425 | } |
src/client.test.ts +33 −1
| @@ -364,6 +364,38 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 364 | 364 | expect(seat.x).toBe(startedAt.x); |
| 365 | 365 | expect(seat.y).toBe(startedAt.y); |
| 366 | 366 | 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"); | |
| 367 | 399 | |
| 368 | 400 | // Back to the first-person view for the last of it, and a frame of it to |
| 369 | 401 | // 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 () = | ||
| 410 | 442 | // connection rather than left holding a seat for the rest of the suite. |
| 411 | 443 | openedSocket?.close(); |
| 412 | 444 | 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 | ||
| 281 | 281 | wsTwo.onmessage = (event) => resolve(JSON.parse(String(event.data))); |
| 282 | 282 | wsTwo.onerror = reject; |
| 283 | 283 | }); |
| 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); | |
| 285 | 288 | // No coordinate anywhere, not under "me", not under a different key either. |
| 286 | 289 | 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 | ||
| 287 | 299 | wsOne.close(); |
| 288 | 300 | wsTwo.close(); |
| 301 | wsThree.close(); | |
| 289 | 302 | await Bun.sleep(50); |
| 290 | 303 | }); |
| 291 | 304 |
src/server.ts +11 −5
| @@ -23,6 +23,7 @@ import { | ||
| 23 | 23 | unwatch, |
| 24 | 24 | watch, |
| 25 | 25 | type Command, |
| 26 | type Why, | |
| 26 | 27 | } from "./arena.ts"; |
| 27 | 28 | import { recentResults } from "./db.ts"; |
| 28 | 29 | import { gridRows, TILE } from "./map.ts"; |
| @@ -158,7 +159,7 @@ const cleanName = (raw: unknown): string => { | ||
| 158 | 159 | const setCookie = (name: string, value: string, httpOnly: boolean): string => |
| 159 | 160 | `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=604800${httpOnly ? "; HttpOnly" : ""}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`; |
| 160 | 161 | |
| 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 }; | |
| 162 | 163 | |
| 163 | 164 | // ---------------------------------------------------------------- routing |
| 164 | 165 | |
| @@ -201,13 +202,18 @@ export const server = Bun.serve<SocketData, Record<string, never>>({ | ||
| 201 | 202 | if (!sameOrigin(request)) return new Response("forbidden", { status: 403, headers: SECURITY }); |
| 202 | 203 | const token = randomUUID(); |
| 203 | 204 | 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 } }); | |
| 205 | 209 | return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY }); |
| 206 | 210 | } |
| 207 | 211 | const name = cleanName(jar[NAME]) || `Guest-${session.slice(0, 4)}`; |
| 208 | 212 | const player = takeSeat(address, session, name); |
| 209 | 213 | 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 | }); | |
| 211 | 217 | return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY }); |
| 212 | 218 | } |
| 213 | 219 | |
| @@ -291,8 +297,8 @@ export const server = Bun.serve<SocketData, Record<string, never>>({ | ||
| 291 | 297 | websocket: { |
| 292 | 298 | maxPayloadLength: 4 * 1024, |
| 293 | 299 | 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); | |
| 296 | 302 | attach(session, playerId, address, token, (data) => socket.send(data)); |
| 297 | 303 | socket.send(snapshot(playerId)); |
| 298 | 304 | }, |
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"; | ||
| 24 | 24 | import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js"; |
| 25 | 25 | import { moveVector } from "./steer.js"; |
| 26 | 26 | import { drawTopdown } from "./topdown.js"; |
| 27 | import { drawWatching, watching, watchLines } from "./watch.js"; | |
| 27 | 28 | import { aim as aimAt, makeYaw, settle, told, turn } from "./yaw.js"; |
| 28 | 29 | |
| 29 | 30 | const $ = (id) => document.getElementById(id); |
| @@ -109,6 +110,9 @@ function connect() { | ||
| 109 | 110 | if (data.t !== "s") return; |
| 110 | 111 | snap = data; |
| 111 | 112 | 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); | |
| 112 | 116 | // Timed samples for every body in it, ours included: the drawing walks |
| 113 | 117 | // between them rather than chasing the newest one. |
| 114 | 118 | arrive(timeline, snapAt / 1000); |
| @@ -193,6 +197,7 @@ addEventListener("blur", () => { | ||
| 193 | 197 | // look, Escape gives it back. The angle sent is the same angle as ever. |
| 194 | 198 | view.addEventListener("click", () => { |
| 195 | 199 | beeper.wake(); |
| 200 | if (watching(snap)) return; // nothing to aim: capturing the mouse would be a lie | |
| 196 | 201 | if (mode !== "fp" || locked) return; |
| 197 | 202 | if (!view.requestPointerLock) return setMode("top"); // no lock, no first person |
| 198 | 203 | // Asked for too soon after the last release, a browser rejects this; that |
| @@ -306,7 +311,7 @@ function readIntent() { | ||
| 306 | 311 | // Twenty times a second the server hears it. Intent only — a direction, an |
| 307 | 312 | // angle, a held key — never a position: the guess below is for drawing. |
| 308 | 313 | setInterval(() => { |
| 309 | if (!snap || !map) return; | |
| 314 | if (!snap || !map || watching(snap)) return; // nothing to drive, nothing to send | |
| 310 | 315 | const now = readIntent(); |
| 311 | 316 | send({ t: "i", mx: now.mx, my: now.my, a: now.angle, f: now.fire, u: now.use, r: now.reload }); |
| 312 | 317 | // 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); | ||
| 356 | 361 | * match, not of the map — and a guess that does not know it would spend the |
| 357 | 362 | * phase being pulled back through the door. |
| 358 | 363 | */ |
| 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; | |
| 360 | 366 | |
| 361 | 367 | /** The server's answer, caught up to now, then this frame of your own keys. */ |
| 362 | 368 | function predict(dt, now) { |
| @@ -397,6 +403,9 @@ function frame(now) { | ||
| 397 | 403 | nowSec = began / 1000; |
| 398 | 404 | marks = ghosts(presence, nowSec); |
| 399 | 405 | 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(); | |
| 400 | 409 | if (!map) return; |
| 401 | 410 | gunTick(gun, dt); |
| 402 | 411 | ctx.clearRect(0, 0, view.width, view.height); |
| @@ -599,18 +608,7 @@ function paintHud(s) { | ||
| 599 | 608 | paintBuy(s); |
| 600 | 609 | paintRoster(s); |
| 601 | 610 | 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); | |
| 614 | 612 | |
| 615 | 613 | for (const side of ["atk", "def"]) { |
| 616 | 614 | const body = $(side + "board"); |
| @@ -638,6 +636,63 @@ function paintHud(s) { | ||
| 638 | 636 | } |
| 639 | 637 | } |
| 640 | 638 | |
| 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 | ||
| 641 | 696 | /** Is this player standing where the spike arms? The grid says so. */ |
| 642 | 697 | function onSite(me) { |
| 643 | 698 | 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 | } |