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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/cs-engine:torque/11-the-grid-becomes-engine-cells-meshing-an → main 1 commit fork

  • Merged. torque/11-the-grid-becomes-engine-cells-meshing-an was merged into main as 50eb604.

Review

Sign in to review this change.
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
2222 a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and
2323 loss bonuses. The leaderboard is a real SQLite table under `FORGE_APP_DATA`.
2424 - **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
2930 asset files and nothing from the real game is used — the numbers follow CS's
3031 published shape so the habits transfer, and the rest is original.
3132 - **Learned, and measured.** The bots do not aim by rule. A 32-input, two-layer
@@ -54,13 +55,18 @@ the rest.
5455
5556 - `src/engine/` — the part a second game could use, and the rule is that it never
5657 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
5862 builder), `texture` (hashed value noise, fbm and an atlas painter) and `sound`
5963 (a lazy `AudioContext` with noise bursts and tones). `boundary.test.ts`
6064 reads the imports of every file in here and fails if one of them points at
6165 `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),
6470 `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`,
6571 `renderer`, `hud`, `input`, `audio`, `main`. The last five aside, everything is
6672 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", () => {
5656 expect(boxes.length).toBeLessThan(300);
5757 });
5858
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
5990 test("a path from T spawn reaches both sites", () => {
6091 for (const site of [sites.A, sites.B]) {
6192 const path = findPath(spawns.t[0], site);
src/client/map.ts +32 −107
@@ -1,13 +1,18 @@
11 /**
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,
33 * then merged back into as few boxes as possible. One piece of data feeds
44 * everything — what you collide with, what gets drawn, where bots walk, where
55 * the bomb can be planted — so the map can never disagree with itself.
66 *
77 * Layout is dust-shaped and original: two sites, a mid, a long right-hand
88 * 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.
913 */
1014
15 import * as g from "../engine/grid.ts";
1116 import type { Vec3 } from "../engine/math.ts";
1217 import type { Box } from "../engine/trace.ts";
1318
@@ -51,37 +56,36 @@ const CRATES: [number, number, Cell][] = [
5156 [20, 3, "x"], [25, 3, "x"], [18, 39, "x"], [26, 39, "x"], [28, 15, "x"],
5257 ];
5358
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("#");
5661 for (const area of AREAS) {
5762 const [c0, r0, c1, r1] = area.rect;
5863 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;
6065 }
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 };
6368 }
6469
65 export const grid: Cell[] = build();
70 export const grid: g.Grid<Cell> = build();
6671
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) ?? "#";
6974
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));
7583
7684 /** 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);
8286
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);
8589
8690 // --- geometry -------------------------------------------------------------
8791
@@ -90,40 +94,8 @@ export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const;
9094 /** The tracer owns the box; the map hands it out, because that is where it is written. */
9195 export type { Box } from "../engine/trace.ts";
9296
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);
12799
128100 function siteSlab(fill: Cell): Box {
129101 let c0 = GRID_W, r0 = GRID_H, c1 = 0, r1 = 0;
@@ -185,56 +157,9 @@ export function siteAt(pos: Vec3): "A" | "B" | null {
185157 // --- pathfinding ----------------------------------------------------------
186158
187159 /**
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.
191164 */
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 file
static/app.876e3fcdf5.js added +58

Diff not shown — a line 56,611 characters wide (limit 2,000). +58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs