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

forked from trevato/cs · proposes

The scene becomes data: src/client/scene.ts builds a frame, and the renderer draws only that #56

Merged opened this

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

Merged

vesper/cs-engine:vesper/47-the-scene-becomes-data-src-client-scene → main 1 commit fork

  • Merged. vesper/47-the-scene-becomes-data-src-client-scene was merged into main as a024b3f.

Review

Sign in to review this change.
11 files changed +663 −244
README.md +15 −5
@@ -150,19 +150,29 @@ from the angle it subtends, and you turn on the spot.
150150 over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules),
151151 `world` (the simulation),
152152 `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`,
153 `binding`, `renderer`, `hud`, `audio`, `main`. Everything is headless and
154 tested but `renderer`, which is the one module that needs a real WebGL2
153 `binding`, `scene`, `renderer`, `hud`, `audio`, `main`. Everything is headless
154 and tested but `renderer`, which is the one module that needs a real WebGL2
155155 context; `models`, `materials` and `audio` are this game's recipes for the
156156 engine's three asset tools, and `binding` is its key table and the one
157 function from a held action to an `Input`. `main` is the frame, and it takes
157 function from a held action to an `Input`. `scene` is the frame as data —
158 `buildScene(world, camera, into)` fills one scene object with the camera, the
159 sky, the instances to draw in the order to draw them, the line vertices every
160 tracer and spark and beam comes to, and the gun in your hands — and `renderer`
161 draws that and nothing else: it names no world, no physics and no weapons, so
162 a second game can hand it a scene of its own. Which is why every decision
163 about a frame — who is drawn, how far a duck squashes them, how long a tracer
164 lives, how far the last shot kicked the view model — is checked in
165 `scene.test.ts` with no canvas in the room. Nothing is allocated per frame:
166 `main` keeps one scene and one `Float32Array` and the instances are a pool. `main` is the frame, and it takes
158167 its host: `boot(host)` is handed the document, the window, a store, a `fetch`,
159168 a source of match ids and the two factories that make a renderer and a voice,
160169 and the last lines of the file are the only place a browser global is named.
161170 `main.test.ts` boots the whole frame against the stub page in
162171 `stage.test-helper.ts` — elements that record what was hidden, written and
163172 focused, a 2D context that writes down every call, a renderer that keeps the
164 world it was handed, a `fetch` that keeps the bodies, and a clock the test
165 moves — so a click on **play**, the two 64 Hz steps a 1/30 s frame is worth,
173 scene it was handed, a `fetch` that keeps the bodies, and a clock the test
174 moves — `boot` hands back the running frame, which is how a test says what
175 that world should do next — so a click on **play**, the two 64 Hz steps a 1/30 s frame is worth,
166176 the buy overlay suppressing fire but not movement, a lost pointer, the end of
167177 a match, one POST per round scored and a browser with no WebGL2 are all
168178 checked with no browser in the room. `hud` draws onto a `Paint` — the
scripts/build.ts +4 −2
@@ -28,8 +28,10 @@ export type Entry = { name: string; entry: string; ceiling: Ceiling };
2828 * The ceilings are today's sizes plus about 10%, rounded up to a round number:
2929 * enough headroom that an honest feature lands without a ceiling edit, tight
3030 * enough that a stray import of something large trips the test. Measured
31 * 2026-09-17: app 68,457 raw / 31,661 gzipped, range 11,575 raw / 5,241 gzipped —
32 * the range's ceiling was raised for #41, which gave it a game: the targets,
31 * 2026-09-17: app 69,711 raw / 32,142 gzipped, range 11,575 raw / 5,241 gzipped.
32 * The app grew 1,254 bytes for #47, which moved every decision about a frame
33 * into `src/client/scene.ts`; it fits under today's ceiling, so no ceiling moved
34 * with it. The range's ceiling was raised for #41, which gave it a game: the targets,
3335 * the clock and the score, the shot traced through the engine's `rayBox`, and
3436 * the blips the horizon draws them as.
3537 * Raising a ceiling is allowed, and it is a line in the pull that says what was
src/client/main.test.ts +34 −21
@@ -6,36 +6,48 @@
66 * nothing is timed by a real clock — the test decides when a frame happens and
77 * how long it took.
88 *
9 * The world is not reached for behind `boot`'s back: it arrives the way the
10 * renderer gets it, in the draw call the frame makes, and a test that wants a
11 * match that is nearly over says so on that world and steps another frame.
9 * The world is not reached for behind `boot`'s back: `boot` hands back the
10 * running frame and that frame says what it is simulating, so a test that wants
11 * a match that is nearly over says so on that world and steps another frame.
12 * What the renderer is handed is a `Scene` now, and `draws` records those.
1213 */
1314
1415 import { expect, test } from "bun:test";
15 import { boot } from "./main.ts";
16 import { boot, type Frame } from "./main.ts";
1617 import { parseResult } from "../result.ts";
1718 import { fakeStage, type FakeStage } from "./stage.test-helper.ts";
19 import type { World } from "./world.ts";
20
21 /** Every frame booted here, so a stage can be asked what world it is running. */
22 const running = new WeakMap<FakeStage, Frame>();
23
24 /** Boot this stage and remember the frame it started. */
25 function start(stage: FakeStage): FakeStage {
26 running.set(stage, boot(stage.host));
27 return stage;
28 }
29
30 /** The world the frame booted on this stage is simulating, if there is one. */
31 const worldOf = (stage: FakeStage): World | null => running.get(stage)?.world() ?? null;
1832
1933 const TICK = 1 / 64;
2034 const FRAME = 1000 / 30;
2135
2236 /** A booted page, and the match already started on it. */
2337 function playing(options: { webgl?: boolean; coarse?: boolean } = {}): FakeStage {
24 const stage = fakeStage(options);
25 boot(stage.host);
38 const stage = start(fakeStage(options));
2639 stage.by("play").fire("click");
2740 return stage;
2841 }
2942
3043 /** How many 64 Hz steps the world has run, from the time it has simulated. */
31 const stepsRun = (stage: FakeStage): number => Math.round((stage.world()?.time ?? 0) / TICK);
44 const stepsRun = (stage: FakeStage): number => Math.round((worldOf(stage)?.time ?? 0) / TICK);
3245
3346 const button = (stage: FakeStage, action: string) =>
3447 stage.buttons.find((b) => b.dataset.action === action) as FakeStage["buttons"][number];
3548
3649 test("it boots on the page's ids, asks for a frame, and draws nothing until a match starts", () => {
37 const stage = fakeStage();
38 boot(stage.host);
50 const stage = start(fakeStage());
3951 expect(stage.frames.length).toBe(1);
4052 expect(stage.by("menu").hidden).toBe(false);
4153 stage.step(FRAME);
@@ -46,15 +58,14 @@ test("it boots on the page's ids, asks for a frame, and draws nothing until a ma
4658 });
4759
4860 test("the name is remembered between visits, and a blank one is `you`", () => {
49 const first = fakeStage();
50 boot(first.host);
61 const first = start(fakeStage());
5162 first.by("name").value = "Vesper";
5263 first.by("play").fire("click");
5364 expect(first.store.get("cs.name")).toBe("Vesper");
5465
5566 const again = fakeStage();
5667 again.store.set("cs.name", "Vesper");
57 boot(again.host);
68 start(again);
5869 expect(again.by("name").value).toBe("Vesper");
5970
6071 const blank = playing();
@@ -72,8 +83,11 @@ test("clicking play starts a match: the menu goes, the pointer is asked for, the
7283 stage.step(FRAME);
7384 expect(stage.draws.length).toBe(1);
7485 const drawn = stage.draws[0];
75 expect(drawn?.world.players.length).toBe(10);
76 expect(drawn?.camera.fov).toBe(90);
86 // Ten players, nine of them drawable: the level, every living other, no me.
87 expect(worldOf(stage)?.players.length).toBe(10);
88 expect(drawn?.scene.instanceCount).toBe(10);
89 expect(drawn?.scene.instances[0]?.mesh).toBe("level");
90 expect(drawn?.scene.camera.fov).toBe(90);
7791 // The HUD was sized to its box and painted, and the counter says something.
7892 expect([stage.hud.width, stage.hud.height]).toEqual([800, 450]);
7993 expect(stage.hud.paint.calls[0]?.[0]).toBe("clearRect");
@@ -107,7 +121,7 @@ test("B opens the buy overlay: it is rebuilt, it lets the pointer go and it take
107121 expect(stage.document.pointerLockElement).toBe(null);
108122
109123 // Buying takes the money, beeps, and the panel is rebuilt with the new prices.
110 const world = (stage.step(FRAME), stage.world());
124 const world = (stage.step(FRAME), worldOf(stage));
111125 const before = world?.me.money ?? 0;
112126 grid?.children[0]?.fire("click");
113127 expect(world?.me.money).toBeLessThan(before);
@@ -123,7 +137,7 @@ test("B opens the buy overlay: it is rebuilt, it lets the pointer go and it take
123137 test("the buy overlay open suppresses fire, but not movement", () => {
124138 const stage = playing();
125139 stage.step(FRAME);
126 const world = stage.world();
140 const world = worldOf(stage);
127141 if (world === null) throw new Error("the first frame drew nothing");
128142 stage.press("KeyB");
129143 expect(stage.by("buy").hidden).toBe(false);
@@ -182,7 +196,7 @@ test("match_over stops the run, shows the menu and focuses play", () => {
182196 const stage = playing();
183197 stage.lock(true);
184198 stage.step(FRAME);
185 const world = stage.world();
199 const world = worldOf(stage);
186200 if (world === null) throw new Error("the first frame drew nothing");
187201 world.match.phase = "match_over";
188202 world.match.matchWinner = "ct";
@@ -230,7 +244,7 @@ test("one POST per round scored, in the shape the server accepts", async () => {
230244 expect(stage.posts.length).toBe(1);
231245
232246 // A round goes on the board: exactly one more.
233 const world = stage.world();
247 const world = worldOf(stage);
234248 if (world === null) throw new Error("the first frame drew nothing");
235249 world.match.score.ct = 1;
236250 stage.step(FRAME);
@@ -258,7 +272,7 @@ test("a leaderboard that cannot be reached is not a reason to stop playing", asy
258272 await Promise.resolve();
259273 // The page was not overwritten with anything, and the match ran on.
260274 expect(stage.by("stats").innerHTML).toBe("");
261 const world = stage.world();
275 const world = worldOf(stage);
262276 if (world === null) throw new Error("the first frame drew nothing");
263277
264278 // A fetch that throws where it stands is the same kind of nothing.
@@ -275,8 +289,7 @@ test("a leaderboard that cannot be reached is not a reason to stop playing", asy
275289 });
276290
277291 test("no WebGL2 is a sentence on the page, not an exception", () => {
278 const stage = fakeStage({ webgl: false });
279 boot(stage.host);
292 const stage = start(fakeStage({ webgl: false }));
280293 expect(stage.by("menu").innerHTML).toContain("No WebGL2");
281294 expect(stage.by("menu").hidden).toBe(false);
282295 // Nothing was started: no frame was asked for and no listener attached.
src/client/main.ts +35 −20
@@ -1,8 +1,8 @@
11 /**
22 * The frame. The engine's loop says how many 64 Hz steps this frame is worth
33 * and the engine's controls say which actions are held; `binding` turns those
4 * names into the `Input` the world takes, and then the scene and the HUD are
5 * drawn. The page's overlays — menu, buy, pause — are plain DOM, so they are
4 * names into the `Input` the world takes. `scene.ts` then turns the world into
5 * the frame's data and the renderer draws that, and the HUD is painted on top. The page's overlays — menu, buy, pause — are plain DOM, so they are
66 * reachable with a keyboard and big enough for a thumb.
77 *
88 * Nothing in here is a browser. `boot(host)` takes everything it reaches for —
@@ -21,7 +21,8 @@ import {
2121 attachButtons, clamp, createControls, createLoop, type Button, type Listens,
2222 } from "../engine/index.ts";
2323 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
24 import { createRenderer, type Camera } from "./renderer.ts";
24 import { createRenderer } from "./renderer.ts";
25 import { buildScene, createScene, type Camera, type Scene } from "./scene.ts";
2526 import { buyable } from "./weapons.ts";
2627 import {
2728 buy, canBuy, createWorld, eyePos, priceOf, update, type BuyItem, type Player, type World,
@@ -84,8 +85,8 @@ export type Store = {
8485 /** The two things this file reads off a response to POST /api/match. */
8586 export type Reply = { readonly ok: boolean; text(): Promise<string> };
8687
87 /** All this frame asks of a renderer: one draw call. */
88 export type Draws = { draw(world: World, camera: Camera, dt: number): void };
88 /** All this frame asks of a renderer: one draw call, of one scene. */
89 export type Draws = { draw(scene: Scene, dt: number): void };
8990
9091 export type Host = {
9192 document: PageDocument;
@@ -99,17 +100,25 @@ export type Host = {
99100 createAudio(): Audio;
100101 };
101102
103 /**
104 * The running frame, handed back by `boot`: what it is simulating right now, or
105 * null between matches. The page throws it away. It exists because the world no
106 * longer rides along to the renderer — a `Scene` does — so this is how
107 * `main.test.ts` watches and steers a match without reaching behind `boot`.
108 */
109 export type Frame = { world(): World | null };
110
102111 /**
103112 * Wire the page up and start the loop. Exported so `main.test.ts` can boot the
104113 * whole frame against a stub host; in a browser the line at the bottom of this
105114 * file is what calls it, with the real one.
106115 */
107 export function boot(host: Host): void {
116 export function boot(host: Host): Frame {
108117 const doc = host.document;
109118 const win = host.window;
110119 const by = <T extends Panel>(id: string): T => doc.getElementById(id) as T;
111120
112 const scene = by<Stage>("scene");
121 const sceneCanvas = by<Stage>("scene");
113122 const hudCanvas = by<Stage>("hud");
114123 const menu = by("menu");
115124 const pause = by("pause");
@@ -121,15 +130,15 @@ export function boot(host: Host): void {
121130 const sideField = by<Field>("side");
122131 const sensField = by<Field>("sens");
123132
124 const renderer = host.createRenderer(scene);
133 const renderer = host.createRenderer(sceneCanvas);
125134 if (renderer === null) {
126135 menu.innerHTML = "<h2>No WebGL2</h2><p>This browser cannot run the game. Everything else on the page still works.</p>";
127 return;
136 return { world: () => null };
128137 }
129138 const hudCtx = hudCanvas.getContext("2d") as Paint;
130139 const audio = host.createAudio();
131140 const controls = createControls(
132 { surface: scene, window: win, document: doc },
141 { surface: sceneCanvas, window: win, document: doc },
133142 { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 },
134143 );
135144 attachButtons(doc.querySelectorAll("[data-action]"), controls, { tap: CS_TAP });
@@ -140,6 +149,10 @@ export function boot(host: Host): void {
140149 });
141150
142151 let world: World | null = null;
152 // One scene and one camera for the whole run: a frame fills them, never makes
153 // them. `scene.ts` keeps the line buffer inside the scene for the same reason.
154 const frameScene = createScene();
155 const camera: Camera = { pos: [0, 0, 0], yaw: 0, pitch: 0, fov: 90 };
143156 let matchId = "";
144157 let running = false;
145158 let lastSound = 0;
@@ -188,7 +201,7 @@ export function boot(host: Host): void {
188201 doc.exitPointerLock();
189202 (buyPanel.querySelector("button") as Panel | null)?.focus();
190203 } else if (running) {
191 void scene.requestPointerLock();
204 void sceneCanvas.requestPointerLock();
192205 }
193206 }
194207
@@ -246,14 +259,14 @@ export function boot(host: Host): void {
246259 show(pause, false);
247260 show(touchPanel, win.matchMedia("(pointer: coarse)").matches);
248261 audio.resume();
249 void scene.requestPointerLock();
262 void sceneCanvas.requestPointerLock();
250263 host.storage.setItem("cs.name", name);
251264 }
252265
253266 by("play").addEventListener("click", start);
254267 by("resume").addEventListener("click", () => {
255268 show(pause, false);
256 void scene.requestPointerLock();
269 void sceneCanvas.requestPointerLock();
257270 });
258271 by("quit").addEventListener("click", () => {
259272 running = false;
@@ -319,13 +332,14 @@ export function boot(host: Host): void {
319332 }
320333
321334 const view = spectated(world);
322 const camera: Camera = {
323 pos: eyePos(view),
324 yaw: view.yaw - view.punch[0] * 0.4,
325 pitch: view.pitch - view.punch[1] * 0.4,
326 fov: view.scoped ? 40 : 90,
327 };
328 renderer.draw(world, camera, elapsed);
335 const eye = eyePos(view);
336 camera.pos[0] = eye[0];
337 camera.pos[1] = eye[1];
338 camera.pos[2] = eye[2];
339 camera.yaw = view.yaw - view.punch[0] * 0.4;
340 camera.pitch = view.pitch - view.punch[1] * 0.4;
341 camera.fov = view.scoped ? 40 : 90;
342 renderer.draw(buildScene(world, camera, frameScene), elapsed);
329343
330344 const dpr = Math.min(win.devicePixelRatio || 1, 2);
331345 const w = Math.round(hudCanvas.clientWidth * dpr);
@@ -338,6 +352,7 @@ export function boot(host: Host): void {
338352 by("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`;
339353 }
340354 loop.start(frame);
355 return { world: () => world };
341356 }
342357
343358 /**
src/client/models.ts +14 −1
@@ -5,7 +5,7 @@
55 */
66
77 import { addBox, builder, finish, type Builder, type Mesh } from "../engine/index.ts";
8 import type { Box } from "./map.ts";
8 import { boxes, type Box } from "./map.ts";
99
1010 /** The level, as one buffer. Fifty-odd boxes, three hundred quads, one draw. */
1111 export function levelMesh(boxes: Box[]): Mesh {
@@ -63,3 +63,16 @@ export function viewModelMesh(kind: string): Mesh {
6363 if (kind === "rifle" || kind === "smg") cube(b, 0.55, -0.66, 0.35, 0.2, 0.26, 0.8, GRIP); // stock
6464 return finish(b);
6565 }
66
67 /**
68 * A mesh by the name a scene calls it: `level`, `player`, `bomb`, or a weapon
69 * kind for the gun in your hands. The renderer keeps one upload per name and
70 * never learns what a name is made of — that is this file, and this is the one
71 * line a second game's model table would replace.
72 */
73 export function meshFor(name: string): Mesh {
74 if (name === "level") return levelMesh(boxes);
75 if (name === "player") return playerMesh();
76 if (name === "bomb") return bombMesh();
77 return viewModelMesh(name);
78 }
src/client/renderer.ts +74 −124
@@ -1,16 +1,18 @@
11 /**
2 * The WebGL2 renderer. Two programs and five buffers: the level is one static
3 * draw, each player is one draw of a shared mesh, and tracers and sparks share
4 * a single line buffer that is refilled each frame. Nothing is allocated in
5 * the frame loop except the numbers that change.
2 * The WebGL2 renderer. Two programs and a handful of buffers: one upload per
3 * mesh name, one draw per instance the scene lists, and tracers and sparks
4 * share the single line buffer the scene brings with it.
5 *
6 * It decides nothing. A `Scene` from `scene.ts` says where every mesh stands,
7 * what it is tinted, which lines to stroke and which gun is in your hands; this
8 * file turns that into GL calls and knows no game — no world, no physics, no
9 * weapons. What a mesh name is made of is `models.ts`'s business.
610 */
711
8 import { multiply, perspective, viewMatrix, modelMatrix, type Mesh, type Vec3 } from "../engine/index.ts";
9 import { bombMesh, levelMesh, playerMesh, viewModelMesh } from "./models.ts";
12 import { multiply, perspective, viewMatrix, modelMatrix, type Mesh } from "../engine/index.ts";
13 import { meshFor } from "./models.ts";
1014 import { LAYERS, TEX_SIZE, materialTextures } from "./materials.ts";
11 import type { World } from "./world.ts";
12 import { hullHeight } from "./physics.ts";
13 import { WEAPONS } from "./weapons.ts";
15 import type { Scene } from "./scene.ts";
1416
1517 const SCENE_VS = `#version 300 es
1618 precision highp float;
@@ -77,8 +79,6 @@ in vec3 vColor;
7779 out vec4 outColor;
7880 void main() { outColor = vec4(vColor, 1.0); }`;
7981
80 export type Camera = { pos: Vec3; yaw: number; pitch: number; fov: number };
81
8282 type Buffers = { vao: WebGLVertexArrayObject; count: number };
8383
8484 function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
@@ -121,16 +121,8 @@ function upload(gl: WebGL2RenderingContext, mesh: Mesh): Buffers {
121121 return { vao, count: mesh.index.length };
122122 }
123123
124 const SKY: [number, number, number] = [0.66, 0.73, 0.82];
125 const TEAM_TINT: Record<string, [number, number, number]> = {
126 t: [1.15, 0.95, 0.62],
127 ct: [0.72, 0.86, 1.2],
128 };
129
130 const LINE_CAPACITY = 4096; // vertices
131
132124 export type Renderer = {
133 draw(world: World, camera: Camera, dt: number): void;
125 draw(scene: Scene, dt: number): void;
134126 resize(): void;
135127 readonly gl: WebGL2RenderingContext;
136128 };
@@ -139,18 +131,18 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null {
139131 const gl = canvas.getContext("webgl2", { antialias: true, alpha: false, depth: true });
140132 if (gl === null) return null;
141133
142 const scene = link(gl, SCENE_VS, SCENE_FS);
143 const lines = link(gl, LINE_VS, LINE_FS);
134 const surfaces = link(gl, SCENE_VS, SCENE_FS);
135 const strokes = link(gl, LINE_VS, LINE_FS);
144136 const uniform = (program: WebGLProgram, name: string): WebGLUniformLocation | null =>
145137 gl.getUniformLocation(program, name);
146138 const u = {
147 viewProj: uniform(scene, "uViewProj"),
148 model: uniform(scene, "uModel"),
149 eye: uniform(scene, "uEye"),
150 tint: uniform(scene, "uTint"),
151 fog: uniform(scene, "uFog"),
152 atlas: uniform(scene, "uAtlas"),
153 lineViewProj: uniform(lines, "uViewProj"),
139 viewProj: uniform(surfaces, "uViewProj"),
140 model: uniform(surfaces, "uModel"),
141 eye: uniform(surfaces, "uEye"),
142 tint: uniform(surfaces, "uTint"),
143 fog: uniform(surfaces, "uFog"),
144 atlas: uniform(surfaces, "uAtlas"),
145 lineViewProj: uniform(strokes, "uViewProj"),
154146 };
155147
156148 const texture = gl.createTexture();
@@ -165,18 +157,24 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null {
165157 gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT);
166158 gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT);
167159
168 let levelBuffers: Buffers | null = null;
169 const player = upload(gl, playerMesh());
170 const bomb = upload(gl, bombMesh());
171 const viewModels = new Map<string, Buffers>();
160 // One upload per mesh name the scene asks for, the first time it asks.
161 const uploaded = new Map<string, Buffers>();
162 const buffersFor = (name: string): Buffers => {
163 let buffers = uploaded.get(name);
164 if (buffers === undefined) {
165 buffers = upload(gl, meshFor(name));
166 uploaded.set(name, buffers);
167 }
168 return buffers;
169 };
172170
173171 // One buffer for every line drawn this frame: tracers, sparks, the bomb beam.
174 const lineData = new Float32Array(LINE_CAPACITY * 6);
172 // Its size is the scene's to choose, so it is grown to fit on the first draw.
173 let lineBytes = 0;
175174 const lineVao = gl.createVertexArray() as WebGLVertexArrayObject;
176175 const lineBuffer = gl.createBuffer();
177176 gl.bindVertexArray(lineVao);
178177 gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer);
179 gl.bufferData(gl.ARRAY_BUFFER, lineData.byteLength, gl.DYNAMIC_DRAW);
180178 gl.enableVertexAttribArray(0);
181179 gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0);
182180 gl.enableVertexAttribArray(1);
@@ -186,7 +184,6 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null {
186184 gl.enable(gl.DEPTH_TEST);
187185 gl.enable(gl.CULL_FACE);
188186 gl.cullFace(gl.BACK);
189 gl.clearColor(SKY[0], SKY[1], SKY[2], 1);
190187
191188 const resize = (): void => {
192189 const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -198,15 +195,23 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null {
198195 }
199196 };
200197
201 let bobPhase = 0;
198 const ORIGIN: [number, number, number] = [0, 0, 0];
202199
203 const draw = (world: World, camera: Camera, dt: number): void => {
200 /**
201 * One frame of the scene handed in. `dt` is the frame's seconds: nothing in
202 * the draw animates itself any more — the walk bob went to `scene.ts` with
203 * every other decision — and it stays in the signature because a renderer's
204 * own time (a fade, an exposure) would belong here rather than in a scene.
205 */
206 const draw = (scene: Scene, dt: number): void => {
204207 resize();
205208 const width = canvas.width, height = canvas.height;
206209 gl.viewport(0, 0, width, height);
210 gl.clearColor(scene.sky[0], scene.sky[1], scene.sky[2], 1);
207211 gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
208212 gl.enable(gl.DEPTH_TEST);
209213
214 const camera = scene.camera;
210215 const aspect = width / Math.max(1, height);
211216 // A CS field of view is horizontal; the projection wants the vertical one.
212217 const fovY = (2 * Math.atan(Math.tan((camera.fov * Math.PI) / 360) / aspect) * 180) / Math.PI;
@@ -214,113 +219,58 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null {
214219 const view = viewMatrix(camera.pos, camera.yaw, camera.pitch);
215220 const viewProj = multiply(proj, view);
216221
217 if (levelBuffers === null) levelBuffers = upload(gl, levelMesh(world.boxes));
218
219 gl.useProgram(scene);
222 gl.useProgram(surfaces);
220223 gl.activeTexture(gl.TEXTURE0);
221224 gl.bindTexture(gl.TEXTURE_2D_ARRAY, texture);
222225 gl.uniform1i(u.atlas, 0);
223226 gl.uniformMatrix4fv(u.viewProj, false, viewProj);
224227 gl.uniform3fv(u.eye, camera.pos);
225 gl.uniform3fv(u.fog, SKY);
228 gl.uniform3fv(u.fog, scene.sky);
226229
227 const identity = modelMatrix([0, 0, 0], 0);
228 gl.uniformMatrix4fv(u.model, false, identity);
229 gl.uniform3f(u.tint, 1, 1, 1);
230 gl.bindVertexArray(levelBuffers.vao);
231 gl.drawElements(gl.TRIANGLES, levelBuffers.count, gl.UNSIGNED_INT, 0);
232
233 gl.bindVertexArray(player.vao);
234 for (const p of world.players) {
235 if (!p.alive || p === world.me) continue;
236 const squash = hullHeight(p) / 72;
237 gl.uniformMatrix4fv(u.model, false, modelMatrix(p.pos, p.yaw, 1, 1, squash));
238 const tint = TEAM_TINT[p.team] ?? [1, 1, 1];
239 gl.uniform3f(u.tint, tint[0], tint[1], tint[2]);
240 gl.drawElements(gl.TRIANGLES, player.count, gl.UNSIGNED_INT, 0);
241 }
242
243 if (world.bomb.state === "planted" || world.bomb.state === "dropped") {
244 gl.uniformMatrix4fv(u.model, false, modelMatrix(world.bomb.pos, 0));
245 gl.uniform3f(u.tint, 1, 1, 1);
246 gl.bindVertexArray(bomb.vao);
247 gl.drawElements(gl.TRIANGLES, bomb.count, gl.UNSIGNED_INT, 0);
248 }
249
250 // --- tracers and sparks
251 let n = 0;
252 const push = (x: number, y: number, z: number, r: number, g: number, b: number): void => {
253 if (n >= LINE_CAPACITY) return;
254 const i = n * 6;
255 lineData[i] = x; lineData[i + 1] = y; lineData[i + 2] = z;
256 lineData[i + 3] = r; lineData[i + 4] = g; lineData[i + 5] = b;
257 n += 1;
258 };
259 for (const effect of world.effects) {
260 const age = world.time - effect.at;
261 if (effect.kind === "tracer" && age < 0.08) {
262 const fade = 1 - age / 0.08;
263 push(effect.from[0], effect.from[1], effect.from[2], 1, 0.92, 0.6 * fade);
264 push(effect.to[0], effect.to[1], effect.to[2], 1, 0.86, 0.35 * fade);
265 } else if (effect.kind === "impact" && age < 0.25) {
266 const k = 14 * (1 - age / 0.25);
267 for (let s = 0; s < 3; s++) {
268 const a = s * 2.1 + age * 9;
269 push(effect.pos[0], effect.pos[1], effect.pos[2], 0.95, 0.9, 0.75);
270 push(
271 effect.pos[0] + effect.normal[0] * k + Math.cos(a) * k * 0.6,
272 effect.pos[1] + effect.normal[1] * k + Math.sin(a) * k * 0.6,
273 effect.pos[2] + effect.normal[2] * k + Math.sin(a * 1.7) * k * 0.6,
274 0.8, 0.7, 0.5,
275 );
276 }
277 } else if (effect.kind === "blood" && age < 0.3) {
278 const k = 18 * (1 - age / 0.3);
279 push(effect.pos[0], effect.pos[1], effect.pos[2], 0.75, 0.08, 0.08);
280 push(effect.pos[0], effect.pos[1], effect.pos[2] - k, 0.5, 0.05, 0.05);
230 // --- every mesh the scene placed, in the order it placed them
231 let bound: Buffers | null = null;
232 for (let i = 0; i < scene.instanceCount; i++) {
233 const instance = scene.instances[i];
234 if (instance === undefined) break;
235 const buffers = buffersFor(instance.mesh);
236 gl.uniformMatrix4fv(u.model, false, modelMatrix(instance.pos, instance.yaw, 1, 1, instance.scale));
237 gl.uniform3f(u.tint, instance.tint[0], instance.tint[1], instance.tint[2]);
238 if (buffers !== bound) {
239 gl.bindVertexArray(buffers.vao);
240 bound = buffers;
281241 }
242 gl.drawElements(gl.TRIANGLES, buffers.count, gl.UNSIGNED_INT, 0);
282243 }
283 if (world.bomb.state === "planted") {
284 const blink = Math.sin(world.time * 8) > 0 ? 1 : 0.2;
285 push(world.bomb.pos[0], world.bomb.pos[1], world.bomb.pos[2] + 14, 1, 0.2 * blink, 0.15);
286 push(world.bomb.pos[0], world.bomb.pos[1], world.bomb.pos[2] + 140, 1 * blink, 0.15, 0.1);
287 }
244
245 // --- tracers, sparks and beams: whatever the scene wrote into its buffer
246 const n = scene.lineCount;
288247 if (n > 0) {
289 gl.useProgram(lines);
248 gl.useProgram(strokes);
290249 gl.uniformMatrix4fv(u.lineViewProj, false, viewProj);
291250 gl.bindVertexArray(lineVao);
292251 gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer);
293 gl.bufferSubData(gl.ARRAY_BUFFER, 0, lineData, 0, n * 6);
252 if (lineBytes < scene.lines.byteLength) {
253 gl.bufferData(gl.ARRAY_BUFFER, scene.lines.byteLength, gl.DYNAMIC_DRAW);
254 lineBytes = scene.lines.byteLength;
255 }
256 gl.bufferSubData(gl.ARRAY_BUFFER, 0, scene.lines, 0, n * 6);
294257 gl.drawArrays(gl.LINES, 0, n - (n % 2));
295258 }
296259
297260 // --- the gun in your hands, on its own projection and its own depth
298 const me = world.me;
299 if (me.alive && !me.scoped) {
300 const speed = Math.hypot(me.vel[0], me.vel[1]);
301 bobPhase += dt * (4 + speed * 0.02);
302 const bobX = Math.sin(bobPhase) * Math.min(speed / 250, 1) * 0.05;
303 const bobY = Math.abs(Math.cos(bobPhase)) * Math.min(speed / 250, 1) * 0.04;
304 const kick = Math.max(0, 0.12 - (world.time - me.lastShotAt)) * 2.2;
305 const kind = WEAPONS[me.weapon]?.kind ?? "rifle";
306 let model = viewModels.get(kind);
307 if (model === undefined) {
308 model = upload(gl, viewModelMesh(kind));
309 viewModels.set(kind, model);
310 }
261 const held = scene.viewModel;
262 if (held !== null) {
263 const buffers = buffersFor(held.mesh);
311264 gl.clear(gl.DEPTH_BUFFER_BIT);
312 gl.useProgram(scene);
265 gl.useProgram(surfaces);
313266 const vmProj = perspective(58, aspect, 0.05, 40);
314267 gl.uniformMatrix4fv(u.viewProj, false, vmProj);
315268 gl.uniform3f(u.tint, 1, 1, 1);
316 gl.uniform3fv(u.eye, [0, 0, 0]);
317 const place = modelMatrix([0, 0, 0], 0);
318 place[12] = bobX;
319 place[13] = bobY - kick * 0.12;
320 place[14] = kick * 0.35;
269 gl.uniform3fv(u.eye, ORIGIN);
270 const place = modelMatrix(held.offset, 0);
321271 gl.uniformMatrix4fv(u.model, false, place);
322 gl.bindVertexArray(model.vao);
323 gl.drawElements(gl.TRIANGLES, model.count, gl.UNSIGNED_INT, 0);
272 gl.bindVertexArray(buffers.vao);
273 gl.drawElements(gl.TRIANGLES, buffers.count, gl.UNSIGNED_INT, 0);
324274 }
325275 gl.bindVertexArray(null);
326276 };
src/client/scene.test.ts added +233
@@ -0,0 +1,233 @@
1 /**
2 * What a frame is, checked without a pixel. There is no canvas here and no
3 * WebGL: `buildScene` takes a world and a camera and leaves plain numbers, so
4 * every decision the renderer used to make — who is drawn, how far a duck
5 * squashes a player, how long a tracer lives, which gun is in your hands and
6 * how far the last shot kicked it — is a number a test can read.
7 *
8 * The last test is the boundary itself: the renderer may not name the world,
9 * the physics or the weapons again, because a second game that hands it a scene
10 * would have neither.
11 */
12
13 import { expect, test } from "bun:test";
14 import { readFileSync } from "node:fs";
15 import { join } from "node:path";
16 import { HULL } from "./physics.ts";
17 import { buildScene, createScene, LINE_CAPACITY, SKY, type Camera } from "./scene.ts";
18 import { SHORT } from "./match.ts";
19 import { createWorld, type Effect, type World } from "./world.ts";
20
21 const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90 });
22
23 /** A match nobody is playing: ten players, the bomb carried, no effects. */
24 function world(): World {
25 const w = createWorld(SHORT, "you", "ct", 5, 7);
26 w.time = 100;
27 w.effects.length = 0;
28 return w;
29 }
30
31 const meshes = (scene: ReturnType<typeof createScene>): string[] =>
32 scene.instances.slice(0, scene.instanceCount).map((i) => i.mesh);
33
34 test("the camera and the sky are copied into the scene, not referenced", () => {
35 const scene = buildScene(world(), camera(), createScene());
36 expect(scene.camera).toEqual({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90 });
37 expect(scene.sky).toEqual(SKY);
38 expect(scene.sky).toEqual([0.66, 0.73, 0.82]);
39 });
40
41 test("the level is drawn first, every living other player after it, and never me", () => {
42 const w = world();
43 const scene = buildScene(w, camera(), createScene());
44 // The level, then the nine others: I am the eye, and I am not in my own view.
45 expect(meshes(scene)).toEqual(["level", ...Array<string>(9).fill("player")]);
46 const mine = w.me.pos;
47 const drawn = scene.instances.slice(1, scene.instanceCount);
48 expect(drawn.some((i) => i.pos[0] === mine[0] && i.pos[1] === mine[1] && i.pos[2] === mine[2] && i.mesh === "player"))
49 .toBe(false);
50
51 // A dead player is not an instance.
52 const other = w.players.find((p) => p !== w.me) as (typeof w.players)[number];
53 other.alive = false;
54 buildScene(w, camera(), scene);
55 expect(meshes(scene)).toEqual(["level", ...Array<string>(8).fill("player")]);
56 });
57
58 test("a duck squashes a player by the hull it has, a standing one by nothing", () => {
59 const w = world();
60 const other = w.players.find((p) => p !== w.me) as (typeof w.players)[number];
61 const scene = createScene();
62
63 other.duckAmount = 0;
64 buildScene(w, camera(), scene);
65 const standing = scene.instances.slice(1, scene.instanceCount).find((i) => i.pos[0] === other.pos[0]);
66 expect(standing?.scale).toBe(1);
67
68 other.duckAmount = 1;
69 buildScene(w, camera(), scene);
70 const ducking = scene.instances.slice(1, scene.instanceCount).find((i) => i.pos[0] === other.pos[0]);
71 expect(ducking?.scale).toBeCloseTo(HULL.duck / HULL.stand, 10);
72 expect(ducking?.scale).toBeCloseTo(54 / 72, 10);
73 });
74
75 test("a team is a tint, and the two sides are not the same colour", () => {
76 const w = world();
77 const scene = buildScene(w, camera(), createScene());
78 const drawn = scene.instances.slice(1, scene.instanceCount);
79 const tints = new Set(drawn.map((i) => i.tint.join(",")));
80 expect(tints.size).toBe(2);
81 expect(tints).toContain([1.15, 0.95, 0.62].join(","));
82 expect(tints).toContain([0.72, 0.86, 1.2].join(","));
83 });
84
85 test("a tracer 40 ms old is two vertices and one 120 ms old is none", () => {
86 const w = world();
87 const tracer = (at: number): Effect => ({ kind: "tracer", from: [0, 0, 0], to: [100, 0, 0], at });
88 const scene = createScene();
89
90 w.effects.push(tracer(w.time - 0.04));
91 buildScene(w, camera(), scene);
92 expect(scene.lineCount).toBe(2);
93 // Half way through its 0.08 s, so half faded: the blue of the near end.
94 expect(scene.lines[5]).toBeCloseTo(0.3, 6);
95
96 w.effects[0] = tracer(w.time - 0.12);
97 buildScene(w, camera(), scene);
98 expect(scene.lineCount).toBe(0);
99 });
100
101 test("an impact is three sparks for a quarter of a second, and blood two for 0.3", () => {
102 const w = world();
103 const scene = createScene();
104
105 w.effects.push({ kind: "impact", at: w.time - 0.2, pos: [1, 2, 3], normal: [0, 0, 1] });
106 buildScene(w, camera(), scene);
107 expect(scene.lineCount).toBe(6);
108 w.effects[0] = { kind: "impact", at: w.time - 0.26, pos: [1, 2, 3], normal: [0, 0, 1] };
109 buildScene(w, camera(), scene);
110 expect(scene.lineCount).toBe(0);
111
112 w.effects[0] = { kind: "blood", at: w.time - 0.29, pos: [1, 2, 3] };
113 buildScene(w, camera(), scene);
114 expect(scene.lineCount).toBe(2);
115 w.effects[0] = { kind: "blood", at: w.time - 0.31, pos: [1, 2, 3] };
116 buildScene(w, camera(), scene);
117 expect(scene.lineCount).toBe(0);
118 });
119
120 test("a planted bomb adds the beam and blinks; a carried one adds nothing", () => {
121 const w = world();
122 const scene = createScene();
123
124 buildScene(w, camera(), scene);
125 expect(w.bomb.state).toBe("carried");
126 expect(scene.lineCount).toBe(0);
127 expect(meshes(scene)).not.toContain("bomb");
128
129 w.bomb.state = "planted";
130 w.bomb.pos = [500, 600, 20];
131 // sin(time * 8) > 0 at this time: the bright half of the blink.
132 w.time = Math.PI / 16;
133 buildScene(w, camera(), scene);
134 expect(meshes(scene)).toContain("bomb");
135 expect(scene.lineCount).toBe(2);
136 expect([scene.lines[0], scene.lines[1], scene.lines[2]]).toEqual([500, 600, 34]);
137 expect(scene.lines[8]).toBe(160);
138 expect(scene.lines[9]).toBe(1);
139
140 // Half a blink later it is dim, and the beam is still exactly two vertices.
141 w.time = (Math.PI / 8) * 1.5;
142 buildScene(w, camera(), scene);
143 expect(scene.lineCount).toBe(2);
144 expect(scene.lines[9]).toBeCloseTo(0.2, 6);
145 });
146
147 test("a flood of effects stops at the line capacity and writes nothing past it", () => {
148 const w = world();
149 for (let i = 0; i < 5000; i++)
150 w.effects.push({ kind: "tracer", from: [i, 0, 0], to: [i, 1, 0], at: w.time - 0.01 });
151 const scene = buildScene(w, camera(), createScene());
152 expect(scene.lineCount).toBe(LINE_CAPACITY);
153 expect(scene.lineCount).toBe(4096);
154 expect(scene.lines.length).toBe(LINE_CAPACITY * 6);
155 // The last vertex written is the cap's, and the buffer never grew.
156 expect(scene.lines[LINE_CAPACITY * 6 - 6]).toBe(LINE_CAPACITY / 2 - 1);
157 });
158
159 test("the view model is the weapon's kind, and its kick decays over 0.12 s", () => {
160 const w = world();
161 const scene = createScene();
162 w.me.weapon = "ak47";
163 w.me.lastShotAt = w.time;
164
165 buildScene(w, camera(), scene);
166 expect(scene.viewModel?.mesh).toBe("rifle");
167 const kicked = scene.viewModel?.offset[2] ?? 0;
168 expect(kicked).toBeCloseTo(0.12 * 2.2 * 0.35, 10);
169
170 // Half way through the kick, half of it is left.
171 w.me.lastShotAt = w.time - 0.06;
172 buildScene(w, camera(), scene);
173 expect(scene.viewModel?.offset[2]).toBeCloseTo(kicked / 2, 10);
174
175 // And after 0.12 s there is none of it left.
176 w.me.lastShotAt = w.time - 0.12;
177 buildScene(w, camera(), scene);
178 expect(scene.viewModel?.offset).toEqual([0, 0, 0]);
179
180 w.me.weapon = "awp";
181 buildScene(w, camera(), scene);
182 expect(scene.viewModel?.mesh).toBe("sniper");
183 });
184
185 test("there is no gun in your hands when you are scoped or dead", () => {
186 const w = world();
187 const scene = createScene();
188
189 w.me.scoped = true;
190 buildScene(w, camera(), scene);
191 expect(scene.viewModel).toBe(null);
192
193 w.me.scoped = false;
194 w.me.alive = false;
195 buildScene(w, camera(), scene);
196 expect(scene.viewModel).toBe(null);
197
198 w.me.alive = true;
199 buildScene(w, camera(), scene);
200 expect(scene.viewModel).not.toBe(null);
201 });
202
203 test("a frame allocates nothing: the same scene, the same buffer, the same instances", () => {
204 const w = world();
205 const scene = createScene();
206 const first = buildScene(w, camera(), scene);
207 const lines = first.lines;
208 const instance = first.instances[1];
209 const held = first.viewModel;
210
211 const second = buildScene(w, camera(), scene);
212 expect(second).toBe(first);
213 expect(second).toBe(scene);
214 expect(second.lines).toBe(lines);
215 expect(second.instances[1]).toBe(instance);
216 expect(second.camera.pos).toBe(first.camera.pos);
217 // Even the view model object survives a frame that has none to show.
218 w.me.alive = false;
219 buildScene(w, camera(), scene);
220 expect(scene.viewModel).toBe(null);
221 w.me.alive = true;
222 buildScene(w, camera(), scene);
223 expect(scene.viewModel).toBe(held);
224 });
225
226 test("the renderer names no world, no physics and no weapons", () => {
227 const source = readFileSync(join(import.meta.dir, "renderer.ts"), "utf8");
228 for (const forbidden of ["./world.ts", "./physics.ts", "./weapons.ts", "./map.ts"])
229 expect(source).not.toContain(forbidden);
230 expect(source).toContain('from "./scene.ts"');
231 expect(source).toContain('from "./models.ts"');
232 expect(source).toContain('from "./materials.ts"');
233 });
src/client/scene.ts added +186
@@ -0,0 +1,186 @@
1 /**
2 * The frame, as data. Everything a draw has to decide about this game — which
3 * mesh stands where, what a team is tinted, how far a tracer has faded, which
4 * gun is in your hands and how far the last shot kicked it — is decided here,
5 * in plain numbers a test can read without a canvas. `renderer.ts` then draws
6 * exactly this and knows nothing else, which is what lets a second game hand it
7 * a scene of its own.
8 *
9 * Nothing is allocated in a frame. `createScene()` makes the one scene and the
10 * one line buffer a game ever needs, and `buildScene(world, camera, into)`
11 * fills that scene in place: the instance pool grows to the busiest frame it
12 * has seen and then stops, the vectors are written into rather than replaced,
13 * and the view model object is kept even on the frames where there is none.
14 */
15
16 import { clamp, type Vec3 } from "../engine/index.ts";
17 import { HULL, hullHeight } from "./physics.ts";
18 import { WEAPONS } from "./weapons.ts";
19 import type { World } from "./world.ts";
20
21 /** Where the eye is and what it can see. A horizontal field of view, as CS reports it. */
22 export type Camera = { pos: Vec3; yaw: number; pitch: number; fov: number };
23
24 export type Tint = [number, number, number];
25
26 /**
27 * One mesh, placed. `mesh` is the name the renderer resolves — `level`,
28 * `player`, `bomb`, or a weapon kind for a view model — and `scale` is the
29 * vertical squash a duck applies, the only scaling this game asks for.
30 */
31 export type Instance = { mesh: string; pos: Vec3; yaw: number; scale: number; tint: Tint };
32
33 /** The gun in your hands: which one, and where it sits in view space. */
34 export type ViewModel = { mesh: string; offset: Vec3 };
35
36 export type Scene = {
37 camera: Camera;
38 sky: Tint;
39 /** The pool. Only the first `instanceCount` of these are this frame's. */
40 instances: Instance[];
41 instanceCount: number;
42 /** Line vertices, six floats each: x, y, z, r, g, b. */
43 lines: Float32Array;
44 /** Vertices written this frame — `lineCount * 6` floats of `lines` are live. */
45 lineCount: number;
46 viewModel: ViewModel | null;
47 /**
48 * What the scene remembers between frames so a frame allocates nothing: the
49 * walk cycle the view model bobs on, the world time it last saw, and the one
50 * view model object `viewModel` points at whenever there is a gun to draw.
51 */
52 readonly keep: { bobPhase: number; time: number; viewModel: ViewModel };
53 };
54
55 /** Line vertices one frame may draw. Past this, an effect is simply not drawn. */
56 export const LINE_CAPACITY = 4096;
57
58 /** The horizon, and the colour distance fades into. */
59 export const SKY: Tint = [0.66, 0.73, 0.82];
60
61 const TEAM_TINT: Record<string, Tint> = {
62 t: [1.15, 0.95, 0.62],
63 ct: [0.72, 0.86, 1.2],
64 };
65
66 const WHITE: Tint = [1, 1, 1];
67 const ORIGIN: Vec3 = [0, 0, 0];
68
69 /** The one scene a game keeps, with the one buffer every frame refills. */
70 export function createScene(): Scene {
71 return {
72 camera: { pos: [0, 0, 0], yaw: 0, pitch: 0, fov: 90 },
73 sky: [SKY[0], SKY[1], SKY[2]],
74 instances: [],
75 instanceCount: 0,
76 lines: new Float32Array(LINE_CAPACITY * 6),
77 lineCount: 0,
78 viewModel: null,
79 keep: { bobPhase: 0, time: 0, viewModel: { mesh: "rifle", offset: [0, 0, 0] } },
80 };
81 }
82
83 /** Take the next instance out of the pool — growing it once, never per frame. */
84 function place(scene: Scene, mesh: string, pos: Vec3, yaw: number, scale: number, tint: Tint): void {
85 let at = scene.instances[scene.instanceCount];
86 if (at === undefined) {
87 at = { mesh, pos: [0, 0, 0], yaw: 0, scale: 1, tint: [1, 1, 1] };
88 scene.instances.push(at);
89 }
90 at.mesh = mesh;
91 at.pos[0] = pos[0]; at.pos[1] = pos[1]; at.pos[2] = pos[2];
92 at.yaw = yaw;
93 at.scale = scale;
94 at.tint[0] = tint[0]; at.tint[1] = tint[1]; at.tint[2] = tint[2];
95 scene.instanceCount += 1;
96 }
97
98 /** One line vertex, or nothing at all once the frame's budget is spent. */
99 function push(scene: Scene, x: number, y: number, z: number, r: number, g: number, b: number): void {
100 if (scene.lineCount >= LINE_CAPACITY) return;
101 const i = scene.lineCount * 6;
102 scene.lines[i] = x; scene.lines[i + 1] = y; scene.lines[i + 2] = z;
103 scene.lines[i + 3] = r; scene.lines[i + 4] = g; scene.lines[i + 5] = b;
104 scene.lineCount += 1;
105 }
106
107 /**
108 * Fill `into` with what this world looks like from this camera, and hand the
109 * same object back. The order the instances go in is the order they are drawn:
110 * the level, then every other living player, then the bomb.
111 */
112 export function buildScene(world: World, camera: Camera, into: Scene): Scene {
113 into.camera.pos[0] = camera.pos[0];
114 into.camera.pos[1] = camera.pos[1];
115 into.camera.pos[2] = camera.pos[2];
116 into.camera.yaw = camera.yaw;
117 into.camera.pitch = camera.pitch;
118 into.camera.fov = camera.fov;
119 into.sky[0] = SKY[0]; into.sky[1] = SKY[1]; into.sky[2] = SKY[2];
120
121 into.instanceCount = 0;
122 place(into, "level", ORIGIN, 0, 1, WHITE);
123 for (const p of world.players) {
124 // The eye is inside your own head: drawing it would fill the screen.
125 if (!p.alive || p === world.me) continue;
126 place(into, "player", p.pos, p.yaw, hullHeight(p) / HULL.stand, TEAM_TINT[p.team] ?? WHITE);
127 }
128 if (world.bomb.state === "planted" || world.bomb.state === "dropped")
129 place(into, "bomb", world.bomb.pos, 0, 1, WHITE);
130
131 // --- tracers, sparks and the planted bomb's beam, all as lines
132 into.lineCount = 0;
133 for (const effect of world.effects) {
134 const age = world.time - effect.at;
135 if (effect.kind === "tracer" && age < 0.08) {
136 const fade = 1 - age / 0.08;
137 push(into, effect.from[0], effect.from[1], effect.from[2], 1, 0.92, 0.6 * fade);
138 push(into, effect.to[0], effect.to[1], effect.to[2], 1, 0.86, 0.35 * fade);
139 } else if (effect.kind === "impact" && age < 0.25) {
140 const k = 14 * (1 - age / 0.25);
141 for (let s = 0; s < 3; s++) {
142 const a = s * 2.1 + age * 9;
143 push(into, effect.pos[0], effect.pos[1], effect.pos[2], 0.95, 0.9, 0.75);
144 push(
145 into,
146 effect.pos[0] + effect.normal[0] * k + Math.cos(a) * k * 0.6,
147 effect.pos[1] + effect.normal[1] * k + Math.sin(a) * k * 0.6,
148 effect.pos[2] + effect.normal[2] * k + Math.sin(a * 1.7) * k * 0.6,
149 0.8, 0.7, 0.5,
150 );
151 }
152 } else if (effect.kind === "blood" && age < 0.3) {
153 const k = 18 * (1 - age / 0.3);
154 push(into, effect.pos[0], effect.pos[1], effect.pos[2], 0.75, 0.08, 0.08);
155 push(into, effect.pos[0], effect.pos[1], effect.pos[2] - k, 0.5, 0.05, 0.05);
156 }
157 }
158 if (world.bomb.state === "planted") {
159 const blink = Math.sin(world.time * 8) > 0 ? 1 : 0.2;
160 push(into, world.bomb.pos[0], world.bomb.pos[1], world.bomb.pos[2] + 14, 1, 0.2 * blink, 0.15);
161 push(into, world.bomb.pos[0], world.bomb.pos[1], world.bomb.pos[2] + 140, 1 * blink, 0.15, 0.1);
162 }
163
164 // --- the gun in your hands: a walk cycle it sways on, a kick that decays
165 const me = world.me;
166 if (me.alive && !me.scoped) {
167 // Simulated seconds, not frame seconds: a match that restarts goes back to
168 // zero and a paused frame moves nothing, so the clamp holds both ends.
169 const dt = clamp(world.time - into.keep.time, 0, 0.1);
170 const speed = Math.hypot(me.vel[0], me.vel[1]);
171 into.keep.bobPhase += dt * (4 + speed * 0.02);
172 const bobX = Math.sin(into.keep.bobPhase) * Math.min(speed / 250, 1) * 0.05;
173 const bobY = Math.abs(Math.cos(into.keep.bobPhase)) * Math.min(speed / 250, 1) * 0.04;
174 const kick = Math.max(0, 0.12 - (world.time - me.lastShotAt)) * 2.2;
175 const held = into.keep.viewModel;
176 held.mesh = WEAPONS[me.weapon]?.kind ?? "rifle";
177 held.offset[0] = bobX;
178 held.offset[1] = bobY - kick * 0.12;
179 held.offset[2] = kick * 0.35;
180 into.viewModel = held;
181 } else {
182 into.viewModel = null;
183 }
184 into.keep.time = world.time;
185 return into;
186 }
src/client/stage.test-helper.ts +10 −13
@@ -7,10 +7,11 @@
77 *
88 * Everything here records rather than does: an element remembers what was
99 * hidden, written and focused, the 2D context writes down every call, the
10 * renderer keeps the world and camera it was handed, and `fetch` keeps the
11 * bodies it was posted. The recorders have exactly the members `boot` and
12 * `hud.ts` call, so a stub that has to be maintained is smaller than the frame
13 * it checks.
10 * renderer keeps the scene it was handed, and `fetch` keeps the bodies it was
11 * posted. The scene is one object the frame refills, so `draws` is a count and
12 * an identity, not a history: read it in the step that produced it. The
13 * recorders have exactly the members `boot` and `hud.ts` call, so a stub that
14 * has to be maintained is smaller than the frame it checks.
1415 *
1516 * It ships in no bundle: only `main.test.ts` imports it. `src/range/dom.ts` is
1617 * the same idea for the other game — it cannot import this one, because
@@ -21,8 +22,7 @@
2122 import type { Audio } from "./audio.ts";
2223 import type { Paint } from "./hud.ts";
2324 import type { Draws, Host, Stage } from "./main.ts";
24 import type { Camera } from "./renderer.ts";
25 import type { World } from "./world.ts";
25 import type { Scene } from "./scene.ts";
2626
2727 type Handler = (event: any) => void;
2828
@@ -174,8 +174,8 @@ export function canvas(id: string): FakeCanvas {
174174 /** One POST the frame made, as it was made. */
175175 export type Post = { url: string; method: string; headers: Record<string, string>; body: string };
176176
177 /** One draw call the frame made, with the world and camera it was given. */
178 export type Drawn = { world: World; camera: Camera; dt: number };
177 /** One draw call the frame made, with the scene it was given. */
178 export type Drawn = { scene: Scene; dt: number };
179179
180180 export type FakeStage = {
181181 /** What `boot` is handed. */
@@ -205,8 +205,6 @@ export type FakeStage = {
205205 store: Map<string, string>;
206206 /** The match ids handed out, newest last. */
207207 ids: string[];
208 /** The world the last frame drew, or null if none has been drawn. */
209 world(): World | null;
210208 };
211209
212210 /**
@@ -271,8 +269,8 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}):
271269 beep: () => void sounds.push("beep"),
272270 };
273271 const renderer: Draws = {
274 draw(world, camera, dt) {
275 draws.push({ world, camera, dt });
272 draw(scene, dt) {
273 draws.push({ scene, dt });
276274 },
277275 };
278276
@@ -316,7 +314,6 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}):
316314 stub.hold(code, false);
317315 },
318316 answer: () => Promise.resolve({ ok: true, text: () => Promise.resolve("<h2>Leaderboard</h2>") }),
319 world: () => draws[draws.length - 1]?.world ?? null,
320317 };
321318 return stub;
322319 }
static/app.6bf398e121.js deleted −58

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

View file
static/app.de0b8ba773.js added +58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs