Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
procedurally generated maps #147
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/138-procedurally-generated-maps → main 3 commits fork
README.md +17 −3
| @@ -32,6 +32,15 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 32 | 32 | CS damage with range falloff, armour and hitgroups, deterministic spray patterns, |
| 33 | 33 | a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and |
| 34 | 34 | loss bonuses. The leaderboard is a real SQLite table under `FORGE_APP_DATA`. |
| 35 | - **Two kinds of map.** `de_forge` is drawn by hand, cell by cell; the other | |
| 36 | kind is carved from a number by `src/client/generate.ts` — pick *generated* | |
| 37 | on the menu and the match id is the seed, so every match is a layout nobody | |
| 38 | has played. A generated map is stamped twice, once at each cell and once half | |
| 39 | a circle away with T for CT and A for B, so it is the same map for both sides | |
| 40 | and the walk to your site is the walk to theirs, measured to the unit. It is | |
| 41 | not shipped until it passes its own gate: spawns to stand on, two sites to | |
| 42 | plant on, a path from each spawn to each site, and no floor that cannot be | |
| 43 | walked to. A map costs 2.1 ms to make, which is the press of the Play button. | |
| 35 | 44 | - **Generated, not licensed.** The map (`de_forge`), the textures, the player and |
| 36 | 45 | weapon models and every sound are produced in code at start-up, by four |
| 37 | 46 | generic tools in the engine — a grid of cells that meshes and paths, a box mesh |
| @@ -268,9 +277,14 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 268 | 277 | level *is* to this game — a grid of lettered cells, the predicate that says |
| 269 | 278 | which letters you can stand on, the boxes, the spawns, the sites, the wall |
| 270 | 279 | height and the material table, plus the questions asked of one: `walkable`, |
| 271 | `siteAt`, `findPath`), `map` (de_forge itself: its areas, its crates, its cell | |
| 272 | alphabet and its spawns and sites, laid over the engine's grid, exported as | |
| 273 | `DE_FORGE` — one value of `Level` and the only one that ships), | |
| 280 | `siteAt`, `findPath`), `cells` (the alphabet both maps are | |
| 281 | written in — the letters, the cell size, the wall height, the materials and | |
| 282 | the predicate — and `levelOf`, which reads a page of those letters into a | |
| 283 | `Level`), `map` (de_forge itself: its areas and its crates, laid over the | |
| 284 | engine's grid, exported as `DE_FORGE`), `generate` (a map from a number: | |
| 285 | rooms, corridors, two sites, two spawns and cover, every cell stamped twice | |
| 286 | so the map is its own opponent's map turned half a circle, and a gate that | |
| 287 | refuses a layout a round cannot be played on), | |
| 274 | 288 | `movement` (Counter-Strike's numbers, and nothing else: `CS_MOVE`, `CS_HULL` |
| 275 | 289 | and `fallDamage`, handed to the engine's mover on every call — the solver |
| 276 | 290 | itself went behind the door in #123 and holds none of them), `weapons`, |
scripts/build.test.ts +1 −1
| @@ -37,7 +37,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri | ||
| 37 | 37 | |
| 38 | 38 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 39 | 39 | expect(ENTRIES).toEqual([ |
| 40 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } }, | |
| 40 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 82_000, gzip: 38_000 } }, | |
| 41 | 41 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 33_000, gzip: 14_000 } }, |
| 42 | 42 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 10_800, gzip: 5_100 } }, |
| 43 | 43 | ]); |
src/client/cells.ts added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | /** | |
| 2 | * The alphabet every map of this game is written in, and what a page of it | |
| 3 | * becomes. A cell is one letter — rock, floor, a crate you can climb, a crate | |
| 4 | * you cannot, the two bombsites, the two spawns — and `levelOf` turns a grid of | |
| 5 | * those letters into a `Level`: the boxes a bullet stops in and the renderer | |
| 6 | * draws, the spawn points, the middle of each site, and the predicate that says | |
| 7 | * what a bot may stand on. | |
| 8 | * | |
| 9 | * It exists because there are two maps now and there will be more. `map.ts` | |
| 10 | * writes de_forge's page by hand and `generate.ts` writes one from a seed; | |
| 11 | * neither of them owns what a letter means, so neither of them can disagree | |
| 12 | * with the other about it. What a level *is* — the type, and the questions | |
| 13 | * asked of one every tick — is `level.ts`, which knows no alphabet at all, and | |
| 14 | * the arithmetic under this file is the engine's `grid.ts`, which knows less | |
| 15 | * still: what counts as solid or walkable goes in as a predicate. | |
| 16 | */ | |
| 17 | ||
| 18 | import * as g from "../engine/index.ts"; | |
| 19 | import type { Box, Grid, Vec3 } from "../engine/index.ts"; | |
| 20 | import type { Level } from "./level.ts"; | |
| 21 | ||
| 22 | /** World units along one side of a cell: Source units, like everything here. */ | |
| 23 | export const CELL = 128; | |
| 24 | ||
| 25 | /** How tall a wall stands. Nothing in this game can see or shoot over one. */ | |
| 26 | export const WALL_HEIGHT = 384; | |
| 27 | ||
| 28 | /** | |
| 29 | * One letter of a map. `#` rock, `.` floor, `x` a crate you can jump onto, `X` | |
| 30 | * one you cannot, `A` and `B` the two bombsites, `t` and `c` the two spawns. | |
| 31 | */ | |
| 32 | export type Cell = "#" | "." | "x" | "X" | "A" | "B" | "t" | "c"; | |
| 33 | ||
| 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; | |
| 36 | ||
| 37 | /** | |
| 38 | * Solid to a walking bot: walls and both kinds of crate. This predicate is the | |
| 39 | * whole of what the engine's search knows about this alphabet, and it is the | |
| 40 | * one thing a level carries that is a function rather than a number. | |
| 41 | */ | |
| 42 | export const passable = (c: string): boolean => c !== "#" && c !== "x" && c !== "X"; | |
| 43 | ||
| 44 | /** The bounding slab of every cell paved with one letter, two units thick. */ | |
| 45 | function siteSlab(grid: Grid<Cell>, fill: Cell): Box { | |
| 46 | let c0 = grid.width, r0 = grid.height, c1 = 0, r1 = 0; | |
| 47 | for (let r = 0; r < grid.height; r++) | |
| 48 | for (let c = 0; c < grid.width; c++) | |
| 49 | if (grid.cells[r * grid.width + c] === fill) { | |
| 50 | c0 = Math.min(c0, c); c1 = Math.max(c1, c); | |
| 51 | r0 = Math.min(r0, r); r1 = Math.max(r1, r); | |
| 52 | } | |
| 53 | if (c1 < c0) throw new Error(`no cell of this map is paved "${fill}": it has no site to plant on`); | |
| 54 | const a = g.worldOf(grid, c0, r0), b = g.worldOf(grid, c1, r1); | |
| 55 | return { | |
| 56 | min: [a[0] - CELL / 2, b[1] - CELL / 2, 0], | |
| 57 | max: [b[0] + CELL / 2, a[1] + CELL / 2, 2], | |
| 58 | mat: MAT.SITE, | |
| 59 | }; | |
| 60 | } | |
| 61 | ||
| 62 | /** Every cell paved with one letter, as world points, in reading order. */ | |
| 63 | export function cellsOf(grid: Grid<Cell>, fill: Cell): Vec3[] { | |
| 64 | const out: Vec3[] = []; | |
| 65 | for (let r = 0; r < grid.height; r++) | |
| 66 | for (let c = 0; c < grid.width; c++) | |
| 67 | if (grid.cells[r * grid.width + c] === fill) out.push(g.worldOf(grid, c, r)); | |
| 68 | return out; | |
| 69 | } | |
| 70 | ||
| 71 | function centre(list: Vec3[]): Vec3 { | |
| 72 | const s = list.reduce((a, p) => [a[0] + p[0], a[1] + p[1], 0] as Vec3, [0, 0, 0] as Vec3); | |
| 73 | return [s[0] / list.length, s[1] / list.length, 0]; | |
| 74 | } | |
| 75 | ||
| 76 | /** | |
| 77 | * A page of letters, read as a level: the floor slab, the two painted sites, | |
| 78 | * the walls and both heights of crate merged into as few boxes as the shape | |
| 79 | * allows, the spawn points and the middle of each site. | |
| 80 | * | |
| 81 | * One piece of data feeds everything — what you collide with, what gets drawn, | |
| 82 | * where bots walk, where the bomb can be planted — so a map can never disagree | |
| 83 | * with itself. A grid with no `A`, no `B`, or no cell for a side to spawn on is | |
| 84 | * not a map anybody can play and says so here rather than at the first round. | |
| 85 | */ | |
| 86 | export function levelOf(name: string, grid: Grid<Cell>): Level { | |
| 87 | const halfX = (grid.width / 2) * CELL; | |
| 88 | const halfY = (grid.height / 2) * CELL; | |
| 89 | const floor: Box = { min: [-halfX, -halfY, -32], max: [halfX, halfY, 0], mat: MAT.FLOOR }; | |
| 90 | const merge = (match: (c: Cell) => boolean, z0: number, z1: number, mat: number): Box[] => | |
| 91 | g.mergeSolid(grid, match, z0, z1, mat); | |
| 92 | const spawns = { t: cellsOf(grid, "t"), ct: cellsOf(grid, "c") }; | |
| 93 | for (const [side, list] of Object.entries(spawns)) | |
| 94 | if (list.length === 0) throw new Error(`no cell of this map is a ${side} spawn`); | |
| 95 | return { | |
| 96 | name, | |
| 97 | grid, | |
| 98 | passable, | |
| 99 | wallHeight: WALL_HEIGHT, | |
| 100 | materials: MAT, | |
| 101 | // Everything solid, in one list: the renderer draws it and bullets stop in it. | |
| 102 | boxes: [ | |
| 103 | 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), | |
| 109 | ], | |
| 110 | spawns, | |
| 111 | sites: { A: centre(cellsOf(grid, "A")), B: centre(cellsOf(grid, "B")) }, | |
| 112 | siteCells: { A: "A", B: "B" }, | |
| 113 | }; | |
| 114 | } |
src/client/generate.test.ts added +228
| @@ -0,0 +1,228 @@ | ||
| 1 | /** | |
| 2 | * The generator, held to the two things it promises: that a seed is a map, the | |
| 3 | * same map on every machine, and that a map it hands back can be played — one | |
| 4 | * place, two sites, ten spawns, and a walk from each spawn to each site that A* | |
| 5 | * finds rather than a flood fill hopes for. | |
| 6 | * | |
| 7 | * Nothing here reads the generator's own gate and calls it a day: `flaws` is | |
| 8 | * what the generator itself refuses on, so the checks below path, flood and | |
| 9 | * count the level again from the outside. The last two tests are the point of | |
| 10 | * the whole file — a round of the real game, with the real bots, on a map | |
| 11 | * nobody wrote, played headlessly and pinned to a digest. | |
| 12 | */ | |
| 13 | ||
| 14 | import { expect, test } from "bun:test"; | |
| 15 | import { botThink } from "./bots.ts"; | |
| 16 | import { MAT, type Cell } from "./cells.ts"; | |
| 17 | import { flaws, generateGrid, generateLevel, nameOf, page, SIZE } from "./generate.ts"; | |
| 18 | import { colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts"; | |
| 19 | import { SHORT } from "./match.ts"; | |
| 20 | import { digest, withoutEntropy } from "./replay.ts"; | |
| 21 | import { applyInput, createWorld, type Player } from "./world.ts"; | |
| 22 | import { region, type Vec3 } from "../engine/index.ts"; | |
| 23 | ||
| 24 | /** How long a walk is, in Source units: the sum of the steps A* handed back. */ | |
| 25 | const walkLength = (path: Vec3[], from: Vec3): number => { | |
| 26 | let at = from, total = 0; | |
| 27 | for (const step of path) { | |
| 28 | total += Math.hypot(step[0] - at[0], step[1] - at[1]); | |
| 29 | at = step; | |
| 30 | } | |
| 31 | return total; | |
| 32 | }; | |
| 33 | ||
| 34 | const letters = (level: Level): Cell[] => level.grid.cells as Cell[]; | |
| 35 | ||
| 36 | test("a seed is a map, and the same seed is the same map for ever", () => { | |
| 37 | expect(page(generateGrid(7))).toEqual(page(generateGrid(7))); | |
| 38 | expect(page(generateGrid(7))).not.toEqual(page(generateGrid(8))); | |
| 39 | // Two levels from one seed agree about every point a round is played at. | |
| 40 | const a = generateLevel(7), b = generateLevel(7); | |
| 41 | expect(a.name).toBe("gen_7"); | |
| 42 | expect(nameOf(1234)).toBe("gen_ya"); | |
| 43 | expect(a.boxes).toEqual(b.boxes); | |
| 44 | expect(a.spawns).toEqual(b.spawns); | |
| 45 | expect(a.sites).toEqual(b.sites); | |
| 46 | }); | |
| 47 | ||
| 48 | test("a map is written on a page of letters, one string a row", () => { | |
| 49 | const grid = generateGrid(3); | |
| 50 | const rows = page(grid); | |
| 51 | expect(rows.length).toBe(SIZE); | |
| 52 | for (const row of rows) expect(row.length).toBe(SIZE); | |
| 53 | expect(rows[0]).toBe("#".repeat(SIZE)); // the border is rock, all the way round | |
| 54 | expect(rows[SIZE - 1]).toBe("#".repeat(SIZE)); | |
| 55 | for (const row of rows) expect(row[0]).toBe("#"); | |
| 56 | expect(rows.join("")).toBe(grid.cells.join("")); | |
| 57 | }); | |
| 58 | ||
| 59 | test("every map is the other side's map turned half a circle", () => { | |
| 60 | // The whole of fairness: the cell at (col, row) is the cell at the far end of | |
| 61 | // the diagonal through the middle, with T for CT and A for B. | |
| 62 | const mate: Record<string, string> = { "#": "#", ".": ".", x: "x", X: "X", A: "B", B: "A", t: "c", c: "t" }; | |
| 63 | for (let seed = 1; seed <= 40; seed++) { | |
| 64 | const grid = generateGrid(seed); | |
| 65 | for (let row = 0; row < grid.height; row++) | |
| 66 | for (let col = 0; col < grid.width; col++) { | |
| 67 | const here = grid.cells[row * grid.width + col] as string; | |
| 68 | const there = grid.cells[(grid.height - 1 - row) * grid.width + (grid.width - 1 - col)] as string; | |
| 69 | if (mate[here] !== there) throw new Error(`seed ${seed}: (${col}, ${row}) is ${here} but its mate is ${there}`); | |
| 70 | } | |
| 71 | } | |
| 72 | }); | |
| 73 | ||
| 74 | test("the walk to your site is the walk to theirs, step for step", () => { | |
| 75 | // Turning the map half a circle turns the T spawn into the CT spawn and A | |
| 76 | // into B, so the two walks are the same walk. Reading order runs north to | |
| 77 | // south, so the mate of the first CT spawn cell is the *last* T spawn cell. | |
| 78 | for (const seed of [2, 13, 44, 101, 777]) { | |
| 79 | const level = generateLevel(seed); | |
| 80 | // The middle of the two sites are exact opposites, because a site is an odd | |
| 81 | // number of cells on both sides and the middle of an odd run is a cell. | |
| 82 | expect(level.sites.A[0]).toBe(-level.sites.B[0]); | |
| 83 | expect(level.sites.A[1]).toBe(-level.sites.B[1]); | |
| 84 | const ct = level.spawns.ct[0] as Vec3; | |
| 85 | const t = level.spawns.t[level.spawns.t.length - 1] as Vec3; | |
| 86 | const ctToA = walkLength(findPath(level, ct, level.sites.A), ct); | |
| 87 | const tToB = walkLength(findPath(level, t, level.sites.B), t); | |
| 88 | const ctToB = walkLength(findPath(level, ct, level.sites.B), ct); | |
| 89 | const tToA = walkLength(findPath(level, t, level.sites.A), t); | |
| 90 | expect(ctToA).toBeGreaterThan(0); | |
| 91 | expect(ctToA).toBeCloseTo(tToB, 6); | |
| 92 | expect(ctToB).toBeCloseTo(tToA, 6); | |
| 93 | } | |
| 94 | }); | |
| 95 | ||
| 96 | test("two hundred seeds all come out playable, first time", () => { | |
| 97 | // The retry loop in `generateLevel` is insurance; this is the claim that it | |
| 98 | // is never spent. Every level here is the seed's first attempt. | |
| 99 | for (let seed = 0; seed < 200; seed++) { | |
| 100 | const level = generateLevel(seed); | |
| 101 | expect(flaws(level)).toEqual([]); | |
| 102 | ||
| 103 | // Ten players have somewhere of their own to stand, and it is floor. | |
| 104 | expect(level.spawns.t.length).toBeGreaterThanOrEqual(8); | |
| 105 | expect(level.spawns.ct.length).toBe(level.spawns.t.length); | |
| 106 | for (const side of ["t", "ct"] as const) | |
| 107 | for (const spot of level.spawns[side]) | |
| 108 | expect(walkable(level, colOf(level, spot[0]), rowOf(level, spot[1]))).toBe(true); | |
| 109 | ||
| 110 | // The middle of each site is on that site and can be planted on. | |
| 111 | expect(siteAt(level, level.sites.A)).toBe("A"); | |
| 112 | expect(siteAt(level, level.sites.B)).toBe("B"); | |
| 113 | ||
| 114 | // One place: everything walkable can be walked to from a spawn. | |
| 115 | const first = level.spawns.t[0] as Vec3; | |
| 116 | const reached = region(level.grid, level.passable, colOf(level, first[0]), rowOf(level, first[1])); | |
| 117 | const stranded = letters(level).filter((c, i) => level.passable(c) && reached[i] === 0); | |
| 118 | expect(stranded).toEqual([]); | |
| 119 | ||
| 120 | // A map with room to move. Measured over a thousand seeds, the tightest | |
| 121 | // map is 286 cells of floor and the most open is 684, of 1,600. | |
| 122 | expect(openCells(level).length).toBeGreaterThan(SIZE * SIZE / 6); | |
| 123 | } | |
| 124 | }); | |
| 125 | ||
| 126 | test("no generated map leaks outside its own grid, or paints a surface this game has not got", () => { | |
| 127 | const known = new Set<number>(Object.values(MAT)); | |
| 128 | for (let seed = 500; seed < 540; seed++) { | |
| 129 | const level = generateLevel(seed); | |
| 130 | const edge = [(level.grid.width / 2) * level.grid.cell, (level.grid.height / 2) * level.grid.cell]; | |
| 131 | for (const box of level.boxes) { | |
| 132 | for (const axis of [0, 1]) { | |
| 133 | expect(box.min[axis as 0 | 1]).toBeGreaterThanOrEqual(-(edge[axis] as number)); | |
| 134 | expect(box.max[axis as 0 | 1]).toBeLessThanOrEqual(edge[axis] as number); | |
| 135 | } | |
| 136 | // No new material means no new atlas layer, which is a bundle nobody pays for. | |
| 137 | expect(known.has(box.mat)).toBe(true); | |
| 138 | } | |
| 139 | } | |
| 140 | }); | |
| 141 | ||
| 142 | test("the gate bites: a map with its site walled off, and one with nowhere to spawn", () => { | |
| 143 | const level = generateLevel(21); | |
| 144 | expect(flaws(level)).toEqual([]); | |
| 145 | ||
| 146 | // Fill the ring around bombsite A with rock and nobody can reach it. The | |
| 147 | // cells inside are still walkable and now cannot be walked to, so the gate | |
| 148 | // says both things. | |
| 149 | const shut = { ...level, grid: { ...level.grid, cells: [...level.grid.cells] } } as Level; | |
| 150 | const { width, height } = shut.grid; | |
| 151 | for (let row = 0; row < height; row++) | |
| 152 | for (let col = 0; col < width; col++) | |
| 153 | if (shut.grid.cells[row * width + col] === "A") | |
| 154 | for (let dr = -1; dr <= 1; dr++) | |
| 155 | for (let dc = -1; dc <= 1; dc++) { | |
| 156 | const c = col + dc, r = row + dr; | |
| 157 | if (c < 0 || r < 0 || c >= width || r >= height) continue; | |
| 158 | if (shut.grid.cells[r * width + c] !== "A") shut.grid.cells[r * width + c] = "#"; | |
| 159 | } | |
| 160 | const complaints = flaws(shut).join("\n"); | |
| 161 | expect(complaints).toContain("no path from the t spawn to bombsite A"); | |
| 162 | expect(complaints).toContain("no path from the ct spawn to bombsite A"); | |
| 163 | expect(complaints).toContain("cannot be walked to"); | |
| 164 | ||
| 165 | // And a level with one spawn cell a side is not a level for ten players. | |
| 166 | const thin = { ...level, spawns: { t: [level.spawns.t[0] as Vec3], ct: [level.spawns.ct[0] as Vec3] } }; | |
| 167 | expect(flaws(thin).length).toBe(2); | |
| 168 | expect(flaws(thin)[0]).toContain("a side is five players"); | |
| 169 | }); | |
| 170 | ||
| 171 | test("a map arrives in a couple of milliseconds, which is a menu press", () => { | |
| 172 | for (let seed = 0; seed < 20; seed++) generateLevel(seed); // warm | |
| 173 | const started = performance.now(); | |
| 174 | const runs = 40; | |
| 175 | for (let seed = 900; seed < 900 + runs; seed++) generateLevel(seed); | |
| 176 | const each = (performance.now() - started) / runs; | |
| 177 | // Measured at 2.1 ms a map on this machine: the carving, the crates that are | |
| 178 | // checked for cutting the map in two, the meshing and four A* searches. | |
| 179 | // Twenty is the ceiling a slow CI box has to beat, not the number to read. | |
| 180 | expect(each).toBeLessThan(20); | |
| 181 | }); | |
| 182 | ||
| 183 | // --- and the game played on it -------------------------------------------- | |
| 184 | ||
| 185 | test("a bot walks a map nobody wrote, on that map's own cells", () => { | |
| 186 | const level = generateLevel(64); | |
| 187 | const world = createWorld(level, SHORT, "me", "ct", 1, 5); | |
| 188 | expect(world.level).toBe(level); | |
| 189 | world.match.phase = "live"; | |
| 190 | world.me.alive = false; | |
| 191 | const bot = world.players.find((p) => p.bot) as Player; | |
| 192 | const from: [number, number] = [bot.pos[0], bot.pos[1]]; | |
| 193 | for (let tick = 0; tick < 64 * 3; tick++) { | |
| 194 | world.time += 1 / 64; | |
| 195 | applyInput(world, bot, botThink(world, bot, 1 / 64), 1 / 64); | |
| 196 | } | |
| 197 | // It went somewhere, along cells of this map, and this map's walls held it in. | |
| 198 | expect(Math.hypot(bot.pos[0] - from[0], bot.pos[1] - from[1])).toBeGreaterThan(200); | |
| 199 | const path = (bot.brain as { path: Vec3[] }).path; | |
| 200 | expect(path.length).toBeGreaterThan(1); | |
| 201 | for (const step of path) | |
| 202 | expect(walkable(level, colOf(level, step[0]), rowOf(level, step[1]))).toBe(true); | |
| 203 | expect(Math.abs(bot.pos[0])).toBeLessThan((level.grid.width / 2) * level.grid.cell); | |
| 204 | expect(Math.abs(bot.pos[1])).toBeLessThan((level.grid.height / 2) * level.grid.cell); | |
| 205 | }); | |
| 206 | ||
| 207 | /** | |
| 208 | * Ten seconds of a five-a-side round on `gen_5`, seed 11, through the same | |
| 209 | * script `replay.ts` pins de_forge with. Computed on this branch, where the | |
| 210 | * generator was written. | |
| 211 | * | |
| 212 | * It pins two things at once: that the simulation is deterministic on a | |
| 213 | * generated map, and that this seed still makes *this* map. Tuning the | |
| 214 | * generator — a room more, a crate fewer — moves it, and that is the point: | |
| 215 | * paste the new digest in the pull that tuned it and say which seed's map | |
| 216 | * changed shape. A digest that moves for any other reason is a bug. | |
| 217 | */ | |
| 218 | const GENERATED_ROUND = "95aedb752f57a576d996a90212f973a4233a2906c2625c12a89b3b2222ef6f2e"; | |
| 219 | ||
| 220 | test("a seeded round on a generated map plays exactly as it played", () => { | |
| 221 | const level = generateLevel(5); | |
| 222 | const run = withoutEntropy(() => digest(createWorld(level, SHORT, "me", "ct", 5, 11), 10 * 64)); | |
| 223 | expect(run.digest).toBe(GENERATED_ROUND); | |
| 224 | // Nobody fell through the floor or stood in a wall for ten seconds. | |
| 225 | for (const p of createWorld(level, SHORT, "me", "ct", 5, 11).players) | |
| 226 | expect(walkable(level, colOf(level, p.pos[0]), rowOf(level, p.pos[1]))).toBe(true); | |
| 227 | expect(worldOf(level, 0, 0)[2]).toBe(0); | |
| 228 | }); |
src/client/generate.ts added +325
| @@ -0,0 +1,325 @@ | ||
| 1 | /** | |
| 2 | * A map from a number. Rooms, corridors, two bombsites, two spawns and cover, | |
| 3 | * carved out of solid rock into the alphabet `cells.ts` reads — so a generated | |
| 4 | * map is one more value of `Level` and nothing in the simulation, the renderer | |
| 5 | * or the bots learns a thing to play it. | |
| 6 | * | |
| 7 | * Two promises hold the whole file up. | |
| 8 | * | |
| 9 | * **It is fair.** Everything is stamped twice: a cell at (col, row) and the cell | |
| 10 | * at (width - 1 - col, height - 1 - row), with `t` becoming `c` and `A` | |
| 11 | * becoming `B` on the way. The map is therefore its own opponent's map turned | |
| 12 | * half a circle, so the walk from T spawn to A is the walk from CT spawn to B, | |
| 13 | * step for step, and no seed can hand one side a shorter round. A test paths | |
| 14 | * both and compares the lengths. | |
| 15 | * | |
| 16 | * **It is playable.** Every room hangs off a spanning tree of corridors that | |
| 17 | * reaches the middle, which is the one room both halves share, so the map is | |
| 18 | * one place by construction rather than by luck; `region` then rubs out | |
| 19 | * anything the tree missed, a crate that would cut the map in two is taken back | |
| 20 | * off, and `flaws` reads the finished level for the things a round needs — | |
| 21 | * spawns to stand on, sites to plant on, and a path from each spawn to each | |
| 22 | * site. A seed whose map has a flaw is not shipped: the next seed is derived | |
| 23 | * from it and the work is done again. | |
| 24 | * | |
| 25 | * Determinism is the point: a seed is a map, on every machine, for ever. The | |
| 26 | * only source of randomness is the engine's `rng`, which is the same generator | |
| 27 | * the rounds themselves run on. | |
| 28 | */ | |
| 29 | ||
| 30 | import { region, rng, type Grid, type Vec3 } from "../engine/index.ts"; | |
| 31 | import { CELL, levelOf, type Cell } from "./cells.ts"; | |
| 32 | import { colOf, findPath, rowOf, type Level } from "./level.ts"; | |
| 33 | ||
| 34 | /** Cells on a side. 40 is 5,120 Source units: a little tighter than de_forge. */ | |
| 35 | export const SIZE = 40; | |
| 36 | ||
| 37 | /** How many attempts a seed gets before the generator gives up on it. */ | |
| 38 | const ATTEMPTS = 8; | |
| 39 | ||
| 40 | /** [col0, row0, col1, row1], inclusive both ends, like de_forge's areas. */ | |
| 41 | type Rect = { c0: number; r0: number; c1: number; r1: number }; | |
| 42 | ||
| 43 | type Point = { col: number; row: number }; | |
| 44 | ||
| 45 | /** What a letter becomes when it is stamped on the other side of the map. */ | |
| 46 | const MATE: Record<Cell, Cell> = { | |
| 47 | "#": "#", ".": ".", x: "x", X: "X", A: "B", B: "A", t: "c", c: "t", | |
| 48 | }; | |
| 49 | ||
| 50 | /** | |
| 51 | * A seed out of whatever text a page can hand over — the match id, usually, | |
| 52 | * which is a uuid the browser made. FNV-1a, thirty-two bits: a hash rather than | |
| 53 | * a clock, so a page that wants a map every match needs no source of numbers of | |
| 54 | * its own and a test can hand in a word and get the same map every time. | |
| 55 | */ | |
| 56 | export function seedFrom(text: string): number { | |
| 57 | let hash = 0x811c9dc5; | |
| 58 | for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619); | |
| 59 | return hash >>> 0; | |
| 60 | } | |
| 61 | ||
| 62 | /** The name a seed's map answers to: `gen_1z3f`, short enough for a menu. */ | |
| 63 | export const nameOf = (seed: number): string => `gen_${(seed >>> 0).toString(36)}`; | |
| 64 | ||
| 65 | /** The page of letters a grid is, one string a row: what a test or a terminal reads. */ | |
| 66 | export const page = (grid: Grid<Cell>): string[] => | |
| 67 | Array.from({ length: grid.height }, (_, r) => grid.cells.slice(r * grid.width, (r + 1) * grid.width).join("")); | |
| 68 | ||
| 69 | // --- carving -------------------------------------------------------------- | |
| 70 | ||
| 71 | /** | |
| 72 | * One map under construction: the cells, and the only way anything is written | |
| 73 | * into them. Every write is two writes — the cell, and the cell half a circle | |
| 74 | * away with its letter turned round — so symmetry is not something to remember | |
| 75 | * at the end but the only thing this page can do. | |
| 76 | */ | |
| 77 | function sheet(size: number): { | |
| 78 | cells: Cell[]; | |
| 79 | set: (col: number, row: number, cell: Cell) => void; | |
| 80 | fill: (rect: Rect, cell: Cell) => void; | |
| 81 | } { | |
| 82 | const cells: Cell[] = new Array(size * size).fill("#"); | |
| 83 | // The border is rock and stays rock: nothing is ever carved into the outer ring. | |
| 84 | const inside = (col: number, row: number): boolean => | |
| 85 | col >= 1 && row >= 1 && col <= size - 2 && row <= size - 2; | |
| 86 | const set = (col: number, row: number, cell: Cell): void => { | |
| 87 | if (!inside(col, row)) return; | |
| 88 | cells[row * size + col] = cell; | |
| 89 | cells[(size - 1 - row) * size + (size - 1 - col)] = MATE[cell]; | |
| 90 | }; | |
| 91 | const fill = (rect: Rect, cell: Cell): void => { | |
| 92 | for (let r = rect.r0; r <= rect.r1; r++) | |
| 93 | for (let c = rect.c0; c <= rect.c1; c++) set(c, r, cell); | |
| 94 | }; | |
| 95 | return { cells, set, fill }; | |
| 96 | } | |
| 97 | ||
| 98 | const centreOf = (rect: Rect): Point => ({ | |
| 99 | col: Math.floor((rect.c0 + rect.c1) / 2), | |
| 100 | row: Math.floor((rect.r0 + rect.r1) / 2), | |
| 101 | }); | |
| 102 | ||
| 103 | /** | |
| 104 | * An L from one room to another, two or three cells wide: along the columns to | |
| 105 | * the elbow, then down the rows, both runs carved as rectangles so the corner | |
| 106 | * they share is open and nobody has to cut it. Which run goes first is the | |
| 107 | * seed's business, which is what keeps a map from being all right angles the | |
| 108 | * same way round. | |
| 109 | */ | |
| 110 | function corridor( | |
| 111 | paint: ReturnType<typeof sheet>, | |
| 112 | a: Point, | |
| 113 | b: Point, | |
| 114 | width: number, | |
| 115 | columnsFirst: boolean, | |
| 116 | ): void { | |
| 117 | const elbow = columnsFirst ? { col: b.col, row: a.row } : { col: a.col, row: b.row }; | |
| 118 | const span = (from: Point, to: Point): Rect => ({ | |
| 119 | c0: Math.min(from.col, to.col), | |
| 120 | r0: Math.min(from.row, to.row), | |
| 121 | c1: Math.max(from.col, to.col) + (from.row === to.row ? 0 : width - 1), | |
| 122 | r1: Math.max(from.row, to.row) + (from.col === to.col ? 0 : width - 1), | |
| 123 | }); | |
| 124 | paint.fill(span(a, elbow), "."); | |
| 125 | paint.fill(span(elbow, b), "."); | |
| 126 | } | |
| 127 | ||
| 128 | /** | |
| 129 | * The corridors: a spanning tree over the rooms, nearest room first, grown from | |
| 130 | * the middle — so every room reaches the middle, and the middle is the room | |
| 131 | * both halves of the map share. Then one or two edges more, chosen at random, | |
| 132 | * because a map with exactly one route between two places is a queue rather | |
| 133 | * than a fight. | |
| 134 | */ | |
| 135 | function connect(paint: ReturnType<typeof sheet>, rooms: Point[], random: () => number): void { | |
| 136 | const joined = [0]; | |
| 137 | const waiting = rooms.map((_, i) => i).slice(1); | |
| 138 | const width = (): number => (random() < 0.45 ? 3 : 2); | |
| 139 | while (waiting.length > 0) { | |
| 140 | let best = 0, from = 0, far = Infinity; | |
| 141 | for (let w = 0; w < waiting.length; w++) | |
| 142 | for (const j of joined) { | |
| 143 | const p = rooms[waiting[w] as number] as Point, q = rooms[j] as Point; | |
| 144 | const d = Math.hypot(p.col - q.col, p.row - q.row); | |
| 145 | if (d < far) { far = d; best = w; from = j; } | |
| 146 | } | |
| 147 | const next = waiting.splice(best, 1)[0] as number; | |
| 148 | corridor(paint, rooms[from] as Point, rooms[next] as Point, width(), random() < 0.5); | |
| 149 | joined.push(next); | |
| 150 | } | |
| 151 | const extra = 1 + Math.floor(random() * 2); | |
| 152 | for (let i = 0; i < extra && rooms.length > 2; i++) { | |
| 153 | const a = Math.floor(random() * rooms.length); | |
| 154 | const b = (a + 1 + Math.floor(random() * (rooms.length - 1))) % rooms.length; | |
| 155 | corridor(paint, rooms[a] as Point, rooms[b] as Point, width(), random() < 0.5); | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 159 | // --- the map -------------------------------------------------------------- | |
| 160 | ||
| 161 | /** | |
| 162 | * One attempt at a layout, as a grid of letters. Everything is decided in the | |
| 163 | * northern half and stamped into the southern half turned half a circle: the CT | |
| 164 | * spawn is the T spawn, the A site is the B site, and the middle room is its | |
| 165 | * own reflection, which is where the two halves meet. | |
| 166 | */ | |
| 167 | export function generateGrid(seed: number, size = SIZE): Grid<Cell> { | |
| 168 | const random = rng(seed); | |
| 169 | const pick = (from: number, to: number): number => from + Math.floor(random() * (to - from + 1)); | |
| 170 | const paint = sheet(size); | |
| 171 | const middle = size / 2; | |
| 172 | ||
| 173 | // The CT spawn: eight by four, across the top, near the middle column. | |
| 174 | const spawnC0 = pick(middle - 6, middle - 2); | |
| 175 | const spawn: Rect = { c0: spawnC0, r0: 2, c1: spawnC0 + 7, r1: 5 }; | |
| 176 | ||
| 177 | // The A site: seven wide, five or seven deep, out on one flank — clear of the | |
| 178 | // spawn's columns and wholly in the northern half, so its mate is the B site | |
| 179 | // on the other flank and the two can never touch. Both sides of it are odd | |
| 180 | // on purpose: the middle of an odd run of cells is a cell, so the point the | |
| 181 | // bomb is planted at is the centre of a cell rather than the seam between | |
| 182 | // two, and the two sites' middles are exact opposites of each other. A seam | |
| 183 | // rounds the wrong way on one side of the map and hands that side a shorter | |
| 184 | // walk, which is the one thing this file is not allowed to do. | |
| 185 | const siteC0 = random() < 0.5 ? pick(size - 12, size - 9) : pick(2, 6); | |
| 186 | const siteR0 = pick(3, 12); | |
| 187 | const site: Rect = { c0: siteC0, r0: siteR0, c1: siteC0 + 6, r1: siteR0 + (random() < 0.5 ? 4 : 6) }; | |
| 188 | ||
| 189 | // The middle: the one room that is its own mate, and the seam of the map. | |
| 190 | const midC0 = middle - pick(3, 5), midR0 = middle - pick(2, 4); | |
| 191 | const mid: Rect = { c0: midC0, r0: midR0, c1: size - 1 - midC0, r1: size - 1 - midR0 }; | |
| 192 | ||
| 193 | // Three or four halls in the northern half, wherever they land: they may cross | |
| 194 | // anything, because the letters go on last. | |
| 195 | const halls: Rect[] = []; | |
| 196 | for (let i = 0, want = pick(3, 4); i < want; i++) { | |
| 197 | const w = pick(4, 7), h = pick(3, 5); | |
| 198 | const c0 = pick(2, size - 3 - w), r0 = pick(2, middle - 3 - h); | |
| 199 | halls.push({ c0, r0, c1: c0 + w, r1: r0 + h }); | |
| 200 | } | |
| 201 | ||
| 202 | for (const rect of [spawn, site, mid, ...halls]) paint.fill(rect, "."); | |
| 203 | connect(paint, [centreOf(mid), centreOf(spawn), centreOf(site), ...halls.map(centreOf)], random); | |
| 204 | ||
| 205 | // The letters last, so a corridor that crossed a spawn is spawn floor rather | |
| 206 | // than a hole in it. | |
| 207 | paint.fill(site, "A"); | |
| 208 | paint.fill(spawn, "c"); | |
| 209 | ||
| 210 | // Anything the tree missed is rock again: a courtyard nobody can reach is a | |
| 211 | // place the renderer draws, the bots path into and the round never visits. | |
| 212 | // Rubbing it out cell by cell keeps the symmetry it was carved with, because | |
| 213 | // what one spawn can reach is what the other can: the two are the same room | |
| 214 | // turned half a circle, and the middle joins them. | |
| 215 | const grid: Grid<Cell> = { width: size, height: size, cell: CELL, cells: paint.cells }; | |
| 216 | const home = centreOf(spawn); | |
| 217 | const reached = region(grid, (c) => c !== "#", home.col, home.row); | |
| 218 | for (let i = 0; i < grid.cells.length; i++) | |
| 219 | if (reached[i] === 0 && grid.cells[i] !== "#") grid.cells[i] = "#"; | |
| 220 | ||
| 221 | crates(paint, grid, centreOf(site), random); | |
| 222 | return grid; | |
| 223 | } | |
| 224 | ||
| 225 | /** | |
| 226 | * Cover, in pairs: a crate and the crate half a circle away, lower-case to jump | |
| 227 | * on and upper-case to hide behind. A crate goes on floor in a roomy place — | |
| 228 | * every orthogonal neighbour open and six of the eight — never on a spawn and | |
| 229 | * never on a bombsite, whose cells decide where the bomb is planted. If the | |
| 230 | * pair cuts the map in two it comes back off, which is why a generated map can | |
| 231 | * be crated at all: a plug in a two-wide corridor is a round nobody can finish. | |
| 232 | */ | |
| 233 | function crates( | |
| 234 | paint: ReturnType<typeof sheet>, | |
| 235 | grid: Grid<Cell>, | |
| 236 | siteMiddle: Point, | |
| 237 | random: () => number, | |
| 238 | ): void { | |
| 239 | const { width, cells } = grid; | |
| 240 | const open = (col: number, row: number): boolean => | |
| 241 | col >= 0 && row >= 0 && col < width && row < grid.height && cells[row * width + col] !== "#"; | |
| 242 | const walkable = (c: Cell): boolean => c !== "#" && c !== "x" && c !== "X"; | |
| 243 | const roomy = (col: number, row: number): boolean => { | |
| 244 | if (!open(col, row)) return false; | |
| 245 | if (cells[row * width + col] !== ".") return false; | |
| 246 | if (!open(col - 1, row) || !open(col + 1, row) || !open(col, row - 1) || !open(col, row + 1)) return false; | |
| 247 | let around = 0; | |
| 248 | for (let dr = -1; dr <= 1; dr++) | |
| 249 | for (let dc = -1; dc <= 1; dc++) if ((dc !== 0 || dr !== 0) && open(col + dc, row + dr)) around++; | |
| 250 | return around >= 6; | |
| 251 | }; | |
| 252 | const spots: Point[] = []; | |
| 253 | for (let row = 1; row < grid.height / 2; row++) | |
| 254 | for (let col = 1; col < width - 1; col++) if (roomy(col, row)) spots.push({ col, row }); | |
| 255 | let want = 6 + Math.floor(random() * 5); | |
| 256 | while (want > 0 && spots.length > 0) { | |
| 257 | const spot = spots.splice(Math.floor(random() * spots.length), 1)[0] as Point; | |
| 258 | if (!roomy(spot.col, spot.row)) continue; | |
| 259 | const before = cells[spot.row * width + spot.col] as Cell; | |
| 260 | const mate = cells[(grid.height - 1 - spot.row) * width + (width - 1 - spot.col)] as Cell; | |
| 261 | paint.set(spot.col, spot.row, random() < 0.7 ? "x" : "X"); | |
| 262 | const still = region(grid, walkable, siteMiddle.col, siteMiddle.row); | |
| 263 | let cut = false; | |
| 264 | for (let i = 0; i < cells.length && !cut; i++) | |
| 265 | if (still[i] === 0 && walkable(cells[i] as Cell)) cut = true; | |
| 266 | if (cut) { | |
| 267 | cells[spot.row * width + spot.col] = before; | |
| 268 | cells[(grid.height - 1 - spot.row) * width + (width - 1 - spot.col)] = mate; | |
| 269 | continue; | |
| 270 | } | |
| 271 | want--; | |
| 272 | } | |
| 273 | } | |
| 274 | ||
| 275 | // --- the gate ------------------------------------------------------------- | |
| 276 | ||
| 277 | /** | |
| 278 | * What is wrong with this map, in sentences. A round needs somewhere for ten | |
| 279 | * players to stand, two sites big enough to plant on, and a way from each spawn | |
| 280 | * to each site that A* can actually find — the bots walk it, so it is asked of | |
| 281 | * A* rather than of a flood fill. An empty list is a map that can be played. | |
| 282 | */ | |
| 283 | export function flaws(level: Level): string[] { | |
| 284 | const bad: string[] = []; | |
| 285 | for (const side of ["t", "ct"] as const) | |
| 286 | if (level.spawns[side].length < 8) | |
| 287 | bad.push(`${level.name}: ${level.spawns[side].length} cells to spawn ${side} on, and a side is five players`); | |
| 288 | for (const letter of ["A", "B"] as const) { | |
| 289 | const paved = level.grid.cells.filter((c) => c === letter).length; | |
| 290 | if (paved < 10) bad.push(`${level.name}: bombsite ${letter} is ${paved} cells, too small to fight over`); | |
| 291 | } | |
| 292 | for (const side of ["t", "ct"] as const) | |
| 293 | for (const letter of ["A", "B"] as const) { | |
| 294 | const from = level.spawns[side][0] as Vec3; | |
| 295 | if (findPath(level, from, level.sites[letter]).length === 0) | |
| 296 | bad.push(`${level.name}: no path from the ${side} spawn to bombsite ${letter}`); | |
| 297 | } | |
| 298 | const first = level.spawns.t[0] as Vec3; | |
| 299 | const reached = region(level.grid, level.passable, colOf(level, first[0]), rowOf(level, first[1])); | |
| 300 | let stranded = 0; | |
| 301 | for (let i = 0; i < level.grid.cells.length; i++) | |
| 302 | if (reached[i] === 0 && level.passable(level.grid.cells[i] as Cell)) stranded++; | |
| 303 | if (stranded > 0) bad.push(`${level.name}: ${stranded} cells are walkable and cannot be walked to`); | |
| 304 | return bad; | |
| 305 | } | |
| 306 | ||
| 307 | /** | |
| 308 | * A map from a seed, whole and playable. The seed is tried, and a layout with a | |
| 309 | * flaw in it is not shipped: the seed is mixed into another one and the work is | |
| 310 | * done again, up to eight times. In 2,000 seeds measured none needed a second | |
| 311 | * attempt, so the loop is insurance rather than a plan, and a seed that somehow | |
| 312 | * spends all eight says so loudly instead of handing back a map nobody can | |
| 313 | * finish a round on. | |
| 314 | */ | |
| 315 | export function generateLevel(seed: number, size = SIZE): Level { | |
| 316 | let at = seed >>> 0; | |
| 317 | let last: string[] = []; | |
| 318 | for (let attempt = 0; attempt < ATTEMPTS; attempt++) { | |
| 319 | const level = levelOf(nameOf(seed), generateGrid(at, size)); | |
| 320 | last = flaws(level); | |
| 321 | if (last.length === 0) return level; | |
| 322 | at = (Math.imul(at, 1664525) + 1013904223) >>> 0; | |
| 323 | } | |
| 324 | throw new Error(`no map came out of seed ${seed >>> 0} in ${ATTEMPTS} attempts: ${last.join("; ")}`); | |
| 325 | } |
src/client/main.test.ts +31
| @@ -17,6 +17,8 @@ | ||
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | 19 | import { expect, test } from "bun:test"; |
| 20 | import { nameOf, seedFrom } from "./generate.ts"; | |
| 21 | import type { Level } from "./level.ts"; | |
| 20 | 22 | import { boot, gameMeshes, type Frame } from "./main.ts"; |
| 21 | 23 | import { LAYERS, materialTextures } from "./materials.ts"; |
| 22 | 24 | import { WEAPONS } from "./weapons.ts"; |
| @@ -438,3 +440,32 @@ test("every mesh name a scene of this game can place is handed in at constructio | ||
| 438 | 440 | expect(name).toMatch(/^[a-z]+$/); |
| 439 | 441 | } |
| 440 | 442 | }); |
| 443 | ||
| 444 | test("the map field chooses the map, and a generated one costs one mesh", () => { | |
| 445 | const stage = painted(start(fakeStage())); | |
| 446 | // de_forge is what the meshes were built from, so nothing is swapped for it. | |
| 447 | stage.by("play").fire("click"); | |
| 448 | expect(worldOf(stage)?.level.name).toBe("de_forge"); | |
| 449 | expect(stage.replaced).toEqual([]); | |
| 450 | expect(stage.by("onmap").textContent).toBe("Playing de_forge."); | |
| 451 | ||
| 452 | // Generated: the level is carved from this match's id, and the renderer is | |
| 453 | // handed that level's geometry under the name it already holds — one mesh, | |
| 454 | // not a second renderer. | |
| 455 | stage.by("quit").fire("click"); | |
| 456 | (stage.by("map") as unknown as { value: string }).value = "generated"; | |
| 457 | stage.by("play").fire("click"); | |
| 458 | const level = worldOf(stage)?.level; | |
| 459 | expect(level?.name).toBe(nameOf(seedFrom(stage.ids[stage.ids.length - 1] as string))); | |
| 460 | expect(stage.replaced.map((r) => r.name)).toEqual(["level"]); | |
| 461 | expect(stage.replaced[0]?.mesh.index.length).toBe(gameMeshes(level as Level).level?.index.length); | |
| 462 | expect(stage.by("onmap").textContent).toBe(`Playing ${level?.name}.`); | |
| 463 | ||
| 464 | // And a value nobody rendered is de_forge, like an unknown anything here. | |
| 465 | stage.by("quit").fire("click"); | |
| 466 | (stage.by("map") as unknown as { value: string }).value = "de_moon"; | |
| 467 | stage.by("play").fire("click"); | |
| 468 | expect(worldOf(stage)?.level.name).toBe("de_forge"); | |
| 469 | // The geometry went back to de_forge's with it: two swaps, both of the level. | |
| 470 | expect(stage.replaced.map((r) => r.name)).toEqual(["level", "level"]); | |
| 471 | }); |
src/client/main.ts +24 −3
| @@ -28,6 +28,7 @@ import { | ||
| 28 | 28 | attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer, painter, |
| 29 | 29 | type Button, type Camera, type Listens, type Mesh, |
| 30 | 30 | } from "../engine/index.ts"; |
| 31 | import { generateLevel, seedFrom } from "./generate.ts"; | |
| 31 | 32 | import { DE_FORGE } from "./map.ts"; |
| 32 | 33 | import type { Level } from "./level.ts"; |
| 33 | 34 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; |
| @@ -105,8 +106,15 @@ export type Store = { | ||
| 105 | 106 | /** The two things this file reads off a response to POST /api/match. */ |
| 106 | 107 | export type Reply = { readonly ok: boolean; text(): Promise<string> }; |
| 107 | 108 | |
| 108 | /** All this frame asks of a renderer: one draw call, of one scene. */ | |
| 109 | export type Draws = { draw(scene: Scene, dt: number): void }; | |
| 109 | /** | |
| 110 | * All this frame asks of a renderer: one draw call of one scene, and one mesh | |
| 111 | * swapped under its own name — which is what a match on another map costs, | |
| 112 | * because the geometry is the only thing about the renderer that changes. | |
| 113 | */ | |
| 114 | export type Draws = { | |
| 115 | draw(scene: Scene, dt: number): void; | |
| 116 | replace(name: string, mesh: Mesh): void; | |
| 117 | }; | |
| 110 | 118 | |
| 111 | 119 | /** |
| 112 | 120 | * What a browser that can draw hands back: the renderer, built from the atlas |
| @@ -161,6 +169,8 @@ export function boot(host: Host): Frame { | ||
| 161 | 169 | const lengthField = by<Field>("length"); |
| 162 | 170 | const sideField = by<Field>("side"); |
| 163 | 171 | const sensField = by<Field>("sens"); |
| 172 | const mapField = by<Field>("map"); | |
| 173 | const onMap = by("onmap"); | |
| 164 | 174 | const prep = by("prep"); |
| 165 | 175 | const play = by<Panel & { disabled: boolean }>("play"); |
| 166 | 176 | |
| @@ -218,6 +228,9 @@ export function boot(host: Host): Frame { | ||
| 218 | 228 | const frameScene = createScene(); |
| 219 | 229 | const camera: Camera = { pos: [0, 0, 0], yaw: 0, pitch: 0, fov: 90 }; |
| 220 | 230 | let matchId = ""; |
| 231 | // The map the renderer is holding the geometry of. It starts as the one the | |
| 232 | // meshes were built from, and a match on another map swaps that one mesh. | |
| 233 | let shown: Level = DE_FORGE; | |
| 221 | 234 | let running = false; |
| 222 | 235 | let lastSound = 0; |
| 223 | 236 | let frameAverage = 16.7; |
| @@ -323,8 +336,16 @@ export function boot(host: Host): Frame { | ||
| 323 | 336 | const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT; |
| 324 | 337 | const team = sideField.value === "t" ? "t" : "ct"; |
| 325 | 338 | const name = nameField.value.trim().slice(0, 24) || "you"; |
| 326 | world = createWorld(DE_FORGE, rules, name, team, 5); | |
| 327 | 339 | matchId = host.randomUUID(); |
| 340 | // A map a person chose: de_forge, or one carved out of this match's id. An | |
| 341 | // unknown value in the field is de_forge, like an unknown anything here. | |
| 342 | const level = mapField.value === "generated" ? generateLevel(seedFrom(matchId)) : DE_FORGE; | |
| 343 | if (level !== shown) { | |
| 344 | renderer.replace("level", meshFor("level", level)); | |
| 345 | shown = level; | |
| 346 | } | |
| 347 | onMap.textContent = `Playing ${level.name}.`; | |
| 348 | world = createWorld(level, rules, name, team, 5); | |
| 328 | 349 | posted = -1; |
| 329 | 350 | loop.reset(); |
| 330 | 351 | controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12); |
src/client/map.ts +15 −85
| @@ -7,11 +7,14 @@ | ||
| 7 | 7 | * Layout is dust-shaped and original: two sites, a mid, a long right-hand |
| 8 | 8 | * corridor and a left-hand tunnel, T at the bottom of the grid, CT at the top. |
| 9 | 9 | * |
| 10 | * This file is one map and nothing else. What a level *is* — the grid, the | |
| 11 | * boxes, the spawns, the sites and the questions asked of them — is | |
| 12 | * `src/client/level.ts`, and `DE_FORGE` below is one value of that type, built | |
| 13 | * once here and handed to `createWorld`. Nobody imports this map to find out | |
| 14 | * where they are standing; they read the level off the world they are in. | |
| 10 | * This file is one map and nothing else: the areas, the crates and nothing | |
| 11 | * about what a letter means. What the letters are and what a page of them | |
| 12 | * becomes is `src/client/cells.ts`, which `generate.ts` writes its maps in too; | |
| 13 | * what a level *is* — the grid, the boxes, the spawns, the sites and the | |
| 14 | * questions asked of them — is `src/client/level.ts`, and `DE_FORGE` below is | |
| 15 | * one value of that type, built once here and handed to `createWorld`. Nobody | |
| 16 | * imports this map to find out where they are standing; they read the level off | |
| 17 | * the world they are in. | |
| 15 | 18 | * |
| 16 | 19 | * Everything in here is Counter-Strike. The grid itself — the cell arithmetic, |
| 17 | 20 | * the greedy meshing and the A* — is `src/engine/grid.ts`, which knows nothing |
| @@ -19,15 +22,12 @@ | ||
| 19 | 22 | */ |
| 20 | 23 | |
| 21 | 24 | import * as g from "../engine/index.ts"; |
| 22 | import type { Box, Vec3 } from "../engine/index.ts"; | |
| 25 | import { CELL, levelOf, type Cell } from "./cells.ts"; | |
| 23 | 26 | import type { Level } from "./level.ts"; |
| 24 | 27 | |
| 25 | export const CELL = 128; | |
| 28 | /** De_forge is 44 cells on a side: 5,632 Source units, a little under dust's. */ | |
| 26 | 29 | export const GRID_W = 44; |
| 27 | 30 | export const GRID_H = 44; |
| 28 | export const WALL_HEIGHT = 384; | |
| 29 | ||
| 30 | export type Cell = "#" | "." | "x" | "X" | "A" | "B" | "t" | "c"; | |
| 31 | 31 | |
| 32 | 32 | /** [col0, row0, col1, row1] inclusive, and what the cells become. */ |
| 33 | 33 | type Area = { name: string; rect: [number, number, number, number]; fill: Cell }; |
| @@ -73,81 +73,11 @@ function build(): g.Grid<Cell> { | ||
| 73 | 73 | return { width: GRID_W, height: GRID_H, cell: CELL, cells }; |
| 74 | 74 | } |
| 75 | 75 | |
| 76 | const grid = build(); | |
| 77 | ||
| 78 | /** Off the edge of de_forge is rock, which is what the border is made of anyway. */ | |
| 79 | const cellAt = (col: number, row: number): Cell => g.cellAt(grid, col, row) ?? "#"; | |
| 76 | const grid: g.Grid<Cell> = build(); | |
| 80 | 77 | |
| 81 | 78 | /** |
| 82 | * Solid to a walking bot: walls and both kinds of crate. This predicate is the | |
| 83 | * whole of what the engine's search knows about de_forge, and it is the one | |
| 84 | * thing the level carries that is a function rather than a number. | |
| 79 | * de_forge, whole: the map this game was built on, as one value of `Level`. | |
| 80 | * The letters are `cells.ts`'s and so is the reading of them — this file is the | |
| 81 | * page they are written on and nothing else. | |
| 85 | 82 | */ |
| 86 | const passable = (c: string): boolean => c !== "#" && c !== "x" && c !== "X"; | |
| 87 | ||
| 88 | const worldOf = (col: number, row: number, z = 0): Vec3 => g.worldOf(grid, col, row, z); | |
| 89 | ||
| 90 | // --- geometry ------------------------------------------------------------- | |
| 91 | ||
| 92 | export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const; | |
| 93 | ||
| 94 | const mergeSolid = (match: (c: Cell) => boolean, z0: number, z1: number, mat: number): Box[] => | |
| 95 | g.mergeSolid(grid, match, z0, z1, mat); | |
| 96 | ||
| 97 | function siteSlab(fill: Cell): Box { | |
| 98 | let c0 = GRID_W, r0 = GRID_H, c1 = 0, r1 = 0; | |
| 99 | for (let r = 0; r < GRID_H; r++) | |
| 100 | for (let c = 0; c < GRID_W; c++) | |
| 101 | if (cellAt(c, r) === fill) { | |
| 102 | c0 = Math.min(c0, c); c1 = Math.max(c1, c); | |
| 103 | r0 = Math.min(r0, r); r1 = Math.max(r1, r); | |
| 104 | } | |
| 105 | const a = worldOf(c0, r0), b = worldOf(c1, r1); | |
| 106 | return { | |
| 107 | min: [a[0] - CELL / 2, b[1] - CELL / 2, 0], | |
| 108 | max: [b[0] + CELL / 2, a[1] + CELL / 2, 2], | |
| 109 | mat: MAT.SITE, | |
| 110 | }; | |
| 111 | } | |
| 112 | ||
| 113 | const half = (GRID_W / 2) * CELL; | |
| 114 | const floor: Box = { min: [-half, -half, -32], max: [half, half, 0], mat: MAT.FLOOR }; | |
| 115 | ||
| 116 | /** Everything solid, in one list: the renderer draws it and bullets stop in it. */ | |
| 117 | const boxes: Box[] = [ | |
| 118 | floor, | |
| 119 | siteSlab("A"), | |
| 120 | siteSlab("B"), | |
| 121 | ...mergeSolid((c) => c === "#", 0, WALL_HEIGHT, MAT.WALL), | |
| 122 | ...mergeSolid((c) => c === "x", 0, 64, MAT.CRATE), | |
| 123 | ...mergeSolid((c) => c === "X", 0, 128, MAT.CRATE), | |
| 124 | ]; | |
| 125 | ||
| 126 | // --- places --------------------------------------------------------------- | |
| 127 | ||
| 128 | function cellsOf(fill: Cell): Vec3[] { | |
| 129 | const out: Vec3[] = []; | |
| 130 | for (let r = 0; r < GRID_H; r++) | |
| 131 | for (let c = 0; c < GRID_W; c++) if (cellAt(c, r) === fill) out.push(worldOf(c, r)); | |
| 132 | return out; | |
| 133 | } | |
| 134 | ||
| 135 | function centre(list: Vec3[]): Vec3 { | |
| 136 | const s = list.reduce((a, p) => [a[0] + p[0], a[1] + p[1], 0] as Vec3, [0, 0, 0] as Vec3); | |
| 137 | return [s[0] / list.length, s[1] / list.length, 0]; | |
| 138 | } | |
| 139 | ||
| 140 | const siteCells = { A: cellsOf("A"), B: cellsOf("B") }; | |
| 141 | ||
| 142 | /** de_forge, whole: the one map this game ships, as one value of `Level`. */ | |
| 143 | export const DE_FORGE: Level = { | |
| 144 | name: "de_forge", | |
| 145 | grid, | |
| 146 | passable, | |
| 147 | wallHeight: WALL_HEIGHT, | |
| 148 | materials: MAT, | |
| 149 | boxes, | |
| 150 | spawns: { t: cellsOf("t"), ct: cellsOf("c") }, | |
| 151 | sites: { A: centre(siteCells.A), B: centre(siteCells.B) }, | |
| 152 | siteCells: { A: "A", B: "B" }, | |
| 153 | }; | |
| 83 | export const DE_FORGE: Level = levelOf("de_forge", grid); |
src/client/stage.test-helper.ts +10 −3
| @@ -30,6 +30,7 @@ | ||
| 30 | 30 | |
| 31 | 31 | import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakePaint, type FakeWindow } from "../engine/index.ts"; |
| 32 | 32 | import type { Audio } from "./audio.ts"; |
| 33 | import type { Mesh } from "../engine/index.ts"; | |
| 33 | 34 | import type { BuildRenderer, Draws, Host, Stage } from "./main.ts"; |
| 34 | 35 | import type { Scene } from "./scene.ts"; |
| 35 | 36 | |
| @@ -62,7 +63,7 @@ const GL_VOIDS = [ | ||
| 62 | 63 | "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData", "bufferSubData", |
| 63 | 64 | "enableVertexAttribArray", "vertexAttribPointer", "enable", "cullFace", "viewport", "clearColor", |
| 64 | 65 | "clear", "useProgram", "activeTexture", "uniform1i", "uniform3f", "uniform3fv", |
| 65 | "uniformMatrix4fv", "drawElements", "drawArrays", | |
| 66 | "uniformMatrix4fv", "drawElements", "drawArrays", "deleteVertexArray", "deleteBuffer", | |
| 66 | 67 | ]; |
| 67 | 68 | |
| 68 | 69 | export function glRecorder(): GlRecorder { |
| @@ -122,6 +123,8 @@ export type FakeStage = { | ||
| 122 | 123 | posts: Post[]; |
| 123 | 124 | answer(post: Post): Promise<{ ok: boolean; text(): Promise<string> }>; |
| 124 | 125 | draws: Drawn[]; |
| 126 | /** Every mesh swapped under a name after construction: a match on another map. */ | |
| 127 | replaced: { name: string; mesh: Mesh }[]; | |
| 125 | 128 | /** |
| 126 | 129 | * The atlas bytes each renderer was built from, newest last. The frame paints |
| 127 | 130 | * its atlas a slice at a time and builds the renderer on the last one, so |
| @@ -150,7 +153,7 @@ export type FakeStage = { | ||
| 150 | 153 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 151 | 154 | * served page against this same list rather than a second copy of it. |
| 152 | 155 | */ |
| 153 | export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "play", "prep", "resume", "quit", "fps", "full"]; | |
| 156 | export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "map", "onmap", "play", "prep", "resume", "quit", "fps", "full"]; | |
| 154 | 157 | |
| 155 | 158 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 156 | 159 | const HIDDEN = ["pause", "buy", "touch", "full"]; |
| @@ -192,6 +195,7 @@ export function fakeStage( | ||
| 192 | 195 | |
| 193 | 196 | const posts: Post[] = []; |
| 194 | 197 | const draws: Drawn[] = []; |
| 198 | const replaced: { name: string; mesh: Mesh }[] = []; | |
| 195 | 199 | const atlases: Uint8Array[] = []; |
| 196 | 200 | const sounds: string[] = []; |
| 197 | 201 | const store = new Map<string, string>(); |
| @@ -209,6 +213,9 @@ export function fakeStage( | ||
| 209 | 213 | draw(scene, dt) { |
| 210 | 214 | draws.push({ scene, dt }); |
| 211 | 215 | }, |
| 216 | replace(name, mesh) { | |
| 217 | replaced.push({ name, mesh }); | |
| 218 | }, | |
| 212 | 219 | }; |
| 213 | 220 | |
| 214 | 221 | const stub: FakeStage = { |
| @@ -246,7 +253,7 @@ export function fakeStage( | ||
| 246 | 253 | hud, |
| 247 | 254 | buttons: page.buttons, |
| 248 | 255 | frames: page.frames, |
| 249 | posts, draws, atlases, sounds, store, ids, | |
| 256 | posts, draws, replaced, atlases, sounds, store, ids, | |
| 250 | 257 | by: page.by, |
| 251 | 258 | step: page.step, |
| 252 | 259 | lock(on) { |
src/engine/GUIDE.md +9 −7
| @@ -224,13 +224,15 @@ exactly that reason. | ||
| 224 | 224 | A `Grid` of cells of any type — the cell type is your alphabet, not the |
| 225 | 225 | engine's. `inside` and `cellAt` read it, `worldOf`, `colOf` and `rowOf` are the |
| 226 | 226 | cell-to-world arithmetic, `mergeSolid` greedily merges runs of matching cells |
| 227 | into as few boxes as the shape allows, and `findPath` is A* with no | |
| 228 | corner-cutting. Which cells are solid and which are walkable arrive as | |
| 229 | predicates, so the engine never learns what a `"#"` means. The example has no | |
| 230 | grid — one box is not a map — so read `src/client/level.ts`, which is what a | |
| 231 | level is to the shooter (a grid, a predicate, boxes, spawns and sites, as one | |
| 232 | value), and `src/client/map.ts`, which lays de_forge's cell alphabet over this | |
| 233 | and is one of them. | |
| 227 | into as few boxes as the shape allows, `findPath` is A* with no | |
| 228 | corner-cutting, and `region` flags every cell reachable from one you name, | |
| 229 | taking the same steps the search does — the question a generated layout asks of | |
| 230 | itself before it hands anybody a map. Which cells are solid and which are | |
| 231 | walkable arrive as predicates, so the engine never learns what a `"#"` means. | |
| 232 | The example has no grid — one box is not a map — so read `src/client/level.ts`, | |
| 233 | which is what a level is to the shooter (a grid, a predicate, boxes, spawns and | |
| 234 | sites, as one value), and `src/client/map.ts`, which lays de_forge's cell | |
| 235 | alphabet over this and is one of them. | |
| 234 | 236 | |
| 235 | 237 | ### mesh |
| 236 | 238 |
src/engine/boundary.test.ts +1 −1
| @@ -311,7 +311,7 @@ export const SURFACE = [ | ||
| 311 | 311 | "length", "length2d", "lerp", |
| 312 | 312 | "logitCount", "mat4", "mergeSolid", "modelMatrix", "move", "mul", "multiply", "newActivations", |
| 313 | 313 | "nextInstance", "noise", "normalize", "overlay", "pack", "paintAtlas", "painter", |
| 314 | "paramCount", "perspective", "pushLine", "quadMesh", "quantise", "rad", "rayBox", | |
| 314 | "paramCount", "perspective", "pushLine", "quadMesh", "quantise", "rad", "rayBox", "region", | |
| 315 | 315 | "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength", "softmax", "stuck", "sub", |
| 316 | 316 | "tensorsOf", "traceWorld", "unpack", "update", "v3", "viewMatrix", "visible", "worldOf", |
| 317 | 317 | "zeroGrads", "zeroPolicy", |
src/engine/grid.test.ts +51 −1
| @@ -5,7 +5,9 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { expect, test } from "bun:test"; |
| 8 | import { cellAt, colOf, findPath, inside, mergeSolid, rowOf, worldOf, type Grid } from "./grid.ts"; | |
| 8 | import { | |
| 9 | cellAt, colOf, findPath, inside, mergeSolid, region, rowOf, worldOf, type Grid, | |
| 10 | } from "./grid.ts"; | |
| 9 | 11 | import type { Vec3 } from "./math.ts"; |
| 10 | 12 | |
| 11 | 13 | /** A grid from rows of single characters, `cell` world units on a side. */ |
| @@ -167,3 +169,51 @@ test("a cell is asked about at most once per search", () => { | ||
| 167 | 169 | expect(calls).toBeGreaterThan(0); |
| 168 | 170 | expect(calls).toBeLessThanOrEqual(g.width * g.height); |
| 169 | 171 | }); |
| 172 | ||
| 173 | test("a region is every cell one cell can walk to, and the search agrees with it", () => { | |
| 174 | // col 01234 | |
| 175 | // row0 ..#.. | |
| 176 | // row1 ..#.. the east half is walled off, except nothing joins them | |
| 177 | // row2 ..#.. | |
| 178 | // row3 ..#.. | |
| 179 | // row4 ..#.. | |
| 180 | const g = draw(["..#..", "..#..", "..#..", "..#..", "..#.."]); | |
| 181 | const west = region(g, open, 0, 0); | |
| 182 | expect([...west].filter((f) => f === 1).length).toBe(10); | |
| 183 | // Flagged is reachable and reachable is flagged: the two answers are the same | |
| 184 | // answer, which is why a generator may use the cheap one. | |
| 185 | for (let r = 0; r < g.height; r++) | |
| 186 | for (let c = 0; c < g.width; c++) { | |
| 187 | const reached = findPath(g, open, worldOf(g, 0, 0), worldOf(g, c, r)).length > 0; | |
| 188 | expect(west[r * g.width + c] === 1).toBe(reached || (c === 0 && r === 0)); | |
| 189 | } | |
| 190 | }); | |
| 191 | ||
| 192 | test("a region takes the diagonal only where the search would", () => { | |
| 193 | // Two floors touching at a corner: neither can reach the other, so each is a | |
| 194 | // region of one. | |
| 195 | const shut = draw([".#", "#."]); | |
| 196 | expect([...region(shut, open, 0, 0)]).toEqual([1, 0, 0, 0]); | |
| 197 | // Open one of the two cells beside the seam and they are one place. | |
| 198 | const half = draw([".#", ".."]); | |
| 199 | expect([...region(half, open, 0, 0)]).toEqual([1, 0, 1, 1]); | |
| 200 | }); | |
| 201 | ||
| 202 | test("a region starting nowhere reaches nothing", () => { | |
| 203 | const g = draw([".#", ".."]); | |
| 204 | expect([...region(g, open, 1, 0)]).toEqual([0, 0, 0, 0]); // in the rock | |
| 205 | expect([...region(g, open, -1, 0)]).toEqual([0, 0, 0, 0]); // off the edge | |
| 206 | expect([...region(g, open, 0, 9)]).toEqual([0, 0, 0, 0]); | |
| 207 | expect([...region(g, () => false, 0, 0)]).toEqual([0, 0, 0, 0]); | |
| 208 | }); | |
| 209 | ||
| 210 | test("a region asks about a cell at most once", () => { | |
| 211 | const g = draw([".....", "..#..", "..#..", "..#..", "....."]); | |
| 212 | let calls = 0; | |
| 213 | const counted = (c: string): boolean => { | |
| 214 | calls++; | |
| 215 | return open(c); | |
| 216 | }; | |
| 217 | expect([...region(g, counted, 0, 0)].filter((f) => f === 1).length).toBe(22); | |
| 218 | expect(calls).toBeLessThanOrEqual(g.width * g.height + 1); | |
| 219 | }); |
src/engine/grid.ts +52
| @@ -97,6 +97,58 @@ export function mergeSolid<T>( | ||
| 97 | 97 | return out; |
| 98 | 98 | } |
| 99 | 99 | |
| 100 | /** | |
| 101 | * Every cell you can walk to from one you stand in, by the same steps | |
| 102 | * `findPath` takes: eight ways, and a diagonal only when both cells beside it | |
| 103 | * are passable too. One flag per cell, `1` for reachable and `0` for not, in | |
| 104 | * one sweep — the question a map asks of itself ("is all of this one place?"), | |
| 105 | * which A* answers a route at a time and this answers once. | |
| 106 | * | |
| 107 | * A start cell outside the grid, or one that is not passable itself, reaches | |
| 108 | * nothing and comes back all zeros. `passable` is asked about each cell at most | |
| 109 | * once, like it is in the search below. | |
| 110 | */ | |
| 111 | export function region<T>( | |
| 112 | g: Grid<T>, | |
| 113 | passable: (cell: T) => boolean, | |
| 114 | col: number, | |
| 115 | row: number, | |
| 116 | ): Uint8Array { | |
| 117 | const { width, height } = g; | |
| 118 | const seen = new Uint8Array(width * height); | |
| 119 | if (!inside(g, col, row) || !passable(g.cells[row * width + col] as T)) return seen; | |
| 120 | // -1 not asked yet, 0 no, 1 yes. | |
| 121 | const answer = new Int8Array(seen.length).fill(-1); | |
| 122 | const passableAt = (c: number, r: number): boolean => { | |
| 123 | if (c < 0 || r < 0 || c >= width || r >= height) return false; | |
| 124 | const i = r * width + c; | |
| 125 | const known = answer[i] as number; | |
| 126 | if (known >= 0) return known === 1; | |
| 127 | const ok = passable(g.cells[i] as T) ? 1 : 0; | |
| 128 | answer[i] = ok; | |
| 129 | return ok === 1; | |
| 130 | }; | |
| 131 | const stack: number[] = [row * width + col]; | |
| 132 | seen[row * width + col] = 1; | |
| 133 | while (stack.length > 0) { | |
| 134 | const cur = stack.pop() as number; | |
| 135 | const cc = cur % width, cr = Math.floor(cur / width); | |
| 136 | for (let dr = -1; dr <= 1; dr++) { | |
| 137 | for (let dc = -1; dc <= 1; dc++) { | |
| 138 | if (dc === 0 && dr === 0) continue; | |
| 139 | const nc = cc + dc, nr = cr + dr; | |
| 140 | if (!passableAt(nc, nr)) continue; | |
| 141 | if (dc !== 0 && dr !== 0 && (!passableAt(cc + dc, cr) || !passableAt(cc, cr + dr))) continue; | |
| 142 | const ni = nr * width + nc; | |
| 143 | if (seen[ni] === 1) continue; | |
| 144 | seen[ni] = 1; | |
| 145 | stack.push(ni); | |
| 146 | } | |
| 147 | } | |
| 148 | } | |
| 149 | return seen; | |
| 150 | } | |
| 151 | ||
| 100 | 152 | /** |
| 101 | 153 | * A* over the passable cells, eight ways, no corner-cutting: a diagonal is only |
| 102 | 154 | * taken when both cells beside it are passable too, so nobody walks through the |
src/engine/index.ts +2 −2
| @@ -31,8 +31,8 @@ export type { Box, Hit } from "./trace.ts"; | ||
| 31 | 31 | export { accelerate, airAccelerate, eyeHeight, groundUnder, hullHeight, move, stuck } from "./physics.ts"; |
| 32 | 32 | export type { Body, Hull, MoveTuning, Wish } from "./physics.ts"; |
| 33 | 33 | |
| 34 | /** A grid of cells of any type: cell-to-world arithmetic, greedy meshing, A*. */ | |
| 35 | export { cellAt, colOf, findPath, inside, mergeSolid, rowOf, worldOf } from "./grid.ts"; | |
| 34 | /** A grid of cells of any type: cell-to-world arithmetic, greedy meshing, A*, and what one cell can reach. */ | |
| 35 | export { cellAt, colOf, findPath, inside, mergeSolid, region, rowOf, worldOf } from "./grid.ts"; | |
| 36 | 36 | export type { Grid } from "./grid.ts"; |
| 37 | 37 | |
| 38 | 38 | /** A box builder: positions, normals, uvs and a layer, finished into a `Mesh`. */ |
src/engine/renderer.test.ts +21 −1
| @@ -62,7 +62,8 @@ function stubGl(): StubGl { | ||
| 62 | 62 | "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData", |
| 63 | 63 | "bufferSubData", "enableVertexAttribArray", "vertexAttribPointer", "enable", "cullFace", |
| 64 | 64 | "viewport", "clearColor", "clear", "useProgram", "activeTexture", "uniform1i", "uniform3f", |
| 65 | "uniform3fv", "uniformMatrix4fv", "drawElements", "drawArrays", | |
| 65 | "uniform3fv", "uniformMatrix4fv", "drawElements", "drawArrays", "deleteVertexArray", | |
| 66 | "deleteBuffer", | |
| 66 | 67 | ]) gl[name] = note(name); |
| 67 | 68 | return gl; |
| 68 | 69 | } |
| @@ -175,6 +176,25 @@ test("construction uploads one buffer set per mesh handed in, and nothing later" | ||
| 175 | 176 | expect(frameCalls(two, "texImage3D")).toEqual([]); |
| 176 | 177 | }); |
| 177 | 178 | |
| 179 | test("a mesh is replaced whole: the new one uploaded, the old one given back", () => { | |
| 180 | // What a second map costs: the level's geometry, swapped under its own name, | |
| 181 | // with nothing else about the renderer rebuilt and nothing left on the card. | |
| 182 | const two = renderer({ level: box(), player: box(6) }); | |
| 183 | const madeBefore = of(two.gl, "createBuffer").length; | |
| 184 | two.r.replace("level", box(4)); | |
| 185 | expect(of(two.gl, "createBuffer").length).toBe(madeBefore + 5); | |
| 186 | expect(of(two.gl, "createVertexArray").length).toBe(2 + 1 + 1); | |
| 187 | // Five buffers and one vertex array back to the driver: exactly one mesh. | |
| 188 | expect(of(two.gl, "deleteBuffer").length).toBe(5); | |
| 189 | expect(of(two.gl, "deleteVertexArray").length).toBe(1); | |
| 190 | // And the frame draws the new one: four boxes of indices, not one. | |
| 191 | two.r.draw(scene("level"), 1 / 60); | |
| 192 | const drawn = frameCalls(two, "drawElements"); | |
| 193 | expect(drawn[0]?.[2]).toBe(box(4).index.length); | |
| 194 | // A name it was never handed is a bug, here as in a frame. | |
| 195 | expect(() => two.r.replace("nobody", box())).toThrow(/no mesh named "nobody"/); | |
| 196 | }); | |
| 197 | ||
| 178 | 198 | test("two instances of one mesh bind that mesh's vertex array once", () => { |
| 179 | 199 | const r = renderer(); |
| 180 | 200 | r.r.draw(scene("level"), 1 / 60); |
src/engine/renderer.ts +25 −3
| @@ -121,10 +121,18 @@ export type RendererAssets = { | ||
| 121 | 121 | export type Renderer = { |
| 122 | 122 | draw(scene: Scene, dt: number): void; |
| 123 | 123 | resize(): void; |
| 124 | /** | |
| 125 | * One mesh, uploaded again under a name this renderer already holds, and the | |
| 126 | * old one handed back to the driver. A game that plays a second map needs the | |
| 127 | * geometry of that map and nothing else about the renderer changed, and | |
| 128 | * building a second renderer over the same canvas would leak the first. A | |
| 129 | * name it was never given is a bug, like a frame that names one. | |
| 130 | */ | |
| 131 | replace(name: string, mesh: Mesh): void; | |
| 124 | 132 | readonly gl: WebGL2RenderingContext; |
| 125 | 133 | }; |
| 126 | 134 | |
| 127 | type Buffers = { vao: WebGLVertexArrayObject; count: number }; | |
| 135 | type Buffers = { vao: WebGLVertexArrayObject; count: number; buffers: WebGLBuffer[] }; | |
| 128 | 136 | |
| 129 | 137 | /** The bytes an atlas of this shape must be: RGBA8, every layer, tightly packed. */ |
| 130 | 138 | const atlasBytes = (size: number, layers: number): number => size * size * 4 * layers; |
| @@ -157,8 +165,13 @@ function link(gl: WebGL2RenderingContext, vs: string, fs: string): WebGLProgram | ||
| 157 | 165 | function upload(gl: WebGL2RenderingContext, mesh: Mesh): Buffers { |
| 158 | 166 | const vao = gl.createVertexArray() as WebGLVertexArrayObject; |
| 159 | 167 | gl.bindVertexArray(vao); |
| 168 | // Kept so a mesh that is replaced can be given back whole: a vertex array | |
| 169 | // does not own the buffers it points at, and a game that changes map every | |
| 170 | // match would otherwise leave one level's geometry on the card per round. | |
| 171 | const buffers: WebGLBuffer[] = []; | |
| 160 | 172 | const attribute = (data: Float32Array, location: number, size: number): void => { |
| 161 | 173 | const buffer = gl.createBuffer(); |
| 174 | buffers.push(buffer); | |
| 162 | 175 | gl.bindBuffer(gl.ARRAY_BUFFER, buffer); |
| 163 | 176 | gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); |
| 164 | 177 | gl.enableVertexAttribArray(location); |
| @@ -169,10 +182,11 @@ function upload(gl: WebGL2RenderingContext, mesh: Mesh): Buffers { | ||
| 169 | 182 | attribute(mesh.uv, 2, 2); |
| 170 | 183 | attribute(mesh.layer, 3, 1); |
| 171 | 184 | const index = gl.createBuffer(); |
| 185 | buffers.push(index); | |
| 172 | 186 | gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, index); |
| 173 | 187 | gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.index, gl.STATIC_DRAW); |
| 174 | 188 | gl.bindVertexArray(null); |
| 175 | return { vao, count: mesh.index.length }; | |
| 189 | return { vao, count: mesh.index.length, buffers }; | |
| 176 | 190 | } |
| 177 | 191 | |
| 178 | 192 | /** |
| @@ -356,5 +370,13 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets | ||
| 356 | 370 | gl.bindVertexArray(null); |
| 357 | 371 | }; |
| 358 | 372 | |
| 359 | return { draw, resize, gl }; | |
| 373 | /** A mesh swapped for another under the same name, the old one given back. */ | |
| 374 | const replace = (name: string, mesh: Mesh): void => { | |
| 375 | const old = buffersFor(name); | |
| 376 | uploaded.set(name, upload(gl, mesh)); | |
| 377 | gl.deleteVertexArray(old.vao); | |
| 378 | for (const buffer of old.buffers) gl.deleteBuffer(buffer); | |
| 379 | }; | |
| 380 | ||
| 381 | return { draw, resize, replace, gl }; | |
| 360 | 382 | } |
src/games.ts +14 −2
| @@ -163,6 +163,18 @@ export type Game = { | ||
| 163 | 163 | * page is: range 29,400/12,700 → 33,000/14,000 (it had 42 bytes of headroom |
| 164 | 164 | * after #52 and 587 after #88, so this was the raise those two lines said was |
| 165 | 165 | * coming), example 9,700/4,700 → 10,800/5,100. |
| 166 | * #138 gave the shooter maps nobody wrote — `src/client/generate.ts`, a seed in | |
| 167 | * and a `Level` out — and put them on the menu beside de_forge, so this is the | |
| 168 | * first entry on this page that is a feature rather than a move: the app grew | |
| 169 | * 4,604 raw / 2,035 gzipped bytes for the carving, the crates, the gate that | |
| 170 | * reads a finished map for flaws, and the wiring that swaps the level's mesh | |
| 171 | * when a match starts on another layout. It did not fit 75,400/34,900, and the | |
| 172 | * ceiling is raised here to 82,000/38,000 — today's 77,966 raw / 36,059 gzipped | |
| 173 | * plus about five per cent, which is room for an honest feature and not room | |
| 174 | * for a stray import of something large. The range grew 148 raw / 47 gzipped | |
| 175 | * for the engine's side of that swap (`Renderer.replace`, which it never calls) | |
| 176 | * and the example is unchanged to the byte: it ships no renderer at all. | |
| 177 | * Measured 2026-09-20 after #138, on the bundles this branch commits. | |
| 166 | 178 | * The range's numbers here are #121's moving plates and #125's painter at once: |
| 167 | 179 | * both landed a range bundle and neither swept the other's, so static/ held two |
| 168 | 180 | * and every reader of it failed until a plain `bun run build` resolved them. |
| @@ -173,9 +185,9 @@ export const GAMES: readonly Game[] = [ | ||
| 173 | 185 | href: "/", |
| 174 | 186 | label: "cs", |
| 175 | 187 | entry: "src/client/main.ts", |
| 176 | ceiling: { raw: 75_400, gzip: 34_900 }, | |
| 188 | ceiling: { raw: 82_000, gzip: 38_000 }, | |
| 177 | 189 | blurb: |
| 178 | "a defusal shooter in the browser, with bots that learned to aim, bomb rounds, an economy and the leaderboard this server keeps.", | |
| 190 | "a defusal shooter in the browser, with bots that learned to aim, bomb rounds, an economy, maps carved from a seed and the leaderboard this server keeps.", | |
| 179 | 191 | }, |
| 180 | 192 | { |
| 181 | 193 | name: "range", |
src/pages/game.ts +6 −1
| @@ -69,7 +69,8 @@ const SIGN_IN = "/__forge/signin?to=%2F"; | ||
| 69 | 69 | */ |
| 70 | 70 | const menu = (visitor: Who): string => ` |
| 71 | 71 | <h2>Defuse or detonate</h2> |
| 72 | <p>Five against five on <b>de_forge</b>. Bomb rounds, CS movement and economy, bots that push the sites.</p> | |
| 72 | <p>Five against five on <b>de_forge</b>, or on a map generated for the match. Bomb rounds, | |
| 73 | CS movement and economy, bots that push the sites.</p> | |
| 73 | 74 | <p class="hint">${visitor.role === "anon" |
| 74 | 75 | ? `You are playing as a guest, so the leaderboard lists your score as <b>a guest's</b>. |
| 75 | 76 | <a href="${SIGN_IN}" rel="noreferrer">Sign in to the forge</a> to play under your own name.` |
| @@ -80,9 +81,13 @@ const menu = (visitor: Who): string => ` | ||
| 80 | 81 | }></label> |
| 81 | 82 | <label>Match<select id="length"><option value="short">Short — first to 5</option><option value="long">Competitive — first to 13</option></select></label> |
| 82 | 83 | <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label> |
| 84 | <label>Map<select id="map"><option value="de_forge">de_forge</option><option value="generated">generated — a new one every match</option></select></label> | |
| 83 | 85 | <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label> |
| 84 | 86 | </div> |
| 85 | 87 | <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div> |
| 88 | <p class="hint" id="onmap">A generated map is carved from the match id: two bombsites, two spawns | |
| 89 | and cover, and the same map for both sides — it is the layout turned half a circle, so the walk | |
| 90 | to your site is the walk to theirs.</p> | |
| 86 | 91 | <p class="hint" id="prep">Preparing textures…</p> |
| 87 | 92 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 88 | 93 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy · |
static/app.cc09e8f5e8.js deleted −58
Diff not shown — a line 57,588 characters wide (limit 2,000). −58
View filestatic/app.ec73e969a1.js added +58
Diff not shown — a line 61,852 characters wide (limit 2,000). +58
View filestatic/example.6352072ec9.js → static/example.f6197b684a.js renamed +1 −1
Diff not shown — a line 9,725 characters wide (limit 2,000). +1 −1
View filestatic/range.e985d7477a.js deleted −58
Diff not shown — a line 17,423 characters wide (limit 2,000). −58
View filestatic/range.fb4863848d.js added +58
Diff not shown — a line 17,570 characters wide (limit 2,000). +58
View file