Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The map becomes a value: de_forge is one object the game is handed, not module state it imports #104
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/95-the-map-becomes-a-value-de-forge-is-one → main 3 commits fork
27 files
changed +563
−204
README.md +14 −4
| @@ -191,14 +191,24 @@ subtends — and the panel says which of the two you are looking at. | ||
| 191 | 191 | `example.test.ts` plays it headlessly: a seeded run scores the same twice, a |
| 192 | 192 | shot down the target's bearing hits and one twenty degrees off misses, and a |
| 193 | 193 | second of elapsed time is sixty ticks however the frames are cut. |
| 194 | - `src/client/` — the game, in modules named for one thing each: `map` (de_forge | |
| 195 | itself: its areas, its crates, its cell alphabet and its spawns and sites, laid | |
| 196 | over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules), | |
| 194 | - `src/client/` — the game, in modules named for one thing each: `level` (what a | |
| 195 | level *is* to this game — a grid of lettered cells, the predicate that says | |
| 196 | which letters you can stand on, the boxes, the spawns, the sites, the wall | |
| 197 | height and the material table, plus the questions asked of one: `walkable`, | |
| 198 | `siteAt`, `findPath`), `map` (de_forge itself: its areas, its crates, its cell | |
| 199 | alphabet and its spawns and sites, laid over the engine's grid, exported as | |
| 200 | `DE_FORGE` — one value of `Level` and the only one that ships), | |
| 201 | `physics`, `weapons`, `combat`, `match` (the rules), | |
| 197 | 202 | `world` (the simulation), |
| 198 | 203 | `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`, |
| 199 | 204 | `binding`, `scene`, `hud`, `audio`, `main`. Everything in here is headless and |
| 200 | 205 | tested: the WebGL2 is the engine's now, and this game has no renderer of its |
| 201 | own. `models`, `materials` and `audio` are this game's recipes for the | |
| 206 | own. The level is handed in rather than imported: `createWorld(level, …)` | |
| 207 | keeps it on the world and the bots, the bullets, the bomb and the meshes read | |
| 208 | it from there, so `main` is the only file that names `DE_FORGE` and | |
| 209 | `level.test.ts` builds an eleven-by-nine box room of its own, walks a bot | |
| 210 | across it and stops a bullet in its walls without a line of `src/client/` | |
| 211 | changing to allow it. `models`, `materials` and `audio` are this game's recipes for the | |
| 202 | 212 | engine's three asset tools — `main` builds the atlas and all eight meshes at |
| 203 | 213 | start-up and hands them to `createRenderer`, which keeps them by name — and |
| 204 | 214 | `binding` is its key table and the one |
src/client/bots.test.ts +2 −1
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { applyIntent, botThink, CARD, makeBrain, policy, think } from "./bots.ts"; |
| 3 | import { DE_FORGE } from "./map.ts"; | |
| 3 | 4 | import { COMPETITIVE } from "./match.ts"; |
| 4 | 5 | import { aimAt, bodyCentre, createWorld, type Player } from "./world.ts"; |
| 5 | 6 | |
| 6 | 7 | const duel = (seed = 2024): { world: ReturnType<typeof createWorld>; me: Player; bot: Player } => { |
| 7 | const world = createWorld(COMPETITIVE, "you", "ct", 1, seed); | |
| 8 | const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 1, seed); | |
| 8 | 9 | world.match.phase = "live"; |
| 9 | 10 | const me = world.me; |
| 10 | 11 | const bot = world.players[1] as Player; |
src/client/bots.ts +11 −9
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | |
| 14 | 14 | import { decode, HEADS, STILL, type Intent } from "./action.ts"; |
| 15 | 15 | import { angleDelta, clamp, dist, rng, type Vec3 } from "../engine/index.ts"; |
| 16 | import * as map from "./map.ts"; | |
| 16 | import { colOf, findPath, rowOf, siteAt, walkable, type Level } from "./level.ts"; | |
| 17 | 17 | import { observe, OBS_SIZE, type Memory } from "./observe.ts"; |
| 18 | 18 | import { |
| 19 | 19 | forward, newActivations, sample, unpack, type Activations, type Policy, |
| @@ -118,24 +118,26 @@ function brainOf(world: World, bot: Player): BotBrain { | ||
| 118 | 118 | /** Where this bot is trying to be, given what the round is doing. */ |
| 119 | 119 | function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 { |
| 120 | 120 | const bomb = world.bomb; |
| 121 | const level = world.level; | |
| 122 | const site = level.sites[brain.site]; | |
| 121 | 123 | if (bot.team === "t") { |
| 122 | 124 | if (bomb.state === "planted") { |
| 123 | 125 | // Hold near the bomb rather than walk onto it. |
| 124 | return nearby(brain, bomb.pos, 300); | |
| 126 | return nearby(level, brain, bomb.pos, 300); | |
| 125 | 127 | } |
| 126 | if (bot.hasBomb) return map.sites[brain.site]; | |
| 128 | if (bot.hasBomb) return site; | |
| 127 | 129 | if (bomb.state === "dropped") return bomb.pos; |
| 128 | return nearby(brain, map.sites[brain.site], 200); | |
| 130 | return nearby(level, brain, site, 200); | |
| 129 | 131 | } |
| 130 | 132 | if (bomb.state === "planted") return bomb.pos; |
| 131 | return nearby(brain, map.sites[brain.site], 250); | |
| 133 | return nearby(level, brain, site, 250); | |
| 132 | 134 | } |
| 133 | 135 | |
| 134 | const nearby = (brain: BotBrain, at: Vec3, spread: number): Vec3 => { | |
| 136 | const nearby = (level: Level, brain: BotBrain, at: Vec3, spread: number): Vec3 => { | |
| 135 | 137 | for (let tries = 0; tries < 8; tries++) { |
| 136 | 138 | const x = at[0] + (brain.random() * 2 - 1) * spread; |
| 137 | 139 | const y = at[1] + (brain.random() * 2 - 1) * spread; |
| 138 | if (map.walkable(map.colOf(x), map.rowOf(y))) return [x, y, at[2]]; | |
| 140 | if (walkable(level, colOf(level, x), rowOf(level, y))) return [x, y, at[2]]; | |
| 139 | 141 | } |
| 140 | 142 | return at; |
| 141 | 143 | }; |
| @@ -317,7 +319,7 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 317 | 319 | world.time > brain.repathAt || objective !== brain.plannedFor; |
| 318 | 320 | if (stale) { |
| 319 | 321 | brain.goal = chooseGoal(world, bot, brain); |
| 320 | brain.path = map.findPath(bot.pos, brain.goal); | |
| 322 | brain.path = findPath(world.level, bot.pos, brain.goal); | |
| 321 | 323 | brain.step = 0; |
| 322 | 324 | brain.plannedFor = objective; |
| 323 | 325 | brain.repathAt = world.time + 3 + brain.random() * 3; |
| @@ -342,7 +344,7 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 342 | 344 | /** Planting and defusing: press use when standing where it matters. */ |
| 343 | 345 | function withBomb(world: World, bot: Player, input: Input): Input { |
| 344 | 346 | const bomb = world.bomb; |
| 345 | if (bot.team === "t" && bot.hasBomb && map.siteAt(bot.pos) !== null && world.match.phase === "live") { | |
| 347 | if (bot.team === "t" && bot.hasBomb && siteAt(world.level, bot.pos) !== null && world.match.phase === "live") { | |
| 346 | 348 | input.use = true; |
| 347 | 349 | input.forward = 0; |
| 348 | 350 | input.side = 0; |
src/client/combat.test.ts +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { applyHit, hitboxes, traceBullet, type Actor } from "./combat.ts"; |
| 3 | import type { Box } from "./map.ts"; | |
| 3 | import type { Box } from "./level.ts"; | |
| 4 | 4 | import type { Vec3 } from "../engine/index.ts"; |
| 5 | 5 | import { WEAPONS } from "./weapons.ts"; |
| 6 | 6 |
src/client/combat.ts +1 −1
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * crouching head really is where a crouching head looks. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import type { Box } from "./map.ts"; | |
| 7 | import type { Box } from "./level.ts"; | |
| 8 | 8 | import { dist, rayBox, traceWorld, type Vec3 } from "../engine/index.ts"; |
| 9 | 9 | import { HULL } from "./physics.ts"; |
| 10 | 10 | import { computeDamage, type Hitgroup, type Weapon } from "./weapons.ts"; |
src/client/hud.test.ts +2 −1
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | |
| 24 | 24 | import { expect, test } from "bun:test"; |
| 25 | 25 | import { drawHud, type Paint } from "./hud.ts"; |
| 26 | import { DE_FORGE } from "./map.ts"; | |
| 26 | 27 | import { SHORT } from "./match.ts"; |
| 27 | 28 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 28 | 29 | import { createWorld, currentWeapon, type Ammo, type Player, type World } from "./world.ts"; |
| @@ -71,7 +72,7 @@ function recorder(): Recorder { | ||
| 71 | 72 | * Every test draws a *copy* of it instead, so a test that spends a magazine or |
| 72 | 73 | * kills a bot does not reach the next one. |
| 73 | 74 | */ |
| 74 | const BASE = createWorld(SHORT, "you", "ct", 5, 64); | |
| 75 | const BASE = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 64); | |
| 75 | 76 | |
| 76 | 77 | const copyAmmo = (ammo: Record<string, Ammo>): Record<string, Ammo> => |
| 77 | 78 | Object.fromEntries(Object.entries(ammo).map(([id, a]) => [id, { ...a }])); |
src/client/level.test.ts added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | /** | |
| 2 | * A level is a value, and this file is the proof: an eleven-by-nine box room, | |
| 3 | * written out here and nowhere else, is handed to `createWorld` and the game | |
| 4 | * plays on it. A bot paths from one spawn to the other, walks, and stays inside | |
| 5 | * walls de_forge has never heard of; a bullet stops in one of them. Nothing | |
| 6 | * under `src/client/` changed to allow it — the room only has to be a `Level`. | |
| 7 | * | |
| 8 | * The invariants every level owes are checked here once, over both levels: a | |
| 9 | * spawn you cannot stand on, a site off the edge of the grid or a box hanging | |
| 10 | * outside the floor are bugs in a map, and the test that catches them should | |
| 11 | * not have to be written again for the second one. | |
| 12 | */ | |
| 13 | ||
| 14 | import { expect, test } from "bun:test"; | |
| 15 | import { readdirSync, readFileSync } from "node:fs"; | |
| 16 | import { join } from "node:path"; | |
| 17 | import { traceBullet } from "./combat.ts"; | |
| 18 | import { mergeSolid, worldOf as gridWorldOf, type Grid, type Vec3 } from "../engine/index.ts"; | |
| 19 | import { | |
| 20 | colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Level, | |
| 21 | } from "./level.ts"; | |
| 22 | import { DE_FORGE } from "./map.ts"; | |
| 23 | import { SHORT } from "./match.ts"; | |
| 24 | import { applyInput, createWorld, type Player } from "./world.ts"; | |
| 25 | import { botThink } from "./bots.ts"; | |
| 26 | ||
| 27 | // --- a second level, built here ------------------------------------------ | |
| 28 | ||
| 29 | const CELL = 128; | |
| 30 | const WALL = 256; | |
| 31 | ||
| 32 | /** | |
| 33 | * One room, drawn as it is played: `#` is wall, `t` and `c` are the two spawns, | |
| 34 | * `A` and `B` are the two bombsites a defusal level has by definition, and a | |
| 35 | * dot is floor. Eleven cells east by nine south, which is 1,408 by 1,152 units. | |
| 36 | */ | |
| 37 | const ROWS = [ | |
| 38 | "###########", | |
| 39 | "#t.......A#", | |
| 40 | "#.........#", | |
| 41 | "#.........#", | |
| 42 | "#.........#", | |
| 43 | "#.........#", | |
| 44 | "#.........#", | |
| 45 | "#c.......B#", | |
| 46 | "###########", | |
| 47 | ]; | |
| 48 | ||
| 49 | const GRID: Grid<string> = { | |
| 50 | width: ROWS[0].length, | |
| 51 | height: ROWS.length, | |
| 52 | cell: CELL, | |
| 53 | cells: ROWS.flatMap((row) => [...row]), | |
| 54 | }; | |
| 55 | ||
| 56 | /** The room's whole alphabet: everything that is not rock can be walked on. */ | |
| 57 | const passable = (cell: string): boolean => cell !== "#"; | |
| 58 | ||
| 59 | const halfX = (GRID.width / 2) * CELL; | |
| 60 | const halfY = (GRID.height / 2) * CELL; | |
| 61 | ||
| 62 | /** | |
| 63 | * Where the one cell carrying `letter` sits in the world. The four places are | |
| 64 | * read back off the grid rather than written twice, so the room cannot disagree | |
| 65 | * with the picture above — which is the same trick de_forge plays. | |
| 66 | */ | |
| 67 | function place(letter: string): Vec3 { | |
| 68 | const at = GRID.cells.indexOf(letter); | |
| 69 | return gridWorldOf(GRID, at % GRID.width, Math.floor(at / GRID.width)); | |
| 70 | } | |
| 71 | ||
| 72 | const ROOM: Level = { | |
| 73 | name: "de_room", | |
| 74 | grid: GRID, | |
| 75 | passable, | |
| 76 | wallHeight: WALL, | |
| 77 | materials: { FLOOR: 0, WALL: 1 }, | |
| 78 | boxes: [ | |
| 79 | { min: [-halfX, -halfY, -32], max: [halfX, halfY, 0], mat: 0 }, | |
| 80 | ...mergeSolid(GRID, (cell) => cell === "#", 0, WALL, 1), | |
| 81 | ], | |
| 82 | spawns: { t: [place("t")], ct: [place("c")] }, | |
| 83 | sites: { A: place("A"), B: place("B") }, | |
| 84 | siteCells: { A: "A", B: "B" }, | |
| 85 | }; | |
| 86 | ||
| 87 | // --- what every level owes ------------------------------------------------ | |
| 88 | ||
| 89 | /** Why this level is not one, or nothing at all. Every level answers this. */ | |
| 90 | function problems(level: Level): string[] { | |
| 91 | const bad: string[] = []; | |
| 92 | for (const [team, spots] of Object.entries(level.spawns)) | |
| 93 | for (const spot of spots) | |
| 94 | if (!walkable(level, colOf(level, spot[0]), rowOf(level, spot[1]))) | |
| 95 | bad.push(`${level.name}: a ${team} spawn at ${spot} is not walkable`); | |
| 96 | for (const [name, site] of Object.entries(level.sites)) | |
| 97 | if (siteAt(level, site) !== name) bad.push(`${level.name}: site ${name} at ${site} is not on ${name}`); | |
| 98 | const { width, height, cell } = level.grid; | |
| 99 | const edge = [(width / 2) * cell, (height / 2) * cell]; | |
| 100 | for (const box of level.boxes) | |
| 101 | for (const axis of [0, 1]) | |
| 102 | if (box.min[axis] < -edge[axis] || box.max[axis] > edge[axis]) | |
| 103 | bad.push(`${level.name}: a box runs from ${box.min} to ${box.max}, outside the grid`); | |
| 104 | return bad; | |
| 105 | } | |
| 106 | ||
| 107 | test("both levels keep the promises the type makes: spawns, sites, boxes", () => { | |
| 108 | expect(problems(DE_FORGE)).toEqual([]); | |
| 109 | expect(problems(ROOM)).toEqual([]); | |
| 110 | // And the check bites: a spawn in the wall and a box off the edge are named. | |
| 111 | const broken: Level = { | |
| 112 | ...ROOM, | |
| 113 | spawns: { t: [worldOf(ROOM, 0, 0)], ct: ROOM.spawns.ct }, | |
| 114 | boxes: [{ min: [-9999, 0, 0], max: [0, 0, 0], mat: 0 }], | |
| 115 | }; | |
| 116 | expect(problems(broken).length).toBe(2); | |
| 117 | }); | |
| 118 | ||
| 119 | test("the room is a grid, a predicate and eleven boxes of geometry", () => { | |
| 120 | expect(ROOM.grid.cells.length).toBe(11 * 9); | |
| 121 | expect(walkable(ROOM, 1, 1)).toBe(true); | |
| 122 | expect(walkable(ROOM, 0, 0)).toBe(false); | |
| 123 | expect(walkable(ROOM, -1, 4)).toBe(false); // off the edge is not a cell | |
| 124 | expect(siteAt(ROOM, ROOM.sites.A)).toBe("A"); | |
| 125 | expect(siteAt(ROOM, ROOM.sites.B)).toBe("B"); | |
| 126 | expect(openCells(ROOM).length).toBe(9 * 7); | |
| 127 | }); | |
| 128 | ||
| 129 | test("only the map and the entry name de_forge: everything else is handed a level", () => { | |
| 130 | // The whole point of the type. A module that reaches for `DE_FORGE` is a | |
| 131 | // module that has decided which map the game is on, and only the file that | |
| 132 | // builds it and the file that starts the game get to decide that. | |
| 133 | const mine = import.meta.dir; | |
| 134 | const allowed = new Set(["map.ts", "main.ts"]); | |
| 135 | const offenders = readdirSync(mine) | |
| 136 | .filter((file) => file.endsWith(".ts") && !file.includes(".test")) | |
| 137 | .filter((file) => !allowed.has(file) && readFileSync(join(mine, file), "utf8").includes("DE_FORGE")); | |
| 138 | expect(offenders).toEqual([]); | |
| 139 | }); | |
| 140 | ||
| 141 | // --- and the game played on it -------------------------------------------- | |
| 142 | ||
| 143 | test("a path crosses the room, cell by cell, without leaving it", () => { | |
| 144 | const path = findPath(ROOM, ROOM.spawns.t[0], ROOM.spawns.ct[0]); | |
| 145 | expect(path.length).toBeGreaterThan(4); | |
| 146 | for (let i = 1; i < path.length; i++) | |
| 147 | expect(Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1])).toBeLessThan(CELL * 1.5); | |
| 148 | for (const step of path) expect(walkable(ROOM, colOf(ROOM, step[0]), rowOf(ROOM, step[1]))).toBe(true); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a world on the room is a world on the room, and its bot walks it", () => { | |
| 152 | const world = createWorld(ROOM, SHORT, "me", "ct", 1, 5); | |
| 153 | expect(world.level).toBe(ROOM); | |
| 154 | // The bot spawns on the room's T spawn, not on de_forge's. | |
| 155 | const bot = world.players.find((p) => p.bot) as Player; | |
| 156 | expect([bot.pos[0], bot.pos[1]]).toEqual([ROOM.spawns.t[0][0], ROOM.spawns.t[0][1]]); | |
| 157 | expect(siteAt(ROOM, bot.pos)).toBe(null); | |
| 158 | ||
| 159 | // Three seconds of it thinking and moving, with nobody to shoot at: the round | |
| 160 | // rules are left out on purpose, because a lone bot ends the round at once. | |
| 161 | world.match.phase = "live"; | |
| 162 | world.me.alive = false; | |
| 163 | const from: [number, number] = [bot.pos[0], bot.pos[1]]; | |
| 164 | for (let tick = 0; tick < 64 * 3; tick++) { | |
| 165 | world.time += 1 / 64; | |
| 166 | applyInput(world, bot, botThink(world, bot, 1 / 64), 1 / 64); | |
| 167 | } | |
| 168 | // It walked, on a path made of this room's cells, and this room's walls — | |
| 169 | // not de_forge's — are what kept it inside. | |
| 170 | expect(Math.hypot(bot.pos[0] - from[0], bot.pos[1] - from[1])).toBeGreaterThan(200); | |
| 171 | const path = (bot.brain as { path: Vec3[] }).path; | |
| 172 | expect(path.length).toBeGreaterThan(1); | |
| 173 | for (const step of path) | |
| 174 | expect(walkable(ROOM, colOf(ROOM, step[0]), rowOf(ROOM, step[1]))).toBe(true); | |
| 175 | expect(Math.abs(bot.pos[0])).toBeLessThan(halfX); | |
| 176 | expect(Math.abs(bot.pos[1])).toBeLessThan(halfY); | |
| 177 | }); | |
| 178 | ||
| 179 | test("a bullet fired across the room stops in the room's own wall", () => { | |
| 180 | const world = createWorld(ROOM, SHORT, "me", "ct", 1, 9); | |
| 181 | const from: [number, number, number] = [0, 0, 64]; | |
| 182 | // Due east: the east wall's inner face is the west side of column 10, at | |
| 183 | // (10 - 5.5 + 0.5) * 128 - 64 = 576 units. | |
| 184 | const hit = traceBullet(from, [8192, 0, 64], world.level.boxes, [], -1); | |
| 185 | expect(hit.kind).toBe("world"); | |
| 186 | if (hit.kind !== "world") return; | |
| 187 | expect(hit.point[0]).toBeCloseTo(576, 6); | |
| 188 | expect(hit.normal).toEqual([-1, 0, 0]); | |
| 189 | }); |
src/client/level.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** | |
| 2 | * What a level is, as one value: a grid of lettered cells, what a letter means, | |
| 3 | * the boxes that grid merges into, where each side spawns, where the two sites | |
| 4 | * are, and the numbers it was built at. `map.ts` builds de_forge as one of | |
| 5 | * these and `createWorld` is handed it, so a second map, a second mode or a | |
| 6 | * four-cell test room is a second value rather than a second module. | |
| 7 | * | |
| 8 | * Nothing here knows de_forge's alphabet. A cell is a letter; which letters | |
| 9 | * exist and which of them a player may stand on is the level's own `passable`, | |
| 10 | * and the arithmetic and the algorithms under it — cell to world, meshing, A* — | |
| 11 | * are the engine's `src/engine/grid.ts`, which knows less still. | |
| 12 | * | |
| 13 | * Everything below takes the level first and reads it once. These are the | |
| 14 | * questions the simulation asks every tick, so they stay flat: a lookup here is | |
| 15 | * a bounds check and a multiply, never a search. | |
| 16 | */ | |
| 17 | ||
| 18 | import * as g from "../engine/index.ts"; | |
| 19 | import type { Box, Grid, Vec3 } from "../engine/index.ts"; | |
| 20 | ||
| 21 | /** The tracer owns the box; a level hands it out, because that is what a level is made of. */ | |
| 22 | export type { Box } from "../engine/index.ts"; | |
| 23 | ||
| 24 | /** A cell is a letter. What the letters are, and what each one means, is a level's own. */ | |
| 25 | export type Cell = string; | |
| 26 | ||
| 27 | export type Level = { | |
| 28 | /** What to call it in a log or a test failure. */ | |
| 29 | readonly name: string; | |
| 30 | /** The cells, row-major, with the world arithmetic that goes with them. */ | |
| 31 | readonly grid: Grid<Cell>; | |
| 32 | /** May a player stand on this cell? The whole of what the search knows about the alphabet. */ | |
| 33 | readonly passable: (cell: Cell) => boolean; | |
| 34 | /** How tall a wall stands, in world units. */ | |
| 35 | readonly wallHeight: number; | |
| 36 | /** The material ids this level's boxes are tagged with, by name. */ | |
| 37 | readonly materials: Readonly<Record<string, number>>; | |
| 38 | /** Everything solid, in one list: the renderer draws it and bullets stop in it. */ | |
| 39 | readonly boxes: Box[]; | |
| 40 | /** Where each side starts a round, one point per spawn cell. */ | |
| 41 | readonly spawns: { readonly t: Vec3[]; readonly ct: Vec3[] }; | |
| 42 | /** The middle of each bombsite. */ | |
| 43 | readonly sites: { readonly A: Vec3; readonly B: Vec3 }; | |
| 44 | /** Which letter each site is paved with, so a point can be asked which site it stands in. */ | |
| 45 | readonly siteCells: { readonly A: Cell; readonly B: Cell }; | |
| 46 | }; | |
| 47 | ||
| 48 | /** The cell at (col, row), or `undefined` off the edge — what is out there is the level's business. */ | |
| 49 | export const cellAt = (level: Level, col: number, row: number): Cell | undefined => | |
| 50 | g.cellAt(level.grid, col, row); | |
| 51 | ||
| 52 | /** | |
| 53 | * Can a player stand in this cell? Off the edge of a level is not a cell and so | |
| 54 | * is never walkable, whatever the level's border is made of. | |
| 55 | */ | |
| 56 | export function walkable(level: Level, col: number, row: number): boolean { | |
| 57 | const { width, height, cells } = level.grid; | |
| 58 | if (col < 0 || row < 0 || col >= width || row >= height) return false; | |
| 59 | return level.passable(cells[row * width + col] as Cell); | |
| 60 | } | |
| 61 | ||
| 62 | /** Grid coordinates are north-down; the world is Y-north, centred on zero. */ | |
| 63 | export const worldOf = (level: Level, col: number, row: number, z = 0): Vec3 => | |
| 64 | g.worldOf(level.grid, col, row, z); | |
| 65 | ||
| 66 | export const colOf = (level: Level, x: number): number => g.colOf(level.grid, x); | |
| 67 | export const rowOf = (level: Level, y: number): number => g.rowOf(level.grid, y); | |
| 68 | ||
| 69 | /** Which bombsite a point stands in, if any. */ | |
| 70 | export function siteAt(level: Level, pos: Vec3): "A" | "B" | null { | |
| 71 | const cell = cellAt(level, colOf(level, pos[0]), rowOf(level, pos[1])); | |
| 72 | if (cell === undefined) return null; | |
| 73 | if (cell === level.siteCells.A) return "A"; | |
| 74 | if (cell === level.siteCells.B) return "B"; | |
| 75 | return null; | |
| 76 | } | |
| 77 | ||
| 78 | /** | |
| 79 | * A* over the cells this level calls walkable, eight ways, no corner-cutting: | |
| 80 | * the search is the engine's and what a bot may stand on is the level's answer. | |
| 81 | * de_forge is 1936 cells, and 100 searches between random walkable cells average | |
| 82 | * 0.10 ms each, so a bot changing its goal mid-tick costs a tenth of what it has. | |
| 83 | */ | |
| 84 | export const findPath = (level: Level, from: Vec3, to: Vec3): Vec3[] => | |
| 85 | g.findPath(level.grid, level.passable, from, to); | |
| 86 | ||
| 87 | /** Every point a player can stand in, in reading order: what a spawn picker chooses from. */ | |
| 88 | export function openCells(level: Level, z = 0): Vec3[] { | |
| 89 | const out: Vec3[] = []; | |
| 90 | for (let row = 0; row < level.grid.height; row++) | |
| 91 | for (let col = 0; col < level.grid.width; col++) | |
| 92 | if (walkable(level, col, row)) out.push(worldOf(level, col, row, z)); | |
| 93 | return out; | |
| 94 | } |
src/client/main.ts +7 −4
| @@ -22,6 +22,8 @@ import { | ||
| 22 | 22 | attachButtons, clamp, createControls, createLoop, createRenderer, type Button, type Camera, |
| 23 | 23 | type Listens, type Mesh, |
| 24 | 24 | } from "../engine/index.ts"; |
| 25 | import { DE_FORGE } from "./map.ts"; | |
| 26 | import type { Level } from "./level.ts"; | |
| 25 | 27 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; |
| 26 | 28 | import { LAYERS, TEX_SIZE, materialTextures } from "./materials.ts"; |
| 27 | 29 | import { meshFor } from "./models.ts"; |
| @@ -251,7 +253,7 @@ export function boot(host: Host): Frame { | ||
| 251 | 253 | const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT; |
| 252 | 254 | const team = sideField.value === "t" ? "t" : "ct"; |
| 253 | 255 | const name = nameField.value.trim().slice(0, 24) || "you"; |
| 254 | world = createWorld(rules, name, team, 5); | |
| 256 | world = createWorld(DE_FORGE, rules, name, team, 5); | |
| 255 | 257 | matchId = host.randomUUID(); |
| 256 | 258 | posted = -1; |
| 257 | 259 | loop.reset(); |
| @@ -367,12 +369,13 @@ export function boot(host: Host): Frame { | ||
| 367 | 369 | * |
| 368 | 370 | * Exported because a name this misses is a crash mid-match — the first frame a |
| 369 | 371 | * player picks that gun up — and `main.test.ts` holds it against every name |
| 370 | * `scene.ts` can place. | |
| 372 | * `scene.ts` can place. The level is a value, so which map `level` is made of | |
| 373 | * is an argument; this game plays de_forge, which is the default. | |
| 371 | 374 | */ |
| 372 | export function gameMeshes(): Record<string, Mesh> { | |
| 375 | export function gameMeshes(level: Level = DE_FORGE): Record<string, Mesh> { | |
| 373 | 376 | const kinds = new Set(Object.values(WEAPONS).map((weapon) => weapon.kind)); |
| 374 | 377 | const names = ["level", "player", "bomb", ...kinds]; |
| 375 | return Object.fromEntries(names.map((name) => [name, meshFor(name)])); | |
| 378 | return Object.fromEntries(names.map((name) => [name, meshFor(name, level)])); | |
| 376 | 379 | } |
| 377 | 380 | |
| 378 | 381 | /** |
src/client/map.test.ts +41 −18
| @@ -1,31 +1,40 @@ | ||
| 1 | /** | |
| 2 | * de_forge, pinned. Everything this file asserted when the map was a module of | |
| 3 | * singletons it asserts now about `DE_FORGE`, the one value `map.ts` exports: | |
| 4 | * the same cells, the same fifty boxes, the same thirty-three waypoints from T | |
| 5 | * spawn to A. What a *level* is, and what is true of every one of them, is | |
| 6 | * `level.test.ts`; this is about this map. | |
| 7 | */ | |
| 8 | ||
| 1 | 9 | import { expect, test } from "bun:test"; |
| 2 | import { | |
| 3 | boxes, cellAt, colOf, findPath, GRID_H, GRID_W, rowOf, siteAt, sites, spawns, walkable, worldOf, | |
| 4 | } from "./map.ts"; | |
| 10 | import { cellAt, colOf, findPath, rowOf, siteAt, walkable, worldOf } from "./level.ts"; | |
| 11 | import { DE_FORGE, GRID_H, GRID_W } from "./map.ts"; | |
| 12 | ||
| 13 | const { boxes, sites, spawns } = DE_FORGE; | |
| 5 | 14 | |
| 6 | 15 | test("the map has two sites and two spawns", () => { |
| 7 | 16 | expect(spawns.t.length).toBeGreaterThan(4); |
| 8 | 17 | expect(spawns.ct.length).toBeGreaterThan(4); |
| 9 | expect(siteAt(sites.A)).toBe("A"); | |
| 10 | expect(siteAt(sites.B)).toBe("B"); | |
| 18 | expect(siteAt(DE_FORGE, sites.A)).toBe("A"); | |
| 19 | expect(siteAt(DE_FORGE, sites.B)).toBe("B"); | |
| 11 | 20 | }); |
| 12 | 21 | |
| 13 | 22 | test("the border is solid all the way round", () => { |
| 14 | 23 | for (let c = 0; c < GRID_W; c++) { |
| 15 | expect(cellAt(c, 0)).toBe("#"); | |
| 16 | expect(cellAt(c, GRID_H - 1)).toBe("#"); | |
| 24 | expect(cellAt(DE_FORGE, c, 0)).toBe("#"); | |
| 25 | expect(cellAt(DE_FORGE, c, GRID_H - 1)).toBe("#"); | |
| 17 | 26 | } |
| 18 | 27 | for (let r = 0; r < GRID_H; r++) { |
| 19 | expect(cellAt(0, r)).toBe("#"); | |
| 20 | expect(cellAt(GRID_W - 1, r)).toBe("#"); | |
| 28 | expect(cellAt(DE_FORGE, 0, r)).toBe("#"); | |
| 29 | expect(cellAt(DE_FORGE, GRID_W - 1, r)).toBe("#"); | |
| 21 | 30 | } |
| 22 | 31 | }); |
| 23 | 32 | |
| 24 | 33 | test("grid and world coordinates round-trip", () => { |
| 25 | 34 | for (const [c, r] of [[3, 7], [21, 21], [40, 38]] as [number, number][]) { |
| 26 | const p = worldOf(c, r); | |
| 27 | expect(colOf(p[0])).toBe(c); | |
| 28 | expect(rowOf(p[1])).toBe(r); | |
| 35 | const p = worldOf(DE_FORGE, c, r); | |
| 36 | expect(colOf(DE_FORGE, p[0])).toBe(c); | |
| 37 | expect(rowOf(DE_FORGE, p[1])).toBe(r); | |
| 29 | 38 | } |
| 30 | 39 | }); |
| 31 | 40 | |
| @@ -33,13 +42,13 @@ test("every walkable cell is reachable from T spawn", () => { | ||
| 33 | 42 | // A room nobody can reach is a bug you only find by walking into it. |
| 34 | 43 | const start = spawns.t[0]; |
| 35 | 44 | const seen = new Set<number>(); |
| 36 | const queue = [rowOf(start[1]) * GRID_W + colOf(start[0])]; | |
| 45 | const queue = [rowOf(DE_FORGE, start[1]) * GRID_W + colOf(DE_FORGE, start[0])]; | |
| 37 | 46 | seen.add(queue[0]); |
| 38 | 47 | while (queue.length > 0) { |
| 39 | 48 | const i = queue.pop() as number; |
| 40 | 49 | const c = i % GRID_W, r = Math.floor(i / GRID_W); |
| 41 | 50 | for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { |
| 42 | if (!walkable(c + dc, r + dr)) continue; | |
| 51 | if (!walkable(DE_FORGE, c + dc, r + dr)) continue; | |
| 43 | 52 | const j = (r + dr) * GRID_W + c + dc; |
| 44 | 53 | if (seen.has(j)) continue; |
| 45 | 54 | seen.add(j); |
| @@ -47,7 +56,8 @@ test("every walkable cell is reachable from T spawn", () => { | ||
| 47 | 56 | } |
| 48 | 57 | } |
| 49 | 58 | let walkables = 0; |
| 50 | for (let r = 0; r < GRID_H; r++) for (let c = 0; c < GRID_W; c++) if (walkable(c, r)) walkables++; | |
| 59 | for (let r = 0; r < GRID_H; r++) | |
| 60 | for (let c = 0; c < GRID_W; c++) if (walkable(DE_FORGE, c, r)) walkables++; | |
| 51 | 61 | expect(seen.size).toBe(walkables); |
| 52 | 62 | }); |
| 53 | 63 | |
| @@ -74,7 +84,7 @@ test("de_forge is exactly where it was, to the unit", () => { | ||
| 74 | 84 | test("the route from T spawn to A is the same route it was", () => { |
| 75 | 85 | // A number a change to the search moves: 33 waypoints from the first T spawn |
| 76 | 86 | // cell to the centre of A, 4506 units walked against 4142 as the crow flies. |
| 77 | const path = findPath(spawns.t[0], sites.A); | |
| 87 | const path = findPath(DE_FORGE, spawns.t[0], sites.A); | |
| 78 | 88 | expect(spawns.t[0]).toEqual([576, -1984, 0]); |
| 79 | 89 | expect(path.length).toBe(33); |
| 80 | 90 | expect(path[0]).toEqual([704, -1984, 0]); |
| @@ -89,7 +99,7 @@ test("the route from T spawn to A is the same route it was", () => { | ||
| 89 | 99 | |
| 90 | 100 | test("a path from T spawn reaches both sites", () => { |
| 91 | 101 | for (const site of [sites.A, sites.B]) { |
| 92 | const path = findPath(spawns.t[0], site); | |
| 102 | const path = findPath(DE_FORGE, spawns.t[0], site); | |
| 93 | 103 | expect(path.length).toBeGreaterThan(4); |
| 94 | 104 | // Consecutive waypoints are neighbouring cells, never a jump across a wall. |
| 95 | 105 | for (let i = 1; i < path.length - 1; i++) { |
| @@ -100,5 +110,18 @@ test("a path from T spawn reaches both sites", () => { | ||
| 100 | 110 | }); |
| 101 | 111 | |
| 102 | 112 | test("a path into solid rock comes back empty rather than lying", () => { |
| 103 | expect(findPath(spawns.t[0], worldOf(0, 0))).toEqual([]); | |
| 113 | expect(findPath(DE_FORGE, spawns.t[0], worldOf(DE_FORGE, 0, 0))).toEqual([]); | |
| 114 | }); | |
| 115 | ||
| 116 | test("de_forge carries its own alphabet, its numbers and its material table", () => { | |
| 117 | // The predicate is the level's: nobody outside this file knows what a crate is. | |
| 118 | expect(DE_FORGE.name).toBe("de_forge"); | |
| 119 | expect(DE_FORGE.passable(".")).toBe(true); | |
| 120 | for (const solid of ["#", "x", "X"]) expect(DE_FORGE.passable(solid)).toBe(false); | |
| 121 | for (const open of ["A", "B", "t", "c"]) expect(DE_FORGE.passable(open)).toBe(true); | |
| 122 | expect(DE_FORGE.grid.cell).toBe(128); | |
| 123 | expect(DE_FORGE.grid.cells.length).toBe(GRID_W * GRID_H); | |
| 124 | expect(DE_FORGE.wallHeight).toBe(384); | |
| 125 | expect(DE_FORGE.materials).toEqual({ FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 }); | |
| 126 | expect(DE_FORGE.siteCells).toEqual({ A: "A", B: "B" }); | |
| 104 | 127 | }); |
src/client/map.ts +27 −38
| @@ -7,6 +7,12 @@ | ||
| 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. | |
| 15 | * | |
| 10 | 16 | * Everything in here is Counter-Strike. The grid itself — the cell arithmetic, |
| 11 | 17 | * the greedy meshing and the A* — is `src/engine/grid.ts`, which knows nothing |
| 12 | 18 | * about this alphabet: what counts as solid or walkable goes in as a predicate. |
| @@ -14,6 +20,7 @@ | ||
| 14 | 20 | |
| 15 | 21 | import * as g from "../engine/index.ts"; |
| 16 | 22 | import type { Box, Vec3 } from "../engine/index.ts"; |
| 23 | import type { Level } from "./level.ts"; | |
| 17 | 24 | |
| 18 | 25 | export const CELL = 128; |
| 19 | 26 | export const GRID_W = 44; |
| @@ -66,33 +73,24 @@ function build(): g.Grid<Cell> { | ||
| 66 | 73 | return { width: GRID_W, height: GRID_H, cell: CELL, cells }; |
| 67 | 74 | } |
| 68 | 75 | |
| 69 | export const grid: g.Grid<Cell> = build(); | |
| 76 | const grid = build(); | |
| 70 | 77 | |
| 71 | 78 | /** Off the edge of de_forge is rock, which is what the border is made of anyway. */ |
| 72 | export const cellAt = (col: number, row: number): Cell => g.cellAt(grid, col, row) ?? "#"; | |
| 79 | const cellAt = (col: number, row: number): Cell => g.cellAt(grid, col, row) ?? "#"; | |
| 73 | 80 | |
| 74 | 81 | /** |
| 75 | 82 | * Solid to a walking bot: walls and both kinds of crate. This predicate is the |
| 76 | * whole of what the engine's search knows about de_forge. | |
| 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. | |
| 77 | 85 | */ |
| 78 | export const passable = (c: Cell): boolean => c !== "#" && c !== "x" && c !== "X"; | |
| 86 | const passable = (c: string): boolean => c !== "#" && c !== "x" && c !== "X"; | |
| 79 | 87 | |
| 80 | /** The same question in grid coordinates, which is how bots and the policy ask it. */ | |
| 81 | export const walkable = (col: number, row: number): boolean => passable(cellAt(col, row)); | |
| 82 | ||
| 83 | /** Grid coordinates are north-down; the world is Y-north, centred on zero. */ | |
| 84 | export const worldOf = (col: number, row: number, z = 0): Vec3 => g.worldOf(grid, col, row, z); | |
| 85 | ||
| 86 | export const colOf = (x: number): number => g.colOf(grid, x); | |
| 87 | export const rowOf = (y: number): number => g.rowOf(grid, y); | |
| 88 | const worldOf = (col: number, row: number, z = 0): Vec3 => g.worldOf(grid, col, row, z); | |
| 88 | 89 | |
| 89 | 90 | // --- geometry ------------------------------------------------------------- |
| 90 | 91 | |
| 91 | 92 | export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const; |
| 92 | 93 | |
| 93 | /** The tracer owns the box; the map hands it out, because that is where it is written. */ | |
| 94 | export type { Box } from "../engine/index.ts"; | |
| 95 | ||
| 96 | 94 | const mergeSolid = (match: (c: Cell) => boolean, z0: number, z1: number, mat: number): Box[] => |
| 97 | 95 | g.mergeSolid(grid, match, z0, z1, mat); |
| 98 | 96 | |
| @@ -116,7 +114,7 @@ const half = (GRID_W / 2) * CELL; | ||
| 116 | 114 | const floor: Box = { min: [-half, -half, -32], max: [half, half, 0], mat: MAT.FLOOR }; |
| 117 | 115 | |
| 118 | 116 | /** Everything solid, in one list: the renderer draws it and bullets stop in it. */ |
| 119 | export const boxes: Box[] = [ | |
| 117 | const boxes: Box[] = [ | |
| 120 | 118 | floor, |
| 121 | 119 | siteSlab("A"), |
| 122 | 120 | siteSlab("B"), |
| @@ -134,31 +132,22 @@ function cellsOf(fill: Cell): Vec3[] { | ||
| 134 | 132 | return out; |
| 135 | 133 | } |
| 136 | 134 | |
| 137 | export const spawns: { t: Vec3[]; ct: Vec3[] } = { t: cellsOf("t"), ct: cellsOf("c") }; | |
| 138 | ||
| 139 | const siteCells = { A: cellsOf("A"), B: cellsOf("B") }; | |
| 140 | export const sites: { A: Vec3; B: Vec3 } = { | |
| 141 | A: centre(siteCells.A), | |
| 142 | B: centre(siteCells.B), | |
| 143 | }; | |
| 144 | ||
| 145 | 135 | function centre(list: Vec3[]): Vec3 { |
| 146 | 136 | const s = list.reduce((a, p) => [a[0] + p[0], a[1] + p[1], 0] as Vec3, [0, 0, 0] as Vec3); |
| 147 | 137 | return [s[0] / list.length, s[1] / list.length, 0]; |
| 148 | 138 | } |
| 149 | 139 | |
| 150 | /** Which bombsite a point stands in, if any. */ | |
| 151 | export function siteAt(pos: Vec3): "A" | "B" | null { | |
| 152 | const c = cellAt(colOf(pos[0]), rowOf(pos[1])); | |
| 153 | return c === "A" || c === "B" ? c : null; | |
| 154 | } | |
| 140 | const siteCells = { A: cellsOf("A"), B: cellsOf("B") }; | |
| 155 | 141 | |
| 156 | // --- pathfinding ---------------------------------------------------------- | |
| 157 | ||
| 158 | /** | |
| 159 | * A* over the walkable cells, eight ways, no corner-cutting; the search is the | |
| 160 | * engine's and what a bot may stand on is this game's answer. The grid is 1936 | |
| 161 | * cells, and 100 searches between random walkable cells average 0.10 ms each, | |
| 162 | * so a bot changing its goal mid-tick costs a tenth of what it has. | |
| 163 | */ | |
| 164 | export const findPath = (from: Vec3, to: Vec3): Vec3[] => g.findPath(grid, passable, from, to); | |
| 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 | }; |
src/client/models.test.ts +8 −7
| @@ -17,7 +17,8 @@ import { expect, test } from "bun:test"; | ||
| 17 | 17 | import { readFileSync } from "node:fs"; |
| 18 | 18 | import { join } from "node:path"; |
| 19 | 19 | import type { Mesh } from "../engine/index.ts"; |
| 20 | import { boxes, type Box } from "./map.ts"; | |
| 20 | import type { Box } from "./level.ts"; | |
| 21 | import { DE_FORGE } from "./map.ts"; | |
| 21 | 22 | import { LAYERS } from "./materials.ts"; |
| 22 | 23 | import { bombMesh, levelMesh, meshFor, playerMesh, viewModelMesh } from "./models.ts"; |
| 23 | 24 | import { HULL } from "./physics.ts"; |
| @@ -94,8 +95,8 @@ test("the level of no boxes is an empty mesh, not a throw", () => { | ||
| 94 | 95 | test("the level boxes grow the mesh and leave it well formed", () => { |
| 95 | 96 | const two = levelMesh([oneBox, { min: [0, 0, 0], max: [128, 128, 128], mat: 2 }]); |
| 96 | 97 | expect(wellFormed(two)).toEqual({ vertices: 48, triangles: 24 }); |
| 97 | const map = levelMesh(boxes); | |
| 98 | expect(wellFormed(map)).toEqual({ vertices: boxes.length * 24, triangles: boxes.length * 12 }); | |
| 98 | const map = levelMesh(DE_FORGE.boxes); | |
| 99 | expect(wellFormed(map)).toEqual({ vertices: DE_FORGE.boxes.length * 24, triangles: DE_FORGE.boxes.length * 12 }); | |
| 99 | 100 | }); |
| 100 | 101 | |
| 101 | 102 | test("the player stands on the floor at exactly the standing hull height", () => { |
| @@ -153,11 +154,11 @@ test("a kind nobody carries falls back to a gun rather than throwing", () => { | ||
| 153 | 154 | }); |
| 154 | 155 | |
| 155 | 156 | test("meshFor names the four recipes and nothing else", () => { |
| 156 | expect(data(meshFor("level"))).toBe(data(levelMesh(boxes))); | |
| 157 | expect(data(meshFor("player"))).toBe(data(playerMesh())); | |
| 158 | expect(data(meshFor("bomb"))).toBe(data(bombMesh())); | |
| 157 | expect(data(meshFor("level", DE_FORGE))).toBe(data(levelMesh(DE_FORGE.boxes))); | |
| 158 | expect(data(meshFor("player", DE_FORGE))).toBe(data(playerMesh())); | |
| 159 | expect(data(meshFor("bomb", DE_FORGE))).toBe(data(bombMesh())); | |
| 159 | 160 | for (const kind of new Set(Object.values(WEAPONS).map((w) => w.kind))) { |
| 160 | expect(data(meshFor(kind))).toBe(data(viewModelMesh(kind))); | |
| 161 | expect(data(meshFor(kind, DE_FORGE))).toBe(data(viewModelMesh(kind))); | |
| 161 | 162 | } |
| 162 | 163 | }); |
| 163 | 164 |
src/client/models.ts +6 −3
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { addBox, builder, finish, type Builder, type Mesh } from "../engine/index.ts"; |
| 8 | import { boxes, type Box } from "./map.ts"; | |
| 8 | import type { Box, Level } from "./level.ts"; | |
| 9 | 9 | import { HULL } from "./physics.ts"; |
| 10 | 10 | |
| 11 | 11 | /** The level, as one buffer. Fifty-odd boxes, three hundred quads, one draw. */ |
| @@ -78,9 +78,12 @@ export function viewModelMesh(kind: string): Mesh { | ||
| 78 | 78 | * hands them to the engine's renderer, which keeps them by name and never |
| 79 | 79 | * learns what a name is made of — that is this file, and this is the one line a |
| 80 | 80 | * second game's model table would replace. |
| 81 | * | |
| 82 | * Which level is drawn is the caller's: the map is a value now, so the geometry | |
| 83 | * of `level` is whatever level the match is played on. | |
| 81 | 84 | */ |
| 82 | export function meshFor(name: string): Mesh { | |
| 83 | if (name === "level") return levelMesh(boxes); | |
| 85 | export function meshFor(name: string, level: Level): Mesh { | |
| 86 | if (name === "level") return levelMesh(level.boxes); | |
| 84 | 87 | if (name === "player") return playerMesh(); |
| 85 | 88 | if (name === "bomb") return bombMesh(); |
| 86 | 89 | return viewModelMesh(name); |
src/client/observe.test.ts +2 −1
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { DE_FORGE } from "./map.ts"; | |
| 2 | 3 | import { COMPETITIVE } from "./match.ts"; |
| 3 | 4 | import { observe, OBS_SIZE } from "./observe.ts"; |
| 4 | 5 | import { aimAt, bodyCentre, canSee, createWorld, type Player } from "./world.ts"; |
| 5 | 6 | |
| 6 | 7 | const duel = (): { world: ReturnType<typeof createWorld>; me: Player; foe: Player } => { |
| 7 | const world = createWorld(COMPETITIVE, "you", "ct", 1, 1234); | |
| 8 | const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 1, 1234); | |
| 8 | 9 | const me = world.me; |
| 9 | 10 | const foe = world.players[1] as Player; |
| 10 | 11 | world.match.phase = "live"; |
src/client/observe.ts +12 −9
| @@ -4,15 +4,15 @@ | ||
| 4 | 4 | * Everything here is egocentric — distances and directions are in the bot's |
| 5 | 5 | * own frame, never the map's — so a policy trained in one corner of de_forge |
| 6 | 6 | * works in every other corner, and so the same 32 numbers describe a duel on a |
| 7 | * site and a duel in a tunnel. Free space is read off the map grid rather than | |
| 8 | * traced, because a grid lookup is a multiply and a trace is three hundred box | |
| 9 | * tests, and this runs once per bot per tick. | |
| 7 | * site and a duel in a tunnel. Free space is read off the grid of whatever level | |
| 8 | * the world is on rather than traced, because a grid lookup is a multiply and a | |
| 9 | * trace is three hundred box tests, and this runs once per bot per tick. | |
| 10 | 10 | * |
| 11 | 11 | * The layout is fixed: change it and the shipped weights mean something else, |
| 12 | 12 | * so OBS_SIZE and the order below are part of the policy's contract. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import * as map from "./map.ts"; | |
| 15 | import { colOf, rowOf, walkable, type Level } from "./level.ts"; | |
| 16 | 16 | import { angleDelta, clamp, dist, type Vec3 } from "../engine/index.ts"; |
| 17 | 17 | import { HULL } from "./physics.ts"; |
| 18 | 18 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| @@ -29,11 +29,11 @@ export type Memory = { | ||
| 29 | 29 | const rad = Math.PI / 180; |
| 30 | 30 | |
| 31 | 31 | /** Is the cell `d` units along (yaw + offset) somewhere a bot could stand? */ |
| 32 | const clearAt = (from: Vec3, yaw: number, offset: number, d: number): number => { | |
| 32 | const clearAt = (level: Level, from: Vec3, yaw: number, offset: number, d: number): number => { | |
| 33 | 33 | const a = (yaw + offset) * rad; |
| 34 | 34 | const x = from[0] + Math.cos(a) * d; |
| 35 | 35 | const y = from[1] + Math.sin(a) * d; |
| 36 | return map.walkable(map.colOf(x), map.rowOf(y)) ? 1 : 0; | |
| 36 | return walkable(level, colOf(level, x), rowOf(level, y)) ? 1 : 0; | |
| 37 | 37 | }; |
| 38 | 38 | |
| 39 | 39 | /** |
| @@ -81,9 +81,12 @@ export function observe( | ||
| 81 | 81 | }); |
| 82 | 82 | const magazine = Math.max(1, weapon.magazine); |
| 83 | 83 | const eye = eyePos(bot); |
| 84 | const ahead = (clearAt(eye, bot.yaw, 0, 64) + clearAt(eye, bot.yaw, 0, 128) + clearAt(eye, bot.yaw, 0, 224)) / 3; | |
| 85 | const right = (clearAt(eye, bot.yaw, -90, 96) + clearAt(eye, bot.yaw, -50, 128)) / 2; | |
| 86 | const left = (clearAt(eye, bot.yaw, 90, 96) + clearAt(eye, bot.yaw, 50, 128)) / 2; | |
| 84 | const level = world.level; | |
| 85 | const ahead = | |
| 86 | (clearAt(level, eye, bot.yaw, 0, 64) + clearAt(level, eye, bot.yaw, 0, 128) + | |
| 87 | clearAt(level, eye, bot.yaw, 0, 224)) / 3; | |
| 88 | const right = (clearAt(level, eye, bot.yaw, -90, 96) + clearAt(level, eye, bot.yaw, -50, 128)) / 2; | |
| 89 | const left = (clearAt(level, eye, bot.yaw, 90, 96) + clearAt(level, eye, bot.yaw, 50, 128)) / 2; | |
| 87 | 90 | |
| 88 | 91 | out[at + 0] = 1; // a bias, so a dead layer still has an offset to lean on |
| 89 | 92 | out[at + 1] = target === null ? 0 : 1; |
src/client/physics.test.ts +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import type { Box } from "./map.ts"; | |
| 2 | import type { Box } from "./level.ts"; | |
| 3 | 3 | import type { Vec3 } from "../engine/index.ts"; |
| 4 | 4 | import { accelerate, airAccelerate, fallDamage, HULL, MOVE, move, type Body, type Wish } from "./physics.ts"; |
| 5 | 5 |
src/client/physics.ts +1 −1
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | * a 32×32×72 hull, an 18-unit step. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import type { Box } from "./map.ts"; | |
| 11 | import type { Box } from "./level.ts"; | |
| 12 | 12 | import { clamp, dot, normalize, type Vec3 } from "../engine/index.ts"; |
| 13 | 13 | |
| 14 | 14 | export const HULL = { radius: 16, stand: 72, duck: 54, eyeStand: 64, eyeDuck: 46 }; |
src/client/replay.test.ts +7 −6
| @@ -20,6 +20,7 @@ | ||
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | 22 | import { expect, test } from "bun:test"; |
| 23 | import { DE_FORGE } from "./map.ts"; | |
| 23 | 24 | import { SHORT } from "./match.ts"; |
| 24 | 25 | import { |
| 25 | 26 | CHECKPOINT_CHARS, compare, digest, fold, reader, snapshot, TICK, TICKS_PER_SECOND, |
| @@ -73,7 +74,7 @@ const ROUND_PIN: Pin = { label: "ten-bot round", constant: "ROUND", digest: ROUN | ||
| 73 | 74 | const DUEL_PIN: Pin = { label: "arena duel", constant: "DUEL", digest: DUEL, seconds: DUEL_SECONDS }; |
| 74 | 75 | |
| 75 | 76 | /** The round, exactly as the pin was taken. */ |
| 76 | const roundRun = (): Run => withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 7), TICKS)); | |
| 77 | const roundRun = (): Run => withoutEntropy(() => digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), TICKS)); | |
| 77 | 78 | |
| 78 | 79 | /** |
| 79 | 80 | * The duel, stepped the arena's own way: the learner takes the script, the |
| @@ -111,9 +112,9 @@ test("a seeded duel in the training arena plays exactly as it played", () => { | ||
| 111 | 112 | |
| 112 | 113 | test("the same seed is the same run, and another seed is another run", () => { |
| 113 | 114 | const four = 4 * TICKS_PER_SECOND; |
| 114 | const a = withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 7), four)); | |
| 115 | const b = withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 7), four)); | |
| 116 | const other = withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 8), four)); | |
| 115 | const a = withoutEntropy(() => digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), four)); | |
| 116 | const b = withoutEntropy(() => digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), four)); | |
| 117 | const other = withoutEntropy(() => digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 8), four)); | |
| 117 | 118 | expect(a.digest).toBe(b.digest); |
| 118 | 119 | expect(a.digest).not.toBe(other.digest); |
| 119 | 120 | // The first four seconds of the long run are the first four seconds, too. |
| @@ -121,7 +122,7 @@ test("the same seed is the same run, and another seed is another run", () => { | ||
| 121 | 122 | }); |
| 122 | 123 | |
| 123 | 124 | test("a move the game could not feel leaves the digest alone; one it could moves it", () => { |
| 124 | const world = createWorld(SHORT, "me", "ct", 5, 7); | |
| 125 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7); | |
| 125 | 126 | const before = snapshot(world); |
| 126 | 127 | world.me.pos[0] += 2e-6; // a fiftieth of the quantum: libm noise, not behaviour |
| 127 | 128 | expect(snapshot(world)).toBe(before); |
| @@ -132,7 +133,7 @@ test("a move the game could not feel leaves the digest alone; one it could moves | ||
| 132 | 133 | test("a nudged simulation is a different run from the first second on", () => { |
| 133 | 134 | const four = 4 * TICKS_PER_SECOND; |
| 134 | 135 | const nudged = withoutEntropy(() => { |
| 135 | const world = createWorld(SHORT, "me", "ct", 5, 7); | |
| 136 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7); | |
| 136 | 137 | (world.players[3] as Player).pos[1] += 1; // one unit, on one bot, before the first tick |
| 137 | 138 | return digest(world, four); |
| 138 | 139 | }); |
src/client/scene.test.ts +2 −1
| @@ -16,6 +16,7 @@ import { join } from "node:path"; | ||
| 16 | 16 | import type { Camera } from "../engine/index.ts"; |
| 17 | 17 | import { HULL } from "./physics.ts"; |
| 18 | 18 | import { buildScene, createScene, LINE_CAPACITY, SKY } from "./scene.ts"; |
| 19 | import { DE_FORGE } from "./map.ts"; | |
| 19 | 20 | import { SHORT } from "./match.ts"; |
| 20 | 21 | import { createWorld, type Effect, type World } from "./world.ts"; |
| 21 | 22 | |
| @@ -23,7 +24,7 @@ const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90 | ||
| 23 | 24 | |
| 24 | 25 | /** A match nobody is playing: ten players, the bomb carried, no effects. */ |
| 25 | 26 | function world(): World { |
| 26 | const w = createWorld(SHORT, "you", "ct", 5, 7); | |
| 27 | const w = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 7); | |
| 27 | 28 | w.time = 100; |
| 28 | 29 | w.effects.length = 0; |
| 29 | 30 | return w; |
src/client/world.test.ts +16 −15
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { siteAt, sites } from "./map.ts"; | |
| 2 | import { siteAt } from "./level.ts"; | |
| 3 | import { DE_FORGE } from "./map.ts"; | |
| 3 | 4 | import { SHORT } from "./match.ts"; |
| 4 | 5 | import { WEAPONS } from "./weapons.ts"; |
| 5 | 6 | import { buy, createWorld, currentWeapon, NO_INPUT, shoot, startRound, update, type Input, type World } from "./world.ts"; |
| @@ -10,7 +11,7 @@ const run = (world: World, seconds: number, input: Input = NO_INPUT): void => { | ||
| 10 | 11 | }; |
| 11 | 12 | |
| 12 | 13 | test("a world starts ten players, one bomb and a freeze time", () => { |
| 13 | const world = createWorld(SHORT, "me", "ct", 5, 1); | |
| 14 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 1); | |
| 14 | 15 | expect(world.players.length).toBe(10); |
| 15 | 16 | expect(world.players.filter((p) => p.team === "t").length).toBe(5); |
| 16 | 17 | expect(world.players.filter((p) => p.bot).length).toBe(9); |
| @@ -20,7 +21,7 @@ test("a world starts ten players, one bomb and a freeze time", () => { | ||
| 20 | 21 | }); |
| 21 | 22 | |
| 22 | 23 | test("nobody moves or shoots during the freeze", () => { |
| 23 | const world = createWorld(SHORT, "me", "ct", 5, 2); | |
| 24 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 2); | |
| 24 | 25 | const start = world.players.map((p) => [...p.pos]); |
| 25 | 26 | run(world, SHORT.freezeTime - 1, { ...NO_INPUT, forward: 1, fire: true }); |
| 26 | 27 | world.players.forEach((p, i) => { |
| @@ -30,7 +31,7 @@ test("nobody moves or shoots during the freeze", () => { | ||
| 30 | 31 | }); |
| 31 | 32 | |
| 32 | 33 | test("buying takes the money and hands over the gun, and only in the freeze", () => { |
| 33 | const world = createWorld(SHORT, "me", "ct", 5, 3); | |
| 34 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 3); | |
| 34 | 35 | world.me.money = 5000; |
| 35 | 36 | expect(buy(world, world.me, "m4a4")).toBe(true); |
| 36 | 37 | expect(world.me.money).toBe(5000 - WEAPONS.m4a4.price); |
| @@ -44,7 +45,7 @@ test("buying takes the money and hands over the gun, and only in the freeze", () | ||
| 44 | 45 | }); |
| 45 | 46 | |
| 46 | 47 | test("a shot costs a bullet and a reload puts it back", () => { |
| 47 | const world = createWorld(SHORT, "me", "ct", 5, 4); | |
| 48 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 4); | |
| 48 | 49 | world.me.money = 5000; |
| 49 | 50 | buy(world, world.me, "m4a4"); |
| 50 | 51 | run(world, SHORT.freezeTime + 0.5); |
| @@ -57,7 +58,7 @@ test("a shot costs a bullet and a reload puts it back", () => { | ||
| 57 | 58 | }); |
| 58 | 59 | |
| 59 | 60 | test("bots leave their spawn once the round goes live", () => { |
| 60 | const world = createWorld(SHORT, "me", "ct", 5, 5); | |
| 61 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 5); | |
| 61 | 62 | const bot = world.players.find((p) => p.bot && p.team === "t"); |
| 62 | 63 | const start = [...bot.pos]; |
| 63 | 64 | run(world, SHORT.freezeTime + 6); |
| @@ -65,7 +66,7 @@ test("bots leave their spawn once the round goes live", () => { | ||
| 65 | 66 | }); |
| 66 | 67 | |
| 67 | 68 | test("a round is played out: bots meet, shoot, and one side wins it", () => { |
| 68 | const world = createWorld(SHORT, "me", "ct", 5, 7); | |
| 69 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7); | |
| 69 | 70 | world.me.alive = false; // stand back and let the bots have it |
| 70 | 71 | run(world, SHORT.freezeTime + SHORT.roundTime + 1); |
| 71 | 72 | expect(world.match.round >= 1).toBe(true); |
| @@ -74,13 +75,13 @@ test("a round is played out: bots meet, shoot, and one side wins it", () => { | ||
| 74 | 75 | }); |
| 75 | 76 | |
| 76 | 77 | test("the terrorists plant, and a planted bomb keeps the round alive past the clock", () => { |
| 77 | const world = createWorld(SHORT, "me", "t", 5, 11); | |
| 78 | const world = createWorld(DE_FORGE, SHORT, "me", "t", 5, 11); | |
| 78 | 79 | run(world, SHORT.freezeTime + 0.2); |
| 79 | 80 | // Put the person on the site holding the bomb and press use. |
| 80 | 81 | for (const p of world.players) p.hasBomb = false; |
| 81 | 82 | world.me.hasBomb = true; |
| 82 | world.me.pos = [sites.A[0], sites.A[1], 2]; | |
| 83 | expect(siteAt(world.me.pos)).toBe("A"); | |
| 83 | world.me.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 2]; | |
| 84 | expect(siteAt(world.level, world.me.pos)).toBe("A"); | |
| 84 | 85 | run(world, SHORT.plantTime + 0.3, { ...NO_INPUT, use: true }); |
| 85 | 86 | expect(world.bomb.state).toBe("planted"); |
| 86 | 87 | expect(world.match.phase).toBe("planted"); |
| @@ -88,13 +89,13 @@ test("the terrorists plant, and a planted bomb keeps the round alive past the cl | ||
| 88 | 89 | }); |
| 89 | 90 | |
| 90 | 91 | test("a defusing CT wins the round, and the money follows the rules", () => { |
| 91 | const world = createWorld(SHORT, "me", "ct", 5, 13); | |
| 92 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 13); | |
| 92 | 93 | run(world, SHORT.freezeTime + 0.2); |
| 93 | world.bomb = { state: "planted", pos: [sites.B[0], sites.B[1], 2], site: "B", plantProgress: 0, defuseProgress: 0, defuser: null }; | |
| 94 | world.bomb = { state: "planted", pos: [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2], site: "B", plantProgress: 0, defuseProgress: 0, defuser: null }; | |
| 94 | 95 | world.bombPlanted = true; |
| 95 | 96 | world.match.phase = "planted"; |
| 96 | 97 | world.match.timer = 30; |
| 97 | world.me.pos = [sites.B[0], sites.B[1], 2]; | |
| 98 | world.me.pos = [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2]; | |
| 98 | 99 | world.me.defuseKit = true; |
| 99 | 100 | const before = world.me.money; |
| 100 | 101 | run(world, SHORT.defuseTimeWithKit + 0.3, { ...NO_INPUT, use: true }); |
| @@ -105,7 +106,7 @@ test("a defusing CT wins the round, and the money follows the rules", () => { | ||
| 105 | 106 | }); |
| 106 | 107 | |
| 107 | 108 | test("a new round puts everyone back with full health and a full magazine", () => { |
| 108 | const world = createWorld(SHORT, "me", "ct", 5, 17); | |
| 109 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 17); | |
| 109 | 110 | world.me.money = 5000; |
| 110 | 111 | buy(world, world.me, "m4a4"); |
| 111 | 112 | world.me.ammo.m4a4.mag = 3; |
| @@ -119,7 +120,7 @@ test("a new round puts everyone back with full health and a full magazine", () = | ||
| 119 | 120 | }); |
| 120 | 121 | |
| 121 | 122 | test("a match plays itself to the end inside its own time", () => { |
| 122 | const world = createWorld({ ...SHORT, winScore: 2, freezeTime: 1, roundTime: 40, endDelay: 1 }, "me", "ct", 5, 23); | |
| 123 | const world = createWorld(DE_FORGE, { ...SHORT, winScore: 2, freezeTime: 1, roundTime: 40, endDelay: 1 }, "me", "ct", 5, 23); | |
| 123 | 124 | world.me.alive = false; |
| 124 | 125 | for (let t = 0; t < 60 * 8 && world.match.phase !== "match_over"; t += TICK) update(world, TICK, NO_INPUT); |
| 125 | 126 | expect(world.match.phase).toBe("match_over"); |
src/client/world.ts +22 −9
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { applyHit, traceBullet, type Actor } from "./combat.ts"; |
| 11 | import * as map from "./map.ts"; | |
| 11 | import { siteAt, type Level } from "./level.ts"; | |
| 12 | 12 | import { angleVectors, angleDelta, clamp, dist, normalize, rng, sub, visible, type Vec3 } from "../engine/index.ts"; |
| 13 | 13 | import * as rules from "./match.ts"; |
| 14 | 14 | import { eyeHeight, hullHeight, move, type Body, type Wish, MOVE } from "./physics.ts"; |
| @@ -95,7 +95,13 @@ export type World = { | ||
| 95 | 95 | feed: FeedEntry[]; |
| 96 | 96 | messages: { at: number; text: string }[]; |
| 97 | 97 | random: () => number; |
| 98 | boxes: typeof map.boxes; | |
| 98 | /** | |
| 99 | * The map this round is played on, whole. Everything that needs to know where | |
| 100 | * it is standing — the bots, the bomb, the bullets, the frame — reads it from | |
| 101 | * here rather than importing one, which is what lets a test play a four-cell | |
| 102 | * room and what a second map will need. | |
| 103 | */ | |
| 104 | level: Level; | |
| 99 | 105 | bombPlanted: boolean; // in this round, for the economy |
| 100 | 106 | pendingBuy: boolean; |
| 101 | 107 | }; |
| @@ -124,7 +130,13 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player | ||
| 124 | 130 | }; |
| 125 | 131 | } |
| 126 | 132 | |
| 133 | /** | |
| 134 | * A world on a level. The level comes first because it is the ground everything | |
| 135 | * else stands on: hand it de_forge and you get the game, hand it a box room and | |
| 136 | * you get the same game in a box room. | |
| 137 | */ | |
| 127 | 138 | export function createWorld( |
| 139 | level: Level, | |
| 128 | 140 | ruleSet: rules.Rules, |
| 129 | 141 | playerName: string, |
| 130 | 142 | playerTeam: Team = "ct", |
| @@ -150,7 +162,7 @@ export function createWorld( | ||
| 150 | 162 | match, players, me, time: 0, |
| 151 | 163 | bomb: { state: "carried", pos: [0, 0, 0], site: null, plantProgress: 0, defuseProgress: 0, defuser: null }, |
| 152 | 164 | effects: [], feed: [], messages: [], |
| 153 | random, boxes: map.boxes, bombPlanted: false, pendingBuy: false, | |
| 165 | random, level, bombPlanted: false, pendingBuy: false, | |
| 154 | 166 | }; |
| 155 | 167 | startRound(world); |
| 156 | 168 | return world; |
| @@ -170,10 +182,11 @@ function say(world: World, text: string): void { | ||
| 170 | 182 | // --- rounds --------------------------------------------------------------- |
| 171 | 183 | |
| 172 | 184 | export function startRound(world: World): void { |
| 173 | const spots = { t: [...map.spawns.t], ct: [...map.spawns.ct] }; | |
| 185 | const spawns = world.level.spawns; | |
| 186 | const spots = { t: [...spawns.t], ct: [...spawns.ct] }; | |
| 174 | 187 | for (const p of world.players) { |
| 175 | 188 | const list = spots[p.team]; |
| 176 | const at = list.splice(Math.floor(world.random() * list.length), 1)[0] ?? map.spawns[p.team][0]; | |
| 189 | const at = list.splice(Math.floor(world.random() * list.length), 1)[0] ?? spawns[p.team][0]; | |
| 177 | 190 | p.pos = [at[0], at[1], 2]; |
| 178 | 191 | p.vel = [0, 0, 0]; |
| 179 | 192 | p.health = 100; |
| @@ -338,7 +351,7 @@ export function shoot(world: World, p: Player): boolean { | ||
| 338 | 351 | const range = w.kind === "knife" ? 64 : RANGE; |
| 339 | 352 | const dir = angleVectors(yaw, pitch).forward; |
| 340 | 353 | const to: Vec3 = [from[0] + dir[0] * range, from[1] + dir[1] * range, from[2] + dir[2] * range]; |
| 341 | const hit = traceBullet(from, to, world.boxes, world.players, p.id); | |
| 354 | const hit = traceBullet(from, to, world.level.boxes, world.players, p.id); | |
| 342 | 355 | world.effects.push({ kind: "shot", at: world.time, pos: from, weapon: w.id, mine: p === world.me }); |
| 343 | 356 | if (w.kind !== "knife") world.effects.push({ kind: "tracer", at: world.time, from, to: hit.kind === "none" ? to : hit.point }); |
| 344 | 357 | |
| @@ -381,7 +394,7 @@ function updateBomb(world: World, p: Player, input: Input, dt: number): void { | ||
| 381 | 394 | const bomb = world.bomb; |
| 382 | 395 | if (bomb.state === "carried" && p.hasBomb && world.match.phase === "live") { |
| 383 | 396 | bomb.pos = [p.pos[0], p.pos[1], p.pos[2]]; |
| 384 | const site = map.siteAt(p.pos); | |
| 397 | const site = siteAt(world.level, p.pos); | |
| 385 | 398 | if (input.use && site !== null && p.onGround) { |
| 386 | 399 | bomb.plantProgress += dt; |
| 387 | 400 | if (bomb.plantProgress >= world.match.rules.plantTime) { |
| @@ -455,7 +468,7 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 455 | 468 | walk: input.walk || p.scoped, |
| 456 | 469 | maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed), |
| 457 | 470 | }; |
| 458 | move(p, wish, dt, world.boxes); | |
| 471 | move(p, wish, dt, world.level.boxes); | |
| 459 | 472 | finishReload(world, p); |
| 460 | 473 | if (input.reload) reload(world, p); |
| 461 | 474 | if (w.zoom !== undefined && input.scope !== p.scoped && world.time > p.nextShotAt - 0.05) p.scoped = input.scope; |
| @@ -519,7 +532,7 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 519 | 532 | |
| 520 | 533 | /** Is `target` visible to `from`, eye to eye? Used by bots and by the HUD. */ |
| 521 | 534 | export function canSee(world: World, from: Player, target: Player): boolean { |
| 522 | return visible(eyePos(from), eyePos(target), world.boxes); | |
| 535 | return visible(eyePos(from), eyePos(target), world.level.boxes); | |
| 523 | 536 | } |
| 524 | 537 | |
| 525 | 538 | export const facing = (p: Player, target: Vec3): number => { |
src/engine/GUIDE.md +4 −2
| @@ -178,8 +178,10 @@ cell-to-world arithmetic, `mergeSolid` greedily merges runs of matching cells | ||
| 178 | 178 | into as few boxes as the shape allows, and `findPath` is A* with no |
| 179 | 179 | corner-cutting. Which cells are solid and which are walkable arrive as |
| 180 | 180 | predicates, so the engine never learns what a `"#"` means. The example has no |
| 181 | grid — one box is not a map — so read `src/client/map.ts`, which lays de_forge's | |
| 182 | cell alphabet over this and hands back the level's boxes and its paths. | |
| 181 | grid — one box is not a map — so read `src/client/level.ts`, which is what a | |
| 182 | level is to the shooter (a grid, a predicate, boxes, spawns and sites, as one | |
| 183 | value), and `src/client/map.ts`, which lays de_forge's cell alphabet over this | |
| 184 | and is one of them. | |
| 183 | 185 | |
| 184 | 186 | ### mesh |
| 185 | 187 |
src/frames.test.ts +2 −1
| @@ -21,6 +21,7 @@ | ||
| 21 | 21 | import { expect, test } from "bun:test"; |
| 22 | 22 | import type { Camera } from "./engine/index.ts"; |
| 23 | 23 | import { INSTANCE_CAPACITY, LINE_CAPACITY, buildScene, createScene } from "./client/scene.ts"; |
| 24 | import { DE_FORGE } from "./client/map.ts"; | |
| 24 | 25 | import { SHORT } from "./client/match.ts"; |
| 25 | 26 | import { createWorld, type Player, type World } from "./client/world.ts"; |
| 26 | 27 | import { |
| @@ -35,7 +36,7 @@ const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90 | ||
| 35 | 36 | |
| 36 | 37 | /** A match with `crowd` extra bodies standing in it, all of them drawable. */ |
| 37 | 38 | function crowded(crowd: number): World { |
| 38 | const world = createWorld(SHORT, "you", "ct", 5, 7); | |
| 39 | const world = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 7); | |
| 39 | 40 | world.time = 100; |
| 40 | 41 | world.effects.length = 0; |
| 41 | 42 | const bot = world.players.find((p) => p !== world.me) as Player; |
src/games.ts +9
| @@ -69,6 +69,15 @@ export type Game = { | ||
| 69 | 69 | * it, because the container ships whole to a game that uses any of it. Measured |
| 70 | 70 | * 2026-09-18 after #78: app 71,186 raw / 33,125 gzipped, range 27,234 raw / |
| 71 | 71 | * 11,655 gzipped. Both fit today's ceilings, so neither moved. |
| 72 | * #95 made the map a value — `src/client/level.ts` says what a level is and | |
| 73 | * `DE_FORGE` is one, handed to `createWorld` instead of imported — and the app | |
| 74 | * grew 460 raw / 143 gzipped bytes for it: the level's own functions, and a | |
| 75 | * level argument on the calls that used to reach for a singleton. Measured | |
| 76 | * 2026-09-18 after #95 merged with #103: app 71,646 raw / 33,272 gzipped, | |
| 77 | * range unchanged at 27,234 raw / 11,656 gzipped — #103's headless page is a | |
| 78 | * test helper and ships in neither bundle, so the app is the same size to the | |
| 79 | * byte and differs only in which short names the minifier handed out. Both | |
| 80 | * fit, so neither ceiling moved. | |
| 72 | 81 | */ |
| 73 | 82 | export const GAMES: readonly Game[] = [ |
| 74 | 83 | { |
src/rl/arena.ts +24 −13
| @@ -14,7 +14,8 @@ | ||
| 14 | 14 | |
| 15 | 15 | import { decode, toInput, type Intent } from "../client/action.ts"; |
| 16 | 16 | import { makeBrain, botThink } from "../client/bots.ts"; |
| 17 | import * as map from "../client/map.ts"; | |
| 17 | import { openCells, type Level } from "../client/level.ts"; | |
| 18 | import { DE_FORGE } from "../client/map.ts"; | |
| 18 | 19 | import { angleDelta, clamp, dist, rng, type Vec3 } from "../engine/index.ts"; |
| 19 | 20 | import { observe, OBS_SIZE, type Memory } from "../client/observe.ts"; |
| 20 | 21 | import type { Input, Player, World } from "../client/world.ts"; |
| @@ -33,16 +34,24 @@ export const EPISODE_TICKS = 384; // six seconds, which is a long fight | ||
| 33 | 34 | */ |
| 34 | 35 | export const SKIP = 4; |
| 35 | 36 | |
| 36 | /** Every cell a player can stand in, once, so a reset is two array lookups. */ | |
| 37 | const OPEN: Vec3[] = (() => { | |
| 38 | const out: Vec3[] = []; | |
| 39 | for (let r = 0; r < map.GRID_H; r++) | |
| 40 | for (let c = 0; c < map.GRID_W; c++) if (map.walkable(c, r)) out.push(map.worldOf(c, r, 2)); | |
| 41 | return out; | |
| 42 | })(); | |
| 37 | /** | |
| 38 | * Every cell a player can stand in on a level, once, so a reset is two array | |
| 39 | * lookups. Kept per level rather than per arena: a training run makes sixty-four | |
| 40 | * arenas on the same map and they can share one list, because nothing writes it. | |
| 41 | */ | |
| 42 | const OPEN = new WeakMap<Level, Vec3[]>(); | |
| 43 | const openOf = (level: Level): Vec3[] => { | |
| 44 | const known = OPEN.get(level); | |
| 45 | if (known !== undefined) return known; | |
| 46 | const made = openCells(level, 2); | |
| 47 | OPEN.set(level, made); | |
| 48 | return made; | |
| 49 | }; | |
| 43 | 50 | |
| 44 | 51 | export type Arena = { |
| 45 | 52 | world: World; |
| 53 | /** Where a fresh duel may put the two of them: every walkable cell of the level. */ | |
| 54 | open: Vec3[]; | |
| 46 | 55 | learner: Player; |
| 47 | 56 | foe: Player; |
| 48 | 57 | memory: Memory; |
| @@ -79,14 +88,15 @@ export function policyFoe(think: (arena: Arena) => Intent): (arena: Arena, dt: n | ||
| 79 | 88 | }; |
| 80 | 89 | } |
| 81 | 90 | |
| 82 | export function createArena(seed: number, opponent = scriptedFoe): Arena { | |
| 91 | export function createArena(seed: number, opponent = scriptedFoe, level: Level = DE_FORGE): Arena { | |
| 83 | 92 | // Two players, one a side: createWorld with a team size of one gives exactly |
| 84 | // that — the person's slot and one bot — and we drive both ourselves. | |
| 85 | const world = createWorld(SHORT, "learner", "ct", 1, seed); | |
| 93 | // that — the person's slot and one bot — and we drive both ourselves. The | |
| 94 | // level is de_forge because that is the game the policy has to be good at. | |
| 95 | const world = createWorld(level, SHORT, "learner", "ct", 1, seed); | |
| 86 | 96 | const learner = world.me; |
| 87 | 97 | const foe = world.players[1] as Player; |
| 88 | 98 | const arena: Arena = { |
| 89 | world, learner, foe, | |
| 99 | world, open: openOf(level), learner, foe, | |
| 90 | 100 | memory: { sinceSeen: 9 }, |
| 91 | 101 | foeMemory: { sinceSeen: 9 }, |
| 92 | 102 | ticks: 0, |
| @@ -167,7 +177,8 @@ export function reset(arena: Arena): void { | ||
| 167 | 177 | // Two spots the right distance apart, preferring a pair that can or cannot |
| 168 | 178 | // see each other as the coin said. The distance is not negotiable and the |
| 169 | 179 | // line of sight is: a duel at the wrong range teaches the wrong weapon. |
| 170 | const cell = (): Vec3 => OPEN[Math.floor(random() * OPEN.length)] as Vec3; | |
| 180 | const open = arena.open; | |
| 181 | const cell = (): Vec3 => open[Math.floor(random() * open.length)] as Vec3; | |
| 171 | 182 | let a = cell(), b = cell(); |
| 172 | 183 | for (let tries = 0; tries < 400; tries++) { |
| 173 | 184 | const d = dist(a, b); |
static/app.98e3474985.js added +58
Diff not shown — a line 59,160 characters wide (limit 2,000). +58
View filestatic/app.f34f5d20d9.js deleted −58
Diff not shown — a line 58,700 characters wide (limit 2,000). −58
View file
new above
offline