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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/216-the-sample-draws-with-the-engine-it-adve → main 2 commits fork
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 | ||
| 40 | 40 | expect(ENTRIES).toEqual([ |
| 41 | 41 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 100_000, gzip: 45_000 } }, |
| 42 | 42 | { 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 } }, | |
| 44 | 44 | ]); |
| 45 | 45 | // And it is the registry projected, not a second list beside it: a game's |
| 46 | 46 | // 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. | ||
| 11 | 11 | The engine is not a framework. It never calls your game — your game calls it, |
| 12 | 12 | from a frame it owns, with a page it opened and numbers it chose. Two real games |
| 13 | 13 | 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. | |
| 18 | 19 | |
| 19 | 20 | **Conventions.** A bare name in backticks — `rayBox`, `Mesh` — is the engine's, |
| 20 | 21 | 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 | ||
| 121 | 122 | the shooter's geometry, `src/client/materials.ts` its nine surfaces, |
| 122 | 123 | `src/client/audio.ts` its voice. A second game writes its own three: the range's |
| 123 | 124 | 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 | ||
| 126 | 136 | |
| 127 | 137 | ### 6. Its own tuning |
| 128 | 138 | |
| @@ -167,11 +177,14 @@ carries the fraction along the segment, the point, the normal and the thing that | ||
| 167 | 177 | was hit, which is whatever you put in the list. |
| 168 | 178 | |
| 169 | 179 | 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. | |
| 171 | 183 | |
| 172 | 184 | ts |
| 173 | 185 | // 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; | |
| 175 | 188 | |
| 176 | 189 | |
| 177 | 190 | ### physics |
| @@ -248,8 +261,11 @@ alphabet over this and is one of them. | ||
| 248 | 261 | uvs and a layer into it, and `finish` packs it into a `Mesh` of typed arrays a |
| 249 | 262 | renderer uploads once. `quadMesh` is the full-screen quad for anything drawn |
| 250 | 263 | 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. | |
| 253 | 269 | |
| 254 | 270 | ### texture |
| 255 | 271 | |
| @@ -258,8 +274,9 @@ A `Shader` is `(u, v) => [r, g, b]`, and that is the whole of the interface. | ||
| 258 | 274 | period, `fbm` is a stack of them, and `paintAtlas` runs a list of shaders into |
| 259 | 275 | one tightly packed RGBA buffer whose layers are the material indices the meshes |
| 260 | 276 | 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. | |
| 263 | 280 | |
| 264 | 281 | Painting is arithmetic, and a big atlas is a lot of it: the shooter's nine |
| 265 | 282 | 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. | ||
| 524 | 541 | `RendererAssets.sky` is the horizon for a frame that names none, and |
| 525 | 542 | `RendererAssets.devicePixelRatio` is `window.devicePixelRatio` handed in as a |
| 526 | 543 | 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. | |
| 531 | 552 | |
| 532 | 553 | Then every frame is the `Scene` above, handed to `Renderer.draw`. Make that |
| 533 | 554 | 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. | ||
| 716 | 737 | |
| 717 | 738 | Two games ship here, and every place they differ is a place the engine chose to |
| 718 | 739 | 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. | |
| 721 | 742 | |
| 722 | 743 | | the game supplies | the shooter | the range | |
| 723 | 744 | | --- | --- | --- | |
src/engine/example.test.ts +61 −1
| @@ -13,9 +13,69 @@ | ||
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | 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"; | |
| 17 | 20 | import type { Frames, Host } from "./index.ts"; |
| 18 | 21 | |
| 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 | ||
| 19 | 79 | /** Degrees of turn one tick is worth: the example's 120°/s at its own tick. */ |
| 20 | 80 | const TURN_PER_TICK = 120 * TICK; |
| 21 | 81 |
src/engine/example.ts +109 −12
| @@ -1,16 +1,27 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 4 | 5 | * executed — every fenced block in `GUIDE.md` is a slice of these lines, and |
| 5 | 6 | * `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 | |
| 8 | 9 | * 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. | |
| 9 | 18 | */ |
| 10 | 19 | |
| 11 | 20 | 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, | |
| 14 | 25 | } from "./index.ts"; |
| 15 | 26 | |
| 16 | 27 | /** `KeyboardEvent.code` to action name: this game's whole table, and it is data. */ |
| @@ -25,32 +36,44 @@ export const TICK = 1 / 60; | ||
| 25 | 36 | /** The room, in metres: one box, and the player is held half a metre inside it. */ |
| 26 | 37 | export const ROOM: Box = { min: [-4, -4, 0], max: [4, 4, 3], mat: 0 }; |
| 27 | 38 | |
| 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 | ||
| 28 | 42 | /** The tuning, all of it: metres per second, degrees per second, metres, metres. */ |
| 29 | 43 | const SPEED = 4, TURN = 120, EYE = 1.6, REACH = 40; |
| 30 | 44 | |
| 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. */ | |
| 32 | 52 | export type World = { |
| 33 | 53 | seed: number; pos: Vec3; yaw: number; score: number; firing: boolean; target: Box; |
| 54 | shot: Vec3; spark: number; | |
| 34 | 55 | }; |
| 35 | 56 | |
| 36 | 57 | /** A plate across the far wall from one number, and the number after it: `rng` |
| 37 | 58 | * hands back its own next state over 2^32, so the multiply recovers that state. */ |
| 38 | 59 | function plate(seed: number): [Box, number] { |
| 39 | 60 | 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]; | |
| 41 | 63 | } |
| 42 | 64 | |
| 43 | 65 | export function createWorld(seed: number): World { |
| 44 | 66 | 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 }; | |
| 46 | 68 | } |
| 47 | 69 | |
| 48 | 70 | /** One shot down the view: one ray, one box, and a point if it lands. */ |
| 49 | 71 | function shoot(world: World): World { |
| 50 | 72 | 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; | |
| 52 | 75 | const [target, seed] = plate(world.seed); |
| 53 | return { ...world, score: world.score + 1, target, seed }; | |
| 76 | return { ...fired, score: world.score + 1, target, seed }; | |
| 54 | 77 | } |
| 55 | 78 | |
| 56 | 79 | /** 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 | ||
| 62 | 85 | const put = (x: number, d: number): number => clamp(x + d * SPEED * dt, ROOM.min[0] + 0.5, ROOM.max[0] - 0.5); |
| 63 | 86 | const pos: Vec3 = [put(world.pos[0], walk[0]), put(world.pos[1], walk[1]), EYE]; |
| 64 | 87 | 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) }; | |
| 66 | 89 | return fire && !world.firing ? shoot(next) : next; // one shot per press, not per tick |
| 67 | 90 | } |
| 68 | 91 | |
| 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 | ||
| 69 | 166 | /** The frame: the host and the clock are handed in, the page's in a game and |
| 70 | 167 | * plain objects in a test, so nothing in here looks anything up. */ |
| 71 | 168 | 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", () => { | ||
| 317 | 317 | for (const file of bundles) { |
| 318 | 318 | const code = readFileSync(join(dir, file), "utf8"); |
| 319 | 319 | // 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. | |
| 323 | 322 | expect(code.length).toBeGreaterThan(1000); |
| 324 | 323 | expect(code).toContain("getContext"); |
| 325 | 324 | // 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", () => { | ||
| 327 | 326 | expect(code).not.toContain(HEADLESS); |
| 328 | 327 | expect(code).not.toContain(`class="([a-z-]+)"`); |
| 329 | 328 | } |
| 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); | |
| 332 | 332 | }); |
src/engine/renderer.test.ts +29
| @@ -17,6 +17,7 @@ import { createRenderer } from "./renderer.ts"; | ||
| 17 | 17 | // imports is not a type it exports. |
| 18 | 18 | import type { Scene } from "./scene.ts"; |
| 19 | 19 | import { addBox, builder, finish, type Mesh } from "./mesh.ts"; |
| 20 | import { multiply, perspective, viewMatrix } from "./math.ts"; | |
| 20 | 21 | |
| 21 | 22 | type Call = [string, ...unknown[]]; |
| 22 | 23 | |
| @@ -129,6 +130,34 @@ function scene(mesh = "level"): Scene { | ||
| 129 | 130 | }; |
| 130 | 131 | } |
| 131 | 132 | |
| 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 | ||
| 132 | 161 | /** Every recorded call of this name, arguments and all. */ |
| 133 | 162 | const of = (gl: StubGl, name: string): Call[] => gl.calls.filter((c) => c[0] === name); |
| 134 | 163 |
src/engine/renderer.ts +17 −1
| @@ -110,6 +110,17 @@ export type RendererAssets = { | ||
| 110 | 110 | meshes: Record<string, Mesh>; |
| 111 | 111 | /** The horizon a frame that names no sky is cleared to and fades into. Black when left out. */ |
| 112 | 112 | 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; | |
| 113 | 124 | /** |
| 114 | 125 | * How many device pixels a CSS pixel is: `() => window.devicePixelRatio` in a |
| 115 | 126 | * 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 | ||
| 140 | 151 | /** Past this the pixels cost more than they show, on a phone especially. */ |
| 141 | 152 | const MAX_PIXEL_RATIO = 2; |
| 142 | 153 | |
| 154 | /** The clip planes a game that says nothing gets: Source units, the shooter's. */ | |
| 155 | const NEAR = 6, FAR = 12000; | |
| 156 | ||
| 143 | 157 | const BLACK: Tint = [0, 0, 0]; |
| 144 | 158 | const ORIGIN: Vec3 = [0, 0, 0]; |
| 145 | 159 | |
| @@ -275,6 +289,8 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets | ||
| 275 | 289 | |
| 276 | 290 | const pixelRatio = assets.devicePixelRatio; |
| 277 | 291 | const ownSky = assets.sky ?? BLACK; |
| 292 | const near = assets.near ?? NEAR; | |
| 293 | const far = assets.far ?? FAR; | |
| 278 | 294 | |
| 279 | 295 | const resize = (): void => { |
| 280 | 296 | const dpr = Math.min((pixelRatio === undefined ? 1 : pixelRatio()) || 1, MAX_PIXEL_RATIO); |
| @@ -305,7 +321,7 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets | ||
| 305 | 321 | const aspect = width / Math.max(1, height); |
| 306 | 322 | // A CS field of view is horizontal; the projection wants the vertical one. |
| 307 | 323 | 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); | |
| 309 | 325 | const view = viewMatrix(camera.pos, camera.yaw, camera.pitch); |
| 310 | 326 | const viewProj = multiply(proj, view); |
| 311 | 327 |
src/example/boot.test.ts +78 −28
| @@ -25,7 +25,7 @@ undoLoading(); | ||
| 25 | 25 | |
| 26 | 26 | /** A booted page, and the undo that puts the globals back. */ |
| 27 | 27 | function booted( |
| 28 | options: { has2d?: boolean; coarse?: boolean; fullscreen?: boolean } = {}, | |
| 28 | options: { has2d?: boolean; webgl?: boolean; coarse?: boolean; fullscreen?: boolean } = {}, | |
| 29 | 29 | ): { p: ExamplePage; undo: () => void } { |
| 30 | 30 | const p = page(options); |
| 31 | 31 | const undo = p.install(); |
| @@ -36,17 +36,27 @@ function booted( | ||
| 36 | 36 | type Call = [string, ...unknown[]]; |
| 37 | 37 | |
| 38 | 38 | /** |
| 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. | |
| 41 | 43 | */ |
| 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 ?? []; | |
| 44 | 46 | const from = all.length; |
| 45 | 47 | p.step(ms); |
| 46 | 48 | const calls = all.slice(from); |
| 47 | 49 | return { calls, texts: calls.filter((c) => c[0] === "fillText").map((c) => String(c[1])) }; |
| 48 | 50 | } |
| 49 | 51 | |
| 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 | ||
| 50 | 60 | /** |
| 51 | 61 | * Where the aim dots are, as one string. They are the only 2×2 rectangles on |
| 52 | 62 | * 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 | ||
| 99 | 109 | } |
| 100 | 110 | }); |
| 101 | 111 | |
| 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", () => { | |
| 103 | 113 | const { p, undo } = booted(); |
| 104 | 114 | try { |
| 105 | 115 | expect(p.frames.length).toBe(1); |
| 106 | 116 | 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]); | |
| 109 | 119 | expect(first.texts).toContain("score 0"); |
| 110 | 120 | expect(first.texts).toContain("0:30"); |
| 111 | 121 | expect(first.texts).toContain("press Enter, or Start, for thirty seconds"); |
| 112 | 122 | 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); | |
| 117 | 128 | // A frame was asked for before this one ran, so the loop keeps going. |
| 118 | 129 | expect(p.frames.length).toBe(1); |
| 119 | 130 | } finally { |
| @@ -121,14 +132,53 @@ test("it boots, asks for a frame, and draws the room and the readout", () => { | ||
| 121 | 132 | } |
| 122 | 133 | }); |
| 123 | 134 | |
| 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", () => { | |
| 126 | 136 | const { p, undo } = booted(); |
| 127 | 137 | 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); | |
| 132 | 182 | } finally { |
| 133 | 183 | undo(); |
| 134 | 184 | } |
| @@ -153,39 +203,39 @@ test("Enter starts a run: the note gets out of the way and the clock counts down | ||
| 153 | 203 | }); |
| 154 | 204 | |
| 155 | 205 | 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 }); | |
| 157 | 207 | try { |
| 158 | 208 | p.window.fire("keydown", { code: "Enter" }); |
| 159 | const still = drawn(p, 16); | |
| 209 | const still = drawn(p, 16, "scene"); | |
| 160 | 210 | p.window.fire("keydown", { code: "ArrowLeft" }); |
| 161 | 211 | for (let i = 0; i < 4; i++) p.step(100); |
| 162 | const turned = drawn(p, 100); | |
| 212 | const turned = drawn(p, 100, "scene"); | |
| 163 | 213 | expect(aim(turned.calls)).not.toBe(aim(still.calls)); |
| 164 | 214 | // Letting go stops the turn: the next two frames aim at the same place. |
| 165 | 215 | p.window.fire("keyup", { code: "ArrowLeft" }); |
| 166 | 216 | 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)); | |
| 169 | 219 | |
| 170 | 220 | // Walking north is up the plan, because the room starts facing north. |
| 171 | 221 | const from = marker(held.calls); |
| 172 | 222 | p.window.fire("keydown", { code: "KeyW" }); |
| 173 | 223 | 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]); | |
| 175 | 225 | } finally { |
| 176 | 226 | undo(); |
| 177 | 227 | } |
| 178 | 228 | }); |
| 179 | 229 | |
| 180 | 230 | test("a key the game does not bind does nothing at all", () => { |
| 181 | const { p, undo } = booted(); | |
| 231 | const { p, undo } = booted({ webgl: false }); | |
| 182 | 232 | try { |
| 183 | 233 | p.window.fire("keydown", { code: "Enter" }); |
| 184 | 234 | p.step(100); |
| 185 | const before = drawn(p, 100); | |
| 235 | const before = drawn(p, 100, "scene"); | |
| 186 | 236 | 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)); | |
| 189 | 239 | } finally { |
| 190 | 240 | undo(); |
| 191 | 241 | } |
src/example/bundle.test.ts +29 −10
| @@ -5,10 +5,11 @@ | ||
| 5 | 5 | * mangled name the entry needed, a tree shake that took the boot line with it — |
| 6 | 6 | * fails here rather than in the preview. |
| 7 | 7 | * |
| 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. | |
| 12 | 13 | * |
| 13 | 14 | * Finding the artifact and importing it afresh is `scripts/bundle.test-helper.ts`, |
| 14 | 15 | * 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; | ||
| 28 | 29 | * Install a stub page, import the committed bundle into it — the entry boots |
| 29 | 30 | * itself on the way in — and hand back the page with the undo. |
| 30 | 31 | */ |
| 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 }> { | |
| 32 | 35 | const p = page(options); |
| 33 | 36 | const undo = p.install(); |
| 34 | 37 | try { |
| @@ -40,7 +43,8 @@ async function booted(options: { has2d?: boolean } = {}): Promise<{ p: ExamplePa | ||
| 40 | 43 | return { p, undo }; |
| 41 | 44 | } |
| 42 | 45 | |
| 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 ?? []; | |
| 44 | 48 | |
| 45 | 49 | test("the committed bundle boots the page and draws what the loop produced", async () => { |
| 46 | 50 | const { p, undo } = await booted(); |
| @@ -48,13 +52,14 @@ test("the committed bundle boots the page and draws what the loop produced", asy | ||
| 48 | 52 | // Importing it was enough: it found the page and asked for a frame. |
| 49 | 53 | expect(p.frames.length).toBe(1); |
| 50 | 54 | 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]); | |
| 52 | 56 | // Drawn text, so minification has nothing to hide behind. |
| 53 | 57 | expect(texts(p)).toContain("score 0"); |
| 54 | 58 | expect(texts(p)).toContain("0:30"); |
| 55 | 59 | 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([]); | |
| 58 | 63 | |
| 59 | 64 | // Enter runs the guide's game: the clock moves and the help line changes. |
| 60 | 65 | 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 | ||
| 77 | 82 | } |
| 78 | 83 | }); |
| 79 | 84 | |
| 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 | ||
| 80 | 97 | test("the example bundles none of the engine it does not use", () => { |
| 81 | 98 | // A tool set ships whole to a game that uses any of it, so the way to say |
| 82 | 99 | // 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", () => { | ||
| 84 | 101 | const code = readFileSync(bundlePath("example"), "utf8"); |
| 85 | 102 | expect(code.length).toBeGreaterThan(1000); // the reader is alive |
| 86 | 103 | 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"]) | |
| 88 | 107 | expect(code).not.toContain(name); |
| 89 | 108 | }); |
| 90 | 109 |
src/example/main.ts +47 −15
| @@ -11,13 +11,21 @@ | ||
| 11 | 11 | * and not the game's, so the table below is the game's plus the one key that |
| 12 | 12 | * starts one: the game says what `KeyW` means, the page says what Enter means. |
| 13 | 13 | * |
| 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. | |
| 16 | 22 | */ |
| 17 | 23 | |
| 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"; | |
| 21 | 29 | |
| 22 | 30 | /** |
| 23 | 31 | * 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) | ||
| 42 | 50 | */ |
| 43 | 51 | export function boot(): void { |
| 44 | 52 | const scene = $<HTMLCanvasElement>("scene"); |
| 53 | const hud = $<HTMLCanvasElement>("hud"); | |
| 45 | 54 | const note = $("note"); |
| 46 | 55 | |
| 47 | 56 | // A canvas that gives no context is a sentence on the page, never a throw: |
| 48 | 57 | // 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) { | |
| 51 | 60 | note.innerHTML = "<h2>No 2D canvas</h2><p>This browser gave the page no drawing context, so the room" + |
| 52 | 61 | " cannot be drawn. The rest of the page still works.</p>"; |
| 53 | 62 | return; |
| 54 | 63 | } |
| 55 | 64 | |
| 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 | ||
| 56 | 79 | const controls = createControls({ surface: scene, window, document }, { keys: BINDINGS, requireLock: false }); |
| 57 | 80 | attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: TAPS }); |
| 58 | 81 | |
| @@ -87,13 +110,14 @@ export function boot(): void { | ||
| 87 | 110 | let starting = false; |
| 88 | 111 | let frameMs = 16.7; |
| 89 | 112 | |
| 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] => { | |
| 92 | 116 | 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; | |
| 97 | 121 | return [width, height]; |
| 98 | 122 | }; |
| 99 | 123 | |
| @@ -124,8 +148,16 @@ export function boot(): void { | ||
| 124 | 148 | } |
| 125 | 149 | } |
| 126 | 150 | |
| 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); | |
| 129 | 161 | $("fps").textContent = `${(1000 / Math.max(1, frameMs)).toFixed(0)} fps · ${frameMs.toFixed(1)} ms`; |
| 130 | 162 | }); |
| 131 | 163 | } |
src/example/plan.test.ts +41 −16
| @@ -8,15 +8,25 @@ | ||
| 8 | 8 | import { expect, test } from "bun:test"; |
| 9 | 9 | import { ROOM, createWorld } from "../engine/example.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | |
| 13 | 13 | const WIDTH = 800, HEIGHT = 450; |
| 14 | 14 | |
| 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. */ | |
| 16 | 20 | function drew(over: Partial<Frame> = {}): FakePaint { |
| 17 | 21 | 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)); | |
| 20 | 30 | return paint; |
| 21 | 31 | } |
| 22 | 32 | |
| @@ -31,7 +41,7 @@ const rects = (paint: FakePaint): Rect[] => | ||
| 31 | 41 | /** The aim dots: the only 2×2 rectangles on the canvas, oldest first. */ |
| 32 | 42 | const dots = (paint: FakePaint): Rect[] => rects(paint).filter((r) => r[2] === 2 && r[3] === 2); |
| 33 | 43 | |
| 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", () => { | |
| 35 | 45 | const paint = drew(); |
| 36 | 46 | const all = rects(paint); |
| 37 | 47 | // 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", | ||
| 44 | 54 | expect(room[0]).toBeGreaterThanOrEqual(0); |
| 45 | 55 | expect(room[0] + room[2]).toBeLessThanOrEqual(WIDTH); |
| 46 | 56 | 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([]); | |
| 49 | 59 | // The player is a crosshair: four arms, one path, one stroke. |
| 50 | 60 | expect(paint.calls.filter((c) => c[0] === "moveTo").length).toBe(4); |
| 51 | 61 | 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 | ||
| 82 | 92 | expect(world.target.max[1]).toBeLessThanOrEqual(ROOM.max[1]); |
| 83 | 93 | }); |
| 84 | 94 | |
| 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 | ||
| 85 | 109 | 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"); | |
| 89 | 113 | // 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 })); | |
| 92 | 116 | const bar = (r: Rect[]): Rect => r.filter((x) => x[3] === 3)[0]!; |
| 93 | 117 | expect(bar(half)[2]).toBeCloseTo(bar(full)[2] / 2, 6); |
| 94 | 118 | }); |
| 95 | 119 | |
| 96 | 120 | 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"); | |
| 100 | 124 | }); |
| 101 | 125 | |
| 102 | 126 | test("a narrow phone is the same frame, laid out inside it", () => { |
| 103 | 127 | 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 })); | |
| 105 | 130 | for (const r of rects(paint)) { |
| 106 | 131 | expect(r[0]).toBeGreaterThanOrEqual(0); |
| 107 | 132 | expect(r[0] + r[2]).toBeLessThanOrEqual(360 + 1); |
src/example/plan.ts +42 −15
| @@ -1,17 +1,24 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 8 | 3 | * |
| 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 | |
| 10 | 17 | * reads the calls back; nothing is remembered between frames. |
| 11 | 18 | */ |
| 12 | 19 | |
| 13 | 20 | 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"; | |
| 15 | 22 | |
| 16 | 23 | /** Where a run is: waiting to start, running, or finished with a score. */ |
| 17 | 24 | export type Phase = "ready" | "live" | "over"; |
| @@ -24,6 +31,8 @@ export type Frame = { | ||
| 24 | 31 | clock: number; |
| 25 | 32 | /** How much of the run is left, 0..1 — the bar under the clock. */ |
| 26 | 33 | fraction: number; |
| 34 | /** Is the room itself on the screen? False is the plan below, in its place. */ | |
| 35 | drawn: boolean; | |
| 27 | 36 | }; |
| 28 | 37 | |
| 29 | 38 | /** The shell's palette, so the canvas and the page around it are one thing. */ |
| @@ -41,22 +50,41 @@ const SAY: Record<Phase, string> = { | ||
| 41 | 50 | over: "time — press Enter for another thirty seconds", |
| 42 | 51 | }; |
| 43 | 52 | |
| 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 { | |
| 46 | 63 | const o = overlay(paint, width, height); |
| 47 | 64 | const { world, clock } = frame; |
| 48 | 65 | 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); | |
| 53 | 69 | o.text(`score ${world.score}`, o.pad, line, { font: o.font(30), fill: TEXT }); |
| 54 | 70 | o.text(o.clock(clock), width - o.pad, line, { align: "right", font: o.font(30), fill: late ? WARN : TEXT }); |
| 55 | 71 | o.bar(o.pad, line + Math.round(8 * o.scale), width - o.pad * 2, Math.max(2, Math.round(4 * o.scale)), |
| 56 | 72 | 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); | |
| 57 | 84 | |
| 58 | 85 | // The plan itself: the room is square, so the biggest square that fits under |
| 59 | 86 | // the readout is the scale everything below is drawn at. |
| 87 | const line = lineOf(o); | |
| 60 | 88 | const side = Math.max(1, Math.min(width - o.pad * 2, height - line - o.pad * 3)); |
| 61 | 89 | const metres = side / (ROOM.max[0] - ROOM.min[0]); |
| 62 | 90 | const cx = width / 2; |
| @@ -82,5 +110,4 @@ export function drawPlan(paint: Paint, width: number, height: number, frame: Fra | ||
| 82 | 110 | o.panel(sx(x) - dot / 2, sy(y) - dot / 2, dot, dot, AIM); |
| 83 | 111 | } |
| 84 | 112 | 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 }); | |
| 86 | 113 | } |
src/example/stage.test-helper.ts +60 −7
| @@ -5,16 +5,60 @@ | ||
| 5 | 5 | * game's is the list of ids `src/pages/example.ts` renders, the buttons it |
| 6 | 6 | * renders, and the line that installs all of it as the globals `main.ts` reads. |
| 7 | 7 | * |
| 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. | |
| 12 | 21 | * |
| 13 | 22 | * It ships in no bundle: only `boot.test.ts` and `bundle.test.ts` import it. |
| 14 | 23 | */ |
| 15 | 24 | |
| 16 | 25 | import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts"; |
| 17 | 26 | |
| 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 | ||
| 18 | 62 | /** Every id `src/pages/example.ts` puts on the page that the entry looks up. */ |
| 19 | 63 | export const EXAMPLE_IDS = ["stage", "scene", "hud", "fps", "touch", "note", "full"]; |
| 20 | 64 | |
| @@ -28,8 +72,10 @@ export type ExamplePage = { | ||
| 28 | 72 | document: FakeDocument; |
| 29 | 73 | window: FakeWindow; |
| 30 | 74 | 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. */ | |
| 32 | 76 | hud: FakeCanvas; |
| 77 | /** Every GL call the renderer made, when this page offered WebGL2 at all. */ | |
| 78 | gl: GlRecorder | null; | |
| 33 | 79 | by(id: string): FakeElement; |
| 34 | 80 | buttons: FakeElement[]; |
| 35 | 81 | /** The frames asked for but not yet run, oldest first. */ |
| @@ -40,9 +86,13 @@ export type ExamplePage = { | ||
| 40 | 86 | install(): () => void; |
| 41 | 87 | }; |
| 42 | 88 | |
| 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 | */ | |
| 44 | 94 | export function page( |
| 45 | options: { has2d?: boolean; coarse?: boolean; fullscreen?: boolean } = {}, | |
| 95 | options: { has2d?: boolean; webgl?: boolean; coarse?: boolean; fullscreen?: boolean } = {}, | |
| 46 | 96 | ): ExamplePage { |
| 47 | 97 | const fake = fakePage({ |
| 48 | 98 | ids: EXAMPLE_IDS, |
| @@ -54,12 +104,15 @@ export function page( | ||
| 54 | 104 | fullscreen: options.fullscreen, |
| 55 | 105 | }); |
| 56 | 106 | const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas]; |
| 107 | const gl = options.webgl === false ? null : glRecorder(); | |
| 108 | scene.gl = gl; | |
| 57 | 109 | |
| 58 | 110 | return { |
| 59 | 111 | document: fake.document, |
| 60 | 112 | window: fake.window, |
| 61 | 113 | scene, |
| 62 | 114 | hud, |
| 115 | gl, | |
| 63 | 116 | buttons: fake.buttons, |
| 64 | 117 | frames: fake.frames, |
| 65 | 118 | by: fake.by, |
src/games.ts +18 −2
| @@ -341,6 +341,22 @@ export type Game = { | ||
| 341 | 341 | * bytes to spare, so the ceiling is held — and it is still the thinnest headroom |
| 342 | 342 | * on this page. Neither other bundle moved a byte: nothing outside |
| 343 | 343 | * `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. | |
| 344 | 360 | */ |
| 345 | 361 | export const GAMES: readonly Game[] = [ |
| 346 | 362 | { |
| @@ -366,8 +382,8 @@ export const GAMES: readonly Game[] = [ | ||
| 366 | 382 | href: "/example", |
| 367 | 383 | label: "example", |
| 368 | 384 | entry: "src/example/main.ts", |
| 369 | ceiling: { raw: 10_800, gzip: 5_100 }, | |
| 385 | ceiling: { raw: 23_500, gzip: 10_200 }, | |
| 370 | 386 | 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.", | |
| 372 | 388 | }, |
| 373 | 389 | ]; |
src/pages/example.ts +21 −17
| @@ -24,10 +24,10 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script | ||
| 24 | 24 | |
| 25 | 25 | const NOTE = ` |
| 26 | 26 | <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> | |
| 31 | 31 | <div class="row"><button type="button" class="primary" data-action="start">Start</button></div> |
| 32 | 32 | `; |
| 33 | 33 | |
| @@ -45,24 +45,28 @@ export function costHtml(cost: Cost): string { | ||
| 45 | 45 | const { script, shooter, game, wiring } = cost; |
| 46 | 46 | return ` <p>The game on this page is not on this page. It is <code>src/engine/example.ts</code>, |
| 47 | 47 | 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 | |
| 53 | 55 | registry entry in <code>src/games.ts</code>, this page, and one line in the map from a game's |
| 54 | 56 | name to its page. No branch was added to the server, and nothing in the header, |
| 55 | 57 | the 404 or the other two pages was edited: they read the registry, so this game appeared in |
| 56 | 58 | all of them the moment it was written down.</p> |
| 57 | 59 | <p>What it did not have to write is the longer list: no loop, no fixed step, no bindings table |
| 58 | 60 | 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> | |
| 66 | 70 | <p>Every number in those two paragraphs is measured, not typed. The byte counts are this |
| 67 | 71 | deployment's own committed bundles under <code>static/</code>, read when this server started |
| 68 | 72 | 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 { | ||
| 76 | 80 | ...options, |
| 77 | 81 | title: "example — what a game costs on this engine", |
| 78 | 82 | 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.", | |
| 80 | 84 | heading: "example", |
| 81 | 85 | nav: navFor("/example"), |
| 82 | 86 | 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 filestatic/app.5e15fd39a1.js deleted −58
Diff not shown — a line 77,848 characters wide (limit 2,000). −58
View filestatic/app.e2fe4849eb.js deleted −58
Diff not shown — a line 79,047 characters wide (limit 2,000). −58
View filestatic/example.4f58463d87.js deleted −1
Diff not shown — a line 9,725 characters wide (limit 2,000). −1
View filestatic/example.58e2f464cf.js added +58
Diff not shown — a line 10,369 characters wide (limit 2,000). +58
View filestatic/range.2aa2c09163.js added +58
Diff not shown — a line 18,852 characters wide (limit 2,000). +58
View filestatic/range.5713b7a936.js deleted −58
Diff not shown — a line 18,819 characters wide (limit 2,000). −58
View file