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

forked from trevato/cs

The sample draws with the engine it advertises: the example room in WebGL2, with the 2D plan as its fallback #222

Merged opened this

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

Merged

vesper/cs-engine:vesper/216-the-sample-draws-with-the-engine-it-adve → main 2 commits fork

  • Merged. vesper/216-the-sample-draws-with-the-engine-it-adve was merged into main as 46dc066.

Review

Sign in to review this change.
22 files changed +771 −323
scripts/build.test.ts +1 −1
@@ -40,7 +40,7 @@ test("the entry table is what the app ships today, each with the size it may not
4040 expect(ENTRIES).toEqual([
4141 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 100_000, gzip: 45_000 } },
4242 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } },
43 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 10_800, gzip: 5_100 } },
43 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 23_500, gzip: 10_200 } },
4444 ]);
4545 // And it is the registry projected, not a second list beside it: a game's
4646 // source and its ceiling are written down once, in `src/games.ts`.
src/engine/GUIDE.md +39 −18
@@ -11,10 +11,11 @@ any use, in the order you would actually write it.
1111 The engine is not a framework. It never calls your game — your game calls it,
1212 from a frame it owns, with a page it opened and numbers it chose. Two real games
1313 do that here: the shooter in `src/client/` and the range in `src/range/`. The
14 example is a third, and the smallest — it has no renderer and nothing saved,
15 which is why it fits in one file and a test can play it in a millisecond — and
16 `src/example/` is the 198 lines that give it a page at `/example`, so the code
17 on this page is also code you can go and click.
14 example is a third, and the smallest — one room, one plate, two meshes, two
15 painted layers and nothing saved, which is why it still fits in one file and a
16 test can play it in a millisecond — and `src/example/` is the wiring that gives
17 it a page at `/example`, so the code on this page is also code you can go and
18 click.
1819
1920 **Conventions.** A bare name in backticks — `rayBox`, `Mesh` — is the engine's,
2021 exported from `src/engine/index.ts`. A game's own name carries its module:
@@ -121,8 +122,17 @@ a sound device — and the recipes belong to the game: `src/client/models.ts` is
121122 the shooter's geometry, `src/client/materials.ts` its nine surfaces,
122123 `src/client/audio.ts` its voice. A second game writes its own three: the range's
123124 are `src/range/level.ts`, `src/range/paint.ts` and `src/range/audio.ts`, and
124 they owe the shooter's nothing. The example writes none at all — it has one box
125 and one plate, and never draws or sounds either.
125 they owe the shooter's nothing. The example writes the two smallest recipes
126 there are — a room and a plate as boxes, and two `Shader`s for the atlas they
127 are painted out of — and no sound at all, and all of it is in the one file:
128
129 ```ts
130 // src/engine/example.ts
131 export const meshes = (): Record<string, Mesh> => ({
132 [ROOM_MESH]: boxMesh(ROOM.max, ROOM.min, ROOM.mat, 2),
133 [PLATE_MESH]: boxMesh(scale(PLATE, -1), PLATE, 1, 0.7),
134 });
135 ```
126136
127137 ### 6. Its own tuning
128138
@@ -167,11 +177,14 @@ carries the fraction along the segment, the point, the normal and the thing that
167177 was hit, which is whatever you put in the list.
168178
169179 A shot in the example is one ray and one box, and it is the whole of hitscan:
170 `src/range/targets.ts` does the same against a list with `traceWorld`.
180 `src/range/targets.ts` does the same against a list with `traceWorld`. A miss is
181 still a shot the player saw leave the barrel, so the tracer is written before the
182 question is asked and only the score depends on the answer.
171183
172184 ```ts
173185 // src/engine/example.ts
174 if (rayBox(world.pos, to, world.target.min, world.target.max) === null) return world;
186 const fired = { ...world, shot: to, spark: SPARK }; // a miss is a tracer too
187 if (rayBox(world.pos, to, world.target.min, world.target.max) === null) return fired;
175188 ```
176189
177190 ### physics
@@ -248,8 +261,11 @@ alphabet over this and is one of them.
248261 uvs and a layer into it, and `finish` packs it into a `Mesh` of typed arrays a
249262 renderer uploads once. `quadMesh` is the full-screen quad for anything drawn
250263 without geometry. Models are functions, not files: `src/client/models.ts` is the
251 shooter's eight of them and `src/range/level.ts` the range's two. The example
252 builds none, which is allowed — a game that never draws brings no geometry.
264 shooter's eight of them and `src/range/level.ts` the range's two. The example's
265 two are one function each way round: a box handed its corners in order is a
266 plate you stand outside, and the same box handed them swapped is a room you
267 stand inside — mirrored on all three axes, so every face is wound to be seen
268 from within and its normal points into the room.
253269
254270 ### texture
255271
@@ -258,8 +274,9 @@ A `Shader` is `(u, v) => [r, g, b]`, and that is the whole of the interface.
258274 period, `fbm` is a stack of them, and `paintAtlas` runs a list of shaders into
259275 one tightly packed RGBA buffer whose layers are the material indices the meshes
260276 already carry. `src/client/materials.ts` is the shooter's nine surfaces and
261 `src/range/paint.ts` the range's three layers; the example paints nothing,
262 because it uploads no texture.
277 `src/range/paint.ts` the range's three layers; the example's two are four lines
278 in `src/engine/example.ts`, and they are the smallest honest atlas — a wall and
279 a plate, thirty-two pixels on a side.
263280
264281 Painting is arithmetic, and a big atlas is a lot of it: the shooter's nine
265282 128-pixel layers are about 170 milliseconds on a desktop and several times that
@@ -524,10 +541,14 @@ name, under the name its instances will ask for.
524541 `RendererAssets.sky` is the horizon for a frame that names none, and
525542 `RendererAssets.devicePixelRatio` is `window.devicePixelRatio` handed in as a
526543 function, because the engine reads no global and a test drives a whole frame
527 with no window in the room. Two games here build all of that and both are the
528 shape to copy: the shooter's atlas and eight meshes in `src/client/main.ts`, and
529 the range's atlas and two in `src/range/scene.ts`, handed to the call in
530 `src/range/main.ts`.
544 with no window in the room. `RendererAssets.near` and `RendererAssets.far` are
545 the clip planes in the game's own unit: they default to the shooter's Source
546 units, 6 and 12,000, and a game measured in metres says so — the example's room
547 is four metres wide and every bit of it would be inside a near plane of 6.
548 Three games here build all of that and all three are the shape to copy: the
549 shooter's atlas and eight meshes in `src/client/main.ts`, the range's atlas and
550 two in `src/range/scene.ts`, and the example's atlas and two in
551 `src/engine/example.ts`, each handed to the call in that game's entry.
531552
532553 Then every frame is the `Scene` above, handed to `Renderer.draw`. Make that
533554 scene once, at start-up, and refill it every frame — both games do, and neither
@@ -716,8 +737,8 @@ is the only thing that catches an autodiff-free derivative being subtly wrong.
716737
717738 Two games ship here, and every place they differ is a place the engine chose to
718739 know nothing. Read down a column to see one game; read across a row to see the
719 split. The example is in no column: it brings a host, a table, a tick and its
720 own tuning, and nothing else in this table at all.
740 split. The example has no column of its own yet — it now brings most of these
741 rows, an entry aside, and issue #130 is where it gets one.
721742
722743 | the game supplies | the shooter | the range |
723744 | --- | --- | --- |
src/engine/example.test.ts +61 −1
@@ -13,9 +13,69 @@
1313 */
1414
1515 import { expect, test } from "bun:test";
16 import { KEYS, ROOM, TICK, createWorld, play, step, type World } from "./example.ts";
16 import {
17 KEYS, PLATE_MESH, ROOM, ROOM_MESH, SHADERS, TICK, assets, createExampleScene, createWorld, look,
18 play, step, type World,
19 } from "./example.ts";
1720 import type { Frames, Host } from "./index.ts";
1821
22 test("what it brings to the renderer is an atlas of the shape it says and two meshes", () => {
23 const a = assets();
24 const size = a.atlas.size;
25 expect(a.atlas.bytes.length).toBe(size * size * 4 * SHADERS.length);
26 expect(Object.keys(a.meshes).sort()).toEqual([PLATE_MESH, ROOM_MESH].sort());
27 // The room is built inside out, so every face is seen from within: its
28 // triangles wind the way its normals point, and its normals point inward.
29 const room = a.meshes[ROOM_MESH]!;
30 expect(room.index.length).toBe(36);
31 for (let i = 0; i < room.index.length; i += 3) {
32 const at = [0, 1, 2].map((k) => room.index[i + k]!);
33 const p = at.map((v) => [room.position[v * 3]!, room.position[v * 3 + 1]!, room.position[v * 3 + 2]!]);
34 const e1 = p[1]!.map((x, j) => x - p[0]![j]!);
35 const e2 = p[2]!.map((x, j) => x - p[0]![j]!);
36 const cross = [
37 e1[1]! * e2[2]! - e1[2]! * e2[1]!,
38 e1[2]! * e2[0]! - e1[0]! * e2[2]!,
39 e1[0]! * e2[1]! - e1[1]! * e2[0]!,
40 ];
41 const n = [room.normal[at[0]! * 3]!, room.normal[at[0]! * 3 + 1]!, room.normal[at[0]! * 3 + 2]!];
42 expect(cross[0]! * n[0]! + cross[1]! * n[1]! + cross[2]! * n[2]!).toBeGreaterThan(0);
43 // Inward: the normal points at the middle of the room from where it sits.
44 const middle = [0, 0, (ROOM.min[2] + ROOM.max[2]) / 2];
45 const to = middle.map((c, j) => c - p[0]![j]!);
46 expect(n[0]! * to[0]! + n[1]! * to[1]! + n[2]! * to[2]!).toBeGreaterThan(0);
47 }
48 // The clip planes are metres, because this game is: the room is eight wide.
49 expect(a.near!).toBeLessThan(1);
50 expect(a.far!).toBeGreaterThan(ROOM.max[0] - ROOM.min[0]);
51 });
52
53 test("a frame is the room, the plate where the seed put it, and a shot still warm", () => {
54 const into = createExampleScene();
55 const world = createWorld(7);
56 const frame = look(world, into);
57 expect(frame.camera.pos).toEqual(world.pos);
58 expect(frame.camera.yaw).toBe(world.yaw);
59 expect(frame.instanceCount).toBe(2);
60 expect(frame.instances[0]!.mesh).toBe(ROOM_MESH);
61 expect(frame.instances[1]!.mesh).toBe(PLATE_MESH);
62 // The plate stands at the middle of the box the bullet is traced against.
63 for (let i = 0; i < 3; i++)
64 expect(frame.instances[1]!.pos[i]).toBeCloseTo((world.target.min[i]! + world.target.max[i]!) / 2, 9);
65 // Nothing was fired, so there is no line in this frame.
66 expect(frame.lineCount).toBe(0);
67 // One shot, aimed at the plate, is two line vertices from the eye.
68 const held = new Set(["fire"]);
69 const fired = step({ ...world, yaw: 90 }, TICK, held);
70 expect(fired.spark).toBeGreaterThan(0);
71 expect(look(fired, into).lineCount).toBe(2);
72 // And it fades: the tracer is gone a tenth of a second later.
73 let cold = fired;
74 for (let i = 0; i < 10; i++) cold = step(cold, TICK, held);
75 expect(cold.spark).toBe(0);
76 expect(look(cold, into).lineCount).toBe(0);
77 });
78
1979 /** Degrees of turn one tick is worth: the example's 120°/s at its own tick. */
2080 const TURN_PER_TICK = 120 * TICK;
2181
src/engine/example.ts +109 −12
@@ -1,16 +1,27 @@
11 /**
2 * The smallest game on this package: one room, one plate, one score and a step
3 * a test can play ten thousand times. It exists so the guide beside it can be
2 * The smallest game on this package: one room, one plate, one score, a step a
3 * test can play ten thousand times, and — since #216 — the four recipes that
4 * turn all of that into a picture. It exists so the guide beside it can be
45 * executed — every fenced block in `GUIDE.md` is a slice of these lines, and
56 * `guide.test.ts` fails the day the two drift apart. It is documentation and
6 * not a third product: no renderer, no page, no persistence, nothing opened and
7 * nothing run at import. `src/range/` is the real second game, and this file is
7 * not a third product: no page, no persistence, nothing opened and nothing run
8 * at import. `src/range/` is the real second game, and this file is still
89 * smaller than the wiring of one on purpose.
10 *
11 * The drawing half is the smallest honest one: two boxes, two painted layers,
12 * a scene of two instances and one line. It is here rather than in
13 * `src/example/` because the renderer, the mesh builder and the atlas are the
14 * largest things behind the door, and a sample that demonstrated the loop and
15 * left those out was a sample of the easy half. What is *not* here is the GL
16 * context and the canvas: `createRenderer` is the page's call, because a canvas
17 * is a browser's and this file knows no browser.
918 */
1019
1120 import {
12 add, angleVectors, clamp, createControls, createLoop, rayBox, rng, scale,
13 type Box, type Frames, type Host, type Vec3,
21 add, addBox, angleVectors, builder, clamp, createControls, createLoop, createScene, fbm, finish,
22 nextInstance, paintAtlas, pushLine, rayBox, resetScene, rng, scale, sub,
23 type Box, type Frames, type Host, type Mesh, type RendererAssets, type Scene, type Shader,
24 type Vec3,
1425 } from "./index.ts";
1526
1627 /** `KeyboardEvent.code` to action name: this game's whole table, and it is data. */
@@ -25,32 +36,44 @@ export const TICK = 1 / 60;
2536 /** The room, in metres: one box, and the player is held half a metre inside it. */
2637 export const ROOM: Box = { min: [-4, -4, 0], max: [4, 4, 3], mat: 0 };
2738
39 /** Half a plate, in metres: the size you shoot at and the size that is drawn. */
40 const PLATE: Vec3 = [0.25, 0.05, 0.35];
41
2842 /** The tuning, all of it: metres per second, degrees per second, metres, metres. */
2943 const SPEED = 4, TURN = 120, EYE = 1.6, REACH = 40;
3044
31 /** Everything this game is, and all of it plain: it prints, copies and compares. */
45 /** The drawing's own numbers: field of view, ticks a tracer is drawn for, and
46 * the atlas's side in pixels — thirty-two is plenty for two flat surfaces. */
47 const FOV = 90, SPARK = 6, TEX = 32;
48
49 /** Everything this game is, and all of it plain: it prints, copies and compares.
50 * `shot` is where the last bullet stopped and `spark` the ticks its tracer has
51 * left, so a frame draws the shot without the page remembering anything. */
3252 export type World = {
3353 seed: number; pos: Vec3; yaw: number; score: number; firing: boolean; target: Box;
54 shot: Vec3; spark: number;
3455 };
3556
3657 /** A plate across the far wall from one number, and the number after it: `rng`
3758 * hands back its own next state over 2^32, so the multiply recovers that state. */
3859 function plate(seed: number): [Box, number] {
3960 const u = rng(seed)();
40 return [{ min: [u * 6 - 3.25, 3.7, 1.2], max: [u * 6 - 2.75, 3.8, 1.9], mat: 1 }, (u * 2 ** 32) >>> 0];
61 const at: Vec3 = [u * 6 - 3, 3.75, 1.55];
62 return [{ min: sub(at, PLATE), max: add(at, PLATE), mat: 1 }, (u * 2 ** 32) >>> 0];
4163 }
4264
4365 export function createWorld(seed: number): World {
4466 const [target, next] = plate(seed);
45 return { seed: next, pos: [0, -3, EYE], yaw: 90, score: 0, firing: false, target };
67 return { seed: next, pos: [0, -3, EYE], yaw: 90, score: 0, firing: false, target, shot: [0, 0, 0], spark: 0 };
4668 }
4769
4870 /** One shot down the view: one ray, one box, and a point if it lands. */
4971 function shoot(world: World): World {
5072 const to = add(world.pos, scale(angleVectors(world.yaw, 0).forward, REACH));
51 if (rayBox(world.pos, to, world.target.min, world.target.max) === null) return world;
73 const fired = { ...world, shot: to, spark: SPARK }; // a miss is a tracer too
74 if (rayBox(world.pos, to, world.target.min, world.target.max) === null) return fired;
5275 const [target, seed] = plate(world.seed);
53 return { ...world, score: world.score + 1, target, seed };
76 return { ...fired, score: world.score + 1, target, seed };
5477 }
5578
5679 /** One tick: a world, the seconds and the held actions in, a new world out. */
@@ -62,10 +85,84 @@ export function step(world: World, dt: number, held: ReadonlySet<string>): World
6285 const put = (x: number, d: number): number => clamp(x + d * SPEED * dt, ROOM.min[0] + 0.5, ROOM.max[0] - 0.5);
6386 const pos: Vec3 = [put(world.pos[0], walk[0]), put(world.pos[1], walk[1]), EYE];
6487 const fire = held.has("fire");
65 const next = { ...world, pos, yaw, firing: fire };
88 const next = { ...world, pos, yaw, firing: fire, spark: Math.max(0, world.spark - 1) };
6689 return fire && !world.firing ? shoot(next) : next; // one shot per press, not per tick
6790 }
6891
92 /** The two surfaces this game is made of, as `Shader`s the atlas painter runs:
93 * a wall of cast concrete, and a plate with a pale bullseye on orange. Nothing
94 * is downloaded — a layer is arithmetic, and `mat` on a box picks which one.
95 * The bullseye is measured from the tile's corner because `addBox` centres a
96 * face on the texture's origin, and the texture wraps: the four corners meet
97 * in the middle of the plate, which is where a bullseye belongs. */
98 export const SHADERS: Shader[] = [
99 (u, v) => { const n = fbm(u * 5, v * 5, 5, 41); return [66 + n * 60, 70 + n * 60, 84 + n * 64]; },
100 (u, v) => (Math.hypot(Math.min(u, 1 - u), Math.min(v, 1 - v)) < 0.22 ? [236, 232, 214] : [226, 176, 74]),
101 ];
102
103 /** One box as a mesh of its own: the builder opened, six quads written, done. */
104 const boxMesh = (min: Vec3, max: Vec3, layer: number, tile: number): Mesh => {
105 const b = builder();
106 addBox(b, min, max, layer, tile);
107 return finish(b);
108 };
109
110 /** The names a frame asks for its two meshes by, and the meshes themselves. The
111 * room is `ROOM` handed in with its corners swapped: a box built inside out,
112 * so every face is wound to be seen from within and its normal points into the
113 * room. The plate is that same half-size, standing at the origin. */
114 export const ROOM_MESH = "room", PLATE_MESH = "plate";
115 export const meshes = (): Record<string, Mesh> => ({
116 [ROOM_MESH]: boxMesh(ROOM.max, ROOM.min, ROOM.mat, 2),
117 [PLATE_MESH]: boxMesh(scale(PLATE, -1), PLATE, 1, 0.7),
118 });
119
120 /** Everything this game brings to `createRenderer`: its atlas at its own size,
121 * its two meshes, the dark it fades into, and clip planes in metres — the
122 * engine's own are a shooter's Source units and would eat this room whole. */
123 export const assets = (): Omit<RendererAssets, "devicePixelRatio"> => ({
124 atlas: { bytes: paintAtlas(SHADERS, TEX), size: TEX, layers: SHADERS.length },
125 meshes: meshes(),
126 sky: [0.04, 0.05, 0.06],
127 near: 0.05,
128 far: 60,
129 });
130
131 /** The frame this game fills, made once: two meshes stand in it and one tracer. */
132 export const createExampleScene = (): Scene => createScene({ instances: 2, lineVertices: 2 });
133
134 /** The one vector a frame writes the plate's centre into, where a mesh built at
135 * world coordinates stands, and the tracer's two colours: a frame allocates
136 * nothing at all, which is the engine's rule for a pool. */
137 const PLACE: Vec3 = [0, 0, 0], ORIGIN: Vec3 = [0, 0, 0];
138 const HOT: Vec3 = [1, 0.9, 0.55], COOL: Vec3 = [1, 0.8, 0.3];
139
140 /** One mesh placed, or nothing at all once the pool is spent. */
141 function place(into: Scene, mesh: string, pos: Vec3, lit: number): void {
142 const at = nextInstance(into);
143 if (at === null) return;
144 at.mesh = mesh;
145 at.pos[0] = pos[0]; at.pos[1] = pos[1]; at.pos[2] = pos[2];
146 at.yaw = 0; at.scale = 1; at.size = 1;
147 at.tint[0] = lit; at.tint[1] = lit; at.tint[2] = lit;
148 }
149
150 /** What this world looks like, written into the scene made above and handed
151 * back: the eye is where you stand, the room is always first, the plate stands
152 * where the seed put it, and a shot still warm is one line from eye to impact.
153 * No GL and no canvas — this is data, and `Renderer.draw` is what reads it. */
154 export function look(world: World, into: Scene): Scene {
155 const camera = into.camera;
156 camera.pos[0] = world.pos[0]; camera.pos[1] = world.pos[1]; camera.pos[2] = world.pos[2];
157 camera.yaw = world.yaw; camera.pitch = 0; camera.fov = FOV;
158 resetScene(into);
159 place(into, ROOM_MESH, ORIGIN, 1.15);
160 for (let i = 0; i < 3; i++) PLACE[i] = ((world.target.min[i] as number) + (world.target.max[i] as number)) / 2;
161 place(into, PLATE_MESH, PLACE, 1.6);
162 if (world.spark > 0) pushLine(into, world.pos, world.shot, HOT, COOL);
163 return into;
164 }
165
69166 /** The frame: the host and the clock are handed in, the page's in a game and
70167 * plain objects in a test, so nothing in here looks anything up. */
71168 export function play(host: Host, frames: Frames, seed: number): { world(): World; stop(): void } {
src/engine/page.test.ts +5 −5
@@ -317,9 +317,8 @@ test("no committed bundle carries the double", () => {
317317 for (const file of bundles) {
318318 const code = readFileSync(join(dir, file), "utf8");
319319 // The reader is alive: these files are what it thinks they are. What every
320 // one of them has is a canvas — not WebGL, which `/example` proves a game
321 // here can do without, and which this file would otherwise be pinning on
322 // every game that ever ships.
320 // one of them has is a canvas; every one of them draws in three dimensions
321 // too, since #216 gave the example the renderer it advertises.
323322 expect(code.length).toBeGreaterThan(1000);
324323 expect(code).toContain("getContext");
325324 // And no byte of this module is in them — the sentence it carries, and the
@@ -327,6 +326,7 @@ test("no committed bundle carries the double", () => {
327326 expect(code).not.toContain(HEADLESS);
328327 expect(code).not.toContain(`class="([a-z-]+)"`);
329328 }
330 // The two games that draw in three dimensions still do.
331 expect(bundles.filter((f) => readFileSync(join(dir, f), "utf8").includes("webgl2")).length).toBe(2);
329 // Every game that ships draws through the engine's renderer.
330 expect(bundles.filter((f) => readFileSync(join(dir, f), "utf8").includes("webgl2")).length)
331 .toBe(bundles.length);
332332 });
src/engine/renderer.test.ts +29
@@ -17,6 +17,7 @@ import { createRenderer } from "./renderer.ts";
1717 // imports is not a type it exports.
1818 import type { Scene } from "./scene.ts";
1919 import { addBox, builder, finish, type Mesh } from "./mesh.ts";
20 import { multiply, perspective, viewMatrix } from "./math.ts";
2021
2122 type Call = [string, ...unknown[]];
2223
@@ -129,6 +130,34 @@ function scene(mesh = "level"): Scene {
129130 };
130131 }
131132
133 /**
134 * The clip planes a game chose, in its own unit. A game measured in metres — the
135 * example is — cannot see anything at all through a near plane of six of them,
136 * so the two numbers are the game's and the engine only defaults them.
137 */
138 test("the projection clips where the game's units say, not where the shooter's do", () => {
139 const gl = stubGl();
140 const canvas = stubCanvas(gl);
141 const metres = createRenderer(canvas as unknown as HTMLCanvasElement, {
142 atlas: atlas(), meshes: { level: box() }, devicePixelRatio: () => 1, near: 0.05, far: 60,
143 });
144 metres?.draw(scene(), 1 / 60);
145 const sent = (g: StubGl): Float32Array =>
146 (g.calls.find((c) => c[0] === "uniformMatrix4fv" && (c[1] as { kind: string }).kind === "uViewProj")
147 ?.[3]) as Float32Array;
148 const aspect = canvas.width / canvas.height;
149 const fovY = (2 * Math.atan(Math.tan((90 * Math.PI) / 360) / aspect) * 180) / Math.PI;
150 const camera = scene().camera;
151 const expected = multiply(perspective(fovY, aspect, 0.05, 60), viewMatrix(camera.pos, 0, 0));
152 expect([...sent(gl)]).toEqual([...expected]);
153
154 // A game that says nothing gets the Source units this engine grew up with.
155 const source = renderer({ level: box() });
156 source.r.draw(scene(), 1 / 60);
157 const left = multiply(perspective(fovY, aspect, 6, 12000), viewMatrix(camera.pos, 0, 0));
158 expect([...sent(source.gl)]).toEqual([...left]);
159 });
160
132161 /** Every recorded call of this name, arguments and all. */
133162 const of = (gl: StubGl, name: string): Call[] => gl.calls.filter((c) => c[0] === name);
134163
src/engine/renderer.ts +17 −1
@@ -110,6 +110,17 @@ export type RendererAssets = {
110110 meshes: Record<string, Mesh>;
111111 /** The horizon a frame that names no sky is cleared to and fades into. Black when left out. */
112112 sky?: Tint;
113 /**
114 * The two distances the projection clips at, in whatever unit the game's
115 * world is written in. A lane 1,536 units long and a room four metres wide
116 * are not the same size, and a near plane that suits one eats the other
117 * whole: the example is in metres and everything it can see is inside the
118 * 6 units this engine grew up with. Left out, they are the shooter's — 6 and
119 * 12,000 — because that is where these numbers came from, and a game that
120 * measures in Source units says nothing.
121 */
122 near?: number;
123 far?: number;
113124 /**
114125 * How many device pixels a CSS pixel is: `() => window.devicePixelRatio` in a
115126 * browser, 1 when left out. It is a function because it changes when a window
@@ -140,6 +151,9 @@ const atlasBytes = (size: number, layers: number): number => size * size * 4 * l
140151 /** Past this the pixels cost more than they show, on a phone especially. */
141152 const MAX_PIXEL_RATIO = 2;
142153
154 /** The clip planes a game that says nothing gets: Source units, the shooter's. */
155 const NEAR = 6, FAR = 12000;
156
143157 const BLACK: Tint = [0, 0, 0];
144158 const ORIGIN: Vec3 = [0, 0, 0];
145159
@@ -275,6 +289,8 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets
275289
276290 const pixelRatio = assets.devicePixelRatio;
277291 const ownSky = assets.sky ?? BLACK;
292 const near = assets.near ?? NEAR;
293 const far = assets.far ?? FAR;
278294
279295 const resize = (): void => {
280296 const dpr = Math.min((pixelRatio === undefined ? 1 : pixelRatio()) || 1, MAX_PIXEL_RATIO);
@@ -305,7 +321,7 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets
305321 const aspect = width / Math.max(1, height);
306322 // A CS field of view is horizontal; the projection wants the vertical one.
307323 const fovY = (2 * Math.atan(Math.tan((camera.fov * Math.PI) / 360) / aspect) * 180) / Math.PI;
308 const proj = perspective(fovY, aspect, 6, 12000);
324 const proj = perspective(fovY, aspect, near, far);
309325 const view = viewMatrix(camera.pos, camera.yaw, camera.pitch);
310326 const viewProj = multiply(proj, view);
311327
src/example/boot.test.ts +78 −28
@@ -25,7 +25,7 @@ undoLoading();
2525
2626 /** A booted page, and the undo that puts the globals back. */
2727 function booted(
28 options: { has2d?: boolean; coarse?: boolean; fullscreen?: boolean } = {},
28 options: { has2d?: boolean; webgl?: boolean; coarse?: boolean; fullscreen?: boolean } = {},
2929 ): { p: ExamplePage; undo: () => void } {
3030 const p = page(options);
3131 const undo = p.install();
@@ -36,17 +36,27 @@ function booted(
3636 type Call = [string, ...unknown[]];
3737
3838 /**
39 * One frame, and the calls it alone made: the recorder keeps every frame ever
40 * drawn, and almost everything here is a question about one of them.
39 * One frame, and the 2D calls it alone made: the recorder keeps every frame
40 * ever drawn, and almost everything here is a question about one of them. The
41 * readout is on the HUD canvas whichever way the room was drawn, so that is
42 * the default; the plan — the fallback — is asked for by naming the scene.
4143 */
42 function drawn(p: ExamplePage, ms: number): { calls: Call[]; texts: string[] } {
43 const all = p.scene.paint?.calls ?? [];
44 function drawn(p: ExamplePage, ms: number, on: "hud" | "scene" = "hud"): { calls: Call[]; texts: string[] } {
45 const all = p[on].paint?.calls ?? [];
4446 const from = all.length;
4547 p.step(ms);
4648 const calls = all.slice(from);
4749 return { calls, texts: calls.filter((c) => c[0] === "fillText").map((c) => String(c[1])) };
4850 }
4951
52 /** One frame, and the GL calls it alone made. */
53 function drew(p: ExamplePage, ms: number): Call[] {
54 const all = p.gl?.calls ?? [];
55 const from = all.length;
56 p.step(ms);
57 return all.slice(from);
58 }
59
5060 /**
5161 * Where the aim dots are, as one string. They are the only 2×2 rectangles on
5262 * the canvas — the room, the plate and the countdown bar are all larger — so
@@ -99,21 +109,22 @@ test("F and the button put the room on the whole screen, and neither lies about
99109 }
100110 });
101111
102 test("it boots, asks for a frame, and draws the room and the readout", () => {
112 test("it boots, asks for a frame, and draws the readout over the room", () => {
103113 const { p, undo } = booted();
104114 try {
105115 expect(p.frames.length).toBe(1);
106116 const first = drawn(p, 16);
107 // The canvas was sized to its box at this page's 1× ratio, and drawn on.
108 expect([p.scene.width, p.scene.height]).toEqual([800, 450]);
117 // The HUD canvas was sized to its box at this page's 1× ratio, and drawn on.
118 expect([p.hud.width, p.hud.height]).toEqual([800, 450]);
109119 expect(first.texts).toContain("score 0");
110120 expect(first.texts).toContain("0:30");
111121 expect(first.texts).toContain("press Enter, or Start, for thirty seconds");
112122 expect(p.by("fps").textContent).toContain("ms");
113 // The room, the plate, the aim and the countdown are filled rectangles:
114 // everything on this canvas came out of the engine's overlay.
115 expect(first.calls.filter((c) => c[0] === "fillRect").length).toBeGreaterThan(4);
116 expect(aim(first.calls).length).toBeGreaterThan(0);
123 // It is cleared and never painted over, because the room is underneath it,
124 // and the crosshair is the one thing the readout draws into the middle.
125 expect(first.calls[0]).toEqual(["clearRect", 0, 0, 800, 450]);
126 expect(first.calls.some((c) => c[0] === "fillRect" && c[3] === 800)).toBe(false);
127 expect(first.calls.filter((c) => c[0] === "moveTo").length).toBe(4);
117128 // A frame was asked for before this one ran, so the loop keeps going.
118129 expect(p.frames.length).toBe(1);
119130 } finally {
@@ -121,14 +132,53 @@ test("it boots, asks for a frame, and draws the room and the readout", () => {
121132 }
122133 });
123134
124 test("the second canvas the shared stage renders is never touched", () => {
125 // One canvas, one context, no WebGL: the smallest a game here can be.
135 test("the room, the plate and the shot are drawn through the engine's renderer", () => {
126136 const { p, undo } = booted();
127137 try {
128 p.step(16);
129 expect(p.hud.paint?.calls).toEqual([]);
130 expect(p.hud.gl).toBeNull();
131 expect(p.scene.locks).toBe(0);
138 // The atlas and both meshes were uploaded once, before any frame ran.
139 const made = p.gl?.calls ?? [];
140 expect(made.filter((c) => c[0] === "texImage3D").length).toBe(1);
141 expect(made.filter((c) => c[0] === "bufferData" && c[2] instanceof Float32Array).length).toBe(8);
142 expect(made.filter((c) => c[0] === "drawElements").length).toBe(0);
143 // The scene canvas is the renderer's: nothing 2D was ever drawn on it.
144 expect(p.scene.paint?.calls).toEqual([]);
145
146 // One frame is one clear and two draws: the room, then the plate.
147 p.window.fire("keydown", { code: "Enter" });
148 const frame = drew(p, 100);
149 expect(frame.filter((c) => c[0] === "clear").length).toBe(1);
150 expect(frame.filter((c) => c[0] === "drawElements").length).toBe(2);
151 // Nothing is stroked until something is fired.
152 expect(frame.filter((c) => c[0] === "drawArrays").length).toBe(0);
153
154 // A shot is a tracer: two line vertices, uploaded and drawn as LINES.
155 p.window.fire("keydown", { code: "Space" });
156 const shot = drew(p, 100);
157 const lines = shot.filter((c) => c[0] === "drawArrays");
158 expect(lines.length).toBe(1);
159 expect(lines[0]?.[3]).toBe(2);
160 // And it fades: a tenth of a second later there is no line left to draw.
161 p.window.fire("keyup", { code: "Space" });
162 expect(drew(p, 100).filter((c) => c[0] === "drawArrays").length).toBe(0);
163 } finally {
164 undo();
165 }
166 });
167
168 test("a browser with no WebGL2 gets the plan on the same canvas, and still plays", () => {
169 const { p, undo } = booted({ webgl: false });
170 try {
171 p.window.fire("keydown", { code: "Enter" });
172 const plan = drawn(p, 16, "scene");
173 // The room, the plate and the aim dots are filled rectangles, and the
174 // first of them covers the canvas: this is the whole frame, in 2D.
175 expect(plan.calls[0]).toEqual(["fillRect", 0, 0, 800, 450]);
176 expect(plan.calls.filter((c) => c[0] === "fillRect").length).toBeGreaterThan(4);
177 expect(aim(plan.calls).length).toBeGreaterThan(0);
178 // The readout is on the HUD either way, and says the same words.
179 expect(drawn(p, 16).texts).toContain("arrows turn · WASD walks · Space fires");
180 // No crosshair in the middle of the readout: the plan draws the player.
181 expect(drawn(p, 16).calls.filter((c) => c[0] === "moveTo").length).toBe(0);
132182 } finally {
133183 undo();
134184 }
@@ -153,39 +203,39 @@ test("Enter starts a run: the note gets out of the way and the clock counts down
153203 });
154204
155205 test("a held key reaches the game: turning moves the aim, walking moves the player", () => {
156 const { p, undo } = booted();
206 const { p, undo } = booted({ webgl: false });
157207 try {
158208 p.window.fire("keydown", { code: "Enter" });
159 const still = drawn(p, 16);
209 const still = drawn(p, 16, "scene");
160210 p.window.fire("keydown", { code: "ArrowLeft" });
161211 for (let i = 0; i < 4; i++) p.step(100);
162 const turned = drawn(p, 100);
212 const turned = drawn(p, 100, "scene");
163213 expect(aim(turned.calls)).not.toBe(aim(still.calls));
164214 // Letting go stops the turn: the next two frames aim at the same place.
165215 p.window.fire("keyup", { code: "ArrowLeft" });
166216 p.step(100);
167 const held = drawn(p, 100);
168 expect(aim(drawn(p, 100).calls)).toBe(aim(held.calls));
217 const held = drawn(p, 100, "scene");
218 expect(aim(drawn(p, 100, "scene").calls)).toBe(aim(held.calls));
169219
170220 // Walking north is up the plan, because the room starts facing north.
171221 const from = marker(held.calls);
172222 p.window.fire("keydown", { code: "KeyW" });
173223 for (let i = 0; i < 4; i++) p.step(100);
174 expect(marker(drawn(p, 100).calls)[1]).toBeLessThan(from[1]);
224 expect(marker(drawn(p, 100, "scene").calls)[1]).toBeLessThan(from[1]);
175225 } finally {
176226 undo();
177227 }
178228 });
179229
180230 test("a key the game does not bind does nothing at all", () => {
181 const { p, undo } = booted();
231 const { p, undo } = booted({ webgl: false });
182232 try {
183233 p.window.fire("keydown", { code: "Enter" });
184234 p.step(100);
185 const before = drawn(p, 100);
235 const before = drawn(p, 100, "scene");
186236 p.window.fire("keydown", { code: "KeyQ" });
187 expect(aim(drawn(p, 100).calls)).toBe(aim(before.calls));
188 expect(marker(drawn(p, 100).calls)).toEqual(marker(before.calls));
237 expect(aim(drawn(p, 100, "scene").calls)).toBe(aim(before.calls));
238 expect(marker(drawn(p, 100, "scene").calls)).toEqual(marker(before.calls));
189239 } finally {
190240 undo();
191241 }
src/example/bundle.test.ts +29 −10
@@ -5,10 +5,11 @@
55 * mangled name the entry needed, a tree shake that took the boot line with it —
66 * fails here rather than in the preview.
77 *
8 * It also reads the bytes for what is *not* in them, which is this game's whole
9 * claim: it is 2D, so no renderer, no atlas, no sound device and no policy
10 * network came along behind the engine's door. A door is only worth having if
11 * importing it costs what you use, and this file is where that is measured.
8 * It also reads the bytes for what is and is not in them, which is this game's
9 * whole claim: since #216 it draws, so the renderer and the atlas painter are
10 * in there — and no sound device, no policy network and no trainer, because it
11 * opens no sound and runs no bot. A door is only worth having if importing it
12 * costs what you use, and this file is where that is measured.
1213 *
1314 * Finding the artifact and importing it afresh is `scripts/bundle.test-helper.ts`,
1415 * shared with both other games — the same file, the same trick, one copy of it.
@@ -28,7 +29,9 @@ const HAD_DOCUMENT = "document" in globalThis;
2829 * Install a stub page, import the committed bundle into it — the entry boots
2930 * itself on the way in — and hand back the page with the undo.
3031 */
31 async function booted(options: { has2d?: boolean } = {}): Promise<{ p: ExamplePage; undo: () => void }> {
32 async function booted(
33 options: { has2d?: boolean; webgl?: boolean } = {},
34 ): Promise<{ p: ExamplePage; undo: () => void }> {
3235 const p = page(options);
3336 const undo = p.install();
3437 try {
@@ -40,7 +43,8 @@ async function booted(options: { has2d?: boolean } = {}): Promise<{ p: ExamplePa
4043 return { p, undo };
4144 }
4245
43 const texts = (p: ExamplePage): string[] => p.scene.paint?.texts ?? [];
46 /** The readout's words: the HUD canvas, whichever way the room was drawn. */
47 const texts = (p: ExamplePage): string[] => p.hud.paint?.texts ?? [];
4448
4549 test("the committed bundle boots the page and draws what the loop produced", async () => {
4650 const { p, undo } = await booted();
@@ -48,13 +52,14 @@ test("the committed bundle boots the page and draws what the loop produced", asy
4852 // Importing it was enough: it found the page and asked for a frame.
4953 expect(p.frames.length).toBe(1);
5054 p.step(16);
51 expect([p.scene.width, p.scene.height]).toEqual([800, 450]);
55 expect([p.hud.width, p.hud.height]).toEqual([800, 450]);
5256 // Drawn text, so minification has nothing to hide behind.
5357 expect(texts(p)).toContain("score 0");
5458 expect(texts(p)).toContain("0:30");
5559 expect(p.by("fps").textContent).toContain("ms");
56 // And the second canvas is still untouched by the file a browser runs.
57 expect(p.hud.paint?.calls).toEqual([]);
60 // And the room itself went through the renderer the bundle carries.
61 expect((p.gl?.calls ?? []).filter((c) => c[0] === "drawElements").length).toBe(2);
62 expect(p.scene.paint?.calls).toEqual([]);
5863
5964 // Enter runs the guide's game: the clock moves and the help line changes.
6065 p.window.fire("keydown", { code: "Enter" });
@@ -77,6 +82,18 @@ test("a bundled canvas with no context is a sentence on the page, not an excepti
7782 }
7883 });
7984
85 test("the committed bundle plays for a browser with no WebGL2 too", async () => {
86 const { p, undo } = await booted({ webgl: false });
87 try {
88 p.step(16);
89 // The plan, on the scene canvas: the same game, seen a flatter way.
90 expect(p.scene.paint?.calls.filter((c) => c[0] === "fillRect").length).toBeGreaterThan(4);
91 expect(texts(p)).toContain("score 0");
92 } finally {
93 undo();
94 }
95 });
96
8097 test("the example bundles none of the engine it does not use", () => {
8198 // A tool set ships whole to a game that uses any of it, so the way to say
8299 // this game uses none of these is to read the file. Each name below is one
@@ -84,7 +101,9 @@ test("the example bundles none of the engine it does not use", () => {
84101 const code = readFileSync(bundlePath("example"), "utf8");
85102 expect(code.length).toBeGreaterThan(1000); // the reader is alive
86103 expect(code).toContain("getContext"); // and it is a game on a canvas
87 for (const name of ["webgl2", "AudioContext", "Math.tanh", "atob", "btoa", "createImageBitmap"])
104 expect(code).toContain("webgl2"); // that draws with the renderer it advertises
105 expect(code).toContain("TEXTURE_2D_ARRAY"); // out of an atlas it painted itself
106 for (const name of ["AudioContext", "Math.tanh", "atob", "btoa", "createImageBitmap"])
88107 expect(code).not.toContain(name);
89108 });
90109
src/example/main.ts +47 −15
@@ -11,13 +11,21 @@
1111 * and not the game's, so the table below is the game's plus the one key that
1212 * starts one: the game says what `KeyW` means, the page says what Enter means.
1313 *
14 * 2D and nothing else: one canvas, one context, no WebGL, no atlas, no sound
15 * and no persistence. `bundle.test.ts` reads the committed file to say so.
14 * The room is drawn by the engine's renderer out of the game's own two meshes
15 * and its own two atlas layers, handed over once here, because the canvas and
16 * the GL context are a browser's and the recipes are the game's. A browser with
17 * no WebGL2 keeps the game rather than losing it: `createRenderer` hands back
18 * null, the scene canvas takes a 2D context instead and `./plan.ts` draws the
19 * room from above, which is the range's arrangement exactly. Either way the
20 * readout is 2D on the canvas above. No sound and no persistence:
21 * `bundle.test.ts` reads the committed file to say so.
1622 */
1723
18 import { KEYS, TICK, createWorld, step, type World } from "../engine/example.ts";
19 import { attachButtons, createControls, createFullscreen, createLoop, type Paint } from "../engine/index.ts";
20 import { drawPlan, type Phase } from "./plan.ts";
24 import { KEYS, TICK, assets, createExampleScene, createWorld, look, step, type World } from "../engine/example.ts";
25 import {
26 attachButtons, createControls, createFullscreen, createLoop, createRenderer, type Paint,
27 } from "../engine/index.ts";
28 import { drawPlan, drawReadout, type Phase } from "./plan.ts";
2129
2230 /**
2331 * The game's own table, and the two keys the page adds: the one that starts a
@@ -42,17 +50,32 @@ const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id)
4250 */
4351 export function boot(): void {
4452 const scene = $<HTMLCanvasElement>("scene");
53 const hud = $<HTMLCanvasElement>("hud");
4554 const note = $("note");
4655
4756 // A canvas that gives no context is a sentence on the page, never a throw:
4857 // the text, the links and the terminal line on this page all still work.
49 const paint = scene.getContext("2d") as Paint | null;
50 if (paint === null) {
58 const readout = hud.getContext("2d") as Paint | null;
59 if (readout === null) {
5160 note.innerHTML = "<h2>No 2D canvas</h2><p>This browser gave the page no drawing context, so the room" +
5261 " cannot be drawn. The rest of the page still works.</p>";
5362 return;
5463 }
5564
65 // The room, the plate and the tracer, drawn by the engine's renderer out of
66 // the game's own atlas and its own two meshes. Null is a browser with no
67 // WebGL2, and that browser gets the plan on the same canvas — the same game,
68 // seen a flatter way, which is why the fallback is a second context here and
69 // not a second entry.
70 const renderer = createRenderer(scene, { ...assets(), devicePixelRatio: () => window.devicePixelRatio });
71 const plan = renderer === null ? (scene.getContext("2d") as Paint | null) : null;
72 if (renderer === null && plan === null) {
73 note.innerHTML = "<h2>No drawing context</h2><p>This browser gave the stage neither WebGL2 nor a 2D" +
74 " context, so the room cannot be drawn. The rest of the page still works.</p>";
75 return;
76 }
77 const frameScene = createExampleScene();
78
5679 const controls = createControls({ surface: scene, window, document }, { keys: BINDINGS, requireLock: false });
5780 attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: TAPS });
5881
@@ -87,13 +110,14 @@ export function boot(): void {
87110 let starting = false;
88111 let frameMs = 16.7;
89112
90 /** Match the canvas's backing store to its box, at most twice the CSS pixels. */
91 const size = (): [number, number] => {
113 /** Match a canvas's backing store to its box, at most twice the CSS pixels.
114 * The renderer does this for the scene canvas itself; the HUD is the page's. */
115 const size = (canvas: HTMLCanvasElement): [number, number] => {
92116 const dpr = Math.min(window.devicePixelRatio || 1, 2);
93 const width = Math.max(1, Math.round(scene.clientWidth * dpr));
94 const height = Math.max(1, Math.round(scene.clientHeight * dpr));
95 if (scene.width !== width) scene.width = width;
96 if (scene.height !== height) scene.height = height;
117 const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
118 const height = Math.max(1, Math.round(canvas.clientHeight * dpr));
119 if (canvas.width !== width) canvas.width = width;
120 if (canvas.height !== height) canvas.height = height;
97121 return [width, height];
98122 };
99123
@@ -124,8 +148,16 @@ export function boot(): void {
124148 }
125149 }
126150
127 const [width, height] = size();
128 drawPlan(paint, width, height, { world, phase, clock, fraction: clock / RUN });
151 // One frame, either way round: the engine's renderer over the scene the
152 // game filled, or the plan on the same canvas when there is no WebGL2.
153 const frame = { world, phase, clock, fraction: clock / RUN, drawn: renderer !== null };
154 if (renderer !== null) renderer.draw(look(world, frameScene), elapsed);
155 else if (plan !== null) {
156 const [width, height] = size(scene);
157 drawPlan(plan, width, height, frame);
158 }
159 const [width, height] = size(hud);
160 drawReadout(readout, width, height, frame);
129161 $("fps").textContent = `${(1000 / Math.max(1, frameMs)).toFixed(0)} fps · ${frameMs.toFixed(1)} ms`;
130162 });
131163 }
src/example/plan.test.ts +41 −16
@@ -8,15 +8,25 @@
88 import { expect, test } from "bun:test";
99 import { ROOM, createWorld } from "../engine/example.ts";
1010 import { fakePage, type FakePaint } from "../engine/index.ts";
11 import { drawPlan, type Frame } from "./plan.ts";
11 import { drawPlan, drawReadout, type Frame } from "./plan.ts";
1212
1313 const WIDTH = 800, HEIGHT = 450;
1414
15 /** A recorder, and one frame drawn into it. */
15 /** One frame of this game, whole: the fallback's plan, and the readout above it. */
16 const frameOf = (over: Partial<Frame>): Frame =>
17 ({ world: createWorld(7), phase: "ready", clock: 30, fraction: 1, drawn: false, ...over });
18
19 /** A recorder, and one plan drawn into it. */
1620 function drew(over: Partial<Frame> = {}): FakePaint {
1721 const paint = fakePage().surface.paint as FakePaint;
18 const world = createWorld(7);
19 drawPlan(paint, WIDTH, HEIGHT, { world, phase: "ready", clock: 30, fraction: 1, ...over });
22 drawPlan(paint, WIDTH, HEIGHT, frameOf(over));
23 return paint;
24 }
25
26 /** A recorder, and one readout drawn into it — the canvas over the room. */
27 function read(over: Partial<Frame> = {}): FakePaint {
28 const paint = fakePage().surface.paint as FakePaint;
29 drawReadout(paint, WIDTH, HEIGHT, frameOf(over));
2030 return paint;
2131 }
2232
@@ -31,7 +41,7 @@ const rects = (paint: FakePaint): Rect[] =>
3141 /** The aim dots: the only 2×2 rectangles on the canvas, oldest first. */
3242 const dots = (paint: FakePaint): Rect[] => rects(paint).filter((r) => r[2] === 2 && r[3] === 2);
3343
34 test("a frame is one background, one room, one plate, the aim and the readout", () => {
44 test("the plan is one background, one room, one plate and the aim", () => {
3545 const paint = drew();
3646 const all = rects(paint);
3747 // The first rectangle covers the canvas: there is no clearRect in a frame,
@@ -44,8 +54,8 @@ test("a frame is one background, one room, one plate, the aim and the readout",
4454 expect(room[0]).toBeGreaterThanOrEqual(0);
4555 expect(room[0] + room[2]).toBeLessThanOrEqual(WIDTH);
4656 expect(room[1] + room[3]).toBeLessThanOrEqual(HEIGHT);
47 // The readout, and the one line of help.
48 expect(paint.texts).toEqual(["score 0", "0:30", "press Enter, or Start, for thirty seconds"]);
57 // The readout is the other canvas's: the plan itself says nothing.
58 expect(paint.texts).toEqual([]);
4959 // The player is a crosshair: four arms, one path, one stroke.
5060 expect(paint.calls.filter((c) => c[0] === "moveTo").length).toBe(4);
5161 expect(paint.calls.filter((c) => c[0] === "stroke").length).toBe(1);
@@ -82,26 +92,41 @@ test("the plate is drawn where the game says it is, and is never thinner than a
8292 expect(world.target.max[1]).toBeLessThanOrEqual(ROOM.max[1]);
8393 });
8494
95 test("the readout says the score, the clock and the one line of help", () => {
96 expect(read().texts).toEqual(["score 0", "0:30", "press Enter, or Start, for thirty seconds"]);
97 // It is cleared rather than painted over: the room is drawn under it.
98 expect(read().calls[0]).toEqual(["clearRect", 0, 0, WIDTH, HEIGHT]);
99 expect(rects(read()).some((r) => r[2] === WIDTH && r[3] === HEIGHT)).toBe(false);
100 // The crosshair is the middle of the room, and only when there is a room:
101 // on the plan the player is a crosshair where they stand instead.
102 const middle = read({ drawn: true });
103 expect(middle.calls.filter((c) => c[0] === "moveTo").length).toBe(4);
104 expect(middle.calls.filter((c) => c[0] === "moveTo").map((c) => Number(c[1])))
105 .toContain(WIDTH / 2);
106 expect(read({ drawn: false }).calls.filter((c) => c[0] === "moveTo").length).toBe(0);
107 });
108
85109 test("the clock reads the overlay's m:ss, and the last seconds are said differently", () => {
86 expect(drew({ clock: 30 }).texts).toContain("0:30");
87 expect(drew({ clock: 9.2 }).texts).toContain("0:10");
88 expect(drew({ clock: 0 }).texts).toContain("0:00");
110 expect(read({ clock: 30 }).texts).toContain("0:30");
111 expect(read({ clock: 9.2 }).texts).toContain("0:10");
112 expect(read({ clock: 0 }).texts).toContain("0:00");
89113 // The countdown bar is the fraction of the run that is left.
90 const full = rects(drew({ clock: 30, fraction: 1 }));
91 const half = rects(drew({ clock: 15, fraction: 0.5 }));
114 const full = rects(read({ clock: 30, fraction: 1 }));
115 const half = rects(read({ clock: 15, fraction: 0.5 }));
92116 const bar = (r: Rect[]): Rect => r.filter((x) => x[3] === 3)[0]!;
93117 expect(bar(half)[2]).toBeCloseTo(bar(full)[2] / 2, 6);
94118 });
95119
96120 test("each phase says its own line and no other", () => {
97 expect(drew({ phase: "ready" }).texts[2]).toContain("press Enter");
98 expect(drew({ phase: "live" }).texts[2]).toContain("arrows turn");
99 expect(drew({ phase: "over" }).texts[2]).toContain("time");
121 expect(read({ phase: "ready" }).texts[2]).toContain("press Enter");
122 expect(read({ phase: "live" }).texts[2]).toContain("arrows turn");
123 expect(read({ phase: "over" }).texts[2]).toContain("time");
100124 });
101125
102126 test("a narrow phone is the same frame, laid out inside it", () => {
103127 const paint = fakePage().surface.paint as FakePaint;
104 drawPlan(paint, 360, 270, { world: createWorld(7), phase: "live", clock: 12, fraction: 0.4 });
128 drawPlan(paint, 360, 270, frameOf({ phase: "live", clock: 12, fraction: 0.4 }));
129 drawReadout(paint, 360, 270, frameOf({ phase: "live", clock: 12, fraction: 0.4, drawn: true }));
105130 for (const r of rects(paint)) {
106131 expect(r[0]).toBeGreaterThanOrEqual(0);
107132 expect(r[0] + r[2]).toBeLessThanOrEqual(360 + 1);
src/example/plan.ts +42 −15
@@ -1,17 +1,24 @@
11 /**
2 * The guide's room, from above. The game in `src/engine/example.ts` has four
3 * facts — where you stand, which way you face, where the plate is and what you
4 * have hit — and this draws all four as a plan, through the engine's `overlay`
5 * and nothing else: the floor, the plate and the aim are filled rectangles, the
6 * player is a crosshair, the countdown is a bar and the clock is the overlay's
7 * own `m:ss`. No WebGL, no atlas, no mesh and no sound.
2 * The 2D of this game, which since #216 is two halves with two different jobs.
83 *
9 * It takes a `Paint` and a size, so a test draws a frame into a recorder and
4 * `drawReadout` is the score, the clock, the countdown bar and the one line of
5 * help, drawn on the HUD canvas over whatever drew the world. It is on every
6 * frame either way, and it draws no background, so the room behind it shows.
7 *
8 * `drawPlan` is the room from above, and it is the fallback: a browser with no
9 * WebGL2 gets the game as a plan instead of losing it, the way the range's 2D
10 * horizon works. The game in `src/engine/example.ts` has four facts — where you
11 * stand, which way you face, where the plate is and what you have hit — and
12 * this draws all four: the floor, the plate and the aim are filled rectangles
13 * and the player is a crosshair. No atlas and no mesh: everything here comes
14 * out of the engine's `overlay`.
15 *
16 * Both take a `Paint` and a size, so a test draws a frame into a recorder and
1017 * reads the calls back; nothing is remembered between frames.
1118 */
1219
1320 import { ROOM, type World } from "../engine/example.ts";
14 import { angleVectors, overlay, type Box, type Paint } from "../engine/index.ts";
21 import { angleVectors, overlay, type Box, type Overlay, type Paint } from "../engine/index.ts";
1522
1623 /** Where a run is: waiting to start, running, or finished with a score. */
1724 export type Phase = "ready" | "live" | "over";
@@ -24,6 +31,8 @@ export type Frame = {
2431 clock: number;
2532 /** How much of the run is left, 0..1 — the bar under the clock. */
2633 fraction: number;
34 /** Is the room itself on the screen? False is the plan below, in its place. */
35 drawn: boolean;
2736 };
2837
2938 /** The shell's palette, so the canvas and the page around it are one thing. */
@@ -41,22 +50,41 @@ const SAY: Record<Phase, string> = {
4150 over: "time — press Enter for another thirty seconds",
4251 };
4352
44 /** One frame, drawn. The overlay is built here because the size is this call's. */
45 export function drawPlan(paint: Paint, width: number, height: number, frame: Frame): void {
53 /** Where the readout's baseline is, which is also where the plan starts. */
54 const lineOf = (o: Overlay): number => o.pad + Math.round(30 * o.scale);
55
56 /**
57 * The readout, on every frame however the world was drawn: the score, the
58 * clock, how much of the run is left and the one line of help. Nothing is
59 * cleared and no background is painted — this canvas sits over the scene, and
60 * in WebGL2 the scene is the room itself, so the crosshair is drawn here too.
61 */
62 export function drawReadout(paint: Paint, width: number, height: number, frame: Frame): void {
4663 const o = overlay(paint, width, height);
4764 const { world, clock } = frame;
4865 const late = clock <= HURRY;
49 o.panel(0, 0, width, height, BACK);
50
51 // The readout: the score, the clock, and how much of the run is left.
52 const line = o.pad + Math.round(30 * o.scale);
66 const line = lineOf(o);
67 // Cleared and not painted over: what is under this canvas is the room.
68 paint.clearRect(0, 0, width, height);
5369 o.text(`score ${world.score}`, o.pad, line, { font: o.font(30), fill: TEXT });
5470 o.text(o.clock(clock), width - o.pad, line, { align: "right", font: o.font(30), fill: late ? WARN : TEXT });
5571 o.bar(o.pad, line + Math.round(8 * o.scale), width - o.pad * 2, Math.max(2, Math.round(4 * o.scale)),
5672 frame.fraction, late ? WARN : AIM);
73 // Where the shot goes, when the shot goes into a room you are standing in.
74 // On the plan below it is the dots instead, which is what a plan can say.
75 if (frame.drawn) o.crosshair(width / 2, height / 2, Math.round(4 * o.scale), Math.round(10 * o.scale), AIM);
76 o.text(SAY[frame.phase], width / 2, height - o.pad, { align: "center", font: o.font(18), fill: DIM });
77 }
78
79 /** The room from above: the whole frame, for a browser that cannot draw it. */
80 export function drawPlan(paint: Paint, width: number, height: number, frame: Frame): void {
81 const o = overlay(paint, width, height);
82 const { world } = frame;
83 o.panel(0, 0, width, height, BACK);
5784
5885 // The plan itself: the room is square, so the biggest square that fits under
5986 // the readout is the scale everything below is drawn at.
87 const line = lineOf(o);
6088 const side = Math.max(1, Math.min(width - o.pad * 2, height - line - o.pad * 3));
6189 const metres = side / (ROOM.max[0] - ROOM.min[0]);
6290 const cx = width / 2;
@@ -82,5 +110,4 @@ export function drawPlan(paint: Paint, width: number, height: number, frame: Fra
82110 o.panel(sx(x) - dot / 2, sy(y) - dot / 2, dot, dot, AIM);
83111 }
84112 o.crosshair(sx(world.pos[0]), sy(world.pos[1]), 0, Math.round(8 * o.scale), TEXT);
85 o.text(SAY[frame.phase], width / 2, height - o.pad, { align: "center", font: o.font(18), fill: DIM });
86113 }
src/example/stage.test-helper.ts +60 −7
@@ -5,16 +5,60 @@
55 * game's is the list of ids `src/pages/example.ts` renders, the buttons it
66 * renders, and the line that installs all of it as the globals `main.ts` reads.
77 *
8 * There is no GL recorder and no audio counter here, and that is the point: the
9 * example opens neither, so a page that offers neither is a page it still boots
10 * on. `AudioContext` is deliberately not installed — a boot that reached for
11 * one would throw here rather than pass.
8 * There is no audio counter here, and that is the point: the example opens no
9 * sound device, so a page that offers none is a page it still boots on.
10 * `AudioContext` is deliberately not installed — a boot that reached for one
11 * would throw here rather than pass.
12 *
13 * What it does have, since #216, is a recorder for the GL calls the engine's
14 * renderer makes — the same idea as the range's, written the short way. Both
15 * other games write theirs out call by call; this one answers every name it is
16 * asked for, because the list of names is the renderer's and not this game's,
17 * and a third copy of that list is a third thing to keep in step. It is not a
18 * fake GPU: it records what was asked for and checks no pixel. It stays here
19 * rather than behind the door for the reason the range's does — a page that
20 * knew what a renderer was would be this game's page wearing the engine's name.
1221 *
1322 * It ships in no bundle: only `boot.test.ts` and `bundle.test.ts` import it.
1423 */
1524
1625 import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts";
1726
27 /** Every GL call made, in order, and the arguments it was made with. */
28 export type GlRecorder = { calls: [string, ...unknown[]][] };
29
30 /**
31 * A `WebGL2RenderingContext` that writes down what it was told to do. A SHOUTED
32 * name is an enum and answers a distinct number, so a recorded call reads back;
33 * anything else is a function that notes the call and hands back what GL would:
34 * a resource for a `create…`, true for a compile or link, an empty log, a
35 * location for a uniform.
36 */
37 export function glRecorder(): GlRecorder {
38 const calls: [string, ...unknown[]][] = [];
39 const enums = new Map<string, number>();
40 let made = 0;
41 const answer = (name: string): unknown =>
42 name.startsWith("create") ? { kind: name, id: (made += 1) }
43 : name.endsWith("Parameter") ? true
44 : name.endsWith("InfoLog") ? ""
45 : name === "getUniformLocation" ? { kind: "uniform", id: 0 }
46 : undefined;
47 return new Proxy({ calls } as GlRecorder & Record<string, unknown>, {
48 get(target, key): unknown {
49 if (typeof key !== "string" || key === "calls") return Reflect.get(target, key);
50 if (/^[A-Z][A-Z0-9_]*$/.test(key)) {
51 if (!enums.has(key)) enums.set(key, enums.size + 1);
52 return enums.get(key);
53 }
54 return (...args: unknown[]): unknown => {
55 calls.push([key, ...args]);
56 return answer(key);
57 };
58 },
59 });
60 }
61
1862 /** Every id `src/pages/example.ts` puts on the page that the entry looks up. */
1963 export const EXAMPLE_IDS = ["stage", "scene", "hud", "fps", "touch", "note", "full"];
2064
@@ -28,8 +72,10 @@ export type ExamplePage = {
2872 document: FakeDocument;
2973 window: FakeWindow;
3074 scene: FakeCanvas;
31 /** The second canvas the shared stage renders. This game never touches it. */
75 /** The canvas over the room: the score, the clock and the crosshair. */
3276 hud: FakeCanvas;
77 /** Every GL call the renderer made, when this page offered WebGL2 at all. */
78 gl: GlRecorder | null;
3379 by(id: string): FakeElement;
3480 buttons: FakeElement[];
3581 /** The frames asked for but not yet run, oldest first. */
@@ -40,9 +86,13 @@ export type ExamplePage = {
4086 install(): () => void;
4187 };
4288
43 /** The whole page this game's entry expects. `has2d: false` gives no context. */
89 /**
90 * The whole page this game's entry expects. `has2d: false` gives no 2D context
91 * at all, and `webgl: false` is a browser that cannot give the renderer one —
92 * the fallback this game keeps, and a real browser somebody is using.
93 */
4494 export function page(
45 options: { has2d?: boolean; coarse?: boolean; fullscreen?: boolean } = {},
95 options: { has2d?: boolean; webgl?: boolean; coarse?: boolean; fullscreen?: boolean } = {},
4696 ): ExamplePage {
4797 const fake = fakePage({
4898 ids: EXAMPLE_IDS,
@@ -54,12 +104,15 @@ export function page(
54104 fullscreen: options.fullscreen,
55105 });
56106 const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas];
107 const gl = options.webgl === false ? null : glRecorder();
108 scene.gl = gl;
57109
58110 return {
59111 document: fake.document,
60112 window: fake.window,
61113 scene,
62114 hud,
115 gl,
63116 buttons: fake.buttons,
64117 frames: fake.frames,
65118 by: fake.by,
src/games.ts +18 −2
@@ -341,6 +341,22 @@ export type Game = {
341341 * bytes to spare, so the ceiling is held — and it is still the thinnest headroom
342342 * on this page. Neither other bundle moved a byte: nothing outside
343343 * `src/client/` and `src/pages/game.ts` changed. Measured 2026-09-21.
344 * #216 gave the example the renderer it advertises: two mesh recipes, two atlas
345 * layers and a frame of two instances and one line, all in
346 * `src/engine/example.ts`, drawn by the engine's WebGL2 renderer with the 2D
347 * plan kept as the fallback. The example more than doubled for it — 9,735 →
348 * 21,115 raw and 4,618 → 9,189 gzipped — and almost none of those 11,380 bytes
349 * are the game's: the recipes are about forty lines, and what arrived behind
350 * them is `renderer.ts`, `mesh.ts`, `texture.ts` and `scene.ts`, which a tool
351 * set ships whole. That is the price of the headline, and it is the same price
352 * the range paid. Its ceiling could not hold and is 23,500 / 10,200 here,
353 * today's size plus about eleven per cent, which is what every other number on
354 * this page is. The other two grew 46 raw / 33 gzipped (app) and 34 / 24
355 * (range) for one thing: `RendererAssets.near` and `RendererAssets.far`, the
356 * clip planes a game now chooses, because a room four metres wide is invisible
357 * behind a near plane of six Source units. Both fit, so neither moved. Measured
358 * 2026-09-21: app 98,617 raw / 44,304 gzipped, range 34,810 / 14,569,
359 * example 21,115 / 9,189.
344360 */
345361 export const GAMES: readonly Game[] = [
346362 {
@@ -366,8 +382,8 @@ export const GAMES: readonly Game[] = [
366382 href: "/example",
367383 label: "example",
368384 entry: "src/example/main.ts",
369 ceiling: { raw: 10_800, gzip: 5_100 },
385 ceiling: { raw: 23_500, gzip: 10_200 },
370386 blurb:
371 "the guide's own game, served: one room, one plate, thirty seconds and a score, drawn in 2D through the engine's overlay and nothing else — the price tag on a third game.",
387 "the guide's own game, served: one room, one plate, thirty seconds and a score, drawn in WebGL2 through the engine's own renderer, with the plan from above for a browser that has none — the price tag on a third game.",
372388 },
373389 ];
src/pages/example.ts +21 −17
@@ -24,10 +24,10 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script
2424
2525 const NOTE = `
2626 <h2>The smallest game this engine can run</h2>
27 <p>One room seen from above, one plate on the far wall, thirty seconds and a score. The arrow
28 keys turn you, <kbd>WASD</kbd> walks, <kbd>Space</kbd> fires one shot a press, and the blue
29 dots are where that shot goes. <kbd>F</kbd>, or the button in the corner of the stage, puts
30 it on the whole screen.</p>
27 <p>One room, one plate on the far wall, thirty seconds and a score, drawn in WebGL2 by the
28 engine's own renderer. The arrow keys turn you, <kbd>WASD</kbd> walks and <kbd>Space</kbd>
29 fires one shot a press. <kbd>F</kbd>, or the button in the corner of the stage, puts it on
30 the whole screen. A browser with no WebGL2 gets the same room as a plan from above.</p>
3131 <div class="row"><button type="button" class="primary" data-action="start">Start</button></div>
3232 `;
3333
@@ -45,24 +45,28 @@ export function costHtml(cost: Cost): string {
4545 const { script, shooter, game, wiring } = cost;
4646 return ` <p>The game on this page is not on this page. It is <code>src/engine/example.ts</code>,
4747 the ${game} lines the engine's guide is written out of — a room, a plate, a seeded spawn, a
48 fixed step and one shot per press — and it was already there, already tested, before this
49 route existed. What a third game cost was the wiring around it:
50 <code>src/example/main.ts</code> finds the canvas, hands the engine's controls the game's own
51 bindings table, runs the engine's fixed loop and calls the game's <code>step</code>;
52 <code>src/example/plan.ts</code> draws the result. That is two files and ${wiring} lines, one
48 fixed step, one shot per press, two mesh recipes, two painted atlas layers and the frame they
49 become — and it was already there, already tested, before this route existed. What a third
50 game cost was the wiring around it: <code>src/example/main.ts</code> finds the canvas, hands
51 the engine's controls the game's own bindings table, builds the engine's renderer out of
52 those recipes, runs the engine's fixed loop and calls the game's <code>step</code>;
53 <code>src/example/plan.ts</code> is the readout over the room, and the room from above for a
54 browser with no WebGL2. That is two files and ${wiring} lines, one
5355 registry entry in <code>src/games.ts</code>, this page, and one line in the map from a game's
5456 name to its page. No branch was added to the server, and nothing in the header,
5557 the 404 or the other two pages was edited: they read the registry, so this game appeared in
5658 all of them the moment it was written down.</p>
5759 <p>What it did not have to write is the longer list: no loop, no fixed step, no bindings table
5860 reader, no touch buttons, no pointer handling, no ray against a box, no seeded random, no
59 vector maths, no panel, no bar, no clock, no text scaling — all of that is the package, and
60 this game reaches for it through one door, <code>src/engine/index.ts</code>. It is 2D on one
61 canvas: no WebGL, no atlas, no mesh, no sound and no database, and the door costs what you
62 use — the script this page just downloaded is ${bytes(script.raw)} bytes, ${kB(script.gzip)}
63 of them over the wire, against the shooter's ${bytes(shooter.raw)} bytes and ${kB(shooter.gzip)}.
64 Every rectangle, the crosshair, the countdown bar and the <code>m:ss</code> clock come out of
65 the engine's <code>overlay</code>, and nothing else draws here.</p>
61 vector maths, no shader, no draw call, no panel, no bar, no clock, no text scaling — all of
62 that is the package, and this game reaches for it through one door,
63 <code>src/engine/index.ts</code>. What it brings to the draw is a box, a plate and two
64 shaders of a few lines each; what it still does not ship is sound, a database and the policy
65 network the shooter's bots run. The door costs what you use — the script this page just
66 downloaded is ${bytes(script.raw)} bytes, ${kB(script.gzip)} of them over the wire, against
67 the shooter's ${bytes(shooter.raw)} bytes and ${kB(shooter.gzip)}. The score, the countdown
68 bar, the <code>m:ss</code> clock and the crosshair come out of the engine's
69 <code>overlay</code>; the room, the plate and the tracer come out of its renderer.</p>
6670 <p>Every number in those two paragraphs is measured, not typed. The byte counts are this
6771 deployment's own committed bundles under <code>static/</code>, read when this server started
6872 and gzipped at level ${GZIP_LEVEL} — the ruler <code>scripts/build.ts</code> weighs the
@@ -76,7 +80,7 @@ export function examplePage(options: PageOptions): string {
7680 ...options,
7781 title: "example — what a game costs on this engine",
7882 description:
79 "The guide's own game, served: one room, one plate, thirty seconds and a score, drawn in 2D through the engine's overlay and nothing else.",
83 "The guide's own game, served: one room, one plate, thirty seconds and a score, drawn in WebGL2 through the engine's own renderer, with the plan from above as its fallback.",
8084 heading: "example",
8185 nav: navFor("/example"),
8286 terminalLead: "The shooter's leaderboard in a terminal:",
static/app.1519a8ba7e.js added +58

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

View file
static/app.5e15fd39a1.js deleted −58

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

View file
static/app.e2fe4849eb.js deleted −58

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

View file
static/example.4f58463d87.js deleted −1

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

View file
static/example.58e2f464cf.js added +58

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

View file
static/range.2aa2c09163.js added +58

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

View file
static/range.5713b7a936.js deleted −58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs