Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The range is drawn in three dimensions: the room, the plates and the tracer through the engine’s renderer #76
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/54-the-range-is-drawn-in-three-dimensions-t → main 3 commits fork
19 files
changed +853
−131
README.md +22 −13
| @@ -7,8 +7,9 @@ and the leaderboard of the matches people played. The second, at `/range`, is | ||
| 7 | 7 | built only out of the engine the shooter was taken apart into: a shooting |
| 8 | 8 | **range** — pop-up targets from a seeded spawn, a shot traced through the |
| 9 | 9 | engine's boxes, a thirty second clock and a score, with the fixed step and the |
| 10 | controls under it, a room and an atlas of its own built as data, and no | |
| 11 | renderer yet to draw them. The engine in | |
| 10 | controls under it — and it is **drawn by the engine's renderer**: its own room, | |
| 11 | its own plates and its own tracer, out of an atlas and two meshes it painted | |
| 12 | and built itself. The engine in | |
| 12 | 13 | `src/engine/` is the point of the repository; the shooter is what proves it. |
| 13 | 14 | |
| 14 | 15 | sh |
| @@ -98,9 +99,11 @@ At `/range` the same controls drive the second game, on its own bindings: | ||
| 98 | 99 | <kbd>Shift</kbd> walk, <kbd>F</kbd> or the mouse fires, <kbd>R</kbd> reload, |
| 99 | 100 | <kbd>Enter</kbd> starts a thirty second run. Plates pop up down the lane, each |
| 100 | 101 | worth 100 to 200 points by how far away it stood; the panel keeps the score, the |
| 101 | clock and the accuracy, and the best of the visit. Until the renderer lands | |
| 102 | there is no room and no walking: a plate is a square on the 2D horizon, drawn | |
| 103 | from the angle it subtends, and you turn on the spot. | |
| 102 | clock and the accuracy, and the best of the visit. The room, the plates and the | |
| 103 | tracer are drawn by the engine's renderer; there is no walking yet, so you stand | |
| 104 | at the firing line and turn on the spot. A browser with no WebGL2 keeps the game | |
| 105 | on the 2D horizon it shipped with — a plate as a square drawn from the angle it | |
| 106 | subtends — and the panel says which of the two you are looking at. | |
| 104 | 107 | |
| 105 | 108 | ## Shape |
| 106 | 109 | |
| @@ -118,8 +121,11 @@ from the angle it subtends, and you turn on the spot. | ||
| 118 | 121 | a `m:ss` clock, drawn onto the seventeen members of a 2D context it uses and |
| 119 | 122 | holding no colour a game chose) and `renderer` (the WebGL2: two programs, an |
| 120 | 123 | atlas and named meshes uploaded once at construction, and one `Scene` of |
| 121 | instances and lines drawn per frame — it knows no game, no world and no | |
| 122 | model, and a canvas and a GL context are named in it only as types). | |
| 124 | instances and lines drawn per frame, each instance a mesh name with a place, a | |
| 125 | yaw, a size and a vertical squash — so one uploaded mesh draws at any size, | |
| 126 | which is how the range draws a plate of any half-width from the one cube it | |
| 127 | handed in — and it knows no game, no world and no model, with a canvas and a | |
| 128 | GL context named in it only as types). | |
| 123 | 129 | Three rules keep it a package, and a test holds each: the engine imports |
| 124 | 130 | nothing outside itself, it needs nothing from a browser to load, and |
| 125 | 131 | everything outside it comes in through one door. |
| @@ -239,12 +245,15 @@ from the angle it subtends, and you turn on the spot. | ||
| 239 | 245 | and the clock at zero refuses the trigger; and `boot.test.ts` boots the entry |
| 240 | 246 | itself against the stub page in `dom.ts` — a document of plain objects and a |
| 241 | 247 | clock the test moves — so a key, a button, a thumb stick, a locked mouse, a |
| 242 | canvas that gives no context, and a whole run played with the mouse (turn onto | |
| 243 | the blip the scene drew, click, watch the score move) are all checked with no | |
| 244 | browser in the room. `bundle.test.ts` then does the same to | |
| 248 | canvas that gives no context, a page with WebGL2 and a page without — the | |
| 249 | drawn one against a GL recorder in `dom.ts` that writes down what it was asked | |
| 250 | for, so the room, one draw per standing plate and the tracer are counted — and | |
| 251 | a whole run played with the mouse (turn onto the blip the fallback drew, click, | |
| 252 | watch the score move) are all checked with no browser in the room. `bundle.test.ts` then does the same to | |
| 245 | 253 | `static/range.<hash>.js`: the committed bundle is a build artifact kept in git, |
| 246 | 254 | and this is what checks that the file a browser downloads boots, sizes its |
| 247 | canvases, draws its ticks and answers a key — so a bundling mistake fails | |
| 255 | canvases, draws its ticks, answers a key and — given WebGL2 — uploads its | |
| 256 | atlas and draws the room — so a bundling mistake fails | |
| 248 | 257 | `bun test` rather than the preview. The range's rules are pinned the way the |
| 249 | 258 | shooter's round is: `pin.ts` plays one seeded thirty-second run through one |
| 250 | 259 | fixed script of shots and hashes every tick, against the digest committed in |
| @@ -320,8 +329,8 @@ from the angle it subtends, and you turn on the spot. | ||
| 320 | 329 | |
| 321 | 330 | | bundle | raw | ceiling | gzipped | ceiling | |
| 322 | 331 | | ------- | ------ | ------- | ------- | ------- | |
| 323 | | `app` | 69,711 | 75,400 | 32,139 | 34,900 | | |
| 324 | | `range` | 12,548 | 12,800 | 5,726 | 5,800 | | |
| 332 | | `app` | 70,576 | 75,400 | 32,862 | 34,900 | | |
| 333 | | `range` | 24,214 | 26,700 | 10,500 | 11,600 | | |
| 325 | 334 | |
| 326 | 335 | The ceilings are today's sizes plus about 10%. Gzip is measured at level 9 |
| 327 | 336 | and only at level 9, so the number does not move between a laptop and the |
scripts/build.test.ts +1 −1
| @@ -37,7 +37,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri | ||
| 37 | 37 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 38 | 38 | expect(ENTRIES).toEqual([ |
| 39 | 39 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } }, |
| 40 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 12_800, gzip: 5_800 } }, | |
| 40 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 26_700, gzip: 11_600 } }, | |
| 41 | 41 | ]); |
| 42 | 42 | }); |
| 43 | 43 |
scripts/build.ts +1 −1
| @@ -49,7 +49,7 @@ export type Entry = { name: string; entry: string; ceiling: Ceiling }; | ||
| 49 | 49 | */ |
| 50 | 50 | export const ENTRIES: Entry[] = [ |
| 51 | 51 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } }, |
| 52 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 12_800, gzip: 5_800 } }, | |
| 52 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 26_700, gzip: 11_600 } }, | |
| 53 | 53 | ]; |
| 54 | 54 | |
| 55 | 55 | /** |
src/engine/GUIDE.md +6 −4
| @@ -130,7 +130,7 @@ comes from one of these, in a fixed order. | ||
| 130 | 130 | |
| 131 | 131 | ts |
| 132 | 132 | // src/range/main.ts |
| 133 | if (intent.fire && !firing) shoot(run, EYE, angleVectors(yaw, pitch).forward); | |
| 133 | const shot = shoot(run, EYE, angleVectors(view.yaw, view.pitch).forward); | |
| 134 | 134 | |
| 135 | 135 | |
| 136 | 136 | ### trace |
| @@ -333,10 +333,12 @@ split. | ||
| 333 | 333 | | a host | a stub page in `src/client/stage.test-helper.ts` | a stub page in `src/range/dom.ts` | |
| 334 | 334 | | a bindings table | `src/client/binding.ts` — buy, scoreboard, weapon slots | `src/range/binding.ts` — a trigger and a start, nothing else | |
| 335 | 335 | | a tick rate | 64 Hz | 50 Hz | |
| 336 | | mesh recipes | `src/client/models.ts` | none: it draws no geometry yet | | |
| 337 | | texture recipes | `src/client/materials.ts` | none: the readout paints its own 2D | | |
| 336 | | mesh recipes | `src/client/models.ts` | `src/range/level.ts` — a room and a plate | | |
| 337 | | texture recipes | `src/client/materials.ts` | `src/range/paint.ts` — three 64² layers | | |
| 338 | 338 | | sound recipes | `src/client/audio.ts` | none yet | |
| 339 | | renderer assets | an atlas and eight meshes, from `src/client/main.ts` | none: it draws a 2D horizon in `src/range/readout.ts` | | |
| 339 | | renderer assets | an atlas and eight meshes, from `src/client/main.ts` | an atlas and two meshes, from `src/range/scene.ts` | | |
| 340 | | a frame as data | `src/client/scene.ts` | `src/range/scene.ts` | | |
| 341 | | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays | | |
| 340 | 342 | | the tuning | `src/client/physics.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` | |
| 341 | 343 | |
| 342 | 344 | ## What the engine deliberately does not know |
src/engine/renderer.test.ts +17
| @@ -189,6 +189,23 @@ test("two instances of one mesh bind that mesh's vertex array once", () => { | ||
| 189 | 189 | expect(vaos[0]).toBe(arrays[0] as Resource); |
| 190 | 190 | }); |
| 191 | 191 | |
| 192 | test("an instance stands at its own size, squashed by its own scale", () => { | |
| 193 | const r = renderer(); | |
| 194 | const two = scene("level"); | |
| 195 | // One mesh at two sizes: the first left alone, the second half as big and | |
| 196 | // ducked to half of that. `size` left out is 1, which is what the shooter | |
| 197 | // writes and what the first instance here does. | |
| 198 | (two.instances[1] as { size: number; scale: number }).size = 0.5; | |
| 199 | (two.instances[1] as { size: number; scale: number }).scale = 0.5; | |
| 200 | r.r.draw(two, 1 / 60); | |
| 201 | const models = frameCalls(r, "uniformMatrix4fv") | |
| 202 | .filter((c) => (c[1] as Resource).kind === "uModel") | |
| 203 | .map((c) => c[3] as Float32Array); | |
| 204 | // A yaw of zero puts the three scales on the diagonal, and nowhere else. | |
| 205 | expect([models[0]?.[0], models[0]?.[5], models[0]?.[10]]).toEqual([1, 1, 1]); | |
| 206 | expect([models[1]?.[0], models[1]?.[5], models[1]?.[10]]).toEqual([0.5, 0.5, 0.25]); | |
| 207 | }); | |
| 208 | ||
| 192 | 209 | test("a scene of two different meshes binds each of them once", () => { |
| 193 | 210 | const r = renderer(); |
| 194 | 211 | const two = scene("level"); |
src/engine/renderer.ts +20 −5
| @@ -95,11 +95,21 @@ type ViewModel = { mesh: string; offset: Vec3 }; | ||
| 95 | 95 | export type Camera = { pos: Vec3; yaw: number; pitch: number; fov: number }; |
| 96 | 96 | |
| 97 | 97 | /** |
| 98 | * One mesh, placed. `mesh` is the name the game handed that mesh in under, and | |
| 99 | * `scale` squashes it vertically — a duck, in the shooter — which is the only | |
| 100 | * scaling this renderer applies. | |
| 98 | * One mesh, placed. `mesh` is the name the game handed that mesh in under, | |
| 99 | * `size` is how big it stands — one mesh drawn at more than one size, which is | |
| 100 | * how the range draws plates of any half-width from the one cube it uploaded — | |
| 101 | * and `scale` squashes it vertically on top of that, a duck in the shooter. | |
| 102 | * Left out, `size` is 1: a game whose meshes are already the right size never | |
| 103 | * writes it. | |
| 101 | 104 | */ |
| 102 | export type Instance = { mesh: string; pos: Vec3; yaw: number; scale: number; tint: Tint }; | |
| 105 | export type Instance = { | |
| 106 | mesh: string; | |
| 107 | pos: Vec3; | |
| 108 | yaw: number; | |
| 109 | scale: number; | |
| 110 | tint: Tint; | |
| 111 | size?: number; | |
| 112 | }; | |
| 103 | 113 | |
| 104 | 114 | /** |
| 105 | 115 | * One frame, as plain data. A game fills one of these per frame and allocates |
| @@ -344,7 +354,12 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets | ||
| 344 | 354 | const instance = scene.instances[i]; |
| 345 | 355 | if (instance === undefined) break; |
| 346 | 356 | const buffers = buffersFor(instance.mesh); |
| 347 | gl.uniformMatrix4fv(u.model, false, modelMatrix(instance.pos, instance.yaw, 1, 1, instance.scale)); | |
| 357 | const size = instance.size ?? 1; | |
| 358 | gl.uniformMatrix4fv( | |
| 359 | u.model, | |
| 360 | false, | |
| 361 | modelMatrix(instance.pos, instance.yaw, size, size, size * instance.scale), | |
| 362 | ); | |
| 348 | 363 | gl.uniform3f(u.tint, instance.tint[0], instance.tint[1], instance.tint[2]); |
| 349 | 364 | if (buffers !== bound) { |
| 350 | 365 | gl.bindVertexArray(buffers.vao); |
src/pages/range.ts +19 −12
| @@ -4,10 +4,10 @@ | ||
| 4 | 4 | * and the footer; what is left here is the range's — its one overlay, its touch |
| 5 | 5 | * buttons and its prose. |
| 6 | 6 | * |
| 7 | * It says what the page is today — a clock, a score and plates to shoot, with | |
| 8 | * the engine's loop and controls under them — and what it is not yet, so nobody | |
| 9 | * arrives expecting the drawn room #13 will build on it. There is no database | |
| 10 | * on this page: the leaderboard and the terminal stay Counter-Strike's. | |
| 7 | * It says what the page is — a room, plates to shoot, a clock and a score, all | |
| 8 | * of it drawn by the engine's renderer — and what a browser with no WebGL2 | |
| 9 | * gets instead. There is no database on this page: the leaderboard and the | |
| 10 | * terminal stay Counter-Strike's. | |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { navFor } from "./games.ts"; |
| @@ -19,9 +19,11 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script | ||
| 19 | 19 | const NOTE = ` |
| 20 | 20 | <h2>The range: thirty seconds on the clock</h2> |
| 21 | 21 | <p>Capture the mouse, press <kbd>Enter</kbd>, and shoot the plates as they pop up: each one |
| 22 | is a box in space, traced by the engine, and the panel keeps the score, the clock and | |
| 23 | your accuracy. There is no room drawn yet — a plate is a square on the horizon, and you | |
| 24 | turn on the spot.</p> | |
| 22 | is a box in space, traced by the engine and drawn by it, and the panel keeps the score, | |
| 23 | the clock and your accuracy. You stand at the firing line and turn on the spot — there is | |
| 24 | no walking yet. On a browser with no WebGL2 the range falls back to the flat horizon it | |
| 25 | shipped with, plates as squares projected from the angle each subtends, and the same game | |
| 26 | plays on it.</p> | |
| 25 | 27 | <div class="row"><button type="button" class="primary" id="look">Capture the mouse</button></div> |
| 26 | 28 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · |
| 27 | 29 | <kbd>Shift</kbd> walk · <kbd>F</kbd> or the mouse fires · <kbd>R</kbd> reload · |
| @@ -64,16 +66,21 @@ export function rangePage(options: PageOptions): string { | ||
| 64 | 66 | bindings table, and a readout of what both say: ticks run, milliseconds a frame, which |
| 65 | 67 | actions are held, what movement they and the thumb stick add up to, and where you are |
| 66 | 68 | looking.</p> |
| 67 | <p>There is a game on top of that now, and no renderer under it. Press <kbd>Enter</kbd> and | |
| 69 | <p>There is a game on top of that, and the engine's renderer under it. Press <kbd>Enter</kbd> and | |
| 68 | 70 | thirty seconds start: plates pop up down the lane from a seeded spawn, each with its own |
| 69 | 71 | lifetime and worth 100 to 200 points depending on how far away it stood, and a click traces |
| 70 | 72 | a ray from your eye along your own view angles with the engine's own box trace — so a hit |
| 71 | 73 | is a hit in three dimensions, not a hit on a picture. The clock ends the run, stops the |
| 72 | 74 | trigger and reports the score, the hits, the shots and the accuracy; <kbd>Enter</kbd> |
| 73 | starts the next one, and the best of this visit is kept on the page and nowhere else. What | |
| 74 | still waits for the renderer is the seeing: a plate is drawn as a square on the 2D horizon, | |
| 75 | projected from the angle it subtends, and you cannot move — the room, the models and the | |
| 76 | walking arrive with the engine's renderer and its physics.</p> | |
| 75 | starts the next one, and the best of this visit is kept on the page and nowhere else.</p> | |
| 76 | <p>You can see it now, and by the same renderer the shooter uses: the room is one mesh built | |
| 77 | from this game's own numbers, every plate is one instance of one cube sized to its own box, | |
| 78 | the shot leaves a tracer for eighty milliseconds, and the atlas they are painted from is | |
| 79 | three 64-pixel layers of this game's own — not a line of the shooter's map, models or | |
| 80 | materials. What a frame looks like is plain data in <code>src/range/scene.ts</code>, read | |
| 81 | by a test with no browser in the room. Still missing is the walking: the eye stands at the | |
| 82 | firing line until the engine's physics lands. If your browser has no WebGL2, the flat | |
| 83 | horizon is still there and the panel says so.</p> | |
| 77 | 84 | <p>Nothing on this page is stored. There is no endpoint, no table and no cookie behind it: |
| 78 | 85 | the leaderboard, the database and the terminal belong to |
| 79 | 86 | <a href="/">${escape("cs")}</a>, which is the game next door.</p> |
src/range/boot.test.ts +96 −1
| @@ -17,7 +17,7 @@ const { boot } = await import("./main.ts"); | ||
| 17 | 17 | undoLoading(); |
| 18 | 18 | |
| 19 | 19 | /** A booted page, and the undo that puts the globals back. */ |
| 20 | function booted(options: { has2d?: boolean } = {}): { p: FakePage; undo: () => void } { | |
| 20 | function booted(options: { has2d?: boolean; webgl?: boolean } = {}): { p: FakePage; undo: () => void } { | |
| 21 | 21 | const p = page(options); |
| 22 | 22 | const undo = p.install(); |
| 23 | 23 | boot(); |
| @@ -262,3 +262,98 @@ test("the clock ends the run, the entry stops shooting and the best score stays" | ||
| 262 | 262 | undo(); |
| 263 | 263 | } |
| 264 | 264 | }); |
| 265 | ||
| 266 | /** How many plates the panel said were standing in the frame just drawn. */ | |
| 267 | const standing = (p: FakePage): number => | |
| 268 | Number(/(\d+) up/.exec([...hudTexts(p)].reverse().find((line) => line.includes(" up")) ?? "")?.[1]); | |
| 269 | ||
| 270 | /** Every GL call the scene canvas was given, in order. */ | |
| 271 | const glCalls = (p: FakePage): [string, ...unknown[]][] => | |
| 272 | (p.scene.gl?.calls ?? []) as [string, ...unknown[]][]; | |
| 273 | ||
| 274 | /** What one frame asked the GL context for: the calls made after `from`. */ | |
| 275 | const since = (p: FakePage, from: number): [string, ...unknown[]][] => glCalls(p).slice(from); | |
| 276 | ||
| 277 | const named = (calls: [string, ...unknown[]][], name: string): [string, ...unknown[]][] => | |
| 278 | calls.filter((call) => call[0] === name); | |
| 279 | ||
| 280 | test("a browser with WebGL2 is drawn in three dimensions, through the engine's renderer", () => { | |
| 281 | const { p, undo } = booted({ webgl: true }); | |
| 282 | try { | |
| 283 | // The range's own atlas, uploaded once at construction: three 64² layers, | |
| 284 | // not the shooter's nine at 128². | |
| 285 | const upload = named(glCalls(p), "texImage3D")[0]; | |
| 286 | expect(upload?.slice(4, 7)).toEqual([64, 64, 3]); | |
| 287 | // Its two meshes: five buffers each, plus the one the lines share. | |
| 288 | expect(named(glCalls(p), "createBuffer").length).toBe(2 * 5 + 1); | |
| 289 | ||
| 290 | const before = glCalls(p).length; | |
| 291 | p.step(20); | |
| 292 | // One draw, and it is the room: nothing is standing before a run starts. | |
| 293 | expect(named(since(p, before), "drawElements").length).toBe(1); | |
| 294 | // The canvas was sized by the renderer, and nothing 2D was drawn on it. | |
| 295 | expect([p.scene.width, p.scene.height]).toEqual([800, 450]); | |
| 296 | expect(p.scene.paint?.calls).toEqual([]); | |
| 297 | // The HUD is still the 2D panel above it, and it says which way it is drawn. | |
| 298 | expect(hudTexts(p)).toContain("tick 1 · 50 Hz fixed step"); | |
| 299 | expect(hudTexts(p)).toContain("drawn by the engine's renderer"); | |
| 300 | } finally { | |
| 301 | undo(); | |
| 302 | } | |
| 303 | }); | |
| 304 | ||
| 305 | test("every standing plate is one more draw, and a shot is a tracer for 80 ms", () => { | |
| 306 | const { p, undo } = booted({ webgl: true }); | |
| 307 | try { | |
| 308 | p.window.fire("keydown", { code: "Enter" }); | |
| 309 | p.step(20); | |
| 310 | p.window.fire("keyup", { code: "Enter" }); | |
| 311 | ||
| 312 | // One plate up: the room and the plate. | |
| 313 | let before = glCalls(p).length; | |
| 314 | p.step(20); | |
| 315 | expect(standing(p)).toBe(1); | |
| 316 | expect(named(since(p, before), "drawElements").length).toBe(2); | |
| 317 | // No shot yet, so no line was drawn at all. | |
| 318 | expect(named(since(p, before), "drawArrays")).toEqual([]); | |
| 319 | ||
| 320 | // Three plates up by the time the gap has run twice: three draws and the room. | |
| 321 | for (let i = 0; i < 70; i++) p.step(20); | |
| 322 | before = glCalls(p).length; | |
| 323 | p.step(20); | |
| 324 | expect(standing(p)).toBeGreaterThan(1); | |
| 325 | expect(named(since(p, before), "drawElements").length).toBe(1 + standing(p)); | |
| 326 | ||
| 327 | // A shot draws the one tracer, as two vertices of the scene's line buffer. | |
| 328 | p.window.fire("keydown", { code: "KeyF" }); | |
| 329 | before = glCalls(p).length; | |
| 330 | p.step(20); | |
| 331 | const drew = named(since(p, before), "drawArrays")[0]; | |
| 332 | expect(drew?.slice(2)).toEqual([0, 2]); | |
| 333 | ||
| 334 | // 80 ms later it has faded, and the frame draws no line at all. | |
| 335 | p.window.fire("keyup", { code: "KeyF" }); | |
| 336 | before = glCalls(p).length; | |
| 337 | p.step(100); | |
| 338 | expect(named(since(p, before), "drawArrays")).toEqual([]); | |
| 339 | } finally { | |
| 340 | undo(); | |
| 341 | } | |
| 342 | }); | |
| 343 | ||
| 344 | test("a browser with no WebGL2 keeps the game on the 2D horizon, and says so", () => { | |
| 345 | const { p, undo } = booted(); | |
| 346 | try { | |
| 347 | p.step(20); | |
| 348 | expect(p.scene.gl).toBeNull(); | |
| 349 | // The sky, the ground and the horizon, drawn the way this page always did. | |
| 350 | expect(p.scene.paint?.calls.some((call) => call[0] === "fillRect")).toBe(true); | |
| 351 | expect(hudTexts(p)).toContain("no WebGL2 here · the 2D horizon instead"); | |
| 352 | // And the game still plays: Enter still starts thirty seconds. | |
| 353 | p.window.fire("keydown", { code: "Enter" }); | |
| 354 | p.step(20); | |
| 355 | expect(hudTexts(p)).toContain("30.0 s left · 1 up"); | |
| 356 | } finally { | |
| 357 | undo(); | |
| 358 | } | |
| 359 | }); |
src/range/bundle.test.ts +20 −1
| @@ -46,7 +46,9 @@ const HAD_DOCUMENT = "document" in globalThis; | ||
| 46 | 46 | * itself on the way in — and hand back the page with the undo that puts the |
| 47 | 47 | * globals back. |
| 48 | 48 | */ |
| 49 | async function booted(options: { has2d?: boolean } = {}): Promise<{ p: FakePage; undo: () => void }> { | |
| 49 | async function booted( | |
| 50 | options: { has2d?: boolean; webgl?: boolean } = {}, | |
| 51 | ): Promise<{ p: FakePage; undo: () => void }> { | |
| 50 | 52 | const load = (loads += 1); |
| 51 | 53 | const copy = join(copies, `range.${load}.js`); |
| 52 | 54 | copyFileSync(bundlePath(), copy); |
| @@ -114,6 +116,23 @@ test("a bundled canvas with no context is a sentence on the page, not an excepti | ||
| 114 | 116 | } |
| 115 | 117 | }); |
| 116 | 118 | |
| 119 | test("the bundled entry draws the room and the plates when the browser has WebGL2", async () => { | |
| 120 | const { p, undo } = await booted({ webgl: true }); | |
| 121 | try { | |
| 122 | // The range's own atlas, uploaded once by the minified file: three 64² layers. | |
| 123 | const calls = (p.scene.gl?.calls ?? []) as [string, ...unknown[]][]; | |
| 124 | expect(calls.find((call) => call[0] === "texImage3D")?.slice(4, 7)).toEqual([64, 64, 3]); | |
| 125 | const before = calls.length; | |
| 126 | p.step(20); | |
| 127 | // The room, drawn, and no 2D fallback anywhere near the scene canvas. | |
| 128 | expect(calls.slice(before).filter((call) => call[0] === "drawElements").length).toBe(1); | |
| 129 | expect(p.scene.paint?.calls).toEqual([]); | |
| 130 | expect(hudTexts(p)).toContain("drawn by the engine's renderer"); | |
| 131 | } finally { | |
| 132 | undo(); | |
| 133 | } | |
| 134 | }); | |
| 135 | ||
| 117 | 136 | test("the stub page leaves nothing of itself behind", () => { |
| 118 | 137 | // Every boot above undid its install, so no other test inherits a document. |
| 119 | 138 | expect("document" in globalThis).toBe(HAD_DOCUMENT); |
src/range/dom.ts +71 −6
| @@ -4,7 +4,12 @@ | ||
| 4 | 4 | * can be booted and stepped in a test with no browser — the same trick |
| 5 | 5 | * `src/engine/controls.ts` plays with its `Listens` parameter, one level up. |
| 6 | 6 | * |
| 7 | * It ships in no bundle: only `boot.test.ts` imports it. | |
| 7 | * Two recorders hang off a canvas here: the 2D one the readout's panel draws | |
| 8 | * onto, and — when a test asks for `webgl` — a WebGL2 one that writes down every | |
| 9 | * call the engine's renderer makes, so the drawn game is checked by reading a | |
| 10 | * list rather than a screen. | |
| 11 | * | |
| 12 | * It ships in no bundle: only `boot.test.ts` and `bundle.test.ts` import it. | |
| 8 | 13 | */ |
| 9 | 14 | |
| 10 | 15 | import type { Paint } from "./readout.ts"; |
| @@ -70,25 +75,85 @@ export function recorder(): Recorder { | ||
| 70 | 75 | } as Recorder; |
| 71 | 76 | } |
| 72 | 77 | |
| 78 | /** | |
| 79 | * Every GL call the engine's renderer makes, written down in order, and every | |
| 80 | * enum it names — numbered, so a recorded call reads back. It is the same trick | |
| 81 | * the `Recorder` above plays for 2D and `renderer.test.ts` plays inside the | |
| 82 | * engine: not a fake GPU, only a list of what was asked for. A test reads it to | |
| 83 | * see that the room and the plates were drawn; nothing here checks a pixel. | |
| 84 | */ | |
| 85 | export type GlRecorder = Record<string, unknown> & { calls: [string, ...unknown[]][] }; | |
| 86 | ||
| 87 | const GL_ENUMS = [ | |
| 88 | "ARRAY_BUFFER", "BACK", "COLOR_BUFFER_BIT", "COMPILE_STATUS", "CULL_FACE", "DEPTH_BUFFER_BIT", | |
| 89 | "DEPTH_TEST", "DYNAMIC_DRAW", "ELEMENT_ARRAY_BUFFER", "FLOAT", "FRAGMENT_SHADER", "LINEAR", | |
| 90 | "LINEAR_MIPMAP_LINEAR", "LINES", "LINK_STATUS", "REPEAT", "RGBA", "RGBA8", "STATIC_DRAW", | |
| 91 | "TEXTURE0", "TEXTURE_2D_ARRAY", "TEXTURE_MAG_FILTER", "TEXTURE_MIN_FILTER", "TEXTURE_WRAP_S", | |
| 92 | "TEXTURE_WRAP_T", "TRIANGLES", "UNSIGNED_BYTE", "UNSIGNED_INT", "VERTEX_SHADER", | |
| 93 | ]; | |
| 94 | ||
| 95 | const GL_VOIDS = [ | |
| 96 | "shaderSource", "compileShader", "attachShader", "linkProgram", "bindTexture", "texImage3D", | |
| 97 | "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData", "bufferSubData", | |
| 98 | "enableVertexAttribArray", "vertexAttribPointer", "enable", "cullFace", "viewport", "clearColor", | |
| 99 | "clear", "useProgram", "activeTexture", "uniform1i", "uniform3f", "uniform3fv", | |
| 100 | "uniformMatrix4fv", "drawElements", "drawArrays", | |
| 101 | ]; | |
| 102 | ||
| 103 | export function glRecorder(): GlRecorder { | |
| 104 | const calls: [string, ...unknown[]][] = []; | |
| 105 | const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]); | |
| 106 | let made = 0; | |
| 107 | const hand = (kind: string) => (...args: unknown[]): { kind: string; id: number } => { | |
| 108 | calls.push([kind === "shader" ? "createShader" : `create${kind}`, ...args]); | |
| 109 | return { kind, id: (made += 1) }; | |
| 110 | }; | |
| 111 | const gl: GlRecorder = { | |
| 112 | calls, | |
| 113 | ...Object.fromEntries(GL_ENUMS.map((name, i) => [name, i + 1])), | |
| 114 | ...Object.fromEntries(GL_VOIDS.map((name) => [name, note(name)])), | |
| 115 | createShader: hand("shader"), | |
| 116 | createProgram: hand("Program"), | |
| 117 | createTexture: hand("Texture"), | |
| 118 | createBuffer: hand("Buffer"), | |
| 119 | createVertexArray: hand("VertexArray"), | |
| 120 | getUniformLocation: (_program: unknown, name: string) => ({ kind: name, id: 0 }), | |
| 121 | getShaderParameter: () => true, | |
| 122 | getProgramParameter: () => true, | |
| 123 | getShaderInfoLog: () => "", | |
| 124 | getProgramInfoLog: () => "", | |
| 125 | }; | |
| 126 | return gl; | |
| 127 | } | |
| 128 | ||
| 73 | 129 | export type FakeCanvas = FakeElement & { |
| 74 | 130 | clientWidth: number; |
| 75 | 131 | clientHeight: number; |
| 76 | 132 | width: number; |
| 77 | 133 | height: number; |
| 78 | 134 | paint: Recorder | null; |
| 135 | gl: GlRecorder | null; | |
| 79 | 136 | locks: number; |
| 80 | getContext(kind: string): Recorder | null; | |
| 137 | getContext(kind: string): Recorder | GlRecorder | null; | |
| 81 | 138 | requestPointerLock(): void; |
| 82 | 139 | }; |
| 83 | 140 | |
| 84 | /** A canvas whose context is a recorder, or nothing at all when `has2d` is false. */ | |
| 85 | export function canvas(id: string, has2d = true): FakeCanvas { | |
| 141 | /** | |
| 142 | * A canvas whose 2D context is a recorder — or nothing at all when `has2d` is | |
| 143 | * false — and whose WebGL2 context is a GL recorder only when `webgl` is asked | |
| 144 | * for. It is off by default because most of these tests are about the game and | |
| 145 | * not about the draw, and because a browser with no WebGL2 is a real browser | |
| 146 | * this page has to keep working on. | |
| 147 | */ | |
| 148 | export function canvas(id: string, has2d = true, webgl = false): FakeCanvas { | |
| 86 | 149 | return { |
| 87 | 150 | ...element(id), |
| 88 | 151 | clientWidth: 800, clientHeight: 450, width: 0, height: 0, |
| 89 | 152 | paint: has2d ? recorder() : null, |
| 153 | gl: webgl ? glRecorder() : null, | |
| 90 | 154 | locks: 0, |
| 91 | 155 | getContext(kind) { |
| 156 | if (kind === "webgl2") return this.gl; | |
| 92 | 157 | return kind === "2d" ? this.paint : null; |
| 93 | 158 | }, |
| 94 | 159 | requestPointerLock() { |
| @@ -121,8 +186,8 @@ export type FakePage = { | ||
| 121 | 186 | * The whole page the range's entry expects: the stage's canvases, the overlay, |
| 122 | 187 | * the touch pads and a clock that only moves when the test says so. |
| 123 | 188 | */ |
| 124 | export function page(options: { has2d?: boolean } = {}): FakePage { | |
| 125 | const scene = canvas("scene", options.has2d ?? true); | |
| 189 | export function page(options: { has2d?: boolean; webgl?: boolean } = {}): FakePage { | |
| 190 | const scene = canvas("scene", options.has2d ?? true, options.webgl ?? false); | |
| 126 | 191 | const hud = canvas("hud", options.has2d ?? true); |
| 127 | 192 | const buttons = [element("", "duck"), element("", "jump"), element("", "fire")]; |
| 128 | 193 | const elements = new Map<string, FakeElement>([ |
src/range/main.ts +65 −17
| @@ -1,10 +1,16 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The range's entry: the wiring of a second game, and only the wiring. The |
| 3 | 3 | * engine's loop runs a fixed step, the engine's controls collect the held |
| 4 | * actions and the view's turn, `targets.ts` is the game those two play, and | |
| 5 | * `readout.ts` draws the horizon, the plates and the score on the page's two | |
| 6 | * canvases. There is still no room, no movement and no WebGL here: the shooter | |
| 7 | * stands at the origin and turns on the spot, which is #3's and #5's to change. | |
| 4 | * actions and the view's turn, `targets.ts` is the game those two play, | |
| 5 | * `scene.ts` is the frame it looks like and the engine's renderer draws that — | |
| 6 | * the range's own atlas and its own two meshes, handed over once here — with | |
| 7 | * the readout's panel on the HUD canvas above it. | |
| 8 | * | |
| 9 | * A browser with no WebGL2 keeps the game rather than losing it: `createRenderer` | |
| 10 | * hands back null, the scene canvas takes a 2D context instead and | |
| 11 | * `drawHorizon` draws the plates as blips the way this page always did. There | |
| 12 | * is still no movement — the shooter stands at the origin and turns on the | |
| 13 | * spot, which is the physics' to change, not the renderer's. | |
| 8 | 14 | * |
| 9 | 15 | * Nothing outside `src/engine/` and this directory is imported, and |
| 10 | 16 | * `independence.test.ts` reads these files to make sure it stays that way. |
| @@ -12,8 +18,11 @@ | ||
| 12 | 18 | |
| 13 | 19 | import { RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, heldActions, readIntent } from "./binding.ts"; |
| 14 | 20 | import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts"; |
| 21 | import { buildRangeScene, createRangeScene, rangeAssets, type Trail } from "./scene.ts"; | |
| 15 | 22 | import { EYE, createRun, report, shoot, start, step } from "./targets.ts"; |
| 16 | import { angleVectors, attachButtons, clamp, createControls, createLoop } from "../engine/index.ts"; | |
| 23 | import { | |
| 24 | angleVectors, attachButtons, clamp, createControls, createLoop, createRenderer, | |
| 25 | } from "../engine/index.ts"; | |
| 17 | 26 | |
| 18 | 27 | /** The range's own step: 50 Hz, deliberately not the shooter's 64. */ |
| 19 | 28 | const TICK = 1 / 50; |
| @@ -36,15 +45,35 @@ export function boot(): void { | ||
| 36 | 45 | |
| 37 | 46 | // A canvas that gives no context is a sentence on the page, never a throw: |
| 38 | 47 | // everything else here — the text, the links, the terminal line — still works. |
| 39 | const sceneCtx = scene.getContext("2d") as Paint | null; | |
| 40 | 48 | const hudCtx = hud.getContext("2d") as Paint | null; |
| 41 | if (sceneCtx === null || hudCtx === null) { | |
| 49 | if (hudCtx === null) { | |
| 42 | 50 | note.innerHTML = |
| 43 | 51 | "<h2>No 2D canvas</h2><p>This browser gave the page no drawing context, so the readout cannot be" + |
| 44 | 52 | " drawn. The rest of the page still works.</p>"; |
| 45 | 53 | return; |
| 46 | 54 | } |
| 47 | 55 | |
| 56 | // The room, the plates and the tracer, drawn by the engine's renderer out of | |
| 57 | // this game's own atlas and its own two meshes. Null is a browser with no | |
| 58 | // WebGL2, and that browser gets the 2D horizon this page shipped with — the | |
| 59 | // same game, seen a flatter way, which is why the fallback is a context on | |
| 60 | // the same canvas and not a second entry. | |
| 61 | const renderer = createRenderer(scene, { | |
| 62 | ...rangeAssets(), | |
| 63 | devicePixelRatio: () => window.devicePixelRatio, | |
| 64 | }); | |
| 65 | const sceneCtx = renderer === null ? (scene.getContext("2d") as Paint | null) : null; | |
| 66 | if (renderer === null && sceneCtx === null) { | |
| 67 | note.innerHTML = | |
| 68 | "<h2>No 2D canvas</h2><p>This browser gave the page no drawing context, so the range cannot be" + | |
| 69 | " drawn. The rest of the page still works.</p>"; | |
| 70 | return; | |
| 71 | } | |
| 72 | const frameScene = createRangeScene(); | |
| 73 | // One trail, written over on every shot rather than replaced: `at` before the | |
| 74 | // page's clock started is a tracer that has already faded. | |
| 75 | const trail: Trail = { from: [EYE[0], EYE[1], EYE[2]], to: [0, 0, 0], at: -1 }; | |
| 76 | ||
| 48 | 77 | const controls = createControls( |
| 49 | 78 | { surface: scene, window, document }, |
| 50 | 79 | { keys: RANGE_KEYS, mouse: RANGE_MOUSE }, |
| @@ -56,10 +85,14 @@ export function boot(): void { | ||
| 56 | 85 | now: () => performance.now(), |
| 57 | 86 | }); |
| 58 | 87 | |
| 59 | let yaw = 0; | |
| 60 | let pitch = 0; | |
| 88 | // One view object for the whole run: the scene reads it, the readout prints | |
| 89 | // it and the trigger aims down it, and no frame makes a second one. | |
| 90 | const view = { yaw: 0, pitch: 0 }; | |
| 61 | 91 | let ticks = 0; |
| 62 | 92 | let frameMs = 16.7; |
| 93 | // Seconds since the page booted. The run's clock counts down, so a tracer | |
| 94 | // fading needs a clock that counts up. | |
| 95 | let time = 0; | |
| 63 | 96 | |
| 64 | 97 | // The game. It is seeded from the clock, and Enter gives it a new seed — so |
| 65 | 98 | // two runs are two different thirty seconds, and a run is still reproducible |
| @@ -108,9 +141,10 @@ export function boot(): void { | ||
| 108 | 141 | |
| 109 | 142 | function frame(elapsed: number): void { |
| 110 | 143 | frameMs += (elapsed * 1000 - frameMs) * 0.05; |
| 144 | time += elapsed; | |
| 111 | 145 | const [dx, dy] = controls.takeLook(); |
| 112 | yaw = wrapDegrees(yaw - dx); | |
| 113 | pitch = clamp(pitch + dy, -89, 89); | |
| 146 | view.yaw = wrapDegrees(view.yaw - dx); | |
| 147 | view.pitch = clamp(view.pitch + dy, -89, 89); | |
| 114 | 148 | const steps = loop.advance(elapsed); |
| 115 | 149 | ticks += steps; |
| 116 | 150 | |
| @@ -132,12 +166,25 @@ export function boot(): void { | ||
| 132 | 166 | if (wasLive && run.phase === "over") keepBest(); |
| 133 | 167 | |
| 134 | 168 | // One shot per press, down the barrel: the direction is the view's, and |
| 135 | // nothing else goes into it. | |
| 136 | if (intent.fire && !firing) shoot(run, EYE, angleVectors(yaw, pitch).forward); | |
| 169 | // nothing else goes into it. Where the bullet stopped is the tracer's far | |
| 170 | // end, which is the only thing the drawing takes from a shot. | |
| 171 | if (intent.fire && !firing) { | |
| 172 | const shot = shoot(run, EYE, angleVectors(view.yaw, view.pitch).forward); | |
| 173 | if (shot !== null) { | |
| 174 | trail.to[0] = shot.point[0]; | |
| 175 | trail.to[1] = shot.point[1]; | |
| 176 | trail.to[2] = shot.point[2]; | |
| 177 | trail.at = time; | |
| 178 | } | |
| 179 | } | |
| 137 | 180 | firing = intent.fire; |
| 138 | 181 | |
| 139 | const [sw, sh] = size(scene); | |
| 140 | drawHorizon(sceneCtx, { yaw, pitch }, sw, sh, { eye: EYE, targets: run.targets }); | |
| 182 | if (renderer !== null) { | |
| 183 | renderer.draw(buildRangeScene(run, view, trail, time, frameScene), elapsed); | |
| 184 | } else { | |
| 185 | const [sw, sh] = size(scene); | |
| 186 | drawHorizon(sceneCtx as Paint, view, sw, sh, { eye: EYE, targets: run.targets }); | |
| 187 | } | |
| 141 | 188 | const [hw, hh] = size(hud); |
| 142 | 189 | drawReadout( |
| 143 | 190 | hudCtx, |
| @@ -149,9 +196,10 @@ export function boot(): void { | ||
| 149 | 196 | frameMs, |
| 150 | 197 | held: heldActions(controls.held), |
| 151 | 198 | move: { forward: intent.forward, side: intent.side }, |
| 152 | yaw, | |
| 153 | pitch, | |
| 199 | yaw: view.yaw, | |
| 200 | pitch: view.pitch, | |
| 154 | 201 | locked: controls.locked, |
| 202 | drawn: renderer !== null, | |
| 155 | 203 | }, |
| 156 | 204 | hw, |
| 157 | 205 | hh, |
src/range/readout.test.ts +4 −1
| @@ -40,7 +40,7 @@ const RUN = { phase: "live" as const, clock: 12.34, score: 700, hits: 4, shots: | ||
| 40 | 40 | const READOUT: Readout = { |
| 41 | 41 | run: RUN, best: 1200, |
| 42 | 42 | hz: 50, ticks: 1234, frameMs: 16.66, held: ["forward", "fire"], |
| 43 | move: { forward: 1, side: -0.5 }, yaw: 45, pitch: -10, locked: true, | |
| 43 | move: { forward: 1, side: -0.5 }, yaw: 45, pitch: -10, locked: true, drawn: true, | |
| 44 | 44 | }; |
| 45 | 45 | |
| 46 | 46 | test("the panel leads with the score and the clock, then the loop and the controls", () => { |
| @@ -53,6 +53,9 @@ test("the panel leads with the score and the clock, then the loop and the contro | ||
| 53 | 53 | expect(lines[5]).toBe("move 1.00 forward · -0.50 side"); |
| 54 | 54 | expect(lines[6]).toBe("yaw 45.0° · pitch -10.0°"); |
| 55 | 55 | expect(lines[7]).toContain("Esc"); |
| 56 | // And which way the scene itself was drawn, whichever way that was. | |
| 57 | expect(lines[8]).toBe("drawn by the engine's renderer"); | |
| 58 | expect(readoutLines({ ...READOUT, drawn: false })[8]).toBe("no WebGL2 here · the 2D horizon instead"); | |
| 56 | 59 | }); |
| 57 | 60 | |
| 58 | 61 | test("the clock line says how to start a run, how long is left, and how it went", () => { |
src/range/readout.ts +16 −10
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | * What the range draws today: a horizon on the scene canvas so you can see | |
| 3 | * where you are looking, the standing targets projected onto it as blips, and a | |
| 4 | * panel on the HUD canvas with the score, the clock and what the loop and the | |
| 5 | * controls are doing. Plain 2D calls — there is no WebGL here, and no room and | |
| 6 | * no model: a blip is a target's box turned into an angle off the crosshair, | |
| 7 | * not a second renderer. The drawn room is #5's, and #13's after that. | |
| 2 | * The panel, and the fallback under it. `drawReadout` is the HUD every frame | |
| 3 | * draws whichever way the scene was drawn: the score, the clock and what the | |
| 4 | * loop and the controls are doing, over the engine's `overlay`. `drawHorizon` | |
| 5 | * is what the scene canvas gets on a browser with no WebGL2 — a horizon, a | |
| 6 | * compass and the standing plates projected onto it as blips from the angle | |
| 7 | * each subtends — so a browser that cannot draw the room keeps the game it had | |
| 8 | * rather than losing it. With WebGL2 the room, the plates and the tracer are | |
| 9 | * `scene.ts` through the engine's renderer, and none of the 2D below runs. | |
| 8 | 10 | * |
| 9 | 11 | * It takes the calls it makes as a parameter, typed by exactly those calls, so |
| 10 | 12 | * a stub records them in a test and a real `CanvasRenderingContext2D` fits |
| @@ -42,6 +44,8 @@ export type Readout = View & { | ||
| 42 | 44 | move: { forward: number; side: number }; |
| 43 | 45 | /** Is the pointer locked to the stage? */ |
| 44 | 46 | locked: boolean; |
| 47 | /** Is the room being drawn, or is this browser on the 2D horizon instead? */ | |
| 48 | drawn: boolean; | |
| 45 | 49 | }; |
| 46 | 50 | |
| 47 | 51 | const SKY = "#101826"; |
| @@ -82,6 +86,7 @@ export function readoutLines(r: Readout): string[] { | ||
| 82 | 86 | `move ${r.move.forward.toFixed(2)} forward · ${r.move.side.toFixed(2)} side`, |
| 83 | 87 | `yaw ${wrapDegrees(r.yaw).toFixed(1)}° · pitch ${clamp(r.pitch, -89, 89).toFixed(1)}°`, |
| 84 | 88 | r.locked ? "mouse captured · Esc releases it" : "click the view, or drag it on a touch screen", |
| 89 | r.drawn ? "drawn by the engine's renderer" : "no WebGL2 here · the 2D horizon instead", | |
| 85 | 90 | ]; |
| 86 | 91 | } |
| 87 | 92 | |
| @@ -129,10 +134,11 @@ export function blips(view: View, sight: Sight, width: number, height: number): | ||
| 129 | 134 | } |
| 130 | 135 | |
| 131 | 136 | /** |
| 132 | * The scene: sky, ground, the horizon where the pitch puts it, a compass tick | |
| 133 | * every 15° of yaw, and the standing targets as squares on it. It is a | |
| 134 | * placeholder for the room #5 draws, and every part of it comes from the same | |
| 135 | * two angles the panel prints, so what you see and what it says cannot disagree. | |
| 137 | * The fallback scene: sky, ground, the horizon where the pitch puts it, a | |
| 138 | * compass tick every 15° of yaw, and the standing targets as squares on it. | |
| 139 | * Every part of it comes from the same two angles the panel prints, so what you | |
| 140 | * see and what it says cannot disagree — and a browser with no WebGL2 can still | |
| 141 | * find a plate, shoot it and read the score. | |
| 136 | 142 | */ |
| 137 | 143 | export function drawHorizon( |
| 138 | 144 | paint: Paint, |
src/range/scene.test.ts added +182
| @@ -0,0 +1,182 @@ | ||
| 1 | /** | |
| 2 | * The range's frame, read as data. Every expectation here is a number in a | |
| 3 | * scene object — where the room is, where a plate stands and how big, which | |
| 4 | * vertices the tracer wrote — so the whole drawn game is checked with no | |
| 5 | * canvas, no GL and no browser anywhere near it. | |
| 6 | * | |
| 7 | * The plates are built by hand rather than played out of a seeded run: a test | |
| 8 | * about drawing should say where the plate is, not ask `targets.ts` where it | |
| 9 | * put one. `targets.test.ts` and `pin.test.ts` are where the game itself lives. | |
| 10 | */ | |
| 11 | ||
| 12 | import { expect, test } from "bun:test"; | |
| 13 | import { | |
| 14 | MESH, PLATE_HALF, RANGE_SKY, TRACER_LIFE, buildRangeScene, createRangeScene, rangeAssets, | |
| 15 | rangeMeshes, type Trail, | |
| 16 | } from "./scene.ts"; | |
| 17 | import { EYE, PLATE_MAT, createRun, type Run, type Target } from "./targets.ts"; | |
| 18 | ||
| 19 | /** A plate standing at `at`, with `half` its half-width and `left` of its life. */ | |
| 20 | function plate(id: number, at: [number, number, number], half = PLATE_HALF, left = 1): Target { | |
| 21 | return { | |
| 22 | id, | |
| 23 | box: { | |
| 24 | min: [at[0] - half, at[1] - half, at[2] - half], | |
| 25 | max: [at[0] + half, at[1] + half, at[2] + half], | |
| 26 | mat: PLATE_MAT, | |
| 27 | }, | |
| 28 | life: 2 * left, | |
| 29 | span: 2, | |
| 30 | points: 100 + id, | |
| 31 | distance: Math.hypot(at[0] - EYE[0], at[1] - EYE[1], at[2] - EYE[2]), | |
| 32 | }; | |
| 33 | } | |
| 34 | ||
| 35 | /** A live run standing exactly these plates and nothing else. */ | |
| 36 | function standing(targets: Target[]): Run { | |
| 37 | const run = createRun(1); | |
| 38 | run.phase = "live"; | |
| 39 | run.targets = targets; | |
| 40 | return run; | |
| 41 | } | |
| 42 | ||
| 43 | const VIEW = { yaw: 0, pitch: 0 }; | |
| 44 | ||
| 45 | /** The live instances of a scene: the pool past `instanceCount` is last frame's. */ | |
| 46 | const live = (scene: ReturnType<typeof createRangeScene>) => | |
| 47 | scene.instances.slice(0, scene.instanceCount); | |
| 48 | ||
| 49 | test("the room is the first instance, and it is drawn with nothing standing", () => { | |
| 50 | const scene = buildRangeScene(standing([]), VIEW, null, 0, createRangeScene()); | |
| 51 | expect(scene.instanceCount).toBe(1); | |
| 52 | const room = live(scene)[0]; | |
| 53 | expect(room?.mesh).toBe(MESH.ROOM); | |
| 54 | expect(room?.pos).toEqual([0, 0, 0]); | |
| 55 | expect(room?.size).toBe(1); | |
| 56 | // The eye, the angles and the sky are this game's, written every frame. | |
| 57 | expect(scene.camera.pos).toEqual([EYE[0], EYE[1], EYE[2]]); | |
| 58 | expect(scene.sky).toEqual(RANGE_SKY); | |
| 59 | // Indoors and dark: nothing here is the shooter's pale outdoor horizon. | |
| 60 | expect(scene.sky.every((channel) => channel < 0.2)).toBe(true); | |
| 61 | }); | |
| 62 | ||
| 63 | test("three standing plates are three instances, each at its own place", () => { | |
| 64 | const places: [number, number, number][] = [[400, -120, 60], [800, 0, 96], [1200, 300, 40]]; | |
| 65 | const run = standing(places.map((at, i) => plate(i + 1, at))); | |
| 66 | const scene = buildRangeScene(run, VIEW, null, 0, createRangeScene()); | |
| 67 | expect(scene.instanceCount).toBe(4); | |
| 68 | const drawn = live(scene); | |
| 69 | // The room first, then the plates in the order the run has them. | |
| 70 | expect(drawn.map((instance) => instance.mesh)).toEqual([MESH.ROOM, MESH.PLATE, MESH.PLATE, MESH.PLATE]); | |
| 71 | expect(drawn.slice(1).map((instance) => [...instance.pos])).toEqual(places); | |
| 72 | expect(drawn.slice(1).every((instance) => instance.size === 1)).toBe(true); | |
| 73 | }); | |
| 74 | ||
| 75 | test("a plate stands at the size its box has, whatever the mesh was built at", () => { | |
| 76 | const run = standing([plate(1, [500, 0, 64], PLATE_HALF * 2), plate(2, [600, 0, 64], PLATE_HALF / 2)]); | |
| 77 | const drawn = live(buildRangeScene(run, VIEW, null, 0, createRangeScene())); | |
| 78 | expect(drawn[1]?.size).toBe(2); | |
| 79 | expect(drawn[2]?.size).toBe(0.5); | |
| 80 | // Vertically it is not squashed: that scale is a duck, and nothing here ducks. | |
| 81 | expect(drawn.every((instance) => instance.scale === 1)).toBe(true); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a plate that expired is not in the scene at all", () => { | |
| 85 | const run = standing([plate(1, [400, 0, 60])]); | |
| 86 | expect(buildRangeScene(run, VIEW, null, 0, createRangeScene()).instanceCount).toBe(2); | |
| 87 | // What `step` does when a lifetime runs out: the plate is gone from the run. | |
| 88 | run.targets = []; | |
| 89 | const scene = buildRangeScene(run, VIEW, null, 0, createRangeScene()); | |
| 90 | expect(scene.instanceCount).toBe(1); | |
| 91 | expect(live(scene).map((instance) => instance.mesh)).toEqual([MESH.ROOM]); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a plate most of the way through its life is drawn dimmer than a fresh one", () => { | |
| 95 | const run = standing([plate(1, [400, 0, 60], PLATE_HALF, 1), plate(2, [500, 0, 60], PLATE_HALF, 0.2)]); | |
| 96 | const drawn = live(buildRangeScene(run, VIEW, null, 0, createRangeScene())); | |
| 97 | const fresh = drawn[1]?.tint as number[]; | |
| 98 | const going = drawn[2]?.tint as number[]; | |
| 99 | expect(going.every((channel, i) => channel < (fresh[i] as number))).toBe(true); | |
| 100 | // Indoors, with the engine's sun overhead: the faces you shoot at are lit by | |
| 101 | // the tint or by nothing, so a standing plate is tinted above one. | |
| 102 | expect(fresh.every((channel) => channel > 1)).toBe(true); | |
| 103 | expect((drawn[0]?.tint as number[]).every((channel) => channel > 1)).toBe(true); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a shot is two line vertices while it is fresh, and none once it is not", () => { | |
| 107 | const run = standing([]); | |
| 108 | const trail: Trail = { from: [EYE[0], EYE[1], EYE[2]], to: [1200, 40, 70], at: 10 }; | |
| 109 | const scene = createRangeScene(); | |
| 110 | ||
| 111 | // 40 ms old: one line, from the eye to where the bullet stopped. | |
| 112 | buildRangeScene(run, VIEW, trail, 10.04, scene); | |
| 113 | expect(scene.lineCount).toBe(2); | |
| 114 | expect([...scene.lines.slice(0, 3)]).toEqual([EYE[0], EYE[1], EYE[2]]); | |
| 115 | expect([...scene.lines.slice(6, 9)]).toEqual([1200, 40, 70]); | |
| 116 | ||
| 117 | // 120 ms old is past the 80 ms it is drawn for: nothing. | |
| 118 | buildRangeScene(run, VIEW, trail, 10.12, scene); | |
| 119 | expect(scene.lineCount).toBe(0); | |
| 120 | // And the moment it was taken is drawn, while a shot in the future is not. | |
| 121 | buildRangeScene(run, VIEW, trail, 10, scene); | |
| 122 | expect(scene.lineCount).toBe(2); | |
| 123 | buildRangeScene(run, VIEW, trail, 10 + TRACER_LIFE, scene); | |
| 124 | expect(scene.lineCount).toBe(0); | |
| 125 | buildRangeScene(run, VIEW, null, 10.04, scene); | |
| 126 | expect(scene.lineCount).toBe(0); | |
| 127 | }); | |
| 128 | ||
| 129 | test("the tracer fades over the 80 ms rather than blinking out", () => { | |
| 130 | const trail: Trail = { from: [0, 0, 64], to: [1000, 0, 64], at: 0 }; | |
| 131 | const scene = createRangeScene(); | |
| 132 | const blue = (age: number): number => { | |
| 133 | buildRangeScene(standing([]), VIEW, trail, age, scene); | |
| 134 | return scene.lines[5] as number; | |
| 135 | }; | |
| 136 | expect(blue(0)).toBeCloseTo(0.6, 6); | |
| 137 | expect(blue(TRACER_LIFE / 2)).toBeCloseTo(0.3, 6); | |
| 138 | }); | |
| 139 | ||
| 140 | test("the view is the camera's, and nothing else moves the eye", () => { | |
| 141 | const scene = buildRangeScene(standing([]), { yaw: -37.5, pitch: 12.25 }, null, 0, createRangeScene()); | |
| 142 | expect([scene.camera.yaw, scene.camera.pitch]).toEqual([-37.5, 12.25]); | |
| 143 | expect(scene.camera.fov).toBeGreaterThan(0); | |
| 144 | }); | |
| 145 | ||
| 146 | test("every mesh a scene names is one the renderer was handed", () => { | |
| 147 | const run = standing([plate(1, [400, 0, 60]), plate(2, [900, 100, 90])]); | |
| 148 | const scene = buildRangeScene(run, VIEW, { from: EYE, to: [900, 0, 60], at: 0 }, 0, createRangeScene()); | |
| 149 | const handed = Object.keys(rangeMeshes()); | |
| 150 | expect(handed.sort()).toEqual([MESH.PLATE, MESH.ROOM].sort()); | |
| 151 | for (const instance of live(scene)) expect(handed).toContain(instance.mesh); | |
| 152 | expect(scene.viewModel).toBeNull(); // no gun model in this game: nothing to name | |
| 153 | }); | |
| 154 | ||
| 155 | test("the assets are this game's own atlas and its two meshes", () => { | |
| 156 | const assets = rangeAssets(); | |
| 157 | expect(assets.atlas.bytes.length).toBe(assets.atlas.size * assets.atlas.size * 4 * assets.atlas.layers); | |
| 158 | // Small on purpose: three 64² layers, not the shooter's nine at 128². | |
| 159 | expect([assets.atlas.size, assets.atlas.layers]).toEqual([64, 3]); | |
| 160 | expect(Object.keys(assets.meshes).sort()).toEqual([MESH.PLATE, MESH.ROOM].sort()); | |
| 161 | expect(assets.sky).toEqual(RANGE_SKY); | |
| 162 | for (const mesh of Object.values(assets.meshes)) expect(mesh.index.length).toBeGreaterThan(0); | |
| 163 | }); | |
| 164 | ||
| 165 | test("a frame allocates nothing: the pool and the buffer are the ones from before", () => { | |
| 166 | const scene = createRangeScene(); | |
| 167 | const busy = standing([plate(1, [400, 0, 60]), plate(2, [900, 100, 90]), plate(3, [1100, -200, 40])]); | |
| 168 | buildRangeScene(busy, VIEW, null, 0, scene); | |
| 169 | const pool = scene.instances; | |
| 170 | const objects = [...pool]; | |
| 171 | const lines = scene.lines; | |
| 172 | ||
| 173 | // A quieter frame, then a busy one again: the pool grew to the busiest frame | |
| 174 | // and then stopped, and the instance objects are the same objects. | |
| 175 | buildRangeScene(standing([]), VIEW, null, 0, scene); | |
| 176 | expect(scene.instanceCount).toBe(1); | |
| 177 | buildRangeScene(busy, VIEW, { from: EYE, to: [900, 0, 60], at: 0 }, 0, scene); | |
| 178 | expect(scene.instances).toBe(pool); | |
| 179 | expect(scene.instances.length).toBe(objects.length); | |
| 180 | expect([...scene.instances]).toEqual(objects); | |
| 181 | expect(scene.lines).toBe(lines); | |
| 182 | }); |
src/range/scene.ts added +197
| @@ -0,0 +1,197 @@ | ||
| 1 | /** | |
| 2 | * The range's frame, as data: the room, the plates standing in it and the | |
| 3 | * tracer of the last shot, in the engine's `Scene` and nothing else. It is the | |
| 4 | * second game's answer to `src/client/scene.ts`, written without reading it — | |
| 5 | * nothing under `src/client/` is imported here, and `independence.test.ts` | |
| 6 | * fails the day one is. | |
| 7 | * | |
| 8 | * What goes in is a `Run` from `targets.ts`, where the view is pointing, and | |
| 9 | * how many seconds the page has been up; what comes out is where every mesh | |
| 10 | * stands, which two line vertices the tracer is and what this game's horizon | |
| 11 | * is. No canvas, no GL, no colour a renderer chose: `scene.test.ts` reads every | |
| 12 | * frame below with no browser in the room. | |
| 13 | * | |
| 14 | * Nothing is allocated in a frame, as the shooter's scene established: | |
| 15 | * `createRangeScene()` makes the one scene, the one instance pool and the one | |
| 16 | * line buffer the page ever needs, and `buildRangeScene` writes into them. | |
| 17 | */ | |
| 18 | ||
| 19 | import { type Mesh, type RendererAssets, type Scene, type Vec3 } from "../engine/index.ts"; | |
| 20 | import { roomMesh, targetMesh } from "./level.ts"; | |
| 21 | import { RANGE_LAYERS, RANGE_TEX_SIZE, rangeAtlas } from "./paint.ts"; | |
| 22 | import { EYE, STANDARD, type Run } from "./targets.ts"; | |
| 23 | ||
| 24 | export type Tint = [number, number, number]; | |
| 25 | ||
| 26 | /** | |
| 27 | * The two meshes this game draws, under the names its instances ask for. A | |
| 28 | * scene may name nothing else, and `createRenderer` is handed exactly these. | |
| 29 | */ | |
| 30 | export const MESH = { ROOM: "room", PLATE: "plate" } as const; | |
| 31 | ||
| 32 | /** | |
| 33 | * Indoors, and the range's own: a lit room has no sky, so the frame clears to | |
| 34 | * the dark the far wall fades into rather than to the shooter's pale desert. | |
| 35 | */ | |
| 36 | export const RANGE_SKY: Tint = [0.05, 0.06, 0.08]; | |
| 37 | ||
| 38 | /** The range's field of view, horizontally. Its own number, like its tick rate. */ | |
| 39 | export const RANGE_FOV = 90; | |
| 40 | ||
| 41 | /** How long the tracer of a shot is drawn for, in seconds. */ | |
| 42 | export const TRACER_LIFE = 0.08; | |
| 43 | ||
| 44 | /** | |
| 45 | * Line vertices one frame may draw. One tracer is two of them and there is one | |
| 46 | * shot at a time, so this buffer is 48 bytes and never grows. | |
| 47 | */ | |
| 48 | export const LINE_CAPACITY = 2; | |
| 49 | ||
| 50 | /** | |
| 51 | * The half-width the plate mesh is built at. Every plate is drawn from that one | |
| 52 | * cube, sized by `Instance.size` to the half-width its box actually has — so a | |
| 53 | * `Rules` with bigger plates draws bigger plates without a second mesh. | |
| 54 | */ | |
| 55 | export const PLATE_HALF = STANDARD.half; | |
| 56 | ||
| 57 | /** | |
| 58 | * The engine's frame with this game's horizon always written: the range is | |
| 59 | * indoors and its sky never changes, so it is named in every frame rather than | |
| 60 | * left to the renderer's default. | |
| 61 | */ | |
| 62 | export type RangeScene = Scene & { sky: Tint }; | |
| 63 | ||
| 64 | /** Where the view points. Degrees, the engine's way round: pitch is positive down. */ | |
| 65 | export type View = { yaw: number; pitch: number }; | |
| 66 | ||
| 67 | /** | |
| 68 | * The last shot, for as long as it is worth drawing: from the eye to wherever | |
| 69 | * the bullet stopped, and the page's seconds it was taken at. One of these is | |
| 70 | * kept by the entry and written over on every trigger pull, never replaced. | |
| 71 | */ | |
| 72 | export type Trail = { from: Vec3; to: Vec3; at: number }; | |
| 73 | ||
| 74 | /** | |
| 75 | * The range is indoors and the engine's sun is fixed overhead, so the faces you | |
| 76 | * shoot at — every wall and every plate front, all of them facing back down the | |
| 77 | * lane — catch only the ambient. A tint above one is this game lighting its own | |
| 78 | * room with the one knob the renderer gives it, which is where that decision | |
| 79 | * belongs: the engine holds no colour either game chose. | |
| 80 | */ | |
| 81 | const ROOM_LIT: Tint = [1.3, 1.3, 1.35]; | |
| 82 | /** A plate that just popped up: bright enough to find at 1,200 units. */ | |
| 83 | const PLATE_LIT: Tint = [1.8, 1.7, 1.6]; | |
| 84 | /** A plate most of the way through its life, dimmed so a run has a visible rhythm. */ | |
| 85 | const GOING: Tint = [0.9, 0.7, 0.55]; | |
| 86 | const ORIGIN: Vec3 = [0, 0, 0]; | |
| 87 | /** The one vector a frame writes a plate's centre into: allocation-free, as #47 asked. */ | |
| 88 | const PLACE: Vec3 = [0, 0, 0]; | |
| 89 | ||
| 90 | /** Past this fraction of its life a plate is drawn dim: the readout's 0.35, once. */ | |
| 91 | const FADING = 0.35; | |
| 92 | ||
| 93 | /** The meshes `main.ts` hands the renderer, by the names the scene above uses. */ | |
| 94 | export const rangeMeshes = (): Record<string, Mesh> => ({ | |
| 95 | [MESH.ROOM]: roomMesh(), | |
| 96 | [MESH.PLATE]: targetMesh(PLATE_HALF), | |
| 97 | }); | |
| 98 | ||
| 99 | /** | |
| 100 | * Everything this game brings to a renderer: its own atlas at its own size and | |
| 101 | * layer count, its own two meshes and its own horizon. The pixel ratio is the | |
| 102 | * page's, so the entry adds it — that is the one thing in a renderer's assets | |
| 103 | * that is a browser's and not a game's. | |
| 104 | */ | |
| 105 | export const rangeAssets = (): Omit<RendererAssets, "devicePixelRatio"> => ({ | |
| 106 | atlas: { bytes: rangeAtlas(), size: RANGE_TEX_SIZE, layers: RANGE_LAYERS }, | |
| 107 | meshes: rangeMeshes(), | |
| 108 | sky: RANGE_SKY, | |
| 109 | }); | |
| 110 | ||
| 111 | /** The one scene the page keeps, with the one buffer every frame refills. */ | |
| 112 | export function createRangeScene(): RangeScene { | |
| 113 | return { | |
| 114 | camera: { pos: [EYE[0], EYE[1], EYE[2]], yaw: 0, pitch: 0, fov: RANGE_FOV }, | |
| 115 | sky: [RANGE_SKY[0], RANGE_SKY[1], RANGE_SKY[2]], | |
| 116 | instances: [], | |
| 117 | instanceCount: 0, | |
| 118 | lines: new Float32Array(LINE_CAPACITY * 6), | |
| 119 | lineCount: 0, | |
| 120 | viewModel: null, | |
| 121 | }; | |
| 122 | } | |
| 123 | ||
| 124 | /** Take the next instance out of the pool — growing it once, never per frame. */ | |
| 125 | function place(scene: RangeScene, mesh: string, pos: Vec3, size: number, tint: Tint): void { | |
| 126 | let at = scene.instances[scene.instanceCount]; | |
| 127 | if (at === undefined) { | |
| 128 | at = { mesh, pos: [0, 0, 0], yaw: 0, scale: 1, size: 1, tint: [1, 1, 1] }; | |
| 129 | scene.instances.push(at); | |
| 130 | } | |
| 131 | at.mesh = mesh; | |
| 132 | at.pos[0] = pos[0]; at.pos[1] = pos[1]; at.pos[2] = pos[2]; | |
| 133 | at.yaw = 0; | |
| 134 | at.scale = 1; | |
| 135 | at.size = size; | |
| 136 | at.tint[0] = tint[0]; at.tint[1] = tint[1]; at.tint[2] = tint[2]; | |
| 137 | scene.instanceCount += 1; | |
| 138 | } | |
| 139 | ||
| 140 | /** One line vertex, or nothing at all once the frame's budget is spent. */ | |
| 141 | function push(scene: RangeScene, x: number, y: number, z: number, r: number, g: number, b: number): void { | |
| 142 | if (scene.lineCount >= LINE_CAPACITY) return; | |
| 143 | const i = scene.lineCount * 6; | |
| 144 | scene.lines[i] = x; scene.lines[i + 1] = y; scene.lines[i + 2] = z; | |
| 145 | scene.lines[i + 3] = r; scene.lines[i + 4] = g; scene.lines[i + 5] = b; | |
| 146 | scene.lineCount += 1; | |
| 147 | } | |
| 148 | ||
| 149 | /** | |
| 150 | * Fill `into` with what this run looks like from this view, and hand the same | |
| 151 | * object back. The order is the order it is drawn, and the room is always first | |
| 152 | * — the shooter puts its level first for the same reason, so a game reading | |
| 153 | * either scene finds the world before the things standing in it. | |
| 154 | * | |
| 155 | * `now` is the page's seconds, which only the tracer's fade reads: the run's | |
| 156 | * own clock counts down and would fade a shot backwards. | |
| 157 | */ | |
| 158 | export function buildRangeScene( | |
| 159 | run: Run, | |
| 160 | view: View, | |
| 161 | trail: Trail | null, | |
| 162 | now: number, | |
| 163 | into: RangeScene, | |
| 164 | ): RangeScene { | |
| 165 | into.camera.pos[0] = EYE[0]; | |
| 166 | into.camera.pos[1] = EYE[1]; | |
| 167 | into.camera.pos[2] = EYE[2]; | |
| 168 | into.camera.yaw = view.yaw; | |
| 169 | into.camera.pitch = view.pitch; | |
| 170 | into.camera.fov = RANGE_FOV; | |
| 171 | const sky = into.sky; | |
| 172 | sky[0] = RANGE_SKY[0]; sky[1] = RANGE_SKY[1]; sky[2] = RANGE_SKY[2]; | |
| 173 | ||
| 174 | into.instanceCount = 0; | |
| 175 | place(into, MESH.ROOM, ORIGIN, 1, ROOM_LIT); | |
| 176 | for (const target of run.targets) { | |
| 177 | const box = target.box; | |
| 178 | const half = (box.max[0] - box.min[0]) / 2; | |
| 179 | PLACE[0] = (box.min[0] + box.max[0]) / 2; | |
| 180 | PLACE[1] = (box.min[1] + box.max[1]) / 2; | |
| 181 | PLACE[2] = (box.min[2] + box.max[2]) / 2; | |
| 182 | const left = target.span > 0 ? target.life / target.span : 0; | |
| 183 | place(into, MESH.PLATE, PLACE, half / PLATE_HALF, left > FADING ? PLATE_LIT : GOING); | |
| 184 | } | |
| 185 | ||
| 186 | // --- the tracer: two vertices, from the eye to wherever the bullet stopped | |
| 187 | into.lineCount = 0; | |
| 188 | if (trail !== null) { | |
| 189 | const age = now - trail.at; | |
| 190 | if (age >= 0 && age < TRACER_LIFE) { | |
| 191 | const fade = 1 - age / TRACER_LIFE; | |
| 192 | push(into, trail.from[0], trail.from[1], trail.from[2], 1, 0.92, 0.6 * fade); | |
| 193 | push(into, trail.to[0], trail.to[1], trail.to[2], 1, 0.86, 0.35 * fade); | |
| 194 | } | |
| 195 | } | |
| 196 | return into; | |
| 197 | } |
static/app.0c26049cd8.js added +58
Diff not shown — a line 58,997 characters wide (limit 2,000). +58
View filestatic/app.a3f495de96.js deleted −58
Diff not shown — a line 58,979 characters wide (limit 2,000). −58
View filestatic/range.81ebd406a7.js added +58
Diff not shown — a line 14,645 characters wide (limit 2,000). +58
View filestatic/range.925e79efd7.js deleted −1
Diff not shown — a line 12,533 characters wide (limit 2,000). −1
View file
new above
offline