Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The grid becomes engine: cells, meshing and A*; de_forge stays the game's #16
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/11-the-grid-becomes-engine-cells-meshing-an → main 1 commit fork
7 files
changed +490
−172
README.md +13 −7
| @@ -22,10 +22,11 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 22 | 22 | a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and |
| 23 | 23 | loss bonuses. The leaderboard is a real SQLite table under `FORGE_APP_DATA`. |
| 24 | 24 | - **Generated, not licensed.** The map (`de_forge`), the textures, the player and |
| 25 | weapon models and every sound are produced in code at start-up, by three | |
| 26 | generic tools in the engine — a box mesh builder, an atlas painter over hashed | |
| 27 | value noise, and a two-voice sound device — driven by this game's recipes in | |
| 28 | `src/client/{models,materials,audio}.ts`. There are no | |
| 25 | weapon models and every sound are produced in code at start-up, by four | |
| 26 | generic tools in the engine — a grid of cells that meshes and paths, a box mesh | |
| 27 | builder, an atlas painter over hashed value noise, and a two-voice sound | |
| 28 | device — driven by this game's recipes in | |
| 29 | `src/client/{map,models,materials,audio}.ts`. There are no | |
| 29 | 30 | asset files and nothing from the real game is used — the numbers follow CS's |
| 30 | 31 | published shape so the habits transfer, and the rest is original. |
| 31 | 32 | - **Learned, and measured.** The bots do not aim by rule. A 32-input, two-layer |
| @@ -54,13 +55,18 @@ the rest. | ||
| 54 | 55 | |
| 55 | 56 | - `src/engine/` — the part a second game could use, and the rule is that it never |
| 56 | 57 | imports the game: `math` (vectors, matrices, angles, a seeded rng), `trace` |
| 57 | (rays against boxes, and the `Box` they are traced against), `mesh` (a box | |
| 58 | (rays against boxes, and the `Box` they are traced against), `grid` (a grid of | |
| 59 | cells of any type, the cell-to-world arithmetic, greedy meshing and A* with no | |
| 60 | corner-cutting — which cells are solid or walkable arrive as predicates, so it | |
| 61 | knows nothing about de_forge's alphabet), `mesh` (a box | |
| 58 | 62 | builder), `texture` (hashed value noise, fbm and an atlas painter) and `sound` |
| 59 | 63 | (a lazy `AudioContext` with noise bursts and tones). `boundary.test.ts` |
| 60 | 64 | reads the imports of every file in here and fails if one of them points at |
| 61 | 65 | `src/client/`, at `src/rl/` or at a package. |
| 62 | - `src/client/` — the game, in modules named for one thing each: `map`, | |
| 63 | `physics`, `weapons`, `combat`, `match` (the rules), `world` (the simulation), | |
| 66 | - `src/client/` — the game, in modules named for one thing each: `map` (de_forge | |
| 67 | itself: its areas, its crates, its cell alphabet and its spawns and sites, laid | |
| 68 | over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules), | |
| 69 | `world` (the simulation), | |
| 64 | 70 | `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`, |
| 65 | 71 | `renderer`, `hud`, `input`, `audio`, `main`. The last five aside, everything is |
| 66 | 72 | headless and tested; `models`, `materials` and `audio` are this game's recipes |
src/client/map.test.ts +31
| @@ -56,6 +56,37 @@ test("greedy meshing collapses the walls into few boxes", () => { | ||
| 56 | 56 | expect(boxes.length).toBeLessThan(300); |
| 57 | 57 | }); |
| 58 | 58 | |
| 59 | test("de_forge is exactly where it was, to the unit", () => { | |
| 60 | // The whole level comes out of one function, so half a cell of drift in the | |
| 61 | // arithmetic moves every wall, every spawn and every bot with it and nothing | |
| 62 | // else in the suite would say so. These numbers are hand-checkable: | |
| 63 | // 44 cells of 128 units is 5632 across, so the floor runs -2816..2816. | |
| 64 | expect(boxes.length).toBe(50); | |
| 65 | expect(boxes[0]).toEqual({ min: [-2816, -2816, -32], max: [2816, 2816, 0], mat: 0 }); | |
| 66 | // The last box is the last uncrossable crate in reading order, at (37, 26): | |
| 67 | // x = (37 - 22 + 0.5) * 128 = 1984, y = (22 - 26 - 0.5) * 128 = -576, and a | |
| 68 | // crate is one cell wide and 128 tall. | |
| 69 | expect(boxes[boxes.length - 1]).toEqual({ | |
| 70 | min: [1920, -640, 0], max: [2048, -512, 128], mat: 2, | |
| 71 | }); | |
| 72 | }); | |
| 73 | ||
| 74 | test("the route from T spawn to A is the same route it was", () => { | |
| 75 | // A number a change to the search moves: 33 waypoints from the first T spawn | |
| 76 | // cell to the centre of A, 4506 units walked against 4142 as the crow flies. | |
| 77 | const path = findPath(spawns.t[0], sites.A); | |
| 78 | expect(spawns.t[0]).toEqual([576, -1984, 0]); | |
| 79 | expect(path.length).toBe(33); | |
| 80 | expect(path[0]).toEqual([704, -1984, 0]); | |
| 81 | expect(path[path.length - 1]).toEqual(sites.A); | |
| 82 | let walked = 0; | |
| 83 | for (let i = 1; i < path.length; i++) | |
| 84 | walked += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); | |
| 85 | expect(walked).toBeCloseTo(4506.04, 1); | |
| 86 | const crow = Math.hypot(sites.A[0] - spawns.t[0][0], sites.A[1] - spawns.t[0][1]); | |
| 87 | expect(crow).toBeCloseTo(4141.87, 1); | |
| 88 | }); | |
| 89 | ||
| 59 | 90 | test("a path from T spawn reaches both sites", () => { |
| 60 | 91 | for (const site of [sites.A, sites.B]) { |
| 61 | 92 | const path = findPath(spawns.t[0], site); |
src/client/map.ts +32 −107
| @@ -1,13 +1,18 @@ | ||
| 1 | 1 | /** |
| 2 | * The map: a grid of 128-unit cells carved out of solid rock by named areas, | |
| 2 | * de_forge: a grid of 128-unit cells carved out of solid rock by named areas, | |
| 3 | 3 | * then merged back into as few boxes as possible. One piece of data feeds |
| 4 | 4 | * everything — what you collide with, what gets drawn, where bots walk, where |
| 5 | 5 | * the bomb can be planted — so the map can never disagree with itself. |
| 6 | 6 | * |
| 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 | * | |
| 10 | * Everything in here is Counter-Strike. The grid itself — the cell arithmetic, | |
| 11 | * the greedy meshing and the A* — is `src/engine/grid.ts`, which knows nothing | |
| 12 | * about this alphabet: what counts as solid or walkable goes in as a predicate. | |
| 9 | 13 | */ |
| 10 | 14 | |
| 15 | import * as g from "../engine/grid.ts"; | |
| 11 | 16 | import type { Vec3 } from "../engine/math.ts"; |
| 12 | 17 | import type { Box } from "../engine/trace.ts"; |
| 13 | 18 | |
| @@ -51,37 +56,36 @@ const CRATES: [number, number, Cell][] = [ | ||
| 51 | 56 | [20, 3, "x"], [25, 3, "x"], [18, 39, "x"], [26, 39, "x"], [28, 15, "x"], |
| 52 | 57 | ]; |
| 53 | 58 | |
| 54 | function build(): Cell[] { | |
| 55 | const g: Cell[] = new Array(GRID_W * GRID_H).fill("#"); | |
| 59 | function build(): g.Grid<Cell> { | |
| 60 | const cells: Cell[] = new Array(GRID_W * GRID_H).fill("#"); | |
| 56 | 61 | for (const area of AREAS) { |
| 57 | 62 | const [c0, r0, c1, r1] = area.rect; |
| 58 | 63 | for (let r = r0; r <= r1; r++) |
| 59 | for (let c = c0; c <= c1; c++) g[r * GRID_W + c] = area.fill; | |
| 64 | for (let c = c0; c <= c1; c++) cells[r * GRID_W + c] = area.fill; | |
| 60 | 65 | } |
| 61 | for (const [c, r, cell] of CRATES) g[r * GRID_W + c] = cell; | |
| 62 | return g; | |
| 66 | for (const [c, r, cell] of CRATES) cells[r * GRID_W + c] = cell; | |
| 67 | return { width: GRID_W, height: GRID_H, cell: CELL, cells }; | |
| 63 | 68 | } |
| 64 | 69 | |
| 65 | export const grid: Cell[] = build(); | |
| 70 | export const grid: g.Grid<Cell> = build(); | |
| 66 | 71 | |
| 67 | export const cellAt = (col: number, row: number): Cell => | |
| 68 | col < 0 || row < 0 || col >= GRID_W || row >= GRID_H ? "#" : (grid[row * GRID_W + col] as Cell); | |
| 72 | /** Off the edge of de_forge is rock, which is what the border is made of anyway. */ | |
| 73 | export const cellAt = (col: number, row: number): Cell => g.cellAt(grid, col, row) ?? "#"; | |
| 69 | 74 | |
| 70 | /** Solid to a walking bot: walls and both kinds of crate. */ | |
| 71 | export const walkable = (col: number, row: number): boolean => { | |
| 72 | const c = cellAt(col, row); | |
| 73 | return c !== "#" && c !== "x" && c !== "X"; | |
| 74 | }; | |
| 75 | /** | |
| 76 | * Solid to a walking bot: walls and both kinds of crate. This predicate is the | |
| 77 | * whole of what the engine's search knows about de_forge. | |
| 78 | */ | |
| 79 | export const passable = (c: Cell): boolean => c !== "#" && c !== "x" && c !== "X"; | |
| 80 | ||
| 81 | /** The same question in grid coordinates, which is how bots and the policy ask it. */ | |
| 82 | export const walkable = (col: number, row: number): boolean => passable(cellAt(col, row)); | |
| 75 | 83 | |
| 76 | 84 | /** Grid coordinates are north-down; the world is Y-north, centred on zero. */ |
| 77 | export const worldOf = (col: number, row: number, z = 0): Vec3 => [ | |
| 78 | (col - GRID_W / 2 + 0.5) * CELL, | |
| 79 | (GRID_H / 2 - row - 0.5) * CELL, | |
| 80 | z, | |
| 81 | ]; | |
| 85 | export const worldOf = (col: number, row: number, z = 0): Vec3 => g.worldOf(grid, col, row, z); | |
| 82 | 86 | |
| 83 | export const colOf = (x: number): number => Math.floor(x / CELL + GRID_W / 2); | |
| 84 | export const rowOf = (y: number): number => Math.floor(GRID_H / 2 - y / CELL); | |
| 87 | export const colOf = (x: number): number => g.colOf(grid, x); | |
| 88 | export const rowOf = (y: number): number => g.rowOf(grid, y); | |
| 85 | 89 | |
| 86 | 90 | // --- geometry ------------------------------------------------------------- |
| 87 | 91 | |
| @@ -90,40 +94,8 @@ export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const; | ||
| 90 | 94 | /** The tracer owns the box; the map hands it out, because that is where it is written. */ |
| 91 | 95 | export type { Box } from "../engine/trace.ts"; |
| 92 | 96 | |
| 93 | /** | |
| 94 | * Greedy meshing: merge runs of identical solid cells across a row, then merge | |
| 95 | * rows below that match the run exactly. A few hundred boxes instead of a few | |
| 96 | * thousand, which is the difference between one buffer and a stutter. | |
| 97 | */ | |
| 98 | function mergeSolid(match: (c: Cell) => boolean, z0: number, z1: number, mat: number): Box[] { | |
| 99 | const used = new Uint8Array(GRID_W * GRID_H); | |
| 100 | const out: Box[] = []; | |
| 101 | for (let r = 0; r < GRID_H; r++) { | |
| 102 | for (let c = 0; c < GRID_W; c++) { | |
| 103 | const i = r * GRID_W + c; | |
| 104 | if (used[i] === 1 || !match(cellAt(c, r))) continue; | |
| 105 | let w = 1; | |
| 106 | while (c + w < GRID_W && used[i + w] === 0 && match(cellAt(c + w, r))) w++; | |
| 107 | let h = 1; | |
| 108 | grow: while (r + h < GRID_H) { | |
| 109 | for (let k = 0; k < w; k++) { | |
| 110 | const j = (r + h) * GRID_W + c + k; | |
| 111 | if (used[j] === 1 || !match(cellAt(c + k, r + h))) break grow; | |
| 112 | } | |
| 113 | h++; | |
| 114 | } | |
| 115 | for (let dr = 0; dr < h; dr++) for (let dc = 0; dc < w; dc++) used[(r + dr) * GRID_W + c + dc] = 1; | |
| 116 | const a = worldOf(c, r); | |
| 117 | const b = worldOf(c + w - 1, r + h - 1); | |
| 118 | out.push({ | |
| 119 | min: [a[0] - CELL / 2, b[1] - CELL / 2, z0], | |
| 120 | max: [b[0] + CELL / 2, a[1] + CELL / 2, z1], | |
| 121 | mat, | |
| 122 | }); | |
| 123 | } | |
| 124 | } | |
| 125 | return out; | |
| 126 | } | |
| 97 | const mergeSolid = (match: (c: Cell) => boolean, z0: number, z1: number, mat: number): Box[] => | |
| 98 | g.mergeSolid(grid, match, z0, z1, mat); | |
| 127 | 99 | |
| 128 | 100 | function siteSlab(fill: Cell): Box { |
| 129 | 101 | let c0 = GRID_W, r0 = GRID_H, c1 = 0, r1 = 0; |
| @@ -185,56 +157,9 @@ export function siteAt(pos: Vec3): "A" | "B" | null { | ||
| 185 | 157 | // --- pathfinding ---------------------------------------------------------- |
| 186 | 158 | |
| 187 | 159 | /** |
| 188 | * A* over the walkable cells, eight ways, no corner-cutting. The grid is 1936 | |
| 189 | * cells; a full search is well under a millisecond, and bots only ask for one | |
| 190 | * when their goal changes. | |
| 160 | * A* over the walkable cells, eight ways, no corner-cutting; the search is the | |
| 161 | * engine's and what a bot may stand on is this game's answer. The grid is 1936 | |
| 162 | * cells, and 100 searches between random walkable cells average 0.10 ms each, | |
| 163 | * so a bot changing its goal mid-tick costs a tenth of what it has. | |
| 191 | 164 | */ |
| 192 | export function findPath(from: Vec3, to: Vec3): Vec3[] { | |
| 193 | const start = rowOf(from[1]) * GRID_W + colOf(from[0]); | |
| 194 | const goal = rowOf(to[1]) * GRID_W + colOf(to[0]); | |
| 195 | if (start === goal) return [to]; | |
| 196 | const n = GRID_W * GRID_H; | |
| 197 | if (start < 0 || start >= n || goal < 0 || goal >= n) return []; | |
| 198 | const g = new Float32Array(n).fill(Infinity); | |
| 199 | const from_ = new Int32Array(n).fill(-1); | |
| 200 | const open: number[] = [start]; | |
| 201 | const f = new Float32Array(n).fill(Infinity); | |
| 202 | const h = (i: number): number => | |
| 203 | Math.hypot((i % GRID_W) - (goal % GRID_W), Math.floor(i / GRID_W) - Math.floor(goal / GRID_W)); | |
| 204 | g[start] = 0; | |
| 205 | f[start] = h(start); | |
| 206 | const seen = new Uint8Array(n); | |
| 207 | while (open.length > 0) { | |
| 208 | let bestAt = 0; | |
| 209 | for (let i = 1; i < open.length; i++) | |
| 210 | if ((f[open[i] as number] as number) < (f[open[bestAt] as number] as number)) bestAt = i; | |
| 211 | const cur = open.splice(bestAt, 1)[0] as number; | |
| 212 | if (cur === goal) break; | |
| 213 | seen[cur] = 1; | |
| 214 | const cc = cur % GRID_W, cr = Math.floor(cur / GRID_W); | |
| 215 | for (let dr = -1; dr <= 1; dr++) { | |
| 216 | for (let dc = -1; dc <= 1; dc++) { | |
| 217 | if (dc === 0 && dr === 0) continue; | |
| 218 | const nc = cc + dc, nr = cr + dr; | |
| 219 | if (!walkable(nc, nr)) continue; | |
| 220 | if (dc !== 0 && dr !== 0 && (!walkable(cc + dc, cr) || !walkable(cc, cr + dr))) continue; | |
| 221 | const ni = nr * GRID_W + nc; | |
| 222 | if (seen[ni] === 1) continue; | |
| 223 | const step = dc !== 0 && dr !== 0 ? 1.414 : 1; | |
| 224 | const cost = (g[cur] as number) + step; | |
| 225 | if (cost < (g[ni] as number)) { | |
| 226 | g[ni] = cost; | |
| 227 | f[ni] = cost + h(ni); | |
| 228 | from_[ni] = cur; | |
| 229 | if (!open.includes(ni)) open.push(ni); | |
| 230 | } | |
| 231 | } | |
| 232 | } | |
| 233 | } | |
| 234 | if (from_[goal] === -1) return []; | |
| 235 | const out: Vec3[] = []; | |
| 236 | for (let i = goal; i !== start && i !== -1; i = from_[i] as number) | |
| 237 | out.unshift(worldOf(i % GRID_W, Math.floor(i / GRID_W))); | |
| 238 | out.push(to); | |
| 239 | return out; | |
| 240 | } | |
| 165 | export const findPath = (from: Vec3, to: Vec3): Vec3[] => g.findPath(grid, passable, from, to); |
src/engine/grid.test.ts added +169
| @@ -0,0 +1,169 @@ | ||
| 1 | /** | |
| 2 | * The grid, on grids drawn here rather than on anyone's level: a row of a test | |
| 3 | * fixture is a row of cells, `#` is a wall and `.` is floor, and both are just | |
| 4 | * strings to the module under test. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { cellAt, colOf, findPath, inside, mergeSolid, rowOf, worldOf, type Grid } from "./grid.ts"; | |
| 9 | import type { Vec3 } from "./math.ts"; | |
| 10 | ||
| 11 | /** A grid from rows of single characters, `cell` world units on a side. */ | |
| 12 | function draw(rows: string[], cell = 1): Grid<string> { | |
| 13 | const width = (rows[0] as string).length; | |
| 14 | for (const row of rows) expect(row.length).toBe(width); | |
| 15 | return { width, height: rows.length, cell, cells: rows.join("").split("") }; | |
| 16 | } | |
| 17 | ||
| 18 | const open = (c: string): boolean => c !== "#"; | |
| 19 | ||
| 20 | /** The cells a path runs through, dropping the exact `to` it ends on. */ | |
| 21 | const route = (g: Grid<string>, path: Vec3[]): [number, number][] => | |
| 22 | path.slice(0, -1).map((p) => [colOf(g, p[0]), rowOf(g, p[1])]); | |
| 23 | ||
| 24 | test("a grid indexes row-major and says what is outside it", () => { | |
| 25 | const g = draw(["ab", "cd"]); | |
| 26 | expect(g.cells).toEqual(["a", "b", "c", "d"]); | |
| 27 | expect(cellAt(g, 0, 0)).toBe("a"); | |
| 28 | expect(cellAt(g, 1, 0)).toBe("b"); | |
| 29 | expect(cellAt(g, 0, 1)).toBe("c"); | |
| 30 | expect(cellAt(g, 1, 1)).toBe("d"); | |
| 31 | for (const [c, r] of [[-1, 0], [0, -1], [2, 0], [0, 2]] as [number, number][]) { | |
| 32 | expect(inside(g, c, r)).toBe(false); | |
| 33 | expect(cellAt(g, c, r)).toBeUndefined(); | |
| 34 | } | |
| 35 | }); | |
| 36 | ||
| 37 | test("world and grid coordinates round-trip on the corners", () => { | |
| 38 | // 5 x 3 cells of 64 units: 320 units east-west, 192 north-south, centred on | |
| 39 | // zero, so the north-west cell centre is at (-2 * 64, 1 * 64). | |
| 40 | const g = draw([".....", ".....", "....."], 64); | |
| 41 | expect(worldOf(g, 0, 0)).toEqual([-128, 64, 0]); | |
| 42 | expect(worldOf(g, 4, 2)).toEqual([128, -64, 0]); | |
| 43 | expect(worldOf(g, 0, 0, 2)[2]).toBe(2); | |
| 44 | for (const [c, r] of [[0, 0], [4, 0], [0, 2], [4, 2]] as [number, number][]) { | |
| 45 | const p = worldOf(g, c, r); | |
| 46 | expect(colOf(g, p[0])).toBe(c); | |
| 47 | expect(rowOf(g, p[1])).toBe(r); | |
| 48 | } | |
| 49 | // And the edges of the north-west cell still land in it, either side excepted. | |
| 50 | expect(colOf(g, -159.9)).toBe(0); | |
| 51 | expect(colOf(g, -160.1)).toBe(-1); | |
| 52 | expect(rowOf(g, 95.9)).toBe(0); | |
| 53 | expect(rowOf(g, 96.1)).toBe(-1); | |
| 54 | }); | |
| 55 | ||
| 56 | test("a 3 x 2 block of identical cells merges into exactly one box", () => { | |
| 57 | // Cells of 10 units: 30 wide, 20 tall, centred on zero, so the box runs from | |
| 58 | // (-15, -10) to (15, 10) and from z0 to z1 with the material it was given. | |
| 59 | const g = draw(["###", "###"], 10); | |
| 60 | expect(mergeSolid(g, (c) => c === "#", 0, 32, 7)).toEqual([ | |
| 61 | { min: [-15, -10, 0], max: [15, 10, 32], mat: 7 }, | |
| 62 | ]); | |
| 63 | }); | |
| 64 | ||
| 65 | test("meshing splits where a run does not match the row below, in reading order", () => { | |
| 66 | // An L: the top row is three wide, the row under it only one, so the greedy | |
| 67 | // run across the top cannot grow down and the corner comes out separately. | |
| 68 | const g = draw(["###", "#..", "#.."], 10); | |
| 69 | expect(mergeSolid(g, (c) => c === "#", 0, 10, 1)).toEqual([ | |
| 70 | { min: [-15, 5, 0], max: [15, 15, 10], mat: 1 }, | |
| 71 | { min: [-15, -15, 0], max: [-5, 5, 10], mat: 1 }, | |
| 72 | ]); | |
| 73 | }); | |
| 74 | ||
| 75 | test("meshing reads the grid it is given and no other", () => { | |
| 76 | // The risk this test exists for: a `used` array or a grid closed over at | |
| 77 | // module level would let one level mesh another's cells. | |
| 78 | const a = draw(["##", ".."], 10); | |
| 79 | const b = draw(["..", "##"], 10); | |
| 80 | expect(mergeSolid(a, (c) => c === "#", 0, 1, 0)[0]?.min[1]).toBe(0); | |
| 81 | expect(mergeSolid(b, (c) => c === "#", 0, 1, 0)[0]?.min[1]).toBe(-10); | |
| 82 | expect(mergeSolid(a, (c) => c === "#", 0, 1, 0)).toHaveLength(1); | |
| 83 | }); | |
| 84 | ||
| 85 | test("the predicates decide what is solid, not the engine", () => { | |
| 86 | const g = draw(["ab", "ab"], 10); | |
| 87 | expect(mergeSolid(g, (c) => c === "a", 0, 1, 0)).toHaveLength(1); | |
| 88 | expect(mergeSolid(g, (c) => c === "b", 0, 1, 0)).toHaveLength(1); | |
| 89 | expect(mergeSolid(g, () => false, 0, 1, 0)).toEqual([]); | |
| 90 | expect(mergeSolid(g, () => true, 0, 1, 0)).toHaveLength(1); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a path goes around a wall down the middle", () => { | |
| 94 | // col 01234 | |
| 95 | // row0 ..... | |
| 96 | // row1 ..#.. | |
| 97 | // row2 ..#.. west to east has to climb over the wall at row 0 | |
| 98 | // row3 ..#.. | |
| 99 | // row4 ..... | |
| 100 | const g = draw([".....", "..#..", "..#..", "..#..", "....."]); | |
| 101 | const path = findPath(g, open, worldOf(g, 0, 2), worldOf(g, 4, 2)); | |
| 102 | // Straight across would be four steps at 1 each. Round the top is six: four | |
| 103 | // orthogonal and two diagonal, 4 + 2 * 1.414 = 6.83, which is the cheapest | |
| 104 | // way past a wall you may not cut the corner of. | |
| 105 | expect(route(g, path)).toEqual([[0, 1], [1, 0], [2, 0], [3, 0], [4, 1], [4, 2]]); | |
| 106 | for (const [c, r] of route(g, path)) expect(cellAt(g, c, r)).toBe("."); | |
| 107 | }); | |
| 108 | ||
| 109 | test("the last waypoint is the point asked for, not the centre of its cell", () => { | |
| 110 | const g = draw(["..", ".."], 10); | |
| 111 | const to: Vec3 = [3, -3, 0]; | |
| 112 | const path = findPath(g, open, worldOf(g, 0, 0), to); | |
| 113 | expect(path[path.length - 1]).toEqual(to); | |
| 114 | expect(colOf(g, to[0])).toBe(1); | |
| 115 | expect(rowOf(g, to[1])).toBe(1); | |
| 116 | }); | |
| 117 | ||
| 118 | test("a diagonal is refused when a wall touches its corner", () => { | |
| 119 | // Both neighbours solid: the two floors are diagonally adjacent and nothing | |
| 120 | // else, so there is no path at all rather than a step through the seam. | |
| 121 | const shut = draw([".#", "#."]); | |
| 122 | expect(findPath(shut, open, worldOf(shut, 0, 0), worldOf(shut, 1, 1))).toEqual([]); | |
| 123 | // One neighbour solid is refused too: the path goes round the square. | |
| 124 | const half = draw([".#", ".."]); | |
| 125 | const path = findPath(half, open, worldOf(half, 0, 0), worldOf(half, 1, 1)); | |
| 126 | expect(route(half, path)).toEqual([[0, 1], [1, 1]]); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a goal that cannot be reached comes back empty", () => { | |
| 130 | // The east half is walled off from the west by a column of rock. | |
| 131 | const g = draw([".....", "..#..", "..#..", "..#..", "..#.."]); | |
| 132 | const walled = draw(["..#..", "..#..", "..#..", "..#..", "..#.."]); | |
| 133 | expect(findPath(g, open, worldOf(g, 0, 2), worldOf(g, 4, 2)).length).toBeGreaterThan(0); | |
| 134 | expect(findPath(walled, open, worldOf(walled, 0, 2), worldOf(walled, 4, 2))).toEqual([]); | |
| 135 | // A goal standing in the rock itself is unreachable as well. | |
| 136 | expect(findPath(g, open, worldOf(g, 0, 2), worldOf(g, 2, 3))).toEqual([]); | |
| 137 | }); | |
| 138 | ||
| 139 | test("a goal in the cell you already stand in is just the goal", () => { | |
| 140 | const g = draw(["..", ".."], 10); | |
| 141 | const to: Vec3 = [-2, 2, 0]; | |
| 142 | expect(findPath(g, open, [-4, 4, 0], to)).toEqual([to]); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a point outside the grid comes back empty at either end", () => { | |
| 146 | const g = draw(["..", ".."], 10); | |
| 147 | const middle = worldOf(g, 0, 0); | |
| 148 | const west: Vec3 = [-100, 0, 0], north: Vec3 = [0, 100, 0]; | |
| 149 | expect(findPath(g, open, west, middle)).toEqual([]); | |
| 150 | expect(findPath(g, open, middle, west)).toEqual([]); | |
| 151 | expect(findPath(g, open, north, middle)).toEqual([]); | |
| 152 | expect(findPath(g, open, middle, north)).toEqual([]); | |
| 153 | // Even when both ends are outside and would index the same cell. | |
| 154 | expect(findPath(g, open, west, west)).toEqual([]); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a cell is asked about at most once per search", () => { | |
| 158 | // Eight neighbours a cell and a corner check on each diagonal is a lot of | |
| 159 | // questions; the search caches the answers, so the predicate is cheap to write. | |
| 160 | const g = draw([".....", "..#..", "..#..", "..#..", "....."]); | |
| 161 | let calls = 0; | |
| 162 | const counted = (c: string): boolean => { | |
| 163 | calls++; | |
| 164 | return open(c); | |
| 165 | }; | |
| 166 | findPath(g, counted, worldOf(g, 0, 2), worldOf(g, 4, 2)); | |
| 167 | expect(calls).toBeGreaterThan(0); | |
| 168 | expect(calls).toBeLessThanOrEqual(g.width * g.height); | |
| 169 | }); |
src/engine/grid.ts added +187
| @@ -0,0 +1,187 @@ | ||
| 1 | /** | |
| 2 | * A grid of cells, and the arithmetic and algorithms over it. The grid is plain | |
| 3 | * data — a width, a height, how many world units a cell is on a side, and a | |
| 4 | * row-major array of whatever a cell is to the game that built it — so a level | |
| 5 | * is a value you can write out in a test and hand straight back in. | |
| 6 | * | |
| 7 | * Nothing in here knows what a cell means. Which cells are solid and which are | |
| 8 | * walkable arrive as predicates, so one game's `"#"` and another's | |
| 9 | * `{ kind: "rock" }` both mesh and both path. | |
| 10 | * | |
| 11 | * Coordinates: columns run east, rows run south, and the world is Y-north | |
| 12 | * centred on zero, so cell (0, 0) sits at the north-west corner. The units are | |
| 13 | * whatever `cell` is in — this game's are Source units, 128 to a cell. | |
| 14 | */ | |
| 15 | ||
| 16 | import type { Vec3 } from "./math.ts"; | |
| 17 | import type { Box } from "./trace.ts"; | |
| 18 | ||
| 19 | export type Grid<T> = { | |
| 20 | /** Cells across, east. */ | |
| 21 | width: number; | |
| 22 | /** Cells down, south. */ | |
| 23 | height: number; | |
| 24 | /** World units along one side of a cell. */ | |
| 25 | cell: number; | |
| 26 | /** Row-major, `width * height` long: index `row * width + col`. */ | |
| 27 | cells: T[]; | |
| 28 | }; | |
| 29 | ||
| 30 | /** Is (col, row) a cell of this grid at all? Everything else is outside. */ | |
| 31 | export const inside = <T>(g: Grid<T>, col: number, row: number): boolean => | |
| 32 | col >= 0 && row >= 0 && col < g.width && row < g.height; | |
| 33 | ||
| 34 | /** The cell, or `undefined` outside the grid — what outside means is the game's. */ | |
| 35 | export const cellAt = <T>(g: Grid<T>, col: number, row: number): T | undefined => | |
| 36 | inside(g, col, row) ? g.cells[row * g.width + col] : undefined; | |
| 37 | ||
| 38 | /** The world point at the centre of a cell, at height `z`. */ | |
| 39 | export const worldOf = <T>(g: Grid<T>, col: number, row: number, z = 0): Vec3 => [ | |
| 40 | (col - g.width / 2 + 0.5) * g.cell, | |
| 41 | (g.height / 2 - row - 0.5) * g.cell, | |
| 42 | z, | |
| 43 | ]; | |
| 44 | ||
| 45 | /** The column an X falls in, which may be outside the grid. */ | |
| 46 | export const colOf = <T>(g: Grid<T>, x: number): number => Math.floor(x / g.cell + g.width / 2); | |
| 47 | ||
| 48 | /** The row a Y falls in, which may be outside the grid. */ | |
| 49 | export const rowOf = <T>(g: Grid<T>, y: number): number => Math.floor(g.height / 2 - y / g.cell); | |
| 50 | ||
| 51 | /** | |
| 52 | * Greedy meshing: merge runs of matching cells across a row, then merge rows | |
| 53 | * below that match the run exactly, and give each rectangle a box from `z0` to | |
| 54 | * `z1` with material `mat`. A few hundred boxes instead of a few thousand, | |
| 55 | * which is the difference between one buffer and a stutter. | |
| 56 | * | |
| 57 | * Boxes come out in reading order — north row first, then west to east — and a | |
| 58 | * box spans its cells edge to edge, so the west face of column `c` is at | |
| 59 | * `worldOf(g, c, r)[0] - g.cell / 2`. | |
| 60 | */ | |
| 61 | export function mergeSolid<T>( | |
| 62 | g: Grid<T>, | |
| 63 | match: (cell: T) => boolean, | |
| 64 | z0: number, | |
| 65 | z1: number, | |
| 66 | mat: number, | |
| 67 | ): Box[] { | |
| 68 | const { width, height, cell } = g; | |
| 69 | const used = new Uint8Array(width * height); | |
| 70 | const at = (col: number, row: number): boolean => match(g.cells[row * width + col] as T); | |
| 71 | const out: Box[] = []; | |
| 72 | for (let r = 0; r < height; r++) { | |
| 73 | for (let c = 0; c < width; c++) { | |
| 74 | const i = r * width + c; | |
| 75 | if (used[i] === 1 || !at(c, r)) continue; | |
| 76 | let w = 1; | |
| 77 | while (c + w < width && used[i + w] === 0 && at(c + w, r)) w++; | |
| 78 | let h = 1; | |
| 79 | grow: while (r + h < height) { | |
| 80 | for (let k = 0; k < w; k++) { | |
| 81 | const j = (r + h) * width + c + k; | |
| 82 | if (used[j] === 1 || !at(c + k, r + h)) break grow; | |
| 83 | } | |
| 84 | h++; | |
| 85 | } | |
| 86 | for (let dr = 0; dr < h; dr++) | |
| 87 | for (let dc = 0; dc < w; dc++) used[(r + dr) * width + c + dc] = 1; | |
| 88 | const a = worldOf(g, c, r); | |
| 89 | const b = worldOf(g, c + w - 1, r + h - 1); | |
| 90 | out.push({ | |
| 91 | min: [a[0] - cell / 2, b[1] - cell / 2, z0], | |
| 92 | max: [b[0] + cell / 2, a[1] + cell / 2, z1], | |
| 93 | mat, | |
| 94 | }); | |
| 95 | } | |
| 96 | } | |
| 97 | return out; | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * A* over the passable cells, eight ways, no corner-cutting: a diagonal is only | |
| 102 | * taken when both cells beside it are passable too, so nobody walks through the | |
| 103 | * seam where two walls meet. | |
| 104 | * | |
| 105 | * The path is the centre of every cell after the one you stand in, up to and | |
| 106 | * including the goal's cell, then `to` itself so the last step is exact. A goal | |
| 107 | * that cannot be reached, and either end outside the grid, come back empty | |
| 108 | * rather than lying; a `to` in the cell you already stand in is just `[to]`. | |
| 109 | * | |
| 110 | * `passable` is asked about each cell at most once per search — the answers are | |
| 111 | * cached for the call — because a predicate per neighbour is eight questions a | |
| 112 | * cell and this runs whenever a bot changes its mind. | |
| 113 | */ | |
| 114 | export function findPath<T>( | |
| 115 | g: Grid<T>, | |
| 116 | passable: (cell: T) => boolean, | |
| 117 | from: Vec3, | |
| 118 | to: Vec3, | |
| 119 | ): Vec3[] { | |
| 120 | const { width, height } = g; | |
| 121 | const sc = colOf(g, from[0]), sr = rowOf(g, from[1]); | |
| 122 | const gc = colOf(g, to[0]), gr = rowOf(g, to[1]); | |
| 123 | if (!inside(g, sc, sr) || !inside(g, gc, gr)) return []; | |
| 124 | const start = sr * width + sc; | |
| 125 | const goal = gr * width + gc; | |
| 126 | if (start === goal) return [to]; | |
| 127 | const n = width * height; | |
| 128 | // -1 not asked yet, 0 no, 1 yes: one predicate call per cell, at most. | |
| 129 | const answer = new Int8Array(n).fill(-1); | |
| 130 | const passableAt = (col: number, row: number): boolean => { | |
| 131 | if (col < 0 || row < 0 || col >= width || row >= height) return false; | |
| 132 | const i = row * width + col; | |
| 133 | const known = answer[i] as number; | |
| 134 | if (known >= 0) return known === 1; | |
| 135 | const ok = passable(g.cells[i] as T) ? 1 : 0; | |
| 136 | answer[i] = ok; | |
| 137 | return ok === 1; | |
| 138 | }; | |
| 139 | const cost = new Float32Array(n).fill(Infinity); | |
| 140 | const came = new Int32Array(n).fill(-1); | |
| 141 | const f = new Float32Array(n).fill(Infinity); | |
| 142 | const h = (i: number): number => | |
| 143 | Math.hypot((i % width) - gc, Math.floor(i / width) - gr); | |
| 144 | const open: number[] = [start]; | |
| 145 | const queued = new Uint8Array(n); | |
| 146 | queued[start] = 1; | |
| 147 | cost[start] = 0; | |
| 148 | f[start] = h(start); | |
| 149 | const seen = new Uint8Array(n); | |
| 150 | while (open.length > 0) { | |
| 151 | let bestAt = 0; | |
| 152 | for (let i = 1; i < open.length; i++) | |
| 153 | if ((f[open[i] as number] as number) < (f[open[bestAt] as number] as number)) bestAt = i; | |
| 154 | const cur = open.splice(bestAt, 1)[0] as number; | |
| 155 | queued[cur] = 0; | |
| 156 | if (cur === goal) break; | |
| 157 | seen[cur] = 1; | |
| 158 | const cc = cur % width, cr = Math.floor(cur / width); | |
| 159 | for (let dr = -1; dr <= 1; dr++) { | |
| 160 | for (let dc = -1; dc <= 1; dc++) { | |
| 161 | if (dc === 0 && dr === 0) continue; | |
| 162 | const nc = cc + dc, nr = cr + dr; | |
| 163 | if (!passableAt(nc, nr)) continue; | |
| 164 | if (dc !== 0 && dr !== 0 && (!passableAt(cc + dc, cr) || !passableAt(cc, cr + dr))) continue; | |
| 165 | const ni = nr * width + nc; | |
| 166 | if (seen[ni] === 1) continue; | |
| 167 | const step = dc !== 0 && dr !== 0 ? 1.414 : 1; | |
| 168 | const next = (cost[cur] as number) + step; | |
| 169 | if (next < (cost[ni] as number)) { | |
| 170 | cost[ni] = next; | |
| 171 | f[ni] = next + h(ni); | |
| 172 | came[ni] = cur; | |
| 173 | if (queued[ni] === 0) { | |
| 174 | queued[ni] = 1; | |
| 175 | open.push(ni); | |
| 176 | } | |
| 177 | } | |
| 178 | } | |
| 179 | } | |
| 180 | } | |
| 181 | if (came[goal] === -1) return []; | |
| 182 | const out: Vec3[] = []; | |
| 183 | for (let i = goal; i !== start && i !== -1; i = came[i] as number) | |
| 184 | out.unshift(worldOf(g, i % width, Math.floor(i / width))); | |
| 185 | out.push(to); | |
| 186 | return out; | |
| 187 | } |
static/app.6864d577a4.js deleted −58
Diff not shown — a line 56,044 characters wide (limit 2,000). −58
View filestatic/app.876e3fcdf5.js added +58
Diff not shown — a line 56,611 characters wide (limit 2,000). +58
View file
new above
offline