Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes · 4 behind
The range paints its own world: a room, a target and an atlas of its own, all data and no renderer #66
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/50-the-range-paints-its-own-world-a-room-a → main 1 commit fork
5 files
changed +497
−6
README.md +20 −6
| @@ -7,7 +7,8 @@ and the leaderboard of the matches people played. The second, at `/range`, is | ||
| 7 | 7 | built only out of the engine the shooter was taken apart into: a shooting |
| 8 | 8 | **range** — pop-up targets from a seeded spawn, a shot traced through the |
| 9 | 9 | engine's boxes, a thirty second clock and a score, with the fixed step and the |
| 10 | controls under it and no renderer yet. The engine in | |
| 10 | controls under it, a room and an atlas of its own built as data, and no | |
| 11 | renderer yet to draw them. The engine in | |
| 11 | 12 | `src/engine/` is the point of the repository; the shooter is what proves it. |
| 12 | 13 | |
| 13 | 14 | sh |
| @@ -31,7 +32,10 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 31 | 32 | generic tools in the engine — a grid of cells that meshes and paths, a box mesh |
| 32 | 33 | builder, an atlas painter over hashed value noise, and a two-voice sound |
| 33 | 34 | device — driven by this game's recipes in |
| 34 | `src/client/{map,models,materials,audio}.ts`. There are no | |
| 35 | `src/client/{map,models,materials,audio}.ts`. The range does the same for | |
| 36 | itself out of the same two tools and on its own numbers, in | |
| 37 | `src/range/{level,paint}.ts`: a room and a three-layer atlas that owe | |
| 38 | `de_forge` nothing. There are no | |
| 35 | 39 | asset files and nothing from the real game is used — the numbers follow CS's |
| 36 | 40 | published shape so the habits transfer, and the rest is original. |
| 37 | 41 | - **Learned, and measured.** The bots do not aim by rule. A 32-input, two-layer |
| @@ -204,8 +208,18 @@ from the angle it subtends, and you turn on the spot. | ||
| 204 | 208 | game gets that furniture without copying a line out of the shooter; and `main.ts` |
| 205 | 209 | wires the engine's `loop` and `controls` to the shell's ids, steps the run in |
| 206 | 210 | whole ticks and fires one shot per press along the engine's `angleVectors`. |
| 207 | No room, no models, no movement, no WebGL — those need the renderer and the | |
| 208 | physics, and #13 builds the finale on this. `independence.test.ts` reads every source in | |
| 211 | **The range paints its own world**, and it is data before it is a picture: | |
| 212 | `level.ts` is one room — a floor, four walls and a ceiling as six overlapping | |
| 213 | slabs around a 1,792 × 1,280 × 320 unit lane, derived from four named numbers | |
| 214 | so nothing is written twice — with `roomMesh()` and `targetMesh()` built out | |
| 215 | of the engine's box builder, and `paint.ts` is the range's own atlas through | |
| 216 | the engine's painter: three 64² layers (a target face, concrete, the lane's | |
| 217 | painted line) against the shooter's nine at 128², each a couple of lines of | |
| 218 | `fbm`, on the range's own seeds. Neither is `de_forge` repainted and neither | |
| 219 | is imported by the entry, so the bundle has not grown by a byte; `level.test.ts` | |
| 220 | proves the shell has no seam a bullet can leave through and `paint.test.ts` | |
| 221 | pins the atlas byte for byte. No movement and no WebGL yet — those need the | |
| 222 | physics and the renderer, and #13 builds the finale on this. `independence.test.ts` reads every source in | |
| 209 | 223 | here and fails on an import that leaves those three places, reusing the |
| 210 | 224 | `sources`/`specifiersOf` helpers from `src/engine/boundary.test.ts` rather than a |
| 211 | 225 | second copy of the rule — a test may read another test's helpers, which is the |
| @@ -298,8 +312,8 @@ from the angle it subtends, and you turn on the spot. | ||
| 298 | 312 | |
| 299 | 313 | | bundle | raw | ceiling | gzipped | ceiling | |
| 300 | 314 | | ------- | ------ | ------- | ------- | ------- | |
| 301 | | `app` | 68,457 | 75,400 | 31,661 | 34,900 | | |
| 302 | | `range` | 11,575 | 12,800 | 5,241 | 5,800 | | |
| 315 | | `app` | 69,711 | 75,400 | 32,139 | 34,900 | | |
| 316 | | `range` | 12,548 | 12,800 | 5,726 | 5,800 | | |
| 303 | 317 | |
| 304 | 318 | The ceilings are today's sizes plus about 10%. Gzip is measured at level 9 |
| 305 | 319 | and only at level 9, so the number does not move between a laptop and the |
src/range/level.test.ts added +188
| @@ -0,0 +1,188 @@ | ||
| 1 | /** | |
| 2 | * The room, checked headlessly. Nothing in here draws: a room is boxes a bullet | |
| 3 | * stops in and buffers a renderer would upload, and both are arithmetic. The | |
| 4 | * tests that matter are the ones a picture would not show — that the shell has | |
| 5 | * no seam a bullet can leave through, and that every index into the mesh points | |
| 6 | * at a vertex that exists. | |
| 7 | */ | |
| 8 | ||
| 9 | import { expect, test } from "bun:test"; | |
| 10 | import { | |
| 11 | LANE, RANGE_MAT, RANGE_ROOM, SHELL, TILE, insideRoom, roomMesh, targetMesh, | |
| 12 | } from "./level.ts"; | |
| 13 | import { EYE, STANDARD } from "./targets.ts"; | |
| 14 | import { traceWorld, type Mesh, type Vec3 } from "../engine/index.ts"; | |
| 15 | ||
| 16 | /** The inside of the room, written out from `LANE` the way a reader would. */ | |
| 17 | const INNER: [Vec3, Vec3] = [ | |
| 18 | [-LANE.back, -LANE.half, 0], | |
| 19 | [LANE.far, LANE.half, LANE.height], | |
| 20 | ]; | |
| 21 | ||
| 22 | /** The middle of the air, which every ray in here is cast from. */ | |
| 23 | const CENTRE: Vec3 = [ | |
| 24 | (INNER[0][0] + INNER[1][0]) / 2, | |
| 25 | 0, | |
| 26 | LANE.height / 2, | |
| 27 | ]; | |
| 28 | ||
| 29 | const AXES = [0, 1, 2] as const; | |
| 30 | const SIDES = [-1, 1] as const; | |
| 31 | ||
| 32 | const vertexCount = (mesh: Mesh): number => mesh.position.length / 3; | |
| 33 | ||
| 34 | test("every box in the room is a box: min below max on all three axes", () => { | |
| 35 | expect(RANGE_ROOM.length).toBe(6); | |
| 36 | for (const box of RANGE_ROOM) | |
| 37 | for (const i of AXES) { | |
| 38 | expect(box.min[i]).toBeLessThan(box.max[i]); | |
| 39 | expect(Number.isFinite(box.min[i])).toBe(true); | |
| 40 | expect(Number.isFinite(box.max[i])).toBe(true); | |
| 41 | } | |
| 42 | }); | |
| 43 | ||
| 44 | test("the six slabs are a floor, a ceiling and four walls, each one shell thick", () => { | |
| 45 | const faces = new Set<string>(); | |
| 46 | for (const box of RANGE_ROOM) { | |
| 47 | const thin = AXES.filter((i) => (box.max[i] as number) - (box.min[i] as number) === SHELL); | |
| 48 | // Exactly one axis is thin, and on that axis the slab sits against the air. | |
| 49 | expect(thin.length).toBe(1); | |
| 50 | const axis = thin[0] as 0 | 1 | 2; | |
| 51 | const side = box.max[axis] === INNER[0][axis] ? -1 : 1; | |
| 52 | expect(side < 0 ? box.max[axis] : box.min[axis]).toBe(INNER[side < 0 ? 0 : 1][axis]); | |
| 53 | faces.add(`${axis}${side}`); | |
| 54 | } | |
| 55 | expect([...faces].sort()).toEqual(["0-1", "01", "1-1", "11", "2-1", "21"]); | |
| 56 | }); | |
| 57 | ||
| 58 | const lerpAxis = (axis: 0 | 1 | 2, t: number): number => | |
| 59 | (INNER[0][axis] as number) + ((INNER[1][axis] as number) - (INNER[0][axis] as number)) * t; | |
| 60 | ||
| 61 | /** | |
| 62 | * Every way out of this room a ray from the middle of it found. A grid of | |
| 63 | * aiming points across each inner face — corners and edges included, because a | |
| 64 | * corner is exactly where two slabs could fail to meet — each pushed well past | |
| 65 | * the outside of the shell. | |
| 66 | */ | |
| 67 | function leaks(room: typeof RANGE_ROOM): string[] { | |
| 68 | const out: string[] = []; | |
| 69 | for (const axis of AXES) | |
| 70 | for (const side of SIDES) | |
| 71 | for (let i = 0; i <= 8; i++) | |
| 72 | for (let j = 0; j <= 8; j++) { | |
| 73 | const across = AXES.filter((a) => a !== axis) as [0 | 1 | 2, 0 | 1 | 2]; | |
| 74 | const to: Vec3 = [0, 0, 0]; | |
| 75 | to[axis] = (INNER[side < 0 ? 0 : 1][axis] as number) + side * SHELL * 4; | |
| 76 | to[across[0]] = lerpAxis(across[0], i / 8); | |
| 77 | to[across[1]] = lerpAxis(across[1], j / 8); | |
| 78 | const hit = traceWorld(CENTRE, to, room); | |
| 79 | if (hit === null || hit.t >= 1) out.push(`${axis}${side} ${to.join(",")}`); | |
| 80 | } | |
| 81 | return out; | |
| 82 | } | |
| 83 | ||
| 84 | test("the shell has no gap on any side: every ray out of the lane stops in it", () => { | |
| 85 | expect(leaks(RANGE_ROOM)).toEqual([]); | |
| 86 | }); | |
| 87 | ||
| 88 | test("the ray grid has teeth: a doorway left in a wall is found by it", () => { | |
| 89 | // The same room with a 320-unit hole cut through the middle of the north | |
| 90 | // wall — wider than the grid's stride, which is what makes it findable — and | |
| 91 | // nothing else changed. | |
| 92 | const hole: [number, number] = [CENTRE[0] - 160, CENTRE[0] + 160]; | |
| 93 | const cut = RANGE_ROOM.flatMap((box) => | |
| 94 | box.min[1] === LANE.half | |
| 95 | ? [ | |
| 96 | { ...box, max: [hole[0], box.max[1], box.max[2]] as Vec3 }, | |
| 97 | { ...box, min: [hole[1], box.min[1], box.min[2]] as Vec3 }, | |
| 98 | ] | |
| 99 | : [box], | |
| 100 | ); | |
| 101 | expect(leaks(cut).length).toBeGreaterThan(0); | |
| 102 | }); | |
| 103 | ||
| 104 | test("the shooter's eye is inside the room and a point past a wall is not", () => { | |
| 105 | expect(insideRoom(EYE)).toBe(true); | |
| 106 | expect(insideRoom(CENTRE)).toBe(true); | |
| 107 | // A plate at the far end of the lane, at full spread and full height, stands | |
| 108 | // in the air and not in a wall: the room is built around the rules. | |
| 109 | const r = STANDARD; | |
| 110 | expect(insideRoom([r.depth.max + r.half, r.spread + r.half, r.height.max + r.half])).toBe(true); | |
| 111 | expect(insideRoom([r.depth.min - r.half, -r.spread - r.half, r.height.min - r.half])).toBe(true); | |
| 112 | ||
| 113 | for (const outside of [ | |
| 114 | [LANE.far + 1, 0, 64], | |
| 115 | [-LANE.back - 1, 0, 64], | |
| 116 | [0, LANE.half + 1, 64], | |
| 117 | [0, -LANE.half - 1, 64], | |
| 118 | [0, 0, LANE.height + 1], | |
| 119 | [0, 0, -1], | |
| 120 | ] as Vec3[]) | |
| 121 | expect(insideRoom(outside)).toBe(false); | |
| 122 | }); | |
| 123 | ||
| 124 | test("a shot down the lane stops at the wall and not beyond it", () => { | |
| 125 | // The range's own bullet: from the eye, down +X, as far as `STANDARD` carries. | |
| 126 | const to: Vec3 = [EYE[0] + STANDARD.range, EYE[1], EYE[2]]; | |
| 127 | const hit = traceWorld(EYE, to, RANGE_ROOM); | |
| 128 | expect(hit).not.toBeNull(); | |
| 129 | const point = (hit as { point: Vec3 }).point; | |
| 130 | expect(point[0]).toBeCloseTo(LANE.far, 6); | |
| 131 | expect(point[0]).toBeLessThan(to[0]); | |
| 132 | // A step short of the hit is still in the room; a step past it is in concrete. | |
| 133 | expect(insideRoom([point[0] - 1, point[1], point[2]])).toBe(true); | |
| 134 | expect(insideRoom([point[0] + 1, point[1], point[2]])).toBe(false); | |
| 135 | ||
| 136 | // The other five directions stop too, each at its own surface. | |
| 137 | const stops: number[] = []; | |
| 138 | for (const axis of AXES) | |
| 139 | for (const side of SIDES) { | |
| 140 | const far: Vec3 = [EYE[0], EYE[1], EYE[2]]; | |
| 141 | far[axis] += side * STANDARD.range; | |
| 142 | const out = traceWorld(EYE, far, RANGE_ROOM); | |
| 143 | expect(out).not.toBeNull(); | |
| 144 | stops.push((out as { point: Vec3 }).point[axis] as number); | |
| 145 | } | |
| 146 | expect(stops).toEqual([-LANE.back, LANE.far, -LANE.half, LANE.half, 0, LANE.height]); | |
| 147 | }); | |
| 148 | ||
| 149 | test("the room mesh is the room's boxes, with every index inside the vertices", () => { | |
| 150 | const mesh = roomMesh(); | |
| 151 | expect(vertexCount(mesh)).toBe(RANGE_ROOM.length * 24); | |
| 152 | expect(mesh.index.length).toBe(RANGE_ROOM.length * 36); | |
| 153 | expect(mesh.index.length % 3).toBe(0); | |
| 154 | expect(mesh.normal.length).toBe(mesh.position.length); | |
| 155 | expect(mesh.uv.length).toBe(vertexCount(mesh) * 2); | |
| 156 | expect(mesh.layer.length).toBe(vertexCount(mesh)); | |
| 157 | for (const i of mesh.index) { | |
| 158 | expect(Number.isInteger(i)).toBe(true); | |
| 159 | expect(i).toBeLessThan(vertexCount(mesh)); | |
| 160 | } | |
| 161 | for (const value of mesh.position) expect(Number.isFinite(value)).toBe(true); | |
| 162 | // Every vertex carries the layer of the box it came from, 24 at a time. | |
| 163 | for (let box = 0; box < RANGE_ROOM.length; box++) | |
| 164 | for (let v = 0; v < 24; v++) | |
| 165 | expect(mesh.layer[box * 24 + v]).toBe((RANGE_ROOM[box] as { mat: number }).mat); | |
| 166 | // The lane's paint tiles rather than stretching: the floor is a dozen tiles | |
| 167 | // long, so its uv runs to the far end of the shell divided by the tile. | |
| 168 | expect(Math.max(...mesh.uv)).toBeCloseTo((LANE.far + SHELL) / TILE, 6); | |
| 169 | }); | |
| 170 | ||
| 171 | test("the target mesh is one plate at the origin, its face fitted once", () => { | |
| 172 | const mesh = targetMesh(); | |
| 173 | expect(vertexCount(mesh)).toBe(24); | |
| 174 | expect(mesh.index.length).toBe(36); | |
| 175 | for (const i of mesh.index) expect(i).toBeLessThan(vertexCount(mesh)); | |
| 176 | for (let v = 0; v < 24; v++) { | |
| 177 | for (const axis of AXES) | |
| 178 | expect(Math.abs(mesh.position[v * 3 + axis] as number)).toBeCloseTo(STANDARD.half, 6); | |
| 179 | expect(mesh.layer[v]).toBe(RANGE_MAT.PLATE); | |
| 180 | } | |
| 181 | // Each face spans exactly one tile of the atlas: the bullseye reads once. | |
| 182 | expect(Math.max(...mesh.uv) - Math.min(...mesh.uv)).toBeCloseTo(1, 6); | |
| 183 | ||
| 184 | // The plate a renderer draws is the size of the plate a bullet hits. | |
| 185 | const half = 40; | |
| 186 | const wider = targetMesh(half); | |
| 187 | expect(Math.max(...wider.position)).toBeCloseTo(half, 6); | |
| 188 | }); |
src/range/level.ts added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | /** | |
| 2 | * The range's world, as data: one room — a floor, four walls and a ceiling — | |
| 3 | * around a lane long enough to shoot down, and the two meshes that draw it. | |
| 4 | * | |
| 5 | * There is no renderer under this and no physics beside it. A `Box` is what the | |
| 6 | * engine traces a bullet against, a `Mesh` is what a renderer would upload, and | |
| 7 | * both are plain arrays a test can read with no canvas in the room: #13 turns | |
| 8 | * them into a picture, and nothing here has to change when it does. | |
| 9 | * | |
| 10 | * Units are the engine's and the range's, as `targets.ts` set them: Source | |
| 11 | * units, X east down the lane, Y north across it, Z up from the floor. The | |
| 12 | * shooter stands at the origin with their feet on the floor and their eye at | |
| 13 | * `EYE`, and the plates pop up in front of them — so the room is built around | |
| 14 | * that, deeper down +X than anywhere else and only far enough behind the | |
| 15 | * firing line to stand. | |
| 16 | * | |
| 17 | * It is deliberately not a map. There is no grid, no path, no cover and no | |
| 18 | * prop: `src/client/map.ts` is a map, and a second game that needed one would | |
| 19 | * reach for the engine's grid rather than for this file. | |
| 20 | */ | |
| 21 | ||
| 22 | import { addBox, builder, finish, type Box, type Mesh, type Vec3 } from "../engine/index.ts"; | |
| 23 | import { PLATE_MAT, STANDARD } from "./targets.ts"; | |
| 24 | ||
| 25 | /** | |
| 26 | * The air inside, in units: what a shooter and a plate share. Everything else | |
| 27 | * in this file is derived from these five numbers, so the room changes size by | |
| 28 | * one edit and the tests follow it. | |
| 29 | * | |
| 30 | * The lane is 1,792 units long and 1,280 wide with a 320 ceiling: `STANDARD` | |
| 31 | * stands its farthest plate at 1,200 and its widest at ±420, so there are 300 | |
| 32 | * units of air past the farthest plate and 200 at the sides — a lane, not a | |
| 33 | * corridor a plate is wedged into. | |
| 34 | */ | |
| 35 | export const LANE = { | |
| 36 | /** How far behind the firing line the back wall stands. */ | |
| 37 | back: 256, | |
| 38 | /** How far down the lane the far wall stands. */ | |
| 39 | far: 1536, | |
| 40 | /** Half the lane's width: the side walls stand at ±this. */ | |
| 41 | half: 640, | |
| 42 | /** Floor to ceiling. */ | |
| 43 | height: 320, | |
| 44 | } as const; | |
| 45 | ||
| 46 | /** How thick every slab of the shell is. A wall has a depth, so a ray stops in it. */ | |
| 47 | export const SHELL = 32; | |
| 48 | ||
| 49 | /** | |
| 50 | * Which layer of the range's atlas a surface is painted from. `paint.ts` paints | |
| 51 | * them in this order and `paint.test.ts` holds the two together; the plate's | |
| 52 | * layer is `targets.ts`'s, named here rather than written a second time. | |
| 53 | */ | |
| 54 | export const RANGE_MAT = { PLATE: PLATE_MAT, CONCRETE: 1, LANE: 2 } as const; | |
| 55 | ||
| 56 | /** The inside corner-to-corner, low and high. The floor's surface is z = 0. */ | |
| 57 | const INNER: [Vec3, Vec3] = [ | |
| 58 | [-LANE.back, -LANE.half, 0], | |
| 59 | [LANE.far, LANE.half, LANE.height], | |
| 60 | ]; | |
| 61 | ||
| 62 | /** The outside of the shell: the inside, grown by `SHELL` on all six sides. */ | |
| 63 | const OUTER: [Vec3, Vec3] = [ | |
| 64 | [INNER[0][0] - SHELL, INNER[0][1] - SHELL, INNER[0][2] - SHELL], | |
| 65 | [INNER[1][0] + SHELL, INNER[1][1] + SHELL, INNER[1][2] + SHELL], | |
| 66 | ]; | |
| 67 | ||
| 68 | /** | |
| 69 | * One side of the shell: the whole outer block, cut back to the shell on the | |
| 70 | * far side of `axis`. Every slab therefore spans the two axes it is not, which | |
| 71 | * is what makes the corners overlap rather than meet — there is no seam for a | |
| 72 | * bullet to leave through, and no arithmetic anywhere deciding where a corner | |
| 73 | * belongs. | |
| 74 | */ | |
| 75 | function slab(axis: 0 | 1 | 2, side: -1 | 1, mat: number): Box { | |
| 76 | const min: Vec3 = [OUTER[0][0], OUTER[0][1], OUTER[0][2]]; | |
| 77 | const max: Vec3 = [OUTER[1][0], OUTER[1][1], OUTER[1][2]]; | |
| 78 | if (side < 0) max[axis] = INNER[0][axis]; | |
| 79 | else min[axis] = INNER[1][axis]; | |
| 80 | return { min, max, mat }; | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * The room: six slabs, in the order a reader would look for them. The floor | |
| 85 | * carries the lane's paint and everything else is concrete, which is the whole | |
| 86 | * of this room's material list. | |
| 87 | */ | |
| 88 | export const RANGE_ROOM: Box[] = [ | |
| 89 | slab(2, -1, RANGE_MAT.LANE), // floor | |
| 90 | slab(2, 1, RANGE_MAT.CONCRETE), // ceiling | |
| 91 | slab(0, -1, RANGE_MAT.CONCRETE), // behind the firing line | |
| 92 | slab(0, 1, RANGE_MAT.CONCRETE), // the wall you shoot at | |
| 93 | slab(1, -1, RANGE_MAT.CONCRETE), // south | |
| 94 | slab(1, 1, RANGE_MAT.CONCRETE), // north | |
| 95 | ]; | |
| 96 | ||
| 97 | /** Is this point in the air inside the room? A point in a wall is not. */ | |
| 98 | export const insideRoom = (p: Vec3): boolean => | |
| 99 | [0, 1, 2].every( | |
| 100 | (i) => (p[i] as number) > (INNER[0][i] as number) && (p[i] as number) < (INNER[1][i] as number), | |
| 101 | ); | |
| 102 | ||
| 103 | /** How many units of wall one tile of the atlas covers. */ | |
| 104 | export const TILE = 128; | |
| 105 | ||
| 106 | /** | |
| 107 | * The room as one buffer: six boxes, thirty-six quads, one draw. The texture | |
| 108 | * tiles every `TILE` units, so the floor's painted line repeats down the lane | |
| 109 | * rather than being stretched once over 1,792 units of concrete. | |
| 110 | */ | |
| 111 | export function roomMesh(room: Box[] = RANGE_ROOM): Mesh { | |
| 112 | const b = builder(); | |
| 113 | for (const box of room) addBox(b, box.min, box.max, box.mat, TILE); | |
| 114 | return finish(b); | |
| 115 | } | |
| 116 | ||
| 117 | /** | |
| 118 | * One plate, standing at the origin: the mesh every target is drawn with, so a | |
| 119 | * scene places it by instance and the renderer uploads it once. Its size is the | |
| 120 | * rules' — a plate you can draw and a plate you can hit are the same cube — and | |
| 121 | * its texture fits its face exactly, because a target that reads at a distance | |
| 122 | * reads once and not nine times. | |
| 123 | */ | |
| 124 | export function targetMesh(half: number = STANDARD.half): Mesh { | |
| 125 | const b = builder(); | |
| 126 | addBox(b, [-half, -half, -half], [half, half, half], RANGE_MAT.PLATE, half * 2); | |
| 127 | return finish(b); | |
| 128 | } |
src/range/paint.test.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * The atlas, checked headlessly: bytes, not pixels. What a layer looks like is | |
| 3 | * a judgement nobody can assert, so what is asserted is what a bug would break | |
| 4 | * — the size, the range of every byte, that the same seeds paint the same atlas | |
| 5 | * twice, and that every layer a box in `level.ts` names is a layer that exists. | |
| 6 | * | |
| 7 | * The digest below came from a run of `rangeAtlas()`, not from arithmetic: the | |
| 8 | * shaders' seeds (101, 102, 103 and the grit's 104) and `RANGE_TEX_SIZE` are | |
| 9 | * what it pins. Change a seed, a colour or the size on purpose and it moves; | |
| 10 | * paste the new one in with a sentence saying what was repainted. | |
| 11 | */ | |
| 12 | ||
| 13 | import { expect, test } from "bun:test"; | |
| 14 | import { RANGE_LAYERS, RANGE_SHADERS, RANGE_TEX_SIZE, rangeAtlas } from "./paint.ts"; | |
| 15 | import { RANGE_MAT, RANGE_ROOM } from "./level.ts"; | |
| 16 | ||
| 17 | const ATLAS_SHA256 = "8ec1faf3cb36b551c512102547269ea7405ace35e651196cc1e13e2163946e6f"; | |
| 18 | ||
| 19 | test("the atlas is exactly size × size × 4 × layers bytes, and every one is a byte", () => { | |
| 20 | const bytes = rangeAtlas(); | |
| 21 | expect(bytes.length).toBe(RANGE_TEX_SIZE * RANGE_TEX_SIZE * 4 * RANGE_LAYERS); | |
| 22 | expect(bytes).toBeInstanceOf(Uint8Array); | |
| 23 | for (const byte of bytes) { | |
| 24 | expect(Number.isInteger(byte)).toBe(true); | |
| 25 | expect(byte).toBeGreaterThanOrEqual(0); | |
| 26 | expect(byte).toBeLessThanOrEqual(255); | |
| 27 | } | |
| 28 | // Fully opaque: every fourth byte is alpha, and the range has no glass. | |
| 29 | for (let i = 3; i < bytes.length; i += 4) expect(bytes[i]).toBe(255); | |
| 30 | }); | |
| 31 | ||
| 32 | test("the same seeds paint the same atlas twice, byte for byte", () => { | |
| 33 | const digest = (bytes: Uint8Array): string => | |
| 34 | new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); | |
| 35 | expect(digest(rangeAtlas())).toBe(digest(rangeAtlas())); | |
| 36 | expect(digest(rangeAtlas())).toBe(ATLAS_SHA256); | |
| 37 | }); | |
| 38 | ||
| 39 | test("the range's atlas is small on purpose, and smaller than the shooter's nine", () => { | |
| 40 | expect(RANGE_LAYERS).toBe(RANGE_SHADERS.length); | |
| 41 | expect(RANGE_LAYERS).toBe(3); | |
| 42 | expect(RANGE_TEX_SIZE).toBe(64); | |
| 43 | // 48 KB of texture, against the shooter's 576 KB. | |
| 44 | expect(rangeAtlas().length).toBe(48 * 1024); | |
| 45 | }); | |
| 46 | ||
| 47 | test("every layer paints something visible, and no two of them are the same", () => { | |
| 48 | const swatches = new Set<string>(); | |
| 49 | for (const shade of RANGE_SHADERS) { | |
| 50 | for (const [u, v] of [[0.2, 0.2], [0.5, 0.5], [0.8, 0.45]]) { | |
| 51 | const rgb = shade(u, v); | |
| 52 | expect(rgb.length).toBe(3); | |
| 53 | for (const channel of rgb) { | |
| 54 | expect(Number.isFinite(channel)).toBe(true); | |
| 55 | expect(channel).toBeGreaterThan(0); | |
| 56 | } | |
| 57 | } | |
| 58 | const [r, g, b] = shade(0.5, 0.5); | |
| 59 | swatches.add(`${Math.round(r)},${Math.round(g)},${Math.round(b)}`); | |
| 60 | } | |
| 61 | expect(swatches.size).toBe(RANGE_SHADERS.length); | |
| 62 | }); | |
| 63 | ||
| 64 | test("the target face reads at a distance: its middle is far from its edge", () => { | |
| 65 | const face = RANGE_SHADERS[RANGE_MAT.PLATE] as (typeof RANGE_SHADERS)[number]; | |
| 66 | const brightness = (u: number, v: number): number => { | |
| 67 | const [r, g, b] = face(u, v); | |
| 68 | return (r + g + b) / 3; | |
| 69 | }; | |
| 70 | // Centre, ring, field: three bands a plate 1.7° wide still shows as three. | |
| 71 | expect(brightness(0.5, 0.5)).toBeGreaterThan(brightness(0.5, 0.29) * 1.6); | |
| 72 | expect(brightness(0.5, 0.05)).toBeGreaterThan(brightness(0.5, 0.29) * 1.3); | |
| 73 | }); | |
| 74 | ||
| 75 | test("the lane layer has a painted line on it, and the concrete has not", () => { | |
| 76 | const lane = RANGE_SHADERS[RANGE_MAT.LANE] as (typeof RANGE_SHADERS)[number]; | |
| 77 | const concrete = RANGE_SHADERS[RANGE_MAT.CONCRETE] as (typeof RANGE_SHADERS)[number]; | |
| 78 | // The line is at the tile's seam, so it runs down the lane unbroken. | |
| 79 | const [lr, lg, lb] = lane(0.5, 0.01); | |
| 80 | const [fr, fg, fb] = lane(0.5, 0.5); | |
| 81 | expect(lr - lb).toBeGreaterThan(80); // yellow paint | |
| 82 | expect(Math.abs(fr - fb)).toBeLessThan(20); // grey floor | |
| 83 | expect(lg).toBeGreaterThan(fg); | |
| 84 | // Concrete is grey wherever it is asked. | |
| 85 | for (const [u, v] of [[0.5, 0.01], [0.5, 0.5], [0.99, 0.99]]) { | |
| 86 | const [r, , b] = concrete(u as number, v as number); | |
| 87 | expect(Math.abs(r - b)).toBeLessThan(20); | |
| 88 | } | |
| 89 | }); | |
| 90 | ||
| 91 | test("every material a box in the room names is a layer of this atlas", () => { | |
| 92 | const named = [...new Set(RANGE_ROOM.map((box) => box.mat)), ...Object.values(RANGE_MAT)]; | |
| 93 | for (const mat of named) { | |
| 94 | expect(Number.isInteger(mat)).toBe(true); | |
| 95 | expect(mat).toBeGreaterThanOrEqual(0); | |
| 96 | expect(mat).toBeLessThan(RANGE_LAYERS); | |
| 97 | expect(RANGE_SHADERS[mat]).toBeInstanceOf(Function); | |
| 98 | } | |
| 99 | // Every layer is used by something, so the atlas carries nothing dead: the | |
| 100 | // plate is `targets.ts`'s, the other two are the room's. | |
| 101 | expect(new Set(Object.values(RANGE_MAT)).size).toBe(RANGE_LAYERS); | |
| 102 | }); |
src/range/paint.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | /** | |
| 2 | * The three surfaces the range is made of, painted here as shaders for the | |
| 3 | * engine's atlas painter: a target face, concrete, and the lane's floor with a | |
| 4 | * line painted down it. Nothing is downloaded — a layer is a couple of lines of | |
| 5 | * `fbm` over hashed value noise, and `level.ts` refers to one by the index it | |
| 6 | * is painted into. | |
| 7 | * | |
| 8 | * It is deliberately not the shooter's palette painted again. Three layers at | |
| 9 | * 64², not nine at 128², because a room with one kind of wall does not need | |
| 10 | * nine materials and a bright plate at 1,200 units does not need 128 pixels to | |
| 11 | * read: the whole atlas is 48 KB and takes about 12 ms to paint once at | |
| 12 | * start-up on the forge's box, where the shooter's nine 128² layers take about | |
| 13 | * 100 ms. The seeds below are the range's own numbers and not `materials.ts`'s. | |
| 14 | * | |
| 15 | * The plate is layer 0 because `targets.ts` already said `PLATE_MAT = 0`. An | |
| 16 | * atlas is painted to suit the game; a game is not renumbered to suit an atlas. | |
| 17 | */ | |
| 18 | ||
| 19 | import { fbm, hash, paintAtlas, type Shader } from "../engine/index.ts"; | |
| 20 | ||
| 21 | /** One layer, on a side. Small on purpose: see the note above. */ | |
| 22 | export const RANGE_TEX_SIZE = 64; | |
| 23 | ||
| 24 | const tint = (base: [number, number, number], k: number): [number, number, number] => | |
| 25 | [base[0] * k, base[1] * k, base[2] * k]; | |
| 26 | ||
| 27 | /** Layer 0, 1, 2, in the order `RANGE_MAT` in `level.ts` names them. */ | |
| 28 | export const RANGE_SHADERS: Shader[] = [ | |
| 29 | // 0 a target face: a pale bullseye in a dark ring on orange, sized so the | |
| 30 | // three bands are still three bands when the plate is 1.7° wide. | |
| 31 | (u, v) => { | |
| 32 | const r = Math.hypot(u - 0.5, v - 0.5); | |
| 33 | const band = r < 0.14 ? 1.45 : r < 0.28 ? 0.5 : 1; | |
| 34 | return tint([228, 120, 40], (0.9 + fbm(u * 4, v * 4, 4, 101) * 0.2) * band); | |
| 35 | }, | |
| 36 | // 1 concrete: cast, poured in panels, a little gritty. | |
| 37 | (u, v) => { | |
| 38 | const n = fbm(u * 7, v * 7, 7, 102); | |
| 39 | const grit = hash(Math.floor(u * RANGE_TEX_SIZE), Math.floor(v * RANGE_TEX_SIZE), 104) * 0.1; | |
| 40 | const panel = Math.min(u, 1 - u, v, 1 - v) < 0.02 ? 0.85 : 1; | |
| 41 | return tint([142, 143, 138], (0.82 + n * 0.3 + grit) * panel); | |
| 42 | }, | |
| 43 | // 2 the lane floor: the same concrete, worn, with a painted line across the | |
| 44 | // tile — the floor tiles every 128 units along Y, so the lines run down the | |
| 45 | // lane 128 units apart and a shooter can see which one they are standing in. | |
| 46 | (u, v) => { | |
| 47 | const wear = fbm(u * 5, v * 5, 5, 103); | |
| 48 | const line = Math.min(v, 1 - v) < 0.04; | |
| 49 | return line | |
| 50 | ? tint([214, 190, 88], 0.85 + wear * 0.3) | |
| 51 | : tint([118, 119, 116], 0.8 + wear * 0.4); | |
| 52 | }, | |
| 53 | ]; | |
| 54 | ||
| 55 | /** How many layers the array texture has: one per shader, counted once. */ | |
| 56 | export const RANGE_LAYERS = RANGE_SHADERS.length; | |
| 57 | ||
| 58 | /** All three layers, tightly packed RGBA, ready for texImage3D. */ | |
| 59 | export const rangeAtlas = (): Uint8Array => paintAtlas(RANGE_SHADERS, RANGE_TEX_SIZE); |
new above
offline