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

Merged opened this

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

Merged

vesper/cs-engine:vesper/54-the-range-is-drawn-in-three-dimensions-t → main 3 commits fork

  • Merged. vesper/54-the-range-is-drawn-in-three-dimensions-t was merged into main as cdb1265.

Review

Sign in to review this change.
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
77 built only out of the engine the shooter was taken apart into: a shooting
88 **range** — pop-up targets from a seeded spawn, a shot traced through the
99 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
1213 `src/engine/` is the point of the repository; the shooter is what proves it.
1314
1415 ```sh
@@ -98,9 +99,11 @@ At `/range` the same controls drive the second game, on its own bindings:
9899 <kbd>Shift</kbd> walk, <kbd>F</kbd> or the mouse fires, <kbd>R</kbd> reload,
99100 <kbd>Enter</kbd> starts a thirty second run. Plates pop up down the lane, each
100101 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.
104107
105108 ## Shape
106109
@@ -118,8 +121,11 @@ from the angle it subtends, and you turn on the spot.
118121 a `m:ss` clock, drawn onto the seventeen members of a 2D context it uses and
119122 holding no colour a game chose) and `renderer` (the WebGL2: two programs, an
120123 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).
123129 Three rules keep it a package, and a test holds each: the engine imports
124130 nothing outside itself, it needs nothing from a browser to load, and
125131 everything outside it comes in through one door.
@@ -239,12 +245,15 @@ from the angle it subtends, and you turn on the spot.
239245 and the clock at zero refuses the trigger; and `boot.test.ts` boots the entry
240246 itself against the stub page in `dom.ts` — a document of plain objects and a
241247 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
245253 `static/range.<hash>.js`: the committed bundle is a build artifact kept in git,
246254 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
248257 `bun test` rather than the preview. The range's rules are pinned the way the
249258 shooter's round is: `pin.ts` plays one seeded thirty-second run through one
250259 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.
320329
321330 | bundle | raw | ceiling | gzipped | ceiling |
322331 | ------- | ------ | ------- | ------- | ------- |
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 |
325334
326335 The ceilings are today's sizes plus about 10%. Gzip is measured at level 9
327336 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
3737 test("the entry table is what the app ships today, each with the size it may not pass", () => {
3838 expect(ENTRIES).toEqual([
3939 { 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 } },
4141 ]);
4242 });
4343
scripts/build.ts +1 −1
@@ -49,7 +49,7 @@ export type Entry = { name: string; entry: string; ceiling: Ceiling };
4949 */
5050 export const ENTRIES: Entry[] = [
5151 { 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 } },
5353 ];
5454
5555 /**
src/engine/GUIDE.md +6 −4
@@ -130,7 +130,7 @@ comes from one of these, in a fixed order.
130130
131131 ```ts
132132 // 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);
134134 ```
135135
136136 ### trace
@@ -333,10 +333,12 @@ split.
333333 | a host | a stub page in `src/client/stage.test-helper.ts` | a stub page in `src/range/dom.ts` |
334334 | a bindings table | `src/client/binding.ts` — buy, scoreboard, weapon slots | `src/range/binding.ts` — a trigger and a start, nothing else |
335335 | 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 |
338338 | 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 |
340342 | the tuning | `src/client/physics.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` |
341343
342344 ## 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", () => {
189189 expect(vaos[0]).toBe(arrays[0] as Resource);
190190 });
191191
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
192209 test("a scene of two different meshes binds each of them once", () => {
193210 const r = renderer();
194211 const two = scene("level");
src/engine/renderer.ts +20 −5
@@ -95,11 +95,21 @@ type ViewModel = { mesh: string; offset: Vec3 };
9595 export type Camera = { pos: Vec3; yaw: number; pitch: number; fov: number };
9696
9797 /**
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.
101104 */
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 };
103113
104114 /**
105115 * 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
344354 const instance = scene.instances[i];
345355 if (instance === undefined) break;
346356 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 );
348363 gl.uniform3f(u.tint, instance.tint[0], instance.tint[1], instance.tint[2]);
349364 if (buffers !== bound) {
350365 gl.bindVertexArray(buffers.vao);
src/pages/range.ts +19 −12
@@ -4,10 +4,10 @@
44 * and the footer; what is left here is the range's — its one overlay, its touch
55 * buttons and its prose.
66 *
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.
1111 */
1212
1313 import { navFor } from "./games.ts";
@@ -19,9 +19,11 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script
1919 const NOTE = `
2020 <h2>The range: thirty seconds on the clock</h2>
2121 <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>
2527 <div class="row"><button type="button" class="primary" id="look">Capture the mouse</button></div>
2628 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch ·
2729 <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 {
6466 bindings table, and a readout of what both say: ticks run, milliseconds a frame, which
6567 actions are held, what movement they and the thumb stick add up to, and where you are
6668 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
6870 thirty seconds start: plates pop up down the lane from a seeded spawn, each with its own
6971 lifetime and worth 100 to 200 points depending on how far away it stood, and a click traces
7072 a ray from your eye along your own view angles with the engine's own box trace — so a hit
7173 is a hit in three dimensions, not a hit on a picture. The clock ends the run, stops the
7274 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>
7784 <p>Nothing on this page is stored. There is no endpoint, no table and no cookie behind it:
7885 the leaderboard, the database and the terminal belong to
7986 <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");
1717 undoLoading();
1818
1919 /** 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 } {
2121 const p = page(options);
2222 const undo = p.install();
2323 boot();
@@ -262,3 +262,98 @@ test("the clock ends the run, the entry stops shooting and the best score stays"
262262 undo();
263263 }
264264 });
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;
4646 * itself on the way in — and hand back the page with the undo that puts the
4747 * globals back.
4848 */
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 }> {
5052 const load = (loads += 1);
5153 const copy = join(copies, `range.${load}.js`);
5254 copyFileSync(bundlePath(), copy);
@@ -114,6 +116,23 @@ test("a bundled canvas with no context is a sentence on the page, not an excepti
114116 }
115117 });
116118
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
117136 test("the stub page leaves nothing of itself behind", () => {
118137 // Every boot above undid its install, so no other test inherits a document.
119138 expect("document" in globalThis).toBe(HAD_DOCUMENT);
src/range/dom.ts +71 −6
@@ -4,7 +4,12 @@
44 * can be booted and stepped in a test with no browser — the same trick
55 * `src/engine/controls.ts` plays with its `Listens` parameter, one level up.
66 *
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.
813 */
914
1015 import type { Paint } from "./readout.ts";
@@ -70,25 +75,85 @@ export function recorder(): Recorder {
7075 } as Recorder;
7176 }
7277
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
73129 export type FakeCanvas = FakeElement & {
74130 clientWidth: number;
75131 clientHeight: number;
76132 width: number;
77133 height: number;
78134 paint: Recorder | null;
135 gl: GlRecorder | null;
79136 locks: number;
80 getContext(kind: string): Recorder | null;
137 getContext(kind: string): Recorder | GlRecorder | null;
81138 requestPointerLock(): void;
82139 };
83140
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 {
86149 return {
87150 ...element(id),
88151 clientWidth: 800, clientHeight: 450, width: 0, height: 0,
89152 paint: has2d ? recorder() : null,
153 gl: webgl ? glRecorder() : null,
90154 locks: 0,
91155 getContext(kind) {
156 if (kind === "webgl2") return this.gl;
92157 return kind === "2d" ? this.paint : null;
93158 },
94159 requestPointerLock() {
@@ -121,8 +186,8 @@ export type FakePage = {
121186 * The whole page the range's entry expects: the stage's canvases, the overlay,
122187 * the touch pads and a clock that only moves when the test says so.
123188 */
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);
126191 const hud = canvas("hud", options.has2d ?? true);
127192 const buttons = [element("", "duck"), element("", "jump"), element("", "fire")];
128193 const elements = new Map<string, FakeElement>([
src/range/main.ts +65 −17
@@ -1,10 +1,16 @@
11 /**
22 * The range's entry: the wiring of a second game, and only the wiring. The
33 * 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.
814 *
915 * Nothing outside `src/engine/` and this directory is imported, and
1016 * `independence.test.ts` reads these files to make sure it stays that way.
@@ -12,8 +18,11 @@
1218
1319 import { RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, heldActions, readIntent } from "./binding.ts";
1420 import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts";
21 import { buildRangeScene, createRangeScene, rangeAssets, type Trail } from "./scene.ts";
1522 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";
1726
1827 /** The range's own step: 50 Hz, deliberately not the shooter's 64. */
1928 const TICK = 1 / 50;
@@ -36,15 +45,35 @@ export function boot(): void {
3645
3746 // A canvas that gives no context is a sentence on the page, never a throw:
3847 // everything else here — the text, the links, the terminal line — still works.
39 const sceneCtx = scene.getContext("2d") as Paint | null;
4048 const hudCtx = hud.getContext("2d") as Paint | null;
41 if (sceneCtx === null || hudCtx === null) {
49 if (hudCtx === null) {
4250 note.innerHTML =
4351 "<h2>No 2D canvas</h2><p>This browser gave the page no drawing context, so the readout cannot be" +
4452 " drawn. The rest of the page still works.</p>";
4553 return;
4654 }
4755
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
4877 const controls = createControls(
4978 { surface: scene, window, document },
5079 { keys: RANGE_KEYS, mouse: RANGE_MOUSE },
@@ -56,10 +85,14 @@ export function boot(): void {
5685 now: () => performance.now(),
5786 });
5887
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 };
6191 let ticks = 0;
6292 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;
6396
6497 // The game. It is seeded from the clock, and Enter gives it a new seed — so
6598 // two runs are two different thirty seconds, and a run is still reproducible
@@ -108,9 +141,10 @@ export function boot(): void {
108141
109142 function frame(elapsed: number): void {
110143 frameMs += (elapsed * 1000 - frameMs) * 0.05;
144 time += elapsed;
111145 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);
114148 const steps = loop.advance(elapsed);
115149 ticks += steps;
116150
@@ -132,12 +166,25 @@ export function boot(): void {
132166 if (wasLive && run.phase === "over") keepBest();
133167
134168 // 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 }
137180 firing = intent.fire;
138181
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 }
141188 const [hw, hh] = size(hud);
142189 drawReadout(
143190 hudCtx,
@@ -149,9 +196,10 @@ export function boot(): void {
149196 frameMs,
150197 held: heldActions(controls.held),
151198 move: { forward: intent.forward, side: intent.side },
152 yaw,
153 pitch,
199 yaw: view.yaw,
200 pitch: view.pitch,
154201 locked: controls.locked,
202 drawn: renderer !== null,
155203 },
156204 hw,
157205 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:
4040 const READOUT: Readout = {
4141 run: RUN, best: 1200,
4242 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,
4444 };
4545
4646 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
5353 expect(lines[5]).toBe("move 1.00 forward · -0.50 side");
5454 expect(lines[6]).toBe("yaw 45.0° · pitch -10.0°");
5555 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");
5659 });
5760
5861 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 @@
11 /**
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.
810 *
911 * It takes the calls it makes as a parameter, typed by exactly those calls, so
1012 * a stub records them in a test and a real `CanvasRenderingContext2D` fits
@@ -42,6 +44,8 @@ export type Readout = View & {
4244 move: { forward: number; side: number };
4345 /** Is the pointer locked to the stage? */
4446 locked: boolean;
47 /** Is the room being drawn, or is this browser on the 2D horizon instead? */
48 drawn: boolean;
4549 };
4650
4751 const SKY = "#101826";
@@ -82,6 +86,7 @@ export function readoutLines(r: Readout): string[] {
8286 `move ${r.move.forward.toFixed(2)} forward · ${r.move.side.toFixed(2)} side`,
8387 `yaw ${wrapDegrees(r.yaw).toFixed(1)}° · pitch ${clamp(r.pitch, -89, 89).toFixed(1)}°`,
8488 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",
8590 ];
8691 }
8792
@@ -129,10 +134,11 @@ export function blips(view: View, sight: Sight, width: number, height: number):
129134 }
130135
131136 /**
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.
136142 */
137143 export function drawHorizon(
138144 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 file
static/app.a3f495de96.js deleted −58

Diff not shown — a line 58,979 characters wide (limit 2,000). −58

View file
static/range.81ebd406a7.js added +58

Diff not shown — a line 14,645 characters wide (limit 2,000). +58

View file
static/range.925e79efd7.js deleted −1

Diff not shown — a line 12,533 characters wide (limit 2,000). −1

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs