Public: anyone can read this; the crew can change it.
forked from trevato/cs
Every generated map looks like its own place: a palette drawn from the seed, out of the materials already in the atlas #239
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/151-every-generated-map-looks-like-its-own-p → main 1 commit fork
15 files
changed +494
−136
README.md +19
| @@ -208,6 +208,21 @@ down and hand to somebody who has never played the round it happened in. | ||
| 208 | 208 | - **Open it.** `/?map=gen_14u5j6g` preselects that map. `?map=` is the shooter's |
| 209 | 209 | parameter and no other page reads it. |
| 210 | 210 | |
| 211 | A name is a look as well as a layout. The atlas holds nine materials, painted | |
| 212 | once at boot; `src/client/looks.ts` writes down eight named sets of them — | |
| 213 | `forge`, `foundry`, `quarry`, `depot`, `bunker`, `kiln`, `plaza`, `hangar` — and | |
| 214 | a generated map picks one from the same seed that carved it, so `gen_9ix` is a | |
| 215 | metal-floored hangar with red-painted sites on every machine and `gen_5` is a | |
| 216 | quarry. Nothing is repainted and nothing more is downloaded: the layers were in | |
| 217 | the texture already, and what changed is which one a box's vertices name. The | |
| 218 | sets are named rather than rolled per surface because nine materials do not | |
| 219 | combine freely — `looks.test.ts` measures each set against the actual shaders | |
| 220 | and holds it to the distances a room needs to read: cover off its floor and off | |
| 221 | its wall, a bombsite off the floor it sits on, and a player legible against all | |
| 222 | three. de_forge is the `forge` set, which is the numbers this game shipped with, | |
| 223 | unchanged. The line beside the menu's map preview ends with the look's name, so | |
| 224 | a player reading rather than looking gets it too. | |
| 225 | ||
| 211 | 226 | A name is an input and it is checked twice against one definition: |
| 212 | 227 | `src/mapname.ts` says what a name is — `gen_` and base36, and the test is the |
| 213 | 228 | round trip, so `gen_0ya` and `gen_zzzzzzzz` are typos rather than maps — |
| @@ -596,6 +611,10 @@ never checked itself. | ||
| 596 | 611 | rooms, corridors, two sites, two spawns and cover, every cell stamped twice |
| 597 | 612 | so the map is its own opponent's map turned half a circle, and a gate that |
| 598 | 613 | refuses a layout a round cannot be played on), |
| 614 | `looks` (what a map is painted in: eight named sets of the nine materials | |
| 615 | already in the atlas — floor, wall, cover and sites — and the seed-to-set | |
| 616 | function a generated map chooses one with; `forge` is de_forge's own and the | |
| 617 | default, and no set is a new layer, a repaint or a byte on the wire), | |
| 599 | 618 | `movement` (Counter-Strike's numbers, and nothing else: `CS_MOVE`, `CS_HULL`, |
| 600 | 619 | `NADE_MOVE`, `NADE_HULL` and `fallDamage`, handed to the engine's mover on |
| 601 | 620 | every call — the solver |
src/client/cells.ts +28 −13
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | import * as g from "../engine/index.ts"; |
| 19 | 19 | import type { Box, Grid, Vec3 } from "../engine/index.ts"; |
| 20 | 20 | import type { Level } from "./level.ts"; |
| 21 | import { FORGE, type Look } from "./looks.ts"; | |
| 21 | 22 | |
| 22 | 23 | /** World units along one side of a cell: Source units, like everything here. */ |
| 23 | 24 | export const CELL = 128; |
| @@ -31,8 +32,14 @@ export const WALL_HEIGHT = 384; | ||
| 31 | 32 | */ |
| 32 | 33 | export type Cell = "#" | "." | "x" | "X" | "A" | "B" | "t" | "c"; |
| 33 | 34 | |
| 34 | /** The material ids this game's boxes are tagged with, by name. */ | |
| 35 | export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const; | |
| 35 | /** | |
| 36 | * The material ids this game's boxes are tagged with, by name — de_forge's own, | |
| 37 | * and what a level is dressed in when it asks for nothing else. Which numbers | |
| 38 | * these are is `looks.ts`'s business now: a generated map hands `levelOf` a | |
| 39 | * different set from the same seed that carved it, and everything below is | |
| 40 | * written in the names rather than in the numbers, so it never has to know. | |
| 41 | */ | |
| 42 | export const MAT = FORGE.materials; | |
| 36 | 43 | |
| 37 | 44 | /** |
| 38 | 45 | * Solid to a walking bot: walls and both kinds of crate. This predicate is the |
| @@ -41,8 +48,8 @@ export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const; | ||
| 41 | 48 | */ |
| 42 | 49 | export const passable = (c: string): boolean => c !== "#" && c !== "x" && c !== "X"; |
| 43 | 50 | |
| 44 | /** The bounding slab of every cell paved with one letter, two units thick. */ | |
| 45 | function siteSlab(grid: Grid<Cell>, fill: Cell): Box { | |
| 51 | /** The bounding slab of every cell paved with one letter, two units thick, in the level's own site material. */ | |
| 52 | function siteSlab(grid: Grid<Cell>, fill: Cell, mat: number): Box { | |
| 46 | 53 | let c0 = grid.width, r0 = grid.height, c1 = 0, r1 = 0; |
| 47 | 54 | for (let r = 0; r < grid.height; r++) |
| 48 | 55 | for (let c = 0; c < grid.width; c++) |
| @@ -55,7 +62,7 @@ function siteSlab(grid: Grid<Cell>, fill: Cell): Box { | ||
| 55 | 62 | return { |
| 56 | 63 | min: [a[0] - CELL / 2, b[1] - CELL / 2, 0], |
| 57 | 64 | max: [b[0] + CELL / 2, a[1] + CELL / 2, 2], |
| 58 | mat: MAT.SITE, | |
| 65 | mat, | |
| 59 | 66 | }; |
| 60 | 67 | } |
| 61 | 68 | |
| @@ -82,11 +89,18 @@ function centre(list: Vec3[]): Vec3 { | ||
| 82 | 89 | * where bots walk, where the bomb can be planted — so a map can never disagree |
| 83 | 90 | * with itself. A grid with no `A`, no `B`, or no cell for a side to spawn on is |
| 84 | 91 | * not a map anybody can play and says so here rather than at the first round. |
| 92 | * | |
| 93 | * `look` is what it is painted in: one of the named sets in `looks.ts`, de_forge's | |
| 94 | * own unless a caller says otherwise. It touches the boxes' material tags and | |
| 95 | * nothing else — not a cell, not a spawn, not a site, not one coordinate — so | |
| 96 | * the same page of letters in two looks is the same map twice over, and a | |
| 97 | * generated one can be a different place for free. | |
| 85 | 98 | */ |
| 86 | export function levelOf(name: string, grid: Grid<Cell>): Level { | |
| 99 | export function levelOf(name: string, grid: Grid<Cell>, look: Look = FORGE): Level { | |
| 87 | 100 | const halfX = (grid.width / 2) * CELL; |
| 88 | 101 | const halfY = (grid.height / 2) * CELL; |
| 89 | const floor: Box = { min: [-halfX, -halfY, -32], max: [halfX, halfY, 0], mat: MAT.FLOOR }; | |
| 102 | const mat = look.materials; | |
| 103 | const floor: Box = { min: [-halfX, -halfY, -32], max: [halfX, halfY, 0], mat: mat.FLOOR }; | |
| 90 | 104 | const merge = (match: (c: Cell) => boolean, z0: number, z1: number, mat: number): Box[] => |
| 91 | 105 | g.mergeSolid(grid, match, z0, z1, mat); |
| 92 | 106 | const spawns = { t: cellsOf(grid, "t"), ct: cellsOf(grid, "c") }; |
| @@ -97,15 +111,16 @@ export function levelOf(name: string, grid: Grid<Cell>): Level { | ||
| 97 | 111 | grid, |
| 98 | 112 | passable, |
| 99 | 113 | wallHeight: WALL_HEIGHT, |
| 100 | materials: MAT, | |
| 114 | look: look.name, | |
| 115 | materials: mat, | |
| 101 | 116 | // Everything solid, in one list: the renderer draws it and bullets stop in it. |
| 102 | 117 | boxes: [ |
| 103 | 118 | floor, |
| 104 | siteSlab(grid, "A"), | |
| 105 | siteSlab(grid, "B"), | |
| 106 | ...merge((c) => c === "#", 0, WALL_HEIGHT, MAT.WALL), | |
| 107 | ...merge((c) => c === "x", 0, 64, MAT.CRATE), | |
| 108 | ...merge((c) => c === "X", 0, 128, MAT.CRATE), | |
| 119 | siteSlab(grid, "A", mat.SITE), | |
| 120 | siteSlab(grid, "B", mat.SITE), | |
| 121 | ...merge((c) => c === "#", 0, WALL_HEIGHT, mat.WALL), | |
| 122 | ...merge((c) => c === "x", 0, 64, mat.CRATE), | |
| 123 | ...merge((c) => c === "X", 0, 128, mat.CRATE), | |
| 109 | 124 | ], |
| 110 | 125 | spawns, |
| 111 | 126 | sites: { A: centre(cellsOf(grid, "A")), B: centre(cellsOf(grid, "B")) }, |
src/client/generate.test.ts +108 −4
| @@ -13,9 +13,12 @@ | ||
| 13 | 13 | |
| 14 | 14 | import { expect, test } from "bun:test"; |
| 15 | 15 | import { botThink } from "./bots.ts"; |
| 16 | import { MAT, type Cell } from "./cells.ts"; | |
| 16 | import { levelOf, type Cell } from "./cells.ts"; | |
| 17 | 17 | import { flaws, generateGrid, generateLevel, nameOf, page, seedOf, SIZE } from "./generate.ts"; |
| 18 | import { colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts"; | |
| 18 | import { colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Box, type Level } from "./level.ts"; | |
| 19 | import { LOOKS, lookFor, type Look } from "./looks.ts"; | |
| 20 | import { LAYERS } from "./materials.ts"; | |
| 21 | import { levelMesh } from "./models.ts"; | |
| 19 | 22 | import { SHORT } from "./match.ts"; |
| 20 | 23 | import { digest, withoutEntropy } from "./replay.ts"; |
| 21 | 24 | import { applyInput, createWorld, type Player } from "./world.ts"; |
| @@ -98,6 +101,103 @@ test("a map you can name is the same map on every machine, cell for cell", () => | ||
| 98 | 101 | expect(rowsOf(generateLevel(seed))).toEqual(rows); |
| 99 | 102 | }); |
| 100 | 103 | |
| 104 | /** | |
| 105 | * The mesh `gen_14u5j6g` is drawn from, folded vertex by vertex: position, uv | |
| 106 | * and — the point of #151 — the atlas layer each one is painted from. The map | |
| 107 | * digest above pins the letters; this pins what a player actually sees, which | |
| 108 | * is the letters turned into boxes *and* the look those boxes wear. | |
| 109 | * | |
| 110 | * It moves when the generator's shape moves, when the mesher's does, or when | |
| 111 | * this seed is dressed in another look. All three are allowed and all three are | |
| 112 | * a line in the pull with these constants pasted in from the failure. | |
| 113 | */ | |
| 114 | const NAMED_LOOK = "plaza"; | |
| 115 | const NAMED_MESH = "16133a4f413aa063f8c76efced2dddfc9e00f6502d089ed0ccbcb3f15b9c641c"; | |
| 116 | const NAMED_MESH_BANDS: readonly string[] = [ | |
| 117 | "0e887e4b6e6f", "4f8f475e35b3", "d970b6af42c6", "8108bf5a61ad", | |
| 118 | "9eb216318777", "c5a0dd4cf4b4", "4b75c09568eb", "33f24951e64b", | |
| 119 | "d5ba59402c79", "d7983405773e", "0e370c4f60ef", | |
| 120 | ]; | |
| 121 | ||
| 122 | test("a named map is the same geometry, painted from the same layers, everywhere", () => { | |
| 123 | const seed = seedOf(NAMED); | |
| 124 | if (seed === null) throw new Error(`${NAMED} is not a map name, and it is the one this test is about`); | |
| 125 | const level = generateLevel(seed); | |
| 126 | expect(level.look).toBe(NAMED_LOOK); | |
| 127 | ||
| 128 | const mesh = levelMesh(level.boxes); | |
| 129 | let at = 0; | |
| 130 | const run = fold({ | |
| 131 | ticks: mesh.layer.length, | |
| 132 | step: (vertex) => void (at = vertex), | |
| 133 | line: () => | |
| 134 | [ | |
| 135 | mesh.position[at * 3], mesh.position[at * 3 + 1], mesh.position[at * 3 + 2], | |
| 136 | mesh.uv[at * 2], mesh.uv[at * 2 + 1], mesh.layer[at], | |
| 137 | ].join(" "), | |
| 138 | every: 120, | |
| 139 | }); | |
| 140 | const why = compare( | |
| 141 | run, | |
| 142 | { label: `mesh of ${NAMED}`, constant: "NAMED_MESH", digest: NAMED_MESH, checkpoints: NAMED_MESH_BANDS }, | |
| 143 | { | |
| 144 | file: "src/client/generate.test.ts", | |
| 145 | list: "NAMED_MESH_BANDS", | |
| 146 | hashes: "every vertex of the level mesh: its position, its uv and its atlas layer", | |
| 147 | every: 120, | |
| 148 | unit: "band", | |
| 149 | }, | |
| 150 | ); | |
| 151 | if (why !== null) throw new Error(why); | |
| 152 | }); | |
| 153 | ||
| 154 | /** | |
| 155 | * A look is the seed's, the same on every machine, and a run of seeds does not | |
| 156 | * walk the table in order — the whole reason `lookFor` stirs its seed instead of | |
| 157 | * taking it modulo eight. | |
| 158 | * | |
| 159 | * The fraction measured here, and what it is worth: over seeds 0..99 all eight | |
| 160 | * looks turn up, 88 of the 99 consecutive pairs differ, and 87.5% of all 4,950 | |
| 161 | * pairs do — which is the 7/8 a uniform draw over eight gives. The floors below | |
| 162 | * are well under those so a tweak to the stirring is not a failing test, but a | |
| 163 | * generator that dressed every map the same, or that dealt the looks in order, | |
| 164 | * fails both of them. | |
| 165 | */ | |
| 166 | test("a seed is a look, and two seeds are usually two looks", () => { | |
| 167 | expect(generateLevel(7).look).toBe(generateLevel(7).look); | |
| 168 | expect(lookFor(7).name).toBe(generateLevel(7).look); | |
| 169 | ||
| 170 | const looks = Array.from({ length: 100 }, (_, seed) => generateLevel(seed).look); | |
| 171 | expect(new Set(looks).size).toBe(LOOKS.length); | |
| 172 | let differ = 0; | |
| 173 | for (let i = 1; i < looks.length; i++) if (looks[i] !== looks[i - 1]) differ++; | |
| 174 | expect(differ).toBeGreaterThanOrEqual(75); // 88 of 99 measured | |
| 175 | let pairs = 0; | |
| 176 | for (let i = 0; i < looks.length; i++) | |
| 177 | for (let j = i + 1; j < looks.length; j++) if (looks[i] !== looks[j]) pairs++; | |
| 178 | expect(pairs / ((looks.length * (looks.length - 1)) / 2)).toBeGreaterThan(0.8); // 0.875 measured | |
| 179 | }); | |
| 180 | ||
| 181 | test("a look paints a map and moves nothing else about it", () => { | |
| 182 | // Two levels off one seed, dressed differently by hand: same cells, same | |
| 183 | // spawns, same sites, same boxes to the unit — and different layers on them. | |
| 184 | const grid = generateGrid(31); | |
| 185 | const a = levelOf("gen_31", grid, LOOKS[0] as Look); | |
| 186 | const b = levelOf("gen_31", grid, LOOKS[4] as Look); | |
| 187 | expect(a.grid.cells).toEqual(b.grid.cells); | |
| 188 | expect(a.spawns).toEqual(b.spawns); | |
| 189 | expect(a.sites).toEqual(b.sites); | |
| 190 | expect(a.boxes.length).toBe(b.boxes.length); | |
| 191 | for (let i = 0; i < a.boxes.length; i++) { | |
| 192 | const one = a.boxes[i] as Box, two = b.boxes[i] as Box; | |
| 193 | expect(one.min).toEqual(two.min); | |
| 194 | expect(one.max).toEqual(two.max); | |
| 195 | } | |
| 196 | expect(a.boxes.map((box) => box.mat)).not.toEqual(b.boxes.map((box) => box.mat)); | |
| 197 | expect(a.look).toBe("forge"); | |
| 198 | expect(b.look).toBe("bunker"); | |
| 199 | }); | |
| 200 | ||
| 101 | 201 | test("a map is written on a page of letters, one string a row", () => { |
| 102 | 202 | const grid = generateGrid(3); |
| 103 | 203 | const rows = page(grid); |
| @@ -177,17 +277,21 @@ test("two hundred seeds all come out playable, first time", () => { | ||
| 177 | 277 | }); |
| 178 | 278 | |
| 179 | 279 | test("no generated map leaks outside its own grid, or paints a surface this game has not got", () => { |
| 180 | const known = new Set<number>(Object.values(MAT)); | |
| 181 | 280 | for (let seed = 500; seed < 540; seed++) { |
| 182 | 281 | const level = generateLevel(seed); |
| 282 | // Since #151 a map is dressed from the seed, so the surfaces it may paint | |
| 283 | // are its own look's and not de_forge's — but they are still nine materials | |
| 284 | // already in the atlas, and never a tenth: a new layer is a repaint and a | |
| 285 | // bundle nobody pays for. | |
| 286 | const known = new Set<number>(Object.values(level.materials)); | |
| 183 | 287 | const edge = [(level.grid.width / 2) * level.grid.cell, (level.grid.height / 2) * level.grid.cell]; |
| 184 | 288 | for (const box of level.boxes) { |
| 185 | 289 | for (const axis of [0, 1]) { |
| 186 | 290 | expect(box.min[axis as 0 | 1]).toBeGreaterThanOrEqual(-(edge[axis] as number)); |
| 187 | 291 | expect(box.max[axis as 0 | 1]).toBeLessThanOrEqual(edge[axis] as number); |
| 188 | 292 | } |
| 189 | // No new material means no new atlas layer, which is a bundle nobody pays for. | |
| 190 | 293 | expect(known.has(box.mat)).toBe(true); |
| 294 | expect(box.mat).toBeLessThan(LAYERS); | |
| 191 | 295 | } |
| 192 | 296 | } |
| 193 | 297 | }); |
src/client/generate.ts +10 −1
| @@ -31,6 +31,7 @@ import { region, rng, type Grid, type Vec3 } from "../engine/index.ts"; | ||
| 31 | 31 | import { nameOf, seedOf } from "../mapname.ts"; |
| 32 | 32 | import { CELL, levelOf, type Cell } from "./cells.ts"; |
| 33 | 33 | import { colOf, findPath, rowOf, type Level } from "./level.ts"; |
| 34 | import { lookFor } from "./looks.ts"; | |
| 34 | 35 | |
| 35 | 36 | /** |
| 36 | 37 | * What a map is called and what a name means, both from `src/mapname.ts` and |
| @@ -319,12 +320,20 @@ export function flaws(level: Level): string[] { | ||
| 319 | 320 | * attempt, so the loop is insurance rather than a plan, and a seed that somehow |
| 320 | 321 | * spends all eight says so loudly instead of handing back a map nobody can |
| 321 | 322 | * finish a round on. |
| 323 | * | |
| 324 | * The look is the seed's too, out of `looks.ts`: the floor, the walls, the | |
| 325 | * cover and the sites are painted from one of eight named sets of the nine | |
| 326 | * materials already in the atlas, so a name is a layout *and* a place. It is | |
| 327 | * taken from the seed a person wrote down and not from `at`, which a retry | |
| 328 | * moves — the name promises the look, and a map that needed a second attempt | |
| 329 | * is still the map that name asked for. | |
| 322 | 330 | */ |
| 323 | 331 | export function generateLevel(seed: number, size = SIZE): Level { |
| 324 | 332 | let at = seed >>> 0; |
| 325 | 333 | let last: string[] = []; |
| 334 | const look = lookFor(seed); | |
| 326 | 335 | for (let attempt = 0; attempt < ATTEMPTS; attempt++) { |
| 327 | const level = levelOf(nameOf(seed), generateGrid(at, size)); | |
| 336 | const level = levelOf(nameOf(seed), generateGrid(at, size), look); | |
| 328 | 337 | last = flaws(level); |
| 329 | 338 | if (last.length === 0) return level; |
| 330 | 339 | at = (Math.imul(at, 1664525) + 1013904223) >>> 0; |
src/client/level.test.ts +1
| @@ -74,6 +74,7 @@ const ROOM: Level = { | ||
| 74 | 74 | grid: GRID, |
| 75 | 75 | passable, |
| 76 | 76 | wallHeight: WALL, |
| 77 | look: "room", | |
| 77 | 78 | materials: { FLOOR: 0, WALL: 1 }, |
| 78 | 79 | boxes: [ |
| 79 | 80 | { min: [-halfX, -halfY, -32], max: [halfX, halfY, 0], mat: 0 }, |
src/client/level.ts +14 −1
| @@ -33,7 +33,20 @@ export type Level = { | ||
| 33 | 33 | readonly passable: (cell: Cell) => boolean; |
| 34 | 34 | /** How tall a wall stands, in world units. */ |
| 35 | 35 | readonly wallHeight: number; |
| 36 | /** The material ids this level's boxes are tagged with, by name. */ | |
| 36 | /** | |
| 37 | * What this level is dressed in: the name of the set of materials below, out | |
| 38 | * of `looks.ts`. A level carries it rather than a global holding it, so two | |
| 39 | * levels in one process — the menu's preview and the match's map — can wear | |
| 40 | * different ones, and a name that says "gen_14u5j6g looks like this" is the | |
| 41 | * level's own word rather than a lookup somebody has to repeat. | |
| 42 | */ | |
| 43 | readonly look: string; | |
| 44 | /** | |
| 45 | * The material ids this level's boxes are tagged with, by name: which layer | |
| 46 | * of the atlas its floor, its walls, its cover and its sites are painted | |
| 47 | * from. Every box in `boxes` is already tagged with one of these — this is | |
| 48 | * the reader's index to them, not a second source of truth. | |
| 49 | */ | |
| 37 | 50 | readonly materials: Readonly<Record<string, number>>; |
| 38 | 51 | /** Everything solid, in one list: the renderer draws it and bullets stop in it. */ |
| 39 | 52 | readonly boxes: Box[]; |
src/client/looks.test.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** | |
| 2 | * The looks, measured rather than admired. | |
| 3 | * | |
| 4 | * A named set of materials is only worth having if a room dressed in it still | |
| 5 | * reads: cover has to stand off the floor and off the wall from across the | |
| 6 | * room, a bombsite has to read as painted ground rather than as more floor, and | |
| 7 | * a player has to be a shape against all three. Nine materials do not combine | |
| 8 | * freely, so every set in `looks.ts` is held to those distances here — against | |
| 9 | * the actual shaders in `materials.ts`, sampled, not against a colour somebody | |
| 10 | * wrote in a comment. | |
| 11 | * | |
| 12 | * The distance is a crude perceptual one: each material's mean colour over a | |
| 13 | * 32×32 sample of its shader, differenced with green weighted heaviest and blue | |
| 14 | * lightest, which is the order the eye weighs them in. It is not a colour | |
| 15 | * science; it is enough to tell "sand under wooden crates" (90) from "sand | |
| 16 | * under a sand crate" (0), which is the whole question being asked. | |
| 17 | */ | |
| 18 | ||
| 19 | import { expect, test } from "bun:test"; | |
| 20 | import { FORGE, LOOKS, lookFor, lookNamed, SKIN, FATIGUES, GEAR, type Look } from "./looks.ts"; | |
| 21 | import { LAYERS, MATERIALS } from "./materials.ts"; | |
| 22 | import { MAT } from "./cells.ts"; | |
| 23 | ||
| 24 | /** Each material's mean colour, its shader sampled over the square it is painted into. */ | |
| 25 | const MEAN: readonly [number, number, number][] = MATERIALS.map((shader) => { | |
| 26 | const N = 32; | |
| 27 | let r = 0, g = 0, b = 0; | |
| 28 | for (let i = 0; i < N; i++) | |
| 29 | for (let j = 0; j < N; j++) { | |
| 30 | const c = shader((i + 0.5) / N, (j + 0.5) / N); | |
| 31 | r += c[0]; g += c[1]; b += c[2]; | |
| 32 | } | |
| 33 | return [r / (N * N), g / (N * N), b / (N * N)]; | |
| 34 | }); | |
| 35 | ||
| 36 | /** How far apart two materials look, 0 being the same surface. */ | |
| 37 | function apart(one: number, two: number): number { | |
| 38 | const a = MEAN[one] as [number, number, number], b = MEAN[two] as [number, number, number]; | |
| 39 | return Math.hypot((a[0] - b[0]) * 0.6, (a[1] - b[1]) * 1.0, (a[2] - b[2]) * 0.4); | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * The bars, and where they come from: de_forge is the map this game was tuned | |
| 44 | * on, and its own numbers are site/floor 70, cover/floor 90, cover/wall 63 and | |
| 45 | * floor/wall 31. A look may be quieter than de_forge but not by much — the | |
| 46 | * floors below are under every one of the eight and over every pairing that | |
| 47 | * failed to read when it was looked at. | |
| 48 | */ | |
| 49 | const SITE_OFF_FLOOR = 45; | |
| 50 | const COVER_OFF_FLOOR = 45; | |
| 51 | const COVER_OFF_WALL = 45; | |
| 52 | const FLOOR_OFF_WALL = 30; | |
| 53 | ||
| 54 | /** What a player is made of: the three materials `models.ts` builds one out of. */ | |
| 55 | const PLAYER = [SKIN, FATIGUES, GEAR]; | |
| 56 | ||
| 57 | test("every look is a set of materials this game has already painted", () => { | |
| 58 | expect(LOOKS.length).toBeGreaterThan(1); | |
| 59 | const names = LOOKS.map((look) => look.name); | |
| 60 | expect(new Set(names).size).toBe(names.length); | |
| 61 | for (const look of LOOKS) { | |
| 62 | expect(look.name).toMatch(/^[a-z]+$/); | |
| 63 | const roles = Object.keys(look.materials).sort(); | |
| 64 | expect(roles).toEqual(["CRATE", "FLOOR", "METAL", "SITE", "WALL"]); | |
| 65 | for (const [role, mat] of Object.entries(look.materials)) { | |
| 66 | expect(Number.isInteger(mat)).toBe(true); | |
| 67 | expect(mat, `${look.name}.${role}`).toBeGreaterThanOrEqual(0); | |
| 68 | expect(mat, `${look.name}.${role}`).toBeLessThan(LAYERS); | |
| 69 | } | |
| 70 | expect(lookNamed(look.name)).toBe(look); | |
| 71 | } | |
| 72 | expect(lookNamed("nothing this game paints")).toBeUndefined(); | |
| 73 | }); | |
| 74 | ||
| 75 | test("whatever the look, a site reads as a site and cover reads as cover", () => { | |
| 76 | for (const look of LOOKS) { | |
| 77 | const { FLOOR, WALL, CRATE, SITE } = look.materials; | |
| 78 | const why = (what: string, d: number, bar: number): string => | |
| 79 | `${look.name}: ${what} are ${d.toFixed(0)} apart, under the ${bar} a room needs to read`; | |
| 80 | expect(apart(SITE, FLOOR), why("its site and its floor", apart(SITE, FLOOR), SITE_OFF_FLOOR)) | |
| 81 | .toBeGreaterThanOrEqual(SITE_OFF_FLOOR); | |
| 82 | expect(apart(CRATE, FLOOR), why("its cover and its floor", apart(CRATE, FLOOR), COVER_OFF_FLOOR)) | |
| 83 | .toBeGreaterThanOrEqual(COVER_OFF_FLOOR); | |
| 84 | expect(apart(CRATE, WALL), why("its cover and its wall", apart(CRATE, WALL), COVER_OFF_WALL)) | |
| 85 | .toBeGreaterThanOrEqual(COVER_OFF_WALL); | |
| 86 | expect(apart(FLOOR, WALL), why("its floor and its wall", apart(FLOOR, WALL), FLOOR_OFF_WALL)) | |
| 87 | .toBeGreaterThanOrEqual(FLOOR_OFF_WALL); | |
| 88 | } | |
| 89 | }); | |
| 90 | ||
| 91 | test("whatever the look, a player is a shape against it", () => { | |
| 92 | // A player is skin, fatigues and dark gear. A look may match one of the three | |
| 93 | // — a dark floor under a dark torso is a real place, and Counter-Strike is | |
| 94 | // full of them — but never two, or the silhouette a game is played on goes. | |
| 95 | for (const look of LOOKS) | |
| 96 | for (const [role, surface] of Object.entries(look.materials)) { | |
| 97 | if (role === "METAL") continue; // a name, not a surface: nothing is tagged with it | |
| 98 | const clear = PLAYER.filter((worn) => apart(worn, surface) >= 40); | |
| 99 | expect(clear.length, `${look.name}: a player is lost against its ${role.toLowerCase()}`) | |
| 100 | .toBeGreaterThanOrEqual(2); | |
| 101 | } | |
| 102 | }); | |
| 103 | ||
| 104 | test("de_forge is dressed in exactly the materials it always was", () => { | |
| 105 | expect(FORGE.name).toBe("forge"); | |
| 106 | expect(FORGE).toBe(LOOKS[0] as Look); | |
| 107 | expect(FORGE.materials).toEqual({ FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 }); | |
| 108 | expect(MAT).toEqual(FORGE.materials); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a seed is a look, for ever, and a run of seeds is not a run of looks", () => { | |
| 112 | for (const seed of [0, 1, 7, 4294967295]) expect(lookFor(seed)).toBe(lookFor(seed)); | |
| 113 | // The seed is a uint32 and is treated as one, however it arrives. | |
| 114 | expect(lookFor(-1)).toBe(lookFor(4294967295)); | |
| 115 | // Ten seeds in a row are not the table in order, which `seed % LOOKS.length` | |
| 116 | // would be: that is what the stirring in `lookFor` is for. | |
| 117 | const run = Array.from({ length: LOOKS.length }, (_, i) => lookFor(i).name); | |
| 118 | expect(run).not.toEqual(LOOKS.map((look) => look.name)); | |
| 119 | }); |
src/client/looks.ts added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | /** | |
| 2 | * What a map is dressed in: which of the nine materials in the atlas its floor, | |
| 3 | * its walls, its cover and its bombsites are painted with, as a handful of | |
| 4 | * named sets rather than an index per surface. | |
| 5 | * | |
| 6 | * The atlas is painted once at boot and never repainted, and a box in a level | |
| 7 | * mesh carries a layer number. So a generated map can *look* like its own place | |
| 8 | * for nothing: it picks one of the sets below from the same seed that carved | |
| 9 | * it, `cells.ts` tags its boxes with that set's numbers, and not one byte more | |
| 10 | * goes over the wire or into the texture. Nothing downstream learns anything — | |
| 11 | * the renderer has always drawn whatever layer a vertex names. | |
| 12 | * | |
| 13 | * (Issue #151 calls these palettes. The file is `looks.ts` because | |
| 14 | * `palette.ts` next door is already the colours the 2D is drawn in, and two | |
| 15 | * palettes in one directory is a file nobody can name.) | |
| 16 | * | |
| 17 | * Named, and not random per surface, for one reason: nine materials do not | |
| 18 | * combine freely. A map wants its cover to stand off its floor from across the | |
| 19 | * room, its bombsite to read as painted ground rather than as more floor, and | |
| 20 | * its players to stay legible against all three. `looks.test.ts` measures each | |
| 21 | * set below against the shaders in `materials.ts` and holds it to those three | |
| 22 | * distances, so a look added here without looking at it fails there. | |
| 23 | */ | |
| 24 | ||
| 25 | /** The jobs a level gives a material. `METAL` is a name, not a surface: nothing tags a box with it yet. */ | |
| 26 | export type Role = "FLOOR" | "WALL" | "CRATE" | "SITE" | "METAL"; | |
| 27 | ||
| 28 | /** One dressing of a map: what to call it, and the atlas layer each job is painted from. */ | |
| 29 | export type Look = { | |
| 30 | /** What this set is called, in a log, a terminal and a pull. */ | |
| 31 | readonly name: string; | |
| 32 | /** The material id each job is tagged with. The whole of what a look is. */ | |
| 33 | readonly materials: Readonly<Record<Role, number>>; | |
| 34 | }; | |
| 35 | ||
| 36 | /** The nine materials by name, in the order `materials.ts` paints them. */ | |
| 37 | export const SAND = 0, PLASTER = 1, WOOD = 2, PAINT = 3, METAL = 4; | |
| 38 | export const SKIN = 5, FATIGUES = 6, GEAR = 7, WARNING = 8; | |
| 39 | ||
| 40 | const look = (name: string, FLOOR: number, WALL: number, CRATE: number, SITE: number): Look => | |
| 41 | ({ name, materials: { FLOOR, WALL, CRATE, SITE, METAL } }); | |
| 42 | ||
| 43 | /** | |
| 44 | * The looks, written down. Eight of them, each one looked at in the preview and | |
| 45 | * measured in `looks.test.ts`; `forge` is first because it is de_forge's own, | |
| 46 | * the numbers this game shipped with, and the default every level gets unless | |
| 47 | * it asks for another. | |
| 48 | * | |
| 49 | * Read them as places: sand under plaster with wooden crates is the desert town | |
| 50 | * this game started as; metal under dark steel with a red-painted site is a | |
| 51 | * foundry floor; a timber deck inside plaster walls is a depot. The site is | |
| 52 | * either the painted ground it always was or the warning red — never the floor | |
| 53 | * it sits on, which is the one thing a player has to see from a doorway. | |
| 54 | */ | |
| 55 | export const LOOKS: readonly Look[] = [ | |
| 56 | look("forge", SAND, PLASTER, WOOD, PAINT), | |
| 57 | look("foundry", METAL, GEAR, WOOD, WARNING), | |
| 58 | look("quarry", SAND, GEAR, METAL, WARNING), | |
| 59 | look("depot", WOOD, PLASTER, GEAR, WARNING), | |
| 60 | look("bunker", GEAR, METAL, WOOD, WARNING), | |
| 61 | look("kiln", SAND, METAL, GEAR, PAINT), | |
| 62 | look("plaza", PAINT, PLASTER, GEAR, WARNING), | |
| 63 | look("hangar", METAL, PLASTER, GEAR, WARNING), | |
| 64 | ]; | |
| 65 | ||
| 66 | /** de_forge's own, and what a level is dressed in when it does not choose. */ | |
| 67 | export const FORGE = LOOKS[0] as Look; | |
| 68 | ||
| 69 | /** The look of that name, or `undefined`: what a terminal or a test looks one up with. */ | |
| 70 | export const lookNamed = (name: string): Look | undefined => LOOKS.find((l) => l.name === name); | |
| 71 | ||
| 72 | /** | |
| 73 | * A seed's thirty-two bits stirred into thirty-two others. The seed itself is | |
| 74 | * no good to index with — seeds arrive in runs (`gen_1`, `gen_2`, `gen_3` are a | |
| 75 | * person trying maps), and `seed % 8` would walk the table in order and make | |
| 76 | * the look look like a counter. This is murmur3's finaliser, which is one | |
| 77 | * multiply-xor-shift more than we need and costs nothing. | |
| 78 | */ | |
| 79 | const stir = (seed: number): number => { | |
| 80 | let h = Math.imul(seed ^ 0x9e3779b9, 0x85ebca6b) >>> 0; | |
| 81 | h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0; | |
| 82 | return (h ^ (h >>> 16)) >>> 0; | |
| 83 | }; | |
| 84 | ||
| 85 | /** | |
| 86 | * The look a seed wears. The same seed wears the same one on every machine, for | |
| 87 | * ever: a map name is a layout and a look together, which is what makes one | |
| 88 | * worth writing down and handing to somebody. | |
| 89 | */ | |
| 90 | export const lookFor = (seed: number): Look => LOOKS[stir(seed >>> 0) % LOOKS.length] as Look; |
src/client/map.test.ts +19
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | import { expect, test } from "bun:test"; |
| 10 | 10 | import { cellAt, colOf, findPath, rowOf, siteAt, walkable, worldOf } from "./level.ts"; |
| 11 | 11 | import { DE_FORGE, GRID_H, GRID_W } from "./map.ts"; |
| 12 | import { levelMesh } from "./models.ts"; | |
| 12 | 13 | |
| 13 | 14 | const { boxes, sites, spawns } = DE_FORGE; |
| 14 | 15 | |
| @@ -130,4 +131,22 @@ test("de_forge carries its own alphabet, its numbers and its material table", () | ||
| 130 | 131 | expect(DE_FORGE.wallHeight).toBe(384); |
| 131 | 132 | expect(DE_FORGE.materials).toEqual({ FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 }); |
| 132 | 133 | expect(DE_FORGE.siteCells).toEqual({ A: "A", B: "B" }); |
| 134 | expect(DE_FORGE.look).toBe("forge"); | |
| 135 | }); | |
| 136 | ||
| 137 | /** | |
| 138 | * #151 lets a map choose which of the nine materials it is painted from. This | |
| 139 | * is the claim that de_forge did not choose: the same fifty boxes tagged with | |
| 140 | * the same layers they always were, and the mesh drawn from them the same | |
| 141 | * 1,200 vertices split the same way — 24 of floor, 600 of wall, 528 of crate | |
| 142 | * and 48 of site paint. A look applied to this map by accident lands here. | |
| 143 | */ | |
| 144 | test("de_forge is painted from exactly the layers it always was", () => { | |
| 145 | expect(boxes.map((box) => box.mat)).toEqual([ | |
| 146 | 0, 3, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, | |
| 147 | 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, | |
| 148 | ]); | |
| 149 | const counted: Record<number, number> = {}; | |
| 150 | for (const layer of levelMesh(boxes).layer) counted[layer] = (counted[layer] ?? 0) + 1; | |
| 151 | expect(counted).toEqual({ 0: 24, 1: 600, 2: 528, 3: 48 }); | |
| 133 | 152 | }); |
src/client/preview.test.ts +6
| @@ -112,9 +112,15 @@ test("the line beside it is the level, in words", () => { | ||
| 112 | 112 | // It does not repeat the map's name — the name is the element above it. |
| 113 | 113 | expect(line).not.toContain(GENERATED.name); |
| 114 | 114 | |
| 115 | // And it ends with what the map is painted in, which is the other half of | |
| 116 | // what a generated map is and is not in the picture beside it. | |
| 117 | expect(line.endsWith(` · ${GENERATED.look}`)).toBe(true); | |
| 118 | expect(GENERATED.look).toBe("hangar"); | |
| 119 | ||
| 115 | 120 | // de_forge is a different map and gets a different sentence. |
| 116 | 121 | expect(previewText(DE_FORGE)).not.toBe(line); |
| 117 | 122 | expect(previewText(DE_FORGE)).toContain("44×44 cells"); |
| 123 | expect(previewText(DE_FORGE)).toContain("forge"); | |
| 118 | 124 | }); |
| 119 | 125 | |
| 120 | 126 | test("a compass word is dropped when a thing is not really off centre", () => { |
src/client/preview.ts +7 −1
| @@ -61,13 +61,19 @@ export function compass(at: Vec3, span: number): string { | ||
| 61 | 61 | * level and nothing else. It does not repeat the map's name — the name is the |
| 62 | 62 | * element above it on the page — and it stays short because it sits in a column |
| 63 | 63 | * beside a 96-pixel square on a 360-pixel screen. |
| 64 | * | |
| 65 | * It ends with the level's look — which of the named sets of materials in | |
| 66 | * `looks.ts` it is painted in — because since #151 that is half of what a | |
| 67 | * generated map *is*, and the picture beside this sentence is drawn in the | |
| 68 | * sides' colours rather than the map's: the one word is how a player reading | |
| 69 | * instead of looking knows `gen_9ix` is a hangar and not a quarry. | |
| 64 | 70 | */ |
| 65 | 71 | export function previewText(level: Level): string { |
| 66 | 72 | const { grid, sites, spawns } = level; |
| 67 | 73 | const span = Math.max(grid.width, grid.height) * grid.cell; |
| 68 | 74 | const where = (at: Vec3): string => compass(at, span); |
| 69 | 75 | return `${grid.width}×${grid.height} cells · bombsite A ${where(sites.A)}, B ${where(sites.B)} · ` + |
| 70 | `spawns ${where(middleOf(spawns.t))} and ${where(middleOf(spawns.ct))}`; | |
| 76 | `spawns ${where(middleOf(spawns.t))} and ${where(middleOf(spawns.ct))} · ${level.look}`; | |
| 71 | 77 | } |
| 72 | 78 | |
| 73 | 79 | /** |
src/games.ts +15
| @@ -60,6 +60,21 @@ export type Game = { | ||
| 60 | 60 | /** |
| 61 | 61 | * In the order the header lists them, which is also the order they are built. |
| 62 | 62 | * |
| 63 | * #151 gave a generated map its own look — `src/client/looks.ts`, eight named | |
| 64 | * sets of the nine materials already in the atlas, one chosen from the seed — | |
| 65 | * and the app grew 441 raw / 211 gzipped bytes for it: the table, the stirring | |
| 66 | * that indexes it, the look a level now carries, and the one word the menu's | |
| 67 | * preview sentence ends with. No repaint, no tenth layer, nothing new over the | |
| 68 | * wire: the pixels were uploaded at boot either way, and what changed is which | |
| 69 | * layer a box's vertices name. It fits today's | |
| 70 | * 98,000 / 39,000 with 2,530 raw and 1,258 gzipped bytes to spare, so no | |
| 71 | * ceiling moved. Measured 2026-09-21 against a *rebuild* of main rather than | |
| 72 | * against main's committed bundle: #235 left two app bundles in `static/` and | |
| 73 | * the newer of them was already stale, so the number quoted below (92,978) is | |
| 74 | * not what main's source builds — that is 95,029 raw / 37,531 gzipped, and | |
| 75 | * this branch rebuilds it and commits the one file, which is also what puts | |
| 76 | * `bundle.test.ts` back to passing. | |
| 77 | * | |
| 63 | 78 | * #228 gave the bots a round to play rather than five separate ones — a site, a |
| 64 | 79 | * shape, a tempo and a save per side, and a job per bot cut from it — and the |
| 65 | 80 | * app grew 3,804 raw bytes for it (92,978 raw / 36,716 gzipped now): `src/plan/plan.ts` (the plan, |
static/app.07fd7f4c9f.js deleted −58
Diff not shown — a line 73,606 characters wide (limit 2,000). −58
View filestatic/app.5a2ef4f929.js added +58
Diff not shown — a line 76,085 characters wide (limit 2,000). +58
View filestatic/app.d0901a691f.js deleted −58
Diff not shown — a line 71,841 characters wide (limit 2,000). −58
View file
new above
offline