Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes · 4 behind
Boot is not a freeze: the atlas is painted in slices, and the shooter's menu answers while it paints #125
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/99-boot-is-not-a-freeze-the-atlas-is-painte → main 2 commits fork
src/client/bundle.test.ts +23 −3
| @@ -35,6 +35,14 @@ const REAL_FETCH = globalThis.fetch; | ||
| 35 | 35 | |
| 36 | 36 | const FRAME = 1000 / 30; |
| 37 | 37 | |
| 38 | /** | |
| 39 | * How many frames the entry paints its atlas over: one layer each, and this | |
| 40 | * game has nine. Written out rather than imported from `src/client/materials.ts` | |
| 41 | * — nothing under `src/client/` but the stub page belongs in here, and a | |
| 42 | * minified file has no constant to read anyway. | |
| 43 | */ | |
| 44 | const LAYERS = 9; | |
| 45 | ||
| 38 | 46 | /** Every id the shell renders that the entry has to find to run a match. */ |
| 39 | 47 | const NEEDED = ["scene", "hud", "fps", "touch", "play", "name", "length", "side", "sens"]; |
| 40 | 48 | |
| @@ -73,10 +81,12 @@ async function booted( | ||
| 73 | 81 | return { stage, asked, undo }; |
| 74 | 82 | } |
| 75 | 83 | |
| 76 | /** Every GL call the scene canvas was given, and the draws among them. */ | |
| 84 | /** Every GL call the scene canvas was given, and the draws and uploads among them. */ | |
| 77 | 85 | const glCalls = (stage: FakeStage): [string, ...unknown[]][] => stage.scene.gl?.calls ?? []; |
| 78 | 86 | const drawn = (stage: FakeStage): [string, ...unknown[]][] => |
| 79 | 87 | glCalls(stage).filter((call) => call[0] === "drawElements"); |
| 88 | const uploads = (stage: FakeStage): [string, ...unknown[]][] => | |
| 89 | glCalls(stage).filter((call) => call[0] === "texImage3D"); | |
| 80 | 90 | |
| 81 | 91 | test("the committed bundle boots the page, and a click on play plays a match", async () => { |
| 82 | 92 | const { stage, asked, undo } = await booted({ coarse: true }); |
| @@ -86,11 +96,21 @@ test("the committed bundle boots the page, and a click on play plays a match", a | ||
| 86 | 96 | expect(stage.frames.length).toBe(1); |
| 87 | 97 | expect(stage.by("menu").hidden).toBe(false); |
| 88 | 98 | expect(drawn(stage).length).toBe(0); |
| 89 | // Its own atlas, uploaded once by the minified file: nine 128² layers. | |
| 90 | expect(glCalls(stage).find((call) => call[0] === "texImage3D")?.slice(4, 7)).toEqual([128, 128, 9]); | |
| 99 | // Nothing is uploaded yet and Play is held: the minified file paints its | |
| 100 | // atlas across the frames after boot, the same as the sources do. | |
| 101 | expect(uploads(stage).length).toBe(0); | |
| 102 | expect(stage.by("play").disabled).toBe(true); | |
| 91 | 103 | // The touch buttons the shell renders were bound too, every one of them. |
| 92 | 104 | expect(stage.buttons.every((button) => button.listening.size > 0)).toBe(true); |
| 93 | 105 | |
| 106 | // A layer a frame, and on the last of them one upload of nine 128² layers. | |
| 107 | for (let layer = 0; layer < LAYERS; layer++) stage.step(FRAME); | |
| 108 | expect(uploads(stage).length).toBe(1); | |
| 109 | expect(uploads(stage)[0]?.slice(4, 7)).toEqual([128, 128, 9]); | |
| 110 | expect(stage.by("play").disabled).toBe(false); | |
| 111 | expect(stage.by("prep").hidden).toBe(true); | |
| 112 | expect(drawn(stage).length).toBe(0); | |
| 113 | ||
| 94 | 114 | stage.by("name").value = "Vesper"; |
| 95 | 115 | stage.by("side").value = "t"; |
| 96 | 116 | stage.by("length").value = "short"; |
src/client/main.test.ts +78 −3
| @@ -6,6 +6,10 @@ | ||
| 6 | 6 | * nothing is timed by a real clock — the test decides when a frame happens and |
| 7 | 7 | * how long it took. |
| 8 | 8 | * |
| 9 | * The first frames are the atlas: `boot` paints a layer per frame and builds | |
| 10 | * the renderer on the last of them, so a test that wants a match steps those | |
| 11 | * frames first — `painted` below — and two tests are about that window itself. | |
| 12 | * | |
| 9 | 13 | * The world is not reached for behind `boot`'s back: `boot` hands back the |
| 10 | 14 | * running frame and that frame says what it is simulating, so a test that wants |
| 11 | 15 | * a match that is nearly over says so on that world and steps another frame. |
| @@ -14,6 +18,7 @@ | ||
| 14 | 18 | |
| 15 | 19 | import { expect, test } from "bun:test"; |
| 16 | 20 | import { boot, gameMeshes, type Frame } from "./main.ts"; |
| 21 | import { LAYERS, materialTextures } from "./materials.ts"; | |
| 17 | 22 | import { WEAPONS } from "./weapons.ts"; |
| 18 | 23 | import { parseResult } from "../result.ts"; |
| 19 | 24 | import { fakeStage, type FakeStage } from "./stage.test-helper.ts"; |
| @@ -34,9 +39,19 @@ const worldOf = (stage: FakeStage): World | null => running.get(stage)?.world() | ||
| 34 | 39 | const TICK = 1 / 64; |
| 35 | 40 | const FRAME = 1000 / 30; |
| 36 | 41 | |
| 37 | /** A booted page, and the match already started on it. */ | |
| 42 | /** | |
| 43 | * Step the frames the atlas is painted over: one layer each, and the renderer | |
| 44 | * is built on the last of them. Nothing can be played before this is done, so | |
| 45 | * every test that plays goes through it. | |
| 46 | */ | |
| 47 | function painted(stage: FakeStage): FakeStage { | |
| 48 | for (let layer = 0; layer < LAYERS; layer++) stage.step(FRAME); | |
| 49 | return stage; | |
| 50 | } | |
| 51 | ||
| 52 | /** A booted page, its atlas painted, and the match already started on it. */ | |
| 38 | 53 | function playing(options: { webgl?: boolean; coarse?: boolean } = {}): FakeStage { |
| 39 | const stage = start(fakeStage(options)); | |
| 54 | const stage = painted(start(fakeStage(options))); | |
| 40 | 55 | stage.by("play").fire("click"); |
| 41 | 56 | return stage; |
| 42 | 57 | } |
| @@ -58,8 +73,66 @@ test("it boots on the page's ids, asks for a frame, and draws nothing until a ma | ||
| 58 | 73 | expect(stage.frames.length).toBe(1); |
| 59 | 74 | }); |
| 60 | 75 | |
| 76 | test("the atlas is painted a layer a frame, and the menu says so while it is", () => { | |
| 77 | const stage = start(fakeStage()); | |
| 78 | // Nothing was painted inside `boot`: the frame was asked for, the page has it | |
| 79 | // back, and the menu is up and honest about what it is doing. | |
| 80 | expect(stage.atlases.length).toBe(0); | |
| 81 | expect(stage.by("prep").hidden).toBe(false); | |
| 82 | expect(stage.by("play").disabled).toBe(true); | |
| 83 | expect(stage.frames.length).toBe(1); | |
| 84 | ||
| 85 | // Nine layers, nine frames: the renderer is not built until the last one, and | |
| 86 | // the menu is answering the whole way through — the buy panel's key, the | |
| 87 | // pointer, the fields all belong to the page between slices. | |
| 88 | for (let layer = 1; layer < LAYERS; layer++) { | |
| 89 | stage.step(FRAME); | |
| 90 | expect(stage.atlases.length).toBe(0); | |
| 91 | expect(stage.by("prep").hidden).toBe(false); | |
| 92 | } | |
| 93 | stage.step(FRAME); | |
| 94 | ||
| 95 | // One renderer, built once, out of the finished bytes — the same bytes the | |
| 96 | // one-call painter gives — and before anything has been drawn with it. | |
| 97 | expect(stage.atlases.length).toBe(1); | |
| 98 | expect(stage.atlases[0]).toEqual(materialTextures()); | |
| 99 | expect(stage.draws.length).toBe(0); | |
| 100 | expect(stage.by("prep").hidden).toBe(true); | |
| 101 | expect(stage.by("play").disabled).toBe(false); | |
| 102 | ||
| 103 | // And it stays one: the frames keep coming and nothing is painted again. | |
| 104 | stage.step(FRAME); | |
| 105 | stage.step(FRAME); | |
| 106 | expect(stage.atlases.length).toBe(1); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a match cannot be started before the atlas is finished", () => { | |
| 110 | const stage = start(fakeStage()); | |
| 111 | // The button is disabled, but a click that arrives anyway is not a match: no | |
| 112 | // world, no pointer asked for, no id handed out, nothing stored. | |
| 113 | stage.by("play").fire("click"); | |
| 114 | expect(worldOf(stage)).toBeNull(); | |
| 115 | expect(stage.scene.locks).toBe(0); | |
| 116 | expect(stage.ids.length).toBe(0); | |
| 117 | expect(stage.store.size).toBe(0); | |
| 118 | expect(stage.by("menu").hidden).toBe(false); | |
| 119 | ||
| 120 | // Eight of the nine layers in: still not a match. | |
| 121 | for (let layer = 1; layer < LAYERS; layer++) stage.step(FRAME); | |
| 122 | stage.by("play").fire("click"); | |
| 123 | expect(worldOf(stage)).toBeNull(); | |
| 124 | ||
| 125 | // The ninth lands, and the same click is one. | |
| 126 | stage.step(FRAME); | |
| 127 | stage.by("play").fire("click"); | |
| 128 | expect(worldOf(stage)).not.toBeNull(); | |
| 129 | expect(stage.by("menu").hidden).toBe(true); | |
| 130 | stage.step(FRAME); | |
| 131 | expect(stage.draws.length).toBe(1); | |
| 132 | }); | |
| 133 | ||
| 61 | 134 | test("the name is remembered between visits, and a blank one is `you`", () => { |
| 62 | const first = start(fakeStage()); | |
| 135 | const first = painted(start(fakeStage())); | |
| 63 | 136 | first.by("name").value = "Vesper"; |
| 64 | 137 | first.by("play").fire("click"); |
| 65 | 138 | expect(first.store.get("cs.name")).toBe("Vesper"); |
| @@ -298,6 +371,8 @@ test("no WebGL2 is a sentence on the page, not an exception", () => { | ||
| 298 | 371 | expect(stage.window.listening.size).toBe(0); |
| 299 | 372 | expect(stage.document.listening.size).toBe(0); |
| 300 | 373 | expect(stage.draws.length).toBe(0); |
| 374 | // And it cost no atlas: a browser that cannot draw pays for nothing. | |
| 375 | expect(stage.atlases.length).toBe(0); | |
| 301 | 376 | }); |
| 302 | 377 | |
| 303 | 378 | test("every mesh name a scene of this game can place is handed in at construction", () => { |
src/client/main.ts +80 −18
| @@ -6,6 +6,12 @@ | ||
| 6 | 6 | * game's atlas and this game's meshes — and the HUD is painted on top. The page's overlays — menu, buy, pause — are plain DOM, so they are |
| 7 | 7 | * reachable with a keyboard and big enough for a thumb. |
| 8 | 8 | * |
| 9 | * Booting does not freeze the page. This game's atlas is nine 128-pixel layers | |
| 10 | * and about 170 ms of arithmetic, so `boot` paints one layer per frame through | |
| 11 | * the engine's `painter` and builds the renderer on the frame the last row | |
| 12 | * lands: until then the menu says so and holds Play, and every frame in between | |
| 13 | * belongs to the browser. `boot` itself still returns its `Frame` at once. | |
| 14 | * | |
| 9 | 15 | * Nothing in here is a browser. `boot(host)` takes everything it reaches for — |
| 10 | 16 | * the document, the window, a store, a `fetch`, a source of ids, and the two |
| 11 | 17 | * factories that make a renderer and a voice — as one plain object, typed by |
| @@ -19,13 +25,13 @@ import { createAudio, type Audio } from "./audio.ts"; | ||
| 19 | 25 | import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts"; |
| 20 | 26 | import { drawHud, type Paint } from "./hud.ts"; |
| 21 | 27 | import { |
| 22 | attachButtons, clamp, createControls, createLoop, createRenderer, type Button, type Camera, | |
| 23 | type Listens, type Mesh, | |
| 28 | attachButtons, clamp, createControls, createLoop, createRenderer, painter, type Button, | |
| 29 | type Camera, type Listens, type Mesh, | |
| 24 | 30 | } from "../engine/index.ts"; |
| 25 | 31 | import { DE_FORGE } from "./map.ts"; |
| 26 | 32 | import type { Level } from "./level.ts"; |
| 27 | 33 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; |
| 28 | import { LAYERS, TEX_SIZE, materialTextures } from "./materials.ts"; | |
| 34 | import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts"; | |
| 29 | 35 | import { meshFor } from "./models.ts"; |
| 30 | 36 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; |
| 31 | 37 | import { buyable, WEAPONS } from "./weapons.ts"; |
| @@ -93,6 +99,16 @@ export type Reply = { readonly ok: boolean; text(): Promise<string> }; | ||
| 93 | 99 | /** All this frame asks of a renderer: one draw call, of one scene. */ |
| 94 | 100 | export type Draws = { draw(scene: Scene, dt: number): void }; |
| 95 | 101 | |
| 102 | /** | |
| 103 | * What a browser that can draw hands back: the renderer, built from the atlas | |
| 104 | * once the atlas is painted. It is two steps rather than one because the two | |
| 105 | * questions have different prices — whether there is a WebGL2 context is free | |
| 106 | * and is asked before the first frame, and the atlas is 170 milliseconds of | |
| 107 | * arithmetic this frame spreads over nine of them. A browser with no context | |
| 108 | * pays for neither: it gets a sentence in the menu and nothing is painted. | |
| 109 | */ | |
| 110 | export type BuildRenderer = (atlas: Uint8Array) => Draws; | |
| 111 | ||
| 96 | 112 | export type Host = { |
| 97 | 113 | document: PageDocument; |
| 98 | 114 | window: PageWindow; |
| @@ -101,7 +117,7 @@ export type Host = { | ||
| 101 | 117 | /** A fresh match id. `crypto.randomUUID` is one; `src/result.ts` checks it. */ |
| 102 | 118 | randomUUID(): string; |
| 103 | 119 | /** Null when this browser has no WebGL2: the menu says so and nothing starts. */ |
| 104 | createRenderer(canvas: Stage): Draws | null; | |
| 120 | createRenderer(canvas: Stage): BuildRenderer | null; | |
| 105 | 121 | createAudio(): Audio; |
| 106 | 122 | }; |
| 107 | 123 | |
| @@ -134,9 +150,11 @@ export function boot(host: Host): Frame { | ||
| 134 | 150 | const lengthField = by<Field>("length"); |
| 135 | 151 | const sideField = by<Field>("side"); |
| 136 | 152 | const sensField = by<Field>("sens"); |
| 153 | const prep = by("prep"); | |
| 154 | const play = by<Panel & { disabled: boolean }>("play"); | |
| 137 | 155 | |
| 138 | const renderer = host.createRenderer(sceneCanvas); | |
| 139 | if (renderer === null) { | |
| 156 | const build = host.createRenderer(sceneCanvas); | |
| 157 | if (build === null) { | |
| 140 | 158 | menu.innerHTML = "<h2>No WebGL2</h2><p>This browser cannot run the game. Everything else on the page still works.</p>"; |
| 141 | 159 | return { world: () => null }; |
| 142 | 160 | } |
| @@ -153,6 +171,17 @@ export function boot(host: Host): Frame { | ||
| 153 | 171 | now: () => win.performance.now(), |
| 154 | 172 | }); |
| 155 | 173 | |
| 174 | /** | |
| 175 | * The atlas, painted a layer a frame. Nine 128-pixel layers are about 170 ms | |
| 176 | * of arithmetic — eleven frames — and painting them here, inside `boot`, | |
| 177 | * meant the menu the player is looking at answered nothing until it was done. | |
| 178 | * So the buffer is allocated now and filled from the frame loop below; the | |
| 179 | * renderer is built exactly once, on the frame the last row lands, out of the | |
| 180 | * finished bytes, and until then there is nothing to draw with and no match. | |
| 181 | */ | |
| 182 | const atlas = painter(MATERIALS, TEX_SIZE); | |
| 183 | let renderer: Draws | null = null; | |
| 184 | ||
| 156 | 185 | let world: World | null = null; |
| 157 | 186 | // One scene and one camera for the whole run: a frame fills them, never makes |
| 158 | 187 | // them. `scene.ts` keeps the line buffer inside the scene for the same reason. |
| @@ -168,6 +197,12 @@ export function boot(host: Host): Frame { | ||
| 168 | 197 | element.hidden = !on; |
| 169 | 198 | }; |
| 170 | 199 | |
| 200 | // The menu says what it is doing while the atlas is painted, and Play cannot | |
| 201 | // be pressed until there is something to play with. Both are undone on the | |
| 202 | // frame the last slice lands. | |
| 203 | play.disabled = true; | |
| 204 | show(prep, true); | |
| 205 | ||
| 171 | 206 | // --- the buy menu, rebuilt whenever it opens so the prices are honest |
| 172 | 207 | function renderBuy(): void { |
| 173 | 208 | if (world === null) return; |
| @@ -250,6 +285,9 @@ export function boot(host: Host): Frame { | ||
| 250 | 285 | } |
| 251 | 286 | |
| 252 | 287 | function start(): void { |
| 288 | // The button is disabled until the atlas is painted; a click that arrives | |
| 289 | // anyway — a keyboard, a script, a browser being generous — is not a match. | |
| 290 | if (renderer === null) return; | |
| 253 | 291 | const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT; |
| 254 | 292 | const team = sideField.value === "t" ? "t" : "ct"; |
| 255 | 293 | const name = nameField.value.trim().slice(0, 24) || "you"; |
| @@ -268,7 +306,7 @@ export function boot(host: Host): Frame { | ||
| 268 | 306 | host.storage.setItem("cs.name", name); |
| 269 | 307 | } |
| 270 | 308 | |
| 271 | by("play").addEventListener("click", start); | |
| 309 | play.addEventListener("click", start); | |
| 272 | 310 | by("resume").addEventListener("click", () => { |
| 273 | 311 | show(pause, false); |
| 274 | 312 | void sceneCanvas.requestPointerLock(); |
| @@ -303,6 +341,16 @@ export function boot(host: Host): Frame { | ||
| 303 | 341 | |
| 304 | 342 | function frame(elapsed: number): void { |
| 305 | 343 | frameAverage += (elapsed * 1000 - frameAverage) * 0.05; |
| 344 | // One layer of the atlas per frame, and the browser has the rest of it: the | |
| 345 | // menu is clickable the whole way through. The last slice is what builds | |
| 346 | // the renderer, before anything has been drawn with it. | |
| 347 | if (renderer === null) { | |
| 348 | if (!atlas.paint(TEX_SIZE)) return; | |
| 349 | renderer = build(atlas.bytes); | |
| 350 | show(prep, false); | |
| 351 | play.disabled = false; | |
| 352 | return; | |
| 353 | } | |
| 306 | 354 | if (world === null || !running) return; |
| 307 | 355 | |
| 308 | 356 | const me = world.me; |
| @@ -333,7 +381,7 @@ export function boot(host: Host): Frame { | ||
| 333 | 381 | running = false; |
| 334 | 382 | doc.exitPointerLock(); |
| 335 | 383 | show(menu, true); |
| 336 | by("play").focus(); | |
| 384 | play.focus(); | |
| 337 | 385 | } |
| 338 | 386 | |
| 339 | 387 | const view = spectated(world); |
| @@ -384,9 +432,16 @@ export function gameMeshes(level: Level = DE_FORGE): Record<string, Mesh> { | ||
| 384 | 432 | * The cast is the seam — the engine's `createRenderer` wants a real canvas, and |
| 385 | 433 | * a `Stage` is the smaller shape this file and a test can both satisfy — and |
| 386 | 434 | * the assets beside it are this game's, which is why they are passed in here |
| 387 | * rather than reached for behind the door: the atlas `materials.ts` paints, the | |
| 388 | * size and layer count it painted at, the meshes above, the horizon a frame | |
| 389 | * that named none would get, and the pixel ratio of the screen we are on. | |
| 435 | * rather than reached for behind the door: the atlas bytes `boot` paints a | |
| 436 | * slice at a time, the size and layer count they were painted at, the meshes | |
| 437 | * above, the horizon a frame that named none would get, and the pixel ratio of | |
| 438 | * the screen we are on. | |
| 439 | * | |
| 440 | * The context is asked for before anything is painted, and asking twice hands | |
| 441 | * back the same one: a browser with no WebGL2 costs nothing and gets the | |
| 442 | * sentence in the menu, and a browser that has one cannot lose it between the | |
| 443 | * question and the answer — which is the only way the engine's `createRenderer` | |
| 444 | * returns null, and why the line below says so rather than pretending. | |
| 390 | 445 | */ |
| 391 | 446 | const browser = (): Host => ({ |
| 392 | 447 | document, |
| @@ -394,13 +449,20 @@ const browser = (): Host => ({ | ||
| 394 | 449 | storage: localStorage, |
| 395 | 450 | fetch: (url, init) => fetch(url, init), |
| 396 | 451 | randomUUID: () => crypto.randomUUID(), |
| 397 | createRenderer: (canvas) => | |
| 398 | createRenderer(canvas as unknown as HTMLCanvasElement, { | |
| 399 | atlas: { bytes: materialTextures(), size: TEX_SIZE, layers: LAYERS }, | |
| 400 | meshes: gameMeshes(), | |
| 401 | sky: SKY, | |
| 402 | devicePixelRatio: () => window.devicePixelRatio, | |
| 403 | }), | |
| 452 | createRenderer: (canvas) => { | |
| 453 | const surface = canvas as unknown as HTMLCanvasElement; | |
| 454 | if (surface.getContext("webgl2") === null) return null; | |
| 455 | return (bytes) => { | |
| 456 | const drawn = createRenderer(surface, { | |
| 457 | atlas: { bytes, size: TEX_SIZE, layers: LAYERS }, | |
| 458 | meshes: gameMeshes(), | |
| 459 | sky: SKY, | |
| 460 | devicePixelRatio: () => window.devicePixelRatio, | |
| 461 | }); | |
| 462 | if (drawn === null) throw new Error("the WebGL2 context went away while the atlas was painted"); | |
| 463 | return drawn; | |
| 464 | }; | |
| 465 | }, | |
| 404 | 466 | createAudio: () => createAudio(), |
| 405 | 467 | }); |
| 406 | 468 |
src/client/stage.test-helper.ts +20 −4
| @@ -30,7 +30,7 @@ | ||
| 30 | 30 | |
| 31 | 31 | import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakePaint, type FakeWindow } from "../engine/index.ts"; |
| 32 | 32 | import type { Audio } from "./audio.ts"; |
| 33 | import type { Draws, Host, Stage } from "./main.ts"; | |
| 33 | import type { BuildRenderer, Draws, Host, Stage } from "./main.ts"; | |
| 34 | 34 | import type { Scene } from "./scene.ts"; |
| 35 | 35 | |
| 36 | 36 | /** |
| @@ -122,6 +122,13 @@ export type FakeStage = { | ||
| 122 | 122 | posts: Post[]; |
| 123 | 123 | answer(post: Post): Promise<{ ok: boolean; text(): Promise<string> }>; |
| 124 | 124 | draws: Drawn[]; |
| 125 | /** | |
| 126 | * The atlas bytes each renderer was built from, newest last. The frame paints | |
| 127 | * its atlas a slice at a time and builds the renderer on the last one, so | |
| 128 | * this is empty while the menu says it is preparing textures and has exactly | |
| 129 | * one entry from then on. | |
| 130 | */ | |
| 131 | atlases: Uint8Array[]; | |
| 125 | 132 | /** Every sound asked for, as `shot:ak47`, `hit`, `beep` or `resume`. */ |
| 126 | 133 | sounds: string[]; |
| 127 | 134 | store: Map<string, string>; |
| @@ -143,7 +150,7 @@ export type FakeStage = { | ||
| 143 | 150 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 144 | 151 | * served page against this same list rather than a second copy of it. |
| 145 | 152 | */ |
| 146 | export const PAGE_IDS = ["scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "play", "resume", "quit", "fps"]; | |
| 153 | export const PAGE_IDS = ["scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "play", "prep", "resume", "quit", "fps"]; | |
| 147 | 154 | |
| 148 | 155 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 149 | 156 | const HIDDEN = ["pause", "buy", "touch"]; |
| @@ -181,6 +188,7 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): | ||
| 181 | 188 | |
| 182 | 189 | const posts: Post[] = []; |
| 183 | 190 | const draws: Drawn[] = []; |
| 191 | const atlases: Uint8Array[] = []; | |
| 184 | 192 | const sounds: string[] = []; |
| 185 | 193 | const store = new Map<string, string>(); |
| 186 | 194 | const ids: string[] = []; |
| @@ -217,7 +225,15 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): | ||
| 217 | 225 | ids.push(`00000000-0000-4000-8000-${String(uuid).padStart(12, "0")}`); |
| 218 | 226 | return ids[ids.length - 1] as string; |
| 219 | 227 | }, |
| 220 | createRenderer: (_canvas: Stage) => (options.webgl === false ? null : renderer), | |
| 228 | createRenderer: (_canvas: Stage): BuildRenderer | null => | |
| 229 | options.webgl === false | |
| 230 | ? null | |
| 231 | : (bytes) => { | |
| 232 | // Every atlas the frame ever built a renderer out of, kept whole: | |
| 233 | // a test says there was one of them and that it was finished. | |
| 234 | atlases.push(bytes); | |
| 235 | return renderer; | |
| 236 | }, | |
| 221 | 237 | createAudio: () => audio, |
| 222 | 238 | }, |
| 223 | 239 | document: page.document, |
| @@ -226,7 +242,7 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): | ||
| 226 | 242 | hud, |
| 227 | 243 | buttons: page.buttons, |
| 228 | 244 | frames: page.frames, |
| 229 | posts, draws, sounds, store, ids, | |
| 245 | posts, draws, atlases, sounds, store, ids, | |
| 230 | 246 | by: page.by, |
| 231 | 247 | step: page.step, |
| 232 | 248 | lock(on) { |
src/engine/GUIDE.md +17 −2
| @@ -203,8 +203,23 @@ A `Shader` is `(u, v) => [r, g, b]`, and that is the whole of the interface. | ||
| 203 | 203 | period, `fbm` is a stack of them, and `paintAtlas` runs a list of shaders into |
| 204 | 204 | one tightly packed RGBA buffer whose layers are the material indices the meshes |
| 205 | 205 | already carry. `src/client/materials.ts` is the shooter's nine surfaces and |
| 206 | `src/range/paint.ts` the range's three layers, each painted once at start-up; | |
| 207 | the example paints nothing, because it uploads no texture. | |
| 206 | `src/range/paint.ts` the range's three layers; the example paints nothing, | |
| 207 | because it uploads no texture. | |
| 208 | ||
| 209 | Painting is arithmetic, and a big atlas is a lot of it: the shooter's nine | |
| 210 | 128-pixel layers are about 170 milliseconds on a desktop and several times that | |
| 211 | on a phone, which is eleven frames in one call that a page answers nothing | |
| 212 | during. So an atlas can also be painted across frames. `painter` hands back a | |
| 213 | `Painter` over the buffer it allocated up front: `Painter.bytes` is that buffer, | |
| 214 | `Painter.paint` paints at most the number of rows it is given — a row is one row | |
| 215 | of one layer, so `Painter.paint(size)` is exactly one layer — and says whether | |
| 216 | the atlas is finished, and `Painter.rowsLeft` is what is still to do. There is | |
| 217 | no clock in it and no callback out of it: the game asks for the next slice from | |
| 218 | the frame loop it already has, tells the player it is working, and builds the | |
| 219 | renderer on the frame the last slice lands. `src/client/main.ts` does exactly | |
| 220 | that, a layer a frame. An atlas small enough to pay for at start-up stays one | |
| 221 | call: the range's three 64-pixel layers are about 17 ms, and `paintAtlas` is | |
| 222 | `painter` asked for every row at once. | |
| 208 | 223 | |
| 209 | 224 | ### sound |
| 210 | 225 |
src/engine/boundary.test.ts +2 −2
| @@ -284,7 +284,7 @@ export const SURFACE = [ | ||
| 284 | 284 | "Frames", |
| 285 | 285 | "Grid", "Hit", "Host", "Instance", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions", |
| 286 | 286 | "Mat4", "Mesh", "MouseEventish", "Options", "Overlay", |
| 287 | "Packed", "PageOptions", "Paint", "Policy", "Renderer", | |
| 287 | "Packed", "PageOptions", "Paint", "Painter", "Policy", "Renderer", | |
| 288 | 288 | "RendererAssets", |
| 289 | 289 | "Scene", "SceneCapacity", "Shader", "Shape", "SoundDevice", "Stick", "Surface", "Tone", |
| 290 | 290 | "TouchEventish", "Touchish", "Vec3", "add", "addBox", "angleDelta", "angleVectors", |
| @@ -298,7 +298,7 @@ export const SURFACE = [ | ||
| 298 | 298 | "logitCount", "mat4", "mergeSolid", |
| 299 | 299 | "modelMatrix", "mul", "multiply", |
| 300 | 300 | "newActivations", "nextInstance", "noise", "normalize", "overlay", |
| 301 | "pack", "paintAtlas", "paramCount", | |
| 301 | "pack", "paintAtlas", "painter", "paramCount", | |
| 302 | 302 | "perspective", "pushLine", |
| 303 | 303 | "quadMesh", "rad", "rayBox", "resetScene", "rng", "rowOf", |
| 304 | 304 | "sample", "scale", "segmentLength", "softmax", "sub", "tensorsOf", |
src/engine/index.ts +3 −3
| @@ -30,9 +30,9 @@ export type { Grid } from "./grid.ts"; | ||
| 30 | 30 | export { addBox, builder, finish, quadMesh } from "./mesh.ts"; |
| 31 | 31 | export type { Builder, Mesh } from "./mesh.ts"; |
| 32 | 32 | |
| 33 | /** Hashed value noise, fbm, and an atlas painted from a list of shaders. */ | |
| 34 | export { fbm, hash, noise, paintAtlas } from "./texture.ts"; | |
| 35 | export type { Shader } from "./texture.ts"; | |
| 33 | /** Hashed value noise, fbm, and an atlas painted from a list of shaders — whole, or a slice a frame. */ | |
| 34 | export { fbm, hash, noise, paintAtlas, painter } from "./texture.ts"; | |
| 35 | export type { Painter, Shader } from "./texture.ts"; | |
| 36 | 36 | |
| 37 | 37 | /** A lazy `AudioContext` with noise bursts, tones and distance attenuation. */ |
| 38 | 38 | export { attenuate, createSoundDevice } from "./sound.ts"; |
src/engine/texture.test.ts +93 −1
| @@ -1,8 +1,27 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { fbm, hash, noise, paintAtlas, type Shader } from "./texture.ts"; | |
| 2 | import { fbm, hash, noise, paintAtlas, painter, type Shader } from "./texture.ts"; | |
| 3 | 3 | |
| 4 | 4 | const flat = (r: number, g: number, b: number): Shader => () => [r, g, b]; |
| 5 | 5 | |
| 6 | /** | |
| 7 | * The two atlases this repository actually paints, as recipes rather than as | |
| 8 | * imports: the shooter's nine 128-pixel layers and the range's three 64-pixel | |
| 9 | * ones. A test in the engine may not import a game — `boundary.test.ts` holds | |
| 10 | * that — and what matters here is the shape and the cost, not the palette. | |
| 11 | */ | |
| 12 | const nine = (): Shader[] => | |
| 13 | Array.from({ length: 9 }, (_, layer): Shader => (u, v) => [ | |
| 14 | fbm(u * 6, v * 6, 6, layer) * 255, | |
| 15 | u * 255, | |
| 16 | (v + layer * 0.1) * 255, | |
| 17 | ]); | |
| 18 | const three = (): Shader[] => | |
| 19 | Array.from({ length: 3 }, (_, layer): Shader => (u, v) => [ | |
| 20 | hash(Math.floor(u * 64), Math.floor(v * 64), layer) * 255, | |
| 21 | fbm(u * 4, v * 4, 4, layer) * 255, | |
| 22 | v * 255, | |
| 23 | ]); | |
| 24 | ||
| 6 | 25 | test("the hash is stable, spread over 0..1, and moves with the seed", () => { |
| 7 | 26 | expect(hash(3, 7, 1)).toBe(hash(3, 7, 1)); |
| 8 | 27 | expect(hash(3, 7, 1)).not.toBe(hash(3, 7, 2)); |
| @@ -55,6 +74,79 @@ test("a shader that returns out-of-range values is clamped, not wrapped", () => | ||
| 55 | 74 | expect([...atlas.slice(0, 4)]).toEqual([255, 0, 0, 255]); |
| 56 | 75 | }); |
| 57 | 76 | |
| 77 | test("a sliced atlas is the same bytes as a whole one, at every slice size", () => { | |
| 78 | // The two real shapes, painted a row at a time, a layer at a time, and in one | |
| 79 | // call larger than the atlas is: each is byte for byte what `paintAtlas` gives. | |
| 80 | for (const [shaders, size] of [[nine(), 128], [three(), 64]] as [Shader[], number][]) { | |
| 81 | const whole = paintAtlas(shaders, size); | |
| 82 | for (const rows of [1, 7, size, size * 2, size * shaders.length + 10]) { | |
| 83 | const p = painter(shaders, size); | |
| 84 | let slices = 0; | |
| 85 | while (!p.paint(rows)) slices += 1; | |
| 86 | expect(p.bytes).toEqual(whole); | |
| 87 | expect(slices).toBe(Math.max(0, Math.ceil((size * shaders.length) / rows) - 1)); | |
| 88 | } | |
| 89 | } | |
| 90 | }); | |
| 91 | ||
| 92 | test("a slice paints no more rows than it was asked for, and the last one says so", () => { | |
| 93 | let rowsSeen = 0; | |
| 94 | let lastRow = -1; | |
| 95 | const counting: Shader = (u, v) => { | |
| 96 | const row = Math.round(v * 8); | |
| 97 | if (row !== lastRow) { | |
| 98 | lastRow = row; | |
| 99 | rowsSeen += 1; | |
| 100 | } | |
| 101 | return [u * 255, v * 255, 0]; | |
| 102 | }; | |
| 103 | const p = painter([counting, counting], 8); | |
| 104 | expect(p.rowsLeft).toBe(16); | |
| 105 | expect(p.paint(3)).toBe(false); | |
| 106 | expect(rowsSeen).toBe(3); | |
| 107 | expect(p.rowsLeft).toBe(13); | |
| 108 | // A slice wider than what is left paints what is left and no more. | |
| 109 | expect(p.paint(5)).toBe(false); | |
| 110 | expect(rowsSeen).toBe(8); | |
| 111 | expect(p.paint(100)).toBe(true); | |
| 112 | expect(rowsSeen).toBe(16); | |
| 113 | expect(p.rowsLeft).toBe(0); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a call after the atlas is done is a no-op that leaves the bytes alone", () => { | |
| 117 | let calls = 0; | |
| 118 | const once: Shader = () => { | |
| 119 | calls += 1; | |
| 120 | return [calls % 256, 0, 0]; | |
| 121 | }; | |
| 122 | const p = painter([once], 4); | |
| 123 | expect(p.paint(4)).toBe(true); | |
| 124 | const painted = p.bytes.slice(); | |
| 125 | expect(calls).toBe(16); | |
| 126 | expect(p.paint(4)).toBe(true); | |
| 127 | expect(p.paint(1000)).toBe(true); | |
| 128 | expect(calls).toBe(16); | |
| 129 | expect(p.bytes).toEqual(painted); | |
| 130 | // And a slice of no rows at all is the same nothing. | |
| 131 | const fresh = painter([once], 4); | |
| 132 | expect(fresh.paint(0)).toBe(false); | |
| 133 | expect(fresh.paint(-5)).toBe(false); | |
| 134 | expect(calls).toBe(16); | |
| 135 | }); | |
| 136 | ||
| 137 | test("the painter allocates its whole buffer up front, and hands back the same one", () => { | |
| 138 | const p = painter([flat(1, 2, 3), flat(4, 5, 6)], 8); | |
| 139 | const bytes = p.bytes; | |
| 140 | expect(bytes.length).toBe(8 * 8 * 4 * 2); | |
| 141 | p.paint(8); | |
| 142 | expect(p.bytes).toBe(bytes); | |
| 143 | // Half painted: the first layer is opaque and the second is still zeroes. | |
| 144 | expect([...bytes.slice(0, 4)]).toEqual([1, 2, 3, 255]); | |
| 145 | expect([...bytes.slice(8 * 8 * 4, 8 * 8 * 4 + 4)]).toEqual([0, 0, 0, 0]); | |
| 146 | expect(p.paint(8)).toBe(true); | |
| 147 | expect([...bytes.slice(8 * 8 * 4, 8 * 8 * 4 + 4)]).toEqual([4, 5, 6, 255]); | |
| 148 | }); | |
| 149 | ||
| 58 | 150 | test("a shader sees uv across the layer, one texel at a time", () => { |
| 59 | 151 | const seen: [number, number][] = []; |
| 60 | 152 | paintAtlas([(u, v) => { seen.push([u, v]); return [0, 0, 0]; }], 4); |
src/engine/texture.ts +67 −18
| @@ -42,24 +42,73 @@ export const fbm = (x: number, y: number, period: number, seed: number, octaves | ||
| 42 | 42 | export type Shader = (u: number, v: number) => [number, number, number]; |
| 43 | 43 | |
| 44 | 44 | /** |
| 45 | * One `size`×`size` layer per shader, tightly packed RGBA and fully opaque, | |
| 46 | * ready for texImage3D. Anything a shader returns outside 0..255 is clamped, | |
| 47 | * because a surface that is a little too bright should not wrap to black. | |
| 45 | * An atlas being painted, a bounded amount at a time. `bytes` is the buffer it | |
| 46 | * fills — allocated whole at the start, so nothing is copied or grown — and | |
| 47 | * `paint(rows)` paints at most that many rows of it and says whether the whole | |
| 48 | * atlas is done. A row is one row of one layer, counted straight through the | |
| 49 | * stack: `paint(size)` is exactly one layer, whatever the layer count. | |
| 50 | * | |
| 51 | * There is no clock in here and no callback out of it. The caller decides when | |
| 52 | * the next slice happens, which for a game is its own frame loop — the atlas | |
| 53 | * for nine 128-pixel layers is about 170 milliseconds of arithmetic, eleven | |
| 54 | * frames' worth, and a page that spends them in one call answers nothing while | |
| 55 | * it does. `src/client/main.ts` paints a layer a frame through this. | |
| 56 | */ | |
| 57 | export type Painter = { | |
| 58 | /** The bytes being filled: `size * size * 4 * shaders.length` of them. */ | |
| 59 | readonly bytes: Uint8Array; | |
| 60 | /** Rows not painted yet; zero once the atlas is finished. */ | |
| 61 | readonly rowsLeft: number; | |
| 62 | /** Paint at most `rows` more rows. True when the whole atlas is painted. */ | |
| 63 | paint(rows: number): boolean; | |
| 64 | }; | |
| 65 | ||
| 66 | /** | |
| 67 | * A `Painter` for one `size`×`size` layer per shader, tightly packed RGBA and | |
| 68 | * fully opaque, ready for texImage3D. Anything a shader returns outside 0..255 | |
| 69 | * is clamped, because a surface that is a little too bright should not wrap to | |
| 70 | * black. | |
| 71 | * | |
| 72 | * This is the one loop in this file: `paintAtlas` below is this, asked for | |
| 73 | * every row at once. | |
| 74 | */ | |
| 75 | export function painter(shaders: Shader[], size: number): Painter { | |
| 76 | const bytes = new Uint8Array(size * size * 4 * shaders.length); | |
| 77 | const rows = size * shaders.length; | |
| 78 | let row = 0; | |
| 79 | return { | |
| 80 | bytes, | |
| 81 | get rowsLeft() { | |
| 82 | return rows - row; | |
| 83 | }, | |
| 84 | paint(count) { | |
| 85 | // A slice never paints more rows than it was asked for, and a call after | |
| 86 | // the last one is a no-op: `stop` is already `row`. | |
| 87 | const stop = Math.min(rows, row + Math.max(0, Math.floor(count))); | |
| 88 | for (; row < stop; row++) { | |
| 89 | const shade = shaders[Math.floor(row / size)] as Shader; | |
| 90 | const y = row % size; | |
| 91 | for (let x = 0; x < size; x++) { | |
| 92 | const [r, g, b] = shade(x / size, y / size); | |
| 93 | const i = (row * size + x) * 4; | |
| 94 | bytes[i] = Math.max(0, Math.min(255, r)); | |
| 95 | bytes[i + 1] = Math.max(0, Math.min(255, g)); | |
| 96 | bytes[i + 2] = Math.max(0, Math.min(255, b)); | |
| 97 | bytes[i + 3] = 255; | |
| 98 | } | |
| 99 | } | |
| 100 | return row >= rows; | |
| 101 | }, | |
| 102 | }; | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * The whole atlas in one call: every layer, every row, painted before this | |
| 107 | * returns. A game whose atlas is small enough to pay for at start-up — the | |
| 108 | * range's three 64-pixel layers are about 17 ms — asks for it this way. | |
| 48 | 109 | */ |
| 49 | 110 | export function paintAtlas(shaders: Shader[], size: number): Uint8Array { |
| 50 | const out = new Uint8Array(size * size * 4 * shaders.length); | |
| 51 | for (let layer = 0; layer < shaders.length; layer++) { | |
| 52 | const shade = shaders[layer] as Shader; | |
| 53 | for (let y = 0; y < size; y++) { | |
| 54 | for (let x = 0; x < size; x++) { | |
| 55 | const [r, g, b] = shade(x / size, y / size); | |
| 56 | const i = (layer * size * size + y * size + x) * 4; | |
| 57 | out[i] = Math.max(0, Math.min(255, r)); | |
| 58 | out[i + 1] = Math.max(0, Math.min(255, g)); | |
| 59 | out[i + 2] = Math.max(0, Math.min(255, b)); | |
| 60 | out[i + 3] = 255; | |
| 61 | } | |
| 62 | } | |
| 63 | } | |
| 64 | return out; | |
| 111 | const whole = painter(shaders, size); | |
| 112 | whole.paint(size * shaders.length); | |
| 113 | return whole.bytes; | |
| 65 | 114 | } |
src/games.ts +11
| @@ -94,6 +94,17 @@ export type Game = { | ||
| 94 | 94 | * because it is 2D: no renderer, no atlas, no sound and no net ship in it, and |
| 95 | 95 | * `src/example/bundle.test.ts` reads the file to say so. Neither other bundle |
| 96 | 96 | * moved a byte for it. |
| 97 | * #99 made the atlas paintable a slice at a time — `painter` in | |
| 98 | * `src/engine/texture.ts`, which `paintAtlas` is now one call of — and the | |
| 99 | * shooter paints a layer a frame through it instead of freezing the page for | |
| 100 | * 170 ms inside `boot`. The app grew 402 raw / 184 gzipped bytes for the | |
| 101 | * painter's state and the boot that steps it, and the range grew 165 raw / 71 | |
| 102 | * gzipped for the painter under the one call it still makes. Measured | |
| 103 | * 2026-09-20 after #99: app 72,048 raw / 33,457 gzipped, range 27,433 raw / | |
| 104 | * 11,745 gzipped, example 8,754 raw / 4,228 gzipped — the same size to the byte, | |
| 105 | * differing only in which short names the minifier handed out, because it | |
| 106 | * paints no texture and ships none of this. Both fit today's ceilings, so | |
| 107 | * neither moved. | |
| 97 | 108 | */ |
| 98 | 109 | export const GAMES: readonly Game[] = [ |
| 99 | 110 | { |
src/pages/game.ts +8 −1
| @@ -60,6 +60,12 @@ const SIGN_IN = "/__forge/signin?to=%2F"; | ||
| 60 | 60 | * The menu, which says under which name the result will be recorded. A signed |
| 61 | 61 | * in player's name is the forge's and the field only shows it; a guest picks |
| 62 | 62 | * theirs and the page says plainly that it is listed as a guest's. |
| 63 | * | |
| 64 | * Play is rendered disabled with a line under it saying why: the textures are | |
| 65 | * painted a layer per frame once the bundle boots, and `src/client/main.ts` | |
| 66 | * hides that line and enables the button on the frame the last layer lands. | |
| 67 | * Rendered that way rather than switched on by the script so the first paint is | |
| 68 | * already true and nothing moves under the pointer afterwards. | |
| 63 | 69 | */ |
| 64 | 70 | const menu = (visitor: Who): string => ` |
| 65 | 71 | <h2>Defuse or detonate</h2> |
| @@ -76,7 +82,8 @@ const menu = (visitor: Who): string => ` | ||
| 76 | 82 | <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label> |
| 77 | 83 | <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label> |
| 78 | 84 | </div> |
| 79 | <div class="row"><button type="button" class="primary" id="play">Play</button></div> | |
| 85 | <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div> | |
| 86 | <p class="hint" id="prep">Preparing textures…</p> | |
| 80 | 87 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 81 | 88 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy · |
| 82 | 89 | <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>Esc</kbd> pause</p> |
src/range/paint.test.ts +16
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { expect, test } from "bun:test"; |
| 14 | import { painter } from "../engine/index.ts"; | |
| 14 | 15 | import { RANGE_LAYERS, RANGE_SHADERS, RANGE_TEX_SIZE, rangeAtlas } from "./paint.ts"; |
| 15 | 16 | import { RANGE_MAT, RANGE_ROOM } from "./level.ts"; |
| 16 | 17 | |
| @@ -36,6 +37,21 @@ test("the same seeds paint the same atlas twice, byte for byte", () => { | ||
| 36 | 37 | expect(digest(rangeAtlas())).toBe(ATLAS_SHA256); |
| 37 | 38 | }); |
| 38 | 39 | |
| 40 | test("these three layers are the same bytes painted a row at a time as painted whole", () => { | |
| 41 | // `rangeAtlas` is one call because 17 ms at start-up is affordable, but it is | |
| 42 | // the engine's `painter` underneath and a slower machine could spread it over | |
| 43 | // frames the way the shooter does. These are the range's own shaders, painted | |
| 44 | // both ways: a row at a time, a layer at a time, and in one slice wider than | |
| 45 | // the whole atlas. | |
| 46 | const whole = rangeAtlas(); | |
| 47 | for (const rows of [1, RANGE_TEX_SIZE, RANGE_TEX_SIZE * RANGE_LAYERS + 5]) { | |
| 48 | const p = painter(RANGE_SHADERS, RANGE_TEX_SIZE); | |
| 49 | while (!p.paint(rows)); | |
| 50 | expect(p.bytes).toEqual(whole); | |
| 51 | expect(p.rowsLeft).toBe(0); | |
| 52 | } | |
| 53 | }); | |
| 54 | ||
| 39 | 55 | test("the range's atlas is small on purpose, and smaller than the shooter's nine", () => { |
| 40 | 56 | expect(RANGE_LAYERS).toBe(RANGE_SHADERS.length); |
| 41 | 57 | expect(RANGE_LAYERS).toBe(3); |
src/server.test.ts +6
| @@ -58,6 +58,12 @@ test("the page carries every id and action the client reaches for", async () => | ||
| 58 | 58 | for (const id of PAGE_IDS) expect(body).toContain(`id="${id}"`); |
| 59 | 59 | for (const action of PAGE_ACTIONS) expect(body).toContain(`data-action="${action}"`); |
| 60 | 60 | expect(body).toContain(`<div id="stage">`); |
| 61 | // The first paint is already true about what the entry is about to do: the | |
| 62 | // textures are painted over the frames after boot, so Play is served disabled | |
| 63 | // and the line under it says why. src/client/main.ts undoes both when the | |
| 64 | // last layer lands, and nothing on the page moves when it does. | |
| 65 | expect(body).toContain(`id="play" disabled>Play</button>`); | |
| 66 | expect(body).toContain(`id="prep">Preparing textures…</p>`); | |
| 61 | 67 | }); |
| 62 | 68 | |
| 63 | 69 | test("serves the range on its own route, with its own page and its own bundle", async () => { |
static/app.dee3add82f.js added +58
Diff not shown — a line 59,465 characters wide (limit 2,000). +58
View filestatic/app.fcd92e70fb.js deleted −58
Diff not shown — a line 59,160 characters wide (limit 2,000). −58
View filestatic/example.95c913d802.js → static/example.c0d730d304.js renamed +1 −1
Diff not shown — a line 8,748 characters wide (limit 2,000). +1 −1
View filestatic/range.37362a7d93.js deleted −58
Diff not shown — a line 15,483 characters wide (limit 2,000). −58
View filestatic/range.f0416248f9.js added +58
Diff not shown — a line 15,483 characters wide (limit 2,000). +58
View file