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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/47-the-scene-becomes-data-src-client-scene → main 1 commit fork
11 files
changed +663
−244
README.md +15 −5
| @@ -150,19 +150,29 @@ from the angle it subtends, and you turn on the spot. | ||
| 150 | 150 | over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules), |
| 151 | 151 | `world` (the simulation), |
| 152 | 152 | `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 | |
| 155 | 155 | context; `models`, `materials` and `audio` are this game's recipes for the |
| 156 | 156 | 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 | |
| 158 | 167 | its host: `boot(host)` is handed the document, the window, a store, a `fetch`, |
| 159 | 168 | a source of match ids and the two factories that make a renderer and a voice, |
| 160 | 169 | and the last lines of the file are the only place a browser global is named. |
| 161 | 170 | `main.test.ts` boots the whole frame against the stub page in |
| 162 | 171 | `stage.test-helper.ts` — elements that record what was hidden, written and |
| 163 | 172 | 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, | |
| 166 | 176 | the buy overlay suppressing fire but not movement, a lost pointer, the end of |
| 167 | 177 | a match, one POST per round scored and a browser with no WebGL2 are all |
| 168 | 178 | 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 }; | ||
| 28 | 28 | * The ceilings are today's sizes plus about 10%, rounded up to a round number: |
| 29 | 29 | * enough headroom that an honest feature lands without a ceiling edit, tight |
| 30 | 30 | * 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, | |
| 33 | 35 | * the clock and the score, the shot traced through the engine's `rayBox`, and |
| 34 | 36 | * the blips the horizon draws them as. |
| 35 | 37 | * 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 @@ | ||
| 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 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. | |
| 12 | 13 | */ |
| 13 | 14 | |
| 14 | 15 | import { expect, test } from "bun:test"; |
| 15 | import { boot } from "./main.ts"; | |
| 16 | import { boot, type Frame } from "./main.ts"; | |
| 16 | 17 | import { parseResult } from "../result.ts"; |
| 17 | 18 | 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; | |
| 18 | 32 | |
| 19 | 33 | const TICK = 1 / 64; |
| 20 | 34 | const FRAME = 1000 / 30; |
| 21 | 35 | |
| 22 | 36 | /** A booted page, and the match already started on it. */ |
| 23 | 37 | function playing(options: { webgl?: boolean; coarse?: boolean } = {}): FakeStage { |
| 24 | const stage = fakeStage(options); | |
| 25 | boot(stage.host); | |
| 38 | const stage = start(fakeStage(options)); | |
| 26 | 39 | stage.by("play").fire("click"); |
| 27 | 40 | return stage; |
| 28 | 41 | } |
| 29 | 42 | |
| 30 | 43 | /** 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); | |
| 32 | 45 | |
| 33 | 46 | const button = (stage: FakeStage, action: string) => |
| 34 | 47 | stage.buttons.find((b) => b.dataset.action === action) as FakeStage["buttons"][number]; |
| 35 | 48 | |
| 36 | 49 | 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()); | |
| 39 | 51 | expect(stage.frames.length).toBe(1); |
| 40 | 52 | expect(stage.by("menu").hidden).toBe(false); |
| 41 | 53 | 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 | ||
| 46 | 58 | }); |
| 47 | 59 | |
| 48 | 60 | 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()); | |
| 51 | 62 | first.by("name").value = "Vesper"; |
| 52 | 63 | first.by("play").fire("click"); |
| 53 | 64 | expect(first.store.get("cs.name")).toBe("Vesper"); |
| 54 | 65 | |
| 55 | 66 | const again = fakeStage(); |
| 56 | 67 | again.store.set("cs.name", "Vesper"); |
| 57 | boot(again.host); | |
| 68 | start(again); | |
| 58 | 69 | expect(again.by("name").value).toBe("Vesper"); |
| 59 | 70 | |
| 60 | 71 | const blank = playing(); |
| @@ -72,8 +83,11 @@ test("clicking play starts a match: the menu goes, the pointer is asked for, the | ||
| 72 | 83 | stage.step(FRAME); |
| 73 | 84 | expect(stage.draws.length).toBe(1); |
| 74 | 85 | 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); | |
| 77 | 91 | // The HUD was sized to its box and painted, and the counter says something. |
| 78 | 92 | expect([stage.hud.width, stage.hud.height]).toEqual([800, 450]); |
| 79 | 93 | 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 | ||
| 107 | 121 | expect(stage.document.pointerLockElement).toBe(null); |
| 108 | 122 | |
| 109 | 123 | // 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)); | |
| 111 | 125 | const before = world?.me.money ?? 0; |
| 112 | 126 | grid?.children[0]?.fire("click"); |
| 113 | 127 | 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 | ||
| 123 | 137 | test("the buy overlay open suppresses fire, but not movement", () => { |
| 124 | 138 | const stage = playing(); |
| 125 | 139 | stage.step(FRAME); |
| 126 | const world = stage.world(); | |
| 140 | const world = worldOf(stage); | |
| 127 | 141 | if (world === null) throw new Error("the first frame drew nothing"); |
| 128 | 142 | stage.press("KeyB"); |
| 129 | 143 | expect(stage.by("buy").hidden).toBe(false); |
| @@ -182,7 +196,7 @@ test("match_over stops the run, shows the menu and focuses play", () => { | ||
| 182 | 196 | const stage = playing(); |
| 183 | 197 | stage.lock(true); |
| 184 | 198 | stage.step(FRAME); |
| 185 | const world = stage.world(); | |
| 199 | const world = worldOf(stage); | |
| 186 | 200 | if (world === null) throw new Error("the first frame drew nothing"); |
| 187 | 201 | world.match.phase = "match_over"; |
| 188 | 202 | world.match.matchWinner = "ct"; |
| @@ -230,7 +244,7 @@ test("one POST per round scored, in the shape the server accepts", async () => { | ||
| 230 | 244 | expect(stage.posts.length).toBe(1); |
| 231 | 245 | |
| 232 | 246 | // A round goes on the board: exactly one more. |
| 233 | const world = stage.world(); | |
| 247 | const world = worldOf(stage); | |
| 234 | 248 | if (world === null) throw new Error("the first frame drew nothing"); |
| 235 | 249 | world.match.score.ct = 1; |
| 236 | 250 | stage.step(FRAME); |
| @@ -258,7 +272,7 @@ test("a leaderboard that cannot be reached is not a reason to stop playing", asy | ||
| 258 | 272 | await Promise.resolve(); |
| 259 | 273 | // The page was not overwritten with anything, and the match ran on. |
| 260 | 274 | expect(stage.by("stats").innerHTML).toBe(""); |
| 261 | const world = stage.world(); | |
| 275 | const world = worldOf(stage); | |
| 262 | 276 | if (world === null) throw new Error("the first frame drew nothing"); |
| 263 | 277 | |
| 264 | 278 | // 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 | ||
| 275 | 289 | }); |
| 276 | 290 | |
| 277 | 291 | 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 })); | |
| 280 | 293 | expect(stage.by("menu").innerHTML).toContain("No WebGL2"); |
| 281 | 294 | expect(stage.by("menu").hidden).toBe(false); |
| 282 | 295 | // Nothing was started: no frame was asked for and no listener attached. |
src/client/main.ts +35 −20
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The frame. The engine's loop says how many 64 Hz steps this frame is worth |
| 3 | 3 | * 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 | |
| 6 | 6 | * reachable with a keyboard and big enough for a thumb. |
| 7 | 7 | * |
| 8 | 8 | * Nothing in here is a browser. `boot(host)` takes everything it reaches for — |
| @@ -21,7 +21,8 @@ import { | ||
| 21 | 21 | attachButtons, clamp, createControls, createLoop, type Button, type Listens, |
| 22 | 22 | } from "../engine/index.ts"; |
| 23 | 23 | 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"; | |
| 25 | 26 | import { buyable } from "./weapons.ts"; |
| 26 | 27 | import { |
| 27 | 28 | buy, canBuy, createWorld, eyePos, priceOf, update, type BuyItem, type Player, type World, |
| @@ -84,8 +85,8 @@ export type Store = { | ||
| 84 | 85 | /** The two things this file reads off a response to POST /api/match. */ |
| 85 | 86 | export type Reply = { readonly ok: boolean; text(): Promise<string> }; |
| 86 | 87 | |
| 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 }; | |
| 89 | 90 | |
| 90 | 91 | export type Host = { |
| 91 | 92 | document: PageDocument; |
| @@ -99,17 +100,25 @@ export type Host = { | ||
| 99 | 100 | createAudio(): Audio; |
| 100 | 101 | }; |
| 101 | 102 | |
| 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 | ||
| 102 | 111 | /** |
| 103 | 112 | * Wire the page up and start the loop. Exported so `main.test.ts` can boot the |
| 104 | 113 | * whole frame against a stub host; in a browser the line at the bottom of this |
| 105 | 114 | * file is what calls it, with the real one. |
| 106 | 115 | */ |
| 107 | export function boot(host: Host): void { | |
| 116 | export function boot(host: Host): Frame { | |
| 108 | 117 | const doc = host.document; |
| 109 | 118 | const win = host.window; |
| 110 | 119 | const by = <T extends Panel>(id: string): T => doc.getElementById(id) as T; |
| 111 | 120 | |
| 112 | const scene = by<Stage>("scene"); | |
| 121 | const sceneCanvas = by<Stage>("scene"); | |
| 113 | 122 | const hudCanvas = by<Stage>("hud"); |
| 114 | 123 | const menu = by("menu"); |
| 115 | 124 | const pause = by("pause"); |
| @@ -121,15 +130,15 @@ export function boot(host: Host): void { | ||
| 121 | 130 | const sideField = by<Field>("side"); |
| 122 | 131 | const sensField = by<Field>("sens"); |
| 123 | 132 | |
| 124 | const renderer = host.createRenderer(scene); | |
| 133 | const renderer = host.createRenderer(sceneCanvas); | |
| 125 | 134 | if (renderer === null) { |
| 126 | 135 | 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 }; | |
| 128 | 137 | } |
| 129 | 138 | const hudCtx = hudCanvas.getContext("2d") as Paint; |
| 130 | 139 | const audio = host.createAudio(); |
| 131 | 140 | const controls = createControls( |
| 132 | { surface: scene, window: win, document: doc }, | |
| 141 | { surface: sceneCanvas, window: win, document: doc }, | |
| 133 | 142 | { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 }, |
| 134 | 143 | ); |
| 135 | 144 | attachButtons(doc.querySelectorAll("[data-action]"), controls, { tap: CS_TAP }); |
| @@ -140,6 +149,10 @@ export function boot(host: Host): void { | ||
| 140 | 149 | }); |
| 141 | 150 | |
| 142 | 151 | 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 }; | |
| 143 | 156 | let matchId = ""; |
| 144 | 157 | let running = false; |
| 145 | 158 | let lastSound = 0; |
| @@ -188,7 +201,7 @@ export function boot(host: Host): void { | ||
| 188 | 201 | doc.exitPointerLock(); |
| 189 | 202 | (buyPanel.querySelector("button") as Panel | null)?.focus(); |
| 190 | 203 | } else if (running) { |
| 191 | void scene.requestPointerLock(); | |
| 204 | void sceneCanvas.requestPointerLock(); | |
| 192 | 205 | } |
| 193 | 206 | } |
| 194 | 207 | |
| @@ -246,14 +259,14 @@ export function boot(host: Host): void { | ||
| 246 | 259 | show(pause, false); |
| 247 | 260 | show(touchPanel, win.matchMedia("(pointer: coarse)").matches); |
| 248 | 261 | audio.resume(); |
| 249 | void scene.requestPointerLock(); | |
| 262 | void sceneCanvas.requestPointerLock(); | |
| 250 | 263 | host.storage.setItem("cs.name", name); |
| 251 | 264 | } |
| 252 | 265 | |
| 253 | 266 | by("play").addEventListener("click", start); |
| 254 | 267 | by("resume").addEventListener("click", () => { |
| 255 | 268 | show(pause, false); |
| 256 | void scene.requestPointerLock(); | |
| 269 | void sceneCanvas.requestPointerLock(); | |
| 257 | 270 | }); |
| 258 | 271 | by("quit").addEventListener("click", () => { |
| 259 | 272 | running = false; |
| @@ -319,13 +332,14 @@ export function boot(host: Host): void { | ||
| 319 | 332 | } |
| 320 | 333 | |
| 321 | 334 | 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); | |
| 329 | 343 | |
| 330 | 344 | const dpr = Math.min(win.devicePixelRatio || 1, 2); |
| 331 | 345 | const w = Math.round(hudCanvas.clientWidth * dpr); |
| @@ -338,6 +352,7 @@ export function boot(host: Host): void { | ||
| 338 | 352 | by("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`; |
| 339 | 353 | } |
| 340 | 354 | loop.start(frame); |
| 355 | return { world: () => world }; | |
| 341 | 356 | } |
| 342 | 357 | |
| 343 | 358 | /** |
src/client/models.ts +14 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | 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"; | |
| 9 | 9 | |
| 10 | 10 | /** The level, as one buffer. Fifty-odd boxes, three hundred quads, one draw. */ |
| 11 | 11 | export function levelMesh(boxes: Box[]): Mesh { |
| @@ -63,3 +63,16 @@ export function viewModelMesh(kind: string): Mesh { | ||
| 63 | 63 | if (kind === "rifle" || kind === "smg") cube(b, 0.55, -0.66, 0.35, 0.2, 0.26, 0.8, GRIP); // stock |
| 64 | 64 | return finish(b); |
| 65 | 65 | } |
| 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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 6 | 10 | */ |
| 7 | 11 | |
| 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"; | |
| 10 | 14 | 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"; | |
| 14 | 16 | |
| 15 | 17 | const SCENE_VS = `#version 300 es |
| 16 | 18 | precision highp float; |
| @@ -77,8 +79,6 @@ in vec3 vColor; | ||
| 77 | 79 | out vec4 outColor; |
| 78 | 80 | void main() { outColor = vec4(vColor, 1.0); }`; |
| 79 | 81 | |
| 80 | export type Camera = { pos: Vec3; yaw: number; pitch: number; fov: number }; | |
| 81 | ||
| 82 | 82 | type Buffers = { vao: WebGLVertexArrayObject; count: number }; |
| 83 | 83 | |
| 84 | 84 | function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader { |
| @@ -121,16 +121,8 @@ function upload(gl: WebGL2RenderingContext, mesh: Mesh): Buffers { | ||
| 121 | 121 | return { vao, count: mesh.index.length }; |
| 122 | 122 | } |
| 123 | 123 | |
| 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 | ||
| 132 | 124 | export type Renderer = { |
| 133 | draw(world: World, camera: Camera, dt: number): void; | |
| 125 | draw(scene: Scene, dt: number): void; | |
| 134 | 126 | resize(): void; |
| 135 | 127 | readonly gl: WebGL2RenderingContext; |
| 136 | 128 | }; |
| @@ -139,18 +131,18 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null { | ||
| 139 | 131 | const gl = canvas.getContext("webgl2", { antialias: true, alpha: false, depth: true }); |
| 140 | 132 | if (gl === null) return null; |
| 141 | 133 | |
| 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); | |
| 144 | 136 | const uniform = (program: WebGLProgram, name: string): WebGLUniformLocation | null => |
| 145 | 137 | gl.getUniformLocation(program, name); |
| 146 | 138 | 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"), | |
| 154 | 146 | }; |
| 155 | 147 | |
| 156 | 148 | const texture = gl.createTexture(); |
| @@ -165,18 +157,24 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null { | ||
| 165 | 157 | gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT); |
| 166 | 158 | gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT); |
| 167 | 159 | |
| 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 | }; | |
| 172 | 170 | |
| 173 | 171 | // 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; | |
| 175 | 174 | const lineVao = gl.createVertexArray() as WebGLVertexArrayObject; |
| 176 | 175 | const lineBuffer = gl.createBuffer(); |
| 177 | 176 | gl.bindVertexArray(lineVao); |
| 178 | 177 | gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer); |
| 179 | gl.bufferData(gl.ARRAY_BUFFER, lineData.byteLength, gl.DYNAMIC_DRAW); | |
| 180 | 178 | gl.enableVertexAttribArray(0); |
| 181 | 179 | gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); |
| 182 | 180 | gl.enableVertexAttribArray(1); |
| @@ -186,7 +184,6 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null { | ||
| 186 | 184 | gl.enable(gl.DEPTH_TEST); |
| 187 | 185 | gl.enable(gl.CULL_FACE); |
| 188 | 186 | gl.cullFace(gl.BACK); |
| 189 | gl.clearColor(SKY[0], SKY[1], SKY[2], 1); | |
| 190 | 187 | |
| 191 | 188 | const resize = (): void => { |
| 192 | 189 | const dpr = Math.min(window.devicePixelRatio || 1, 2); |
| @@ -198,15 +195,23 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null { | ||
| 198 | 195 | } |
| 199 | 196 | }; |
| 200 | 197 | |
| 201 | let bobPhase = 0; | |
| 198 | const ORIGIN: [number, number, number] = [0, 0, 0]; | |
| 202 | 199 | |
| 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 => { | |
| 204 | 207 | resize(); |
| 205 | 208 | const width = canvas.width, height = canvas.height; |
| 206 | 209 | gl.viewport(0, 0, width, height); |
| 210 | gl.clearColor(scene.sky[0], scene.sky[1], scene.sky[2], 1); | |
| 207 | 211 | gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); |
| 208 | 212 | gl.enable(gl.DEPTH_TEST); |
| 209 | 213 | |
| 214 | const camera = scene.camera; | |
| 210 | 215 | const aspect = width / Math.max(1, height); |
| 211 | 216 | // A CS field of view is horizontal; the projection wants the vertical one. |
| 212 | 217 | 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 { | ||
| 214 | 219 | const view = viewMatrix(camera.pos, camera.yaw, camera.pitch); |
| 215 | 220 | const viewProj = multiply(proj, view); |
| 216 | 221 | |
| 217 | if (levelBuffers === null) levelBuffers = upload(gl, levelMesh(world.boxes)); | |
| 218 | ||
| 219 | gl.useProgram(scene); | |
| 222 | gl.useProgram(surfaces); | |
| 220 | 223 | gl.activeTexture(gl.TEXTURE0); |
| 221 | 224 | gl.bindTexture(gl.TEXTURE_2D_ARRAY, texture); |
| 222 | 225 | gl.uniform1i(u.atlas, 0); |
| 223 | 226 | gl.uniformMatrix4fv(u.viewProj, false, viewProj); |
| 224 | 227 | gl.uniform3fv(u.eye, camera.pos); |
| 225 | gl.uniform3fv(u.fog, SKY); | |
| 228 | gl.uniform3fv(u.fog, scene.sky); | |
| 226 | 229 | |
| 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; | |
| 281 | 241 | } |
| 242 | gl.drawElements(gl.TRIANGLES, buffers.count, gl.UNSIGNED_INT, 0); | |
| 282 | 243 | } |
| 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; | |
| 288 | 247 | if (n > 0) { |
| 289 | gl.useProgram(lines); | |
| 248 | gl.useProgram(strokes); | |
| 290 | 249 | gl.uniformMatrix4fv(u.lineViewProj, false, viewProj); |
| 291 | 250 | gl.bindVertexArray(lineVao); |
| 292 | 251 | 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); | |
| 294 | 257 | gl.drawArrays(gl.LINES, 0, n - (n % 2)); |
| 295 | 258 | } |
| 296 | 259 | |
| 297 | 260 | // --- 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); | |
| 311 | 264 | gl.clear(gl.DEPTH_BUFFER_BIT); |
| 312 | gl.useProgram(scene); | |
| 265 | gl.useProgram(surfaces); | |
| 313 | 266 | const vmProj = perspective(58, aspect, 0.05, 40); |
| 314 | 267 | gl.uniformMatrix4fv(u.viewProj, false, vmProj); |
| 315 | 268 | 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); | |
| 321 | 271 | 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); | |
| 324 | 274 | } |
| 325 | 275 | gl.bindVertexArray(null); |
| 326 | 276 | }; |
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 @@ | ||
| 7 | 7 | * |
| 8 | 8 | * Everything here records rather than does: an element remembers what was |
| 9 | 9 | * 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. | |
| 14 | 15 | * |
| 15 | 16 | * It ships in no bundle: only `main.test.ts` imports it. `src/range/dom.ts` is |
| 16 | 17 | * the same idea for the other game — it cannot import this one, because |
| @@ -21,8 +22,7 @@ | ||
| 21 | 22 | import type { Audio } from "./audio.ts"; |
| 22 | 23 | import type { Paint } from "./hud.ts"; |
| 23 | 24 | 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"; | |
| 26 | 26 | |
| 27 | 27 | type Handler = (event: any) => void; |
| 28 | 28 | |
| @@ -174,8 +174,8 @@ export function canvas(id: string): FakeCanvas { | ||
| 174 | 174 | /** One POST the frame made, as it was made. */ |
| 175 | 175 | export type Post = { url: string; method: string; headers: Record<string, string>; body: string }; |
| 176 | 176 | |
| 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 }; | |
| 179 | 179 | |
| 180 | 180 | export type FakeStage = { |
| 181 | 181 | /** What `boot` is handed. */ |
| @@ -205,8 +205,6 @@ export type FakeStage = { | ||
| 205 | 205 | store: Map<string, string>; |
| 206 | 206 | /** The match ids handed out, newest last. */ |
| 207 | 207 | ids: string[]; |
| 208 | /** The world the last frame drew, or null if none has been drawn. */ | |
| 209 | world(): World | null; | |
| 210 | 208 | }; |
| 211 | 209 | |
| 212 | 210 | /** |
| @@ -271,8 +269,8 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): | ||
| 271 | 269 | beep: () => void sounds.push("beep"), |
| 272 | 270 | }; |
| 273 | 271 | const renderer: Draws = { |
| 274 | draw(world, camera, dt) { | |
| 275 | draws.push({ world, camera, dt }); | |
| 272 | draw(scene, dt) { | |
| 273 | draws.push({ scene, dt }); | |
| 276 | 274 | }, |
| 277 | 275 | }; |
| 278 | 276 | |
| @@ -316,7 +314,6 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): | ||
| 316 | 314 | stub.hold(code, false); |
| 317 | 315 | }, |
| 318 | 316 | answer: () => Promise.resolve({ ok: true, text: () => Promise.resolve("<h2>Leaderboard</h2>") }), |
| 319 | world: () => draws[draws.length - 1]?.world ?? null, | |
| 320 | 317 | }; |
| 321 | 318 | return stub; |
| 322 | 319 | } |
static/app.6bf398e121.js deleted −58
Diff not shown — a line 57,170 characters wide (limit 2,000). −58
View filestatic/app.de0b8ba773.js added +58
Diff not shown — a line 57,283 characters wide (limit 2,000). +58
View file
new above
offline