Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The solver moves behind the door: src/engine/physics.ts, and Counter-Strike's numbers become src/client/movement.ts #140
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/123-the-solver-moves-behind-the-door-src-eng → main 2 commits fork
README.md +13 −6
| @@ -159,7 +159,12 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 159 | 159 | |
| 160 | 160 | - `src/engine/` — the part a second game could use, and the rule is that it never |
| 161 | 161 | imports the game: `math` (vectors, matrices, angles, a seeded rng), `trace` |
| 162 | (rays against boxes, and the `Box` they are traced against), `grid` (a grid of | |
| 162 | (rays against boxes, and the `Box` they are traced against), `physics` (the | |
| 163 | Quake-lineage mover: friction, a capped ground acceleration, the air | |
| 164 | acceleration that makes strafe-jumping work, walls, steps and the crouch — it | |
| 165 | reads no tuned number of its own, because a `MoveTuning` and a `Hull` arrive as | |
| 166 | arguments on every call, so the feel of a game is the pair it hands in), | |
| 167 | `grid` (a grid of | |
| 163 | 168 | cells of any type, the cell-to-world arithmetic, greedy meshing and A* with no |
| 164 | 169 | corner-cutting — which cells are solid or walkable arrive as predicates, so it |
| 165 | 170 | knows nothing about de_forge's alphabet), `mesh` (a box builder), `texture` |
| @@ -214,7 +219,9 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 214 | 219 | `length`, `length2d`, `dist`, `dist2d`, `normalize`, `lerp`, `clamp`, `rad`, |
| 215 | 220 | `deg`, `angleDelta`, `angleVectors`, `mat4`, `multiply`, `perspective`, |
| 216 | 221 | `viewMatrix`, `modelMatrix`, `rng`, `Vec3` and `Mat4`; from `trace` `rayBox`, |
| 217 | `traceWorld`, `visible`, `segmentLength`, `Box` and `Hit`; from `grid` | |
| 222 | `traceWorld`, `visible`, `segmentLength`, `Box` and `Hit`; from `physics` `move`, | |
| 223 | `accelerate`, `airAccelerate`, `groundUnder`, `stuck`, `eyeHeight`, | |
| 224 | `hullHeight`, `Body`, `Wish`, `MoveTuning` and `Hull`; from `grid` | |
| 218 | 225 | `inside`, `cellAt`, `worldOf`, `colOf`, `rowOf`, `mergeSolid`, `findPath` and |
| 219 | 226 | `Grid`; from `mesh` `builder`, `addBox`, `finish`, `quadMesh`, `Builder` and |
| 220 | 227 | `Mesh`; from `texture` `hash`, `noise`, `fbm`, `paintAtlas` and `Shader`; from |
| @@ -255,10 +262,10 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 255 | 262 | `siteAt`, `findPath`), `map` (de_forge itself: its areas, its crates, its cell |
| 256 | 263 | alphabet and its spawns and sites, laid over the engine's grid, exported as |
| 257 | 264 | `DE_FORGE` — one value of `Level` and the only one that ships), |
| 258 | `physics` (the Quake-lineage mover, which reads no number out of its own | |
| 259 | module scope: every function takes what it is tuned by as a required | |
| 260 | argument — subject, situation, tuning, hull — and `MOVE` and `HULL` are the | |
| 261 | Counter-Strike answers its callers hand in), `weapons`, `combat`, `match` | |
| 265 | `movement` (Counter-Strike's numbers, and nothing else: `CS_MOVE`, `CS_HULL` | |
| 266 | and `fallDamage`, handed to the engine's mover on every call — the solver | |
| 267 | itself went behind the door in #123 and holds none of them), `weapons`, | |
| 268 | `combat`, `match` | |
| 262 | 269 | (the rules), |
| 263 | 270 | `world` (the simulation), |
| 264 | 271 | `bots`, `observe`, `action`, `weights`, `models`, `materials`, |
src/client/bundle.test.ts +11
| @@ -208,6 +208,17 @@ test("the shooter bundles the engine's policy network, because its bots run it", | ||
| 208 | 208 | expect(code).not.toContain("btoa"); |
| 209 | 209 | }); |
| 210 | 210 | |
| 211 | test("the shooter bundles the engine's mover, tuned by its own numbers", () => { | |
| 212 | // The other end of `src/range/bundle.test.ts`'s second claim. The solver is | |
| 213 | // the engine's since #123 and the numbers are still this game's: the fields | |
| 214 | // are here because `src/client/movement.ts` writes them, and so is the jump | |
| 215 | // velocity itself, which no engine module knows. | |
| 216 | const code = readFileSync(bundlePath("app"), "utf8"); | |
| 217 | for (const field of ["airSpeedCap", "duckRate", "jumpVelocity", "stopSpeed"]) | |
| 218 | expect(code).toContain(field); | |
| 219 | expect(code).toContain("301.993"); | |
| 220 | }); | |
| 221 | ||
| 211 | 222 | test("the stub page leaves nothing of itself behind", () => { |
| 212 | 223 | // Every boot above undid its install, so no other test inherits a document — |
| 213 | 224 | // nor a `fetch` that answers every URL with a leaderboard. |
src/client/combat.ts +2 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Box } from "./level.ts"; |
| 8 | 8 | import { dist, rayBox, traceWorld, type Vec3 } from "../engine/index.ts"; |
| 9 | import { HULL } from "./physics.ts"; | |
| 9 | import { CS_HULL } from "./movement.ts"; | |
| 10 | 10 | import { computeDamage, type Hitgroup, type Weapon } from "./weapons.ts"; |
| 11 | 11 | |
| 12 | 12 | export type Actor = { |
| @@ -32,7 +32,7 @@ const PARTS: [Hitgroup, number, number, number][] = [ | ||
| 32 | 32 | ]; |
| 33 | 33 | |
| 34 | 34 | export function hitboxes(a: Actor): Part[] { |
| 35 | const height = HULL.stand + (HULL.duck - HULL.stand) * a.duckAmount; | |
| 35 | const height = CS_HULL.stand + (CS_HULL.duck - CS_HULL.stand) * a.duckAmount; | |
| 36 | 36 | return PARTS.map(([group, lo, hi, w]) => ({ |
| 37 | 37 | group, |
| 38 | 38 | min: [a.pos[0] - w, a.pos[1] - w, a.pos[2] + height * lo] as Vec3, |
src/client/hud.ts +2 −2
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { clamp } from "../engine/index.ts"; |
| 14 | import { MOVE } from "./physics.ts"; | |
| 14 | import { CS_MOVE } from "./movement.ts"; | |
| 15 | 15 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 16 | 16 | import { currentWeapon, useProgress, type World } from "./world.ts"; |
| 17 | 17 | |
| @@ -61,7 +61,7 @@ export function drawHud( | ||
| 61 | 61 | if (me.alive) { |
| 62 | 62 | const speed = Math.hypot(me.vel[0], me.vel[1]); |
| 63 | 63 | const spread = inaccuracy(weapon, { |
| 64 | moving: clamp(speed / MOVE.maxSpeed, 0, 1), | |
| 64 | moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1), | |
| 65 | 65 | inAir: !me.onGround, |
| 66 | 66 | ducking: me.duckAmount > 0.5, |
| 67 | 67 | scoped: me.scoped, |
src/client/models.test.ts +8 −8
| @@ -7,10 +7,10 @@ | ||
| 7 | 7 | * there is no vertex order in this file and no float pinned to fifteen digits. |
| 8 | 8 | * |
| 9 | 9 | * The one geometry a frame depends on is the player's height: `scene.ts` |
| 10 | * squashes a ducking player by `hullHeight(p) / HULL.stand` about their feet, | |
| 11 | * so the mesh has to stand with its feet at z 0 and its head at `HULL.stand`. | |
| 10 | * squashes a ducking player by `hullHeight(p) / CS_HULL.stand` about their feet, | |
| 11 | * so the mesh has to stand with its feet at z 0 and its head at `CS_HULL.stand`. | |
| 12 | 12 | * It did not: the head reached 73.5, and `models.ts` now takes the top from |
| 13 | * `HULL` instead of from a literal. | |
| 13 | * `CS_HULL` instead of from a literal. | |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | 16 | import { expect, test } from "bun:test"; |
| @@ -21,7 +21,7 @@ import type { Box } from "./level.ts"; | ||
| 21 | 21 | import { DE_FORGE } from "./map.ts"; |
| 22 | 22 | import { LAYERS } from "./materials.ts"; |
| 23 | 23 | import { bombMesh, levelMesh, meshFor, playerMesh, viewModelMesh } from "./models.ts"; |
| 24 | import { HULL } from "./physics.ts"; | |
| 24 | import { CS_HULL } from "./movement.ts"; | |
| 25 | 25 | import { WEAPONS } from "./weapons.ts"; |
| 26 | 26 | |
| 27 | 27 | /** |
| @@ -106,10 +106,10 @@ test("the player stands on the floor at exactly the standing hull height", () => | ||
| 106 | 106 | // `scene.ts` scales a ducking player from the floor, so the feet are the |
| 107 | 107 | // origin and the top is the hull: a duck squash then lands on the duck hull. |
| 108 | 108 | expect(min[2]).toBe(0); |
| 109 | expect(max[2]).toBe(HULL.stand); | |
| 110 | expect(max[2] * (HULL.duck / HULL.stand)).toBeCloseTo(HULL.duck, 10); | |
| 109 | expect(max[2]).toBe(CS_HULL.stand); | |
| 110 | expect(max[2] * (CS_HULL.duck / CS_HULL.stand)).toBeCloseTo(CS_HULL.duck, 10); | |
| 111 | 111 | // A body, not a tower: it stays inside the hull across, give or take the gun. |
| 112 | expect(max[1] - min[1]).toBeLessThanOrEqual(HULL.radius * 2 + 4); | |
| 112 | expect(max[1] - min[1]).toBeLessThanOrEqual(CS_HULL.radius * 2 + 4); | |
| 113 | 113 | }); |
| 114 | 114 | |
| 115 | 115 | test("the bomb sits on the ground and lights up on a layer of its own", () => { |
| @@ -118,7 +118,7 @@ test("the bomb sits on the ground and lights up on a layer of its own", () => { | ||
| 118 | 118 | const { min, max } = bounds(mesh); |
| 119 | 119 | expect(min[2]).toBe(0); |
| 120 | 120 | expect(max[2]).toBeGreaterThan(0); |
| 121 | expect(max[2]).toBeLessThan(HULL.duck); | |
| 121 | expect(max[2]).toBeLessThan(CS_HULL.duck); | |
| 122 | 122 | // The light on top is the last layer of the atlas, and the case is not it. |
| 123 | 123 | expect(new Set(mesh.layer).size).toBe(2); |
| 124 | 124 | expect([...mesh.layer]).toContain(LAYERS - 1); |
src/client/models.ts +5 −5
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { addBox, builder, finish, type Builder, type Mesh } from "../engine/index.ts"; |
| 8 | 8 | import type { Box, Level } from "./level.ts"; |
| 9 | import { HULL } from "./physics.ts"; | |
| 9 | import { CS_HULL } from "./movement.ts"; | |
| 10 | 10 | |
| 11 | 11 | /** The level, as one buffer. Fifty-odd boxes, three hundred quads, one draw. */ |
| 12 | 12 | export function levelMesh(boxes: Box[]): Mesh { |
| @@ -22,11 +22,11 @@ const cube = (b: Builder, cx: number, cy: number, cz: number, sx: number, sy: nu | ||
| 22 | 22 | * A player: legs, torso, arms, head, and the gun they are holding, built |
| 23 | 23 | * standing at the origin facing +X so a yaw rotation is all a draw needs. |
| 24 | 24 | * |
| 25 | * Feet at z 0 and the crown of the head at `HULL.stand`, both exactly: a | |
| 25 | * Feet at z 0 and the crown of the head at `CS_HULL.stand`, both exactly: a | |
| 26 | 26 | * ducking player is drawn by scaling this from the floor by |
| 27 | * `hullHeight(p) / HULL.stand`, so a model taller than the hull is a head that | |
| 27 | * `hullHeight(p) / CS_HULL.stand`, so a model taller than the hull is a head that | |
| 28 | 28 | * pokes through a vent you could walk under. The head takes its height from |
| 29 | * `HULL` rather than a literal, which is why it cannot drift again. | |
| 29 | * `CS_HULL` rather than a literal, which is why it cannot drift again. | |
| 30 | 30 | */ |
| 31 | 31 | export function playerMesh(): Mesh { |
| 32 | 32 | const b = builder(); |
| @@ -37,7 +37,7 @@ export function playerMesh(): Mesh { | ||
| 37 | 37 | cube(b, 0, 0, 48, 16, 26, 26, GEAR); // torso |
| 38 | 38 | cube(b, 2, -14, 46, 9, 9, 22, KIT); // right arm |
| 39 | 39 | cube(b, 6, 10, 46, 9, 9, 20, KIT); // left arm |
| 40 | cube(b, 1, 0, HULL.stand - HEAD / 2, HEAD, HEAD, HEAD, SKIN); // head, crowned at the hull | |
| 40 | cube(b, 1, 0, CS_HULL.stand - HEAD / 2, HEAD, HEAD, HEAD, SKIN); // head, crowned at the hull | |
| 41 | 41 | cube(b, 14, -6, 46, 26, 5, 7, GEAR); // the gun in their hands |
| 42 | 42 | return finish(b); |
| 43 | 43 | } |
src/client/physics.test.ts → src/client/movement.test.ts renamed +58 −47
| @@ -1,10 +1,21 @@ | ||
| 1 | /** | |
| 2 | * Counter-Strike's numbers, held to what a player feels: 250 u/s on the flat, a | |
| 3 | * jump that clears 54 units, a 30 u/s air cap, an 18-unit step, and the crouch | |
| 4 | * that shuts in a fifth of a second. The solver these run through is the | |
| 5 | * engine's `physics`, which has none of those numbers in it; this file is the | |
| 6 | * game's, because every assertion below is about the pair `src/client/ | |
| 7 | * movement.ts` hands in. | |
| 8 | * | |
| 9 | * The second half runs the same functions on a profile that shares none of the | |
| 10 | * values under test, which is what says the tuning is really an argument. | |
| 11 | */ | |
| 12 | ||
| 1 | 13 | import { expect, test } from "bun:test"; |
| 2 | import type { Box } from "./level.ts"; | |
| 3 | import type { Vec3 } from "../engine/index.ts"; | |
| 4 | 14 | import { |
| 5 | accelerate, airAccelerate, eyeHeight, fallDamage, HULL, hullHeight, MOVE, move, | |
| 6 | type Body, type Hull, type MoveTuning, type Wish, | |
| 7 | } from "./physics.ts"; | |
| 15 | accelerate, airAccelerate, eyeHeight, hullHeight, move, | |
| 16 | type Body, type Box, type Hull, type MoveTuning, type Vec3, type Wish, | |
| 17 | } from "../engine/index.ts"; | |
| 18 | import { CS_HULL, CS_MOVE, fallDamage } from "./movement.ts"; | |
| 8 | 19 | |
| 9 | 20 | const ground: Box = { min: [-1000, -1000, -32], max: [1000, 1000, 0], mat: 0 }; |
| 10 | 21 | |
| @@ -17,7 +28,7 @@ const body = (pos: Vec3 = [0, 0, 0]): Body => ({ | ||
| 17 | 28 | }); |
| 18 | 29 | |
| 19 | 30 | const wish = (over: Partial<Wish> = {}): Wish => ({ |
| 20 | forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: MOVE.maxSpeed, ...over, | |
| 31 | forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: CS_MOVE.maxSpeed, ...over, | |
| 21 | 32 | }); |
| 22 | 33 | |
| 23 | 34 | const run = (b: Body, w: Wish, seconds: number, boxes: Box[], tuning: MoveTuning, hull: Hull): void => { |
| @@ -26,28 +37,28 @@ const run = (b: Body, w: Wish, seconds: number, boxes: Box[], tuning: MoveTuning | ||
| 26 | 37 | |
| 27 | 38 | test("running forwards tops out at the weapon's carry speed", () => { |
| 28 | 39 | const b = body(); |
| 29 | run(b, wish({ forward: 1 }), 3, [ground], MOVE, HULL); | |
| 30 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(MOVE.maxSpeed, 0); | |
| 40 | run(b, wish({ forward: 1 }), 3, [ground], CS_MOVE, CS_HULL); | |
| 41 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(CS_MOVE.maxSpeed, 0); | |
| 31 | 42 | expect(b.pos[0]).toBeGreaterThan(500); |
| 32 | 43 | }); |
| 33 | 44 | |
| 34 | 45 | test("letting go stops you, but not instantly", () => { |
| 35 | 46 | const b = body(); |
| 36 | run(b, wish({ forward: 1 }), 2, [ground], MOVE, HULL); | |
| 37 | move(b, wish(), 1 / 64, [ground], MOVE, HULL); | |
| 47 | run(b, wish({ forward: 1 }), 2, [ground], CS_MOVE, CS_HULL); | |
| 48 | move(b, wish(), 1 / 64, [ground], CS_MOVE, CS_HULL); | |
| 38 | 49 | const afterOneTick = Math.hypot(b.vel[0], b.vel[1]); |
| 39 | 50 | expect(afterOneTick).toBeGreaterThan(200); |
| 40 | run(b, wish(), 1, [ground], MOVE, HULL); | |
| 51 | run(b, wish(), 1, [ground], CS_MOVE, CS_HULL); | |
| 41 | 52 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(1); |
| 42 | 53 | }); |
| 43 | 54 | |
| 44 | 55 | test("air acceleration is capped at 30 u/s along the wish direction", () => { |
| 45 | 56 | const vel: Vec3 = [250, 0, 0]; |
| 46 | airAccelerate(vel, [1, 0, 0], 250, 1, MOVE); | |
| 57 | airAccelerate(vel, [1, 0, 0], 250, 1, CS_MOVE); | |
| 47 | 58 | expect(vel[0]).toBeCloseTo(250, 5); // already faster than the air cap: nothing added |
| 48 | 59 | const sideways: Vec3 = [250, 0, 0]; |
| 49 | airAccelerate(sideways, [0, 1, 0], 250, 1, MOVE); | |
| 50 | expect(sideways[1]).toBeCloseTo(MOVE.airSpeedCap, 5); // across it: the full cap | |
| 60 | airAccelerate(sideways, [0, 1, 0], 250, 1, CS_MOVE); | |
| 61 | expect(sideways[1]).toBeCloseTo(CS_MOVE.airSpeedCap, 5); // across it: the full cap | |
| 51 | 62 | }); |
| 52 | 63 | |
| 53 | 64 | test("ground acceleration never overshoots the wish speed", () => { |
| @@ -59,8 +70,8 @@ test("ground acceleration never overshoots the wish speed", () => { | ||
| 59 | 70 | test("a wall stops you and you keep sliding along it", () => { |
| 60 | 71 | const wall: Box = { min: [200, -1000, 0], max: [264, 1000, 384], mat: 1 }; |
| 61 | 72 | const b = body(); |
| 62 | run(b, wish({ forward: 1, side: -1 }), 3, [ground, wall], MOVE, HULL); | |
| 63 | expect(b.pos[0]).toBeLessThanOrEqual(200 - HULL.radius + 0.01); | |
| 73 | run(b, wish({ forward: 1, side: -1 }), 3, [ground, wall], CS_MOVE, CS_HULL); | |
| 74 | expect(b.pos[0]).toBeLessThanOrEqual(200 - CS_HULL.radius + 0.01); | |
| 64 | 75 | expect(Math.abs(b.pos[1])).toBeGreaterThan(200); // still moving along the wall |
| 65 | 76 | }); |
| 66 | 77 | |
| @@ -68,21 +79,21 @@ test("you step onto a 16-unit ledge and not onto a 40-unit one", () => { | ||
| 68 | 79 | const low: Box = { min: [100, -100, 0], max: [400, 100, 16], mat: 2 }; |
| 69 | 80 | const high: Box = { min: [100, -100, 0], max: [400, 100, 40], mat: 2 }; |
| 70 | 81 | const stepper = body([0, 0, 0]); |
| 71 | run(stepper, wish({ forward: 1 }), 1, [ground, low], MOVE, HULL); | |
| 82 | run(stepper, wish({ forward: 1 }), 1, [ground, low], CS_MOVE, CS_HULL); | |
| 72 | 83 | expect(stepper.pos[2]).toBeCloseTo(16, 1); |
| 73 | 84 | expect(stepper.pos[0]).toBeGreaterThan(150); |
| 74 | 85 | const blocked = body([0, 0, 0]); |
| 75 | run(blocked, wish({ forward: 1 }), 1, [ground, high], MOVE, HULL); | |
| 86 | run(blocked, wish({ forward: 1 }), 1, [ground, high], CS_MOVE, CS_HULL); | |
| 76 | 87 | expect(blocked.pos[2]).toBe(0); |
| 77 | 88 | expect(blocked.pos[0]).toBeLessThan(100); |
| 78 | 89 | }); |
| 79 | 90 | |
| 80 | 91 | test("a jump leaves the ground and gravity brings it back", () => { |
| 81 | 92 | const b = body(); |
| 82 | move(b, wish({ jump: true }), 1 / 64, [ground], MOVE, HULL); | |
| 93 | move(b, wish({ jump: true }), 1 / 64, [ground], CS_MOVE, CS_HULL); | |
| 83 | 94 | expect(b.onGround).toBe(false); |
| 84 | 95 | expect(b.vel[2]).toBeGreaterThan(250); |
| 85 | run(b, wish(), 2, [ground], MOVE, HULL); | |
| 96 | run(b, wish(), 2, [ground], CS_MOVE, CS_HULL); | |
| 86 | 97 | expect(b.onGround).toBe(true); |
| 87 | 98 | expect(b.pos[2]).toBeCloseTo(0, 1); |
| 88 | 99 | }); |
| @@ -90,9 +101,9 @@ test("a jump leaves the ground and gravity brings it back", () => { | ||
| 90 | 101 | test("a jump clears 54 units, the height CS players count on", () => { |
| 91 | 102 | const b = body(); |
| 92 | 103 | let peak = 0; |
| 93 | move(b, wish({ jump: true }), 1 / 64, [ground], MOVE, HULL); | |
| 104 | move(b, wish({ jump: true }), 1 / 64, [ground], CS_MOVE, CS_HULL); | |
| 94 | 105 | for (let t = 0; t < 1; t += 1 / 64) { |
| 95 | move(b, wish(), 1 / 64, [ground], MOVE, HULL); | |
| 106 | move(b, wish(), 1 / 64, [ground], CS_MOVE, CS_HULL); | |
| 96 | 107 | peak = Math.max(peak, b.pos[2]); |
| 97 | 108 | } |
| 98 | 109 | expect(peak).toBeGreaterThan(54); |
| @@ -101,31 +112,31 @@ test("a jump clears 54 units, the height CS players count on", () => { | ||
| 101 | 112 | |
| 102 | 113 | test("crouching shrinks the hull and slows you down", () => { |
| 103 | 114 | const b = body(); |
| 104 | run(b, wish({ forward: 1, duck: true }), 2, [ground], MOVE, HULL); | |
| 115 | run(b, wish({ forward: 1, duck: true }), 2, [ground], CS_MOVE, CS_HULL); | |
| 105 | 116 | expect(b.duckAmount).toBeCloseTo(1, 2); |
| 106 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(MOVE.duckSpeed + 5); | |
| 117 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(CS_MOVE.duckSpeed + 5); | |
| 107 | 118 | }); |
| 108 | 119 | |
| 109 | 120 | test("you cannot stand up under a low ceiling", () => { |
| 110 | 121 | const ceiling: Box = { min: [-100, -100, 60], max: [100, 100, 200], mat: 1 }; |
| 111 | 122 | const b = body(); |
| 112 | 123 | b.duckAmount = 1; |
| 113 | run(b, wish({ duck: true }), 1, [ground, ceiling], MOVE, HULL); | |
| 114 | run(b, wish({ duck: false }), 1, [ground, ceiling], MOVE, HULL); | |
| 124 | run(b, wish({ duck: true }), 1, [ground, ceiling], CS_MOVE, CS_HULL); | |
| 125 | run(b, wish({ duck: false }), 1, [ground, ceiling], CS_MOVE, CS_HULL); | |
| 115 | 126 | expect(b.duckAmount).toBeGreaterThan(0.2); |
| 116 | 127 | }); |
| 117 | 128 | |
| 118 | 129 | test("strafing in the air gains speed a straight line cannot", () => { |
| 119 | 130 | // Hold strafe-left and turn left through the jump: the classic gain. |
| 120 | 131 | const b = body(); |
| 121 | b.vel = [MOVE.maxSpeed, 0, 0]; | |
| 122 | move(b, wish({ forward: 1, jump: true }), 1 / 64, [ground], MOVE, HULL); | |
| 132 | b.vel = [CS_MOVE.maxSpeed, 0, 0]; | |
| 133 | move(b, wish({ forward: 1, jump: true }), 1 / 64, [ground], CS_MOVE, CS_HULL); | |
| 123 | 134 | let yaw = 0; |
| 124 | 135 | while (!b.onGround) { |
| 125 | 136 | yaw += 0.55; |
| 126 | move(b, wish({ side: -1, yaw }), 1 / 128, [ground], MOVE, HULL); | |
| 137 | move(b, wish({ side: -1, yaw }), 1 / 128, [ground], CS_MOVE, CS_HULL); | |
| 127 | 138 | } |
| 128 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeGreaterThan(MOVE.maxSpeed + 2); | |
| 139 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeGreaterThan(CS_MOVE.maxSpeed + 2); | |
| 129 | 140 | }); |
| 130 | 141 | |
| 131 | 142 | test("fall damage starts only after a long drop", () => { |
| @@ -144,7 +155,7 @@ test("fall damage starts only after a long drop", () => { | ||
| 144 | 155 | |
| 145 | 156 | /** Half the gravity, a higher jump, twice the step, a lazier crouch. */ |
| 146 | 157 | const MOON: MoveTuning = { |
| 147 | ...MOVE, | |
| 158 | ...CS_MOVE, | |
| 148 | 159 | gravity: 400, jumpVelocity: 600, maxSpeed: 500, duckSpeed: 200, walkSpeed: 250, |
| 149 | 160 | airAccelerate: 6, airSpeedCap: 60, stepSize: 36, duckRate: 2.5, |
| 150 | 161 | }; |
| @@ -170,26 +181,26 @@ const jump = (tuning: MoveTuning, hull: Hull): { peak: number; ticks: number } = | ||
| 170 | 181 | test("the apex and the hang time are the profile's gravity and jump velocity", () => { |
| 171 | 182 | // v²/2g and 2v/g, by hand: 301.993² / 1600 ≈ 57.0 units and 0.755 s under CS, |
| 172 | 183 | // 600² / 800 = 450 units and 3 s under half gravity and a 600 u/s jump. |
| 173 | const cs = jump(MOVE, HULL); | |
| 184 | const cs = jump(CS_MOVE, CS_HULL); | |
| 174 | 185 | const moon = jump(MOON, GIANT); |
| 175 | expect(cs.peak).toBeCloseTo(MOVE.jumpVelocity ** 2 / (2 * MOVE.gravity), 1); | |
| 186 | expect(cs.peak).toBeCloseTo(CS_MOVE.jumpVelocity ** 2 / (2 * CS_MOVE.gravity), 1); | |
| 176 | 187 | expect(moon.peak).toBeCloseTo(MOON.jumpVelocity ** 2 / (2 * MOON.gravity), 1); |
| 177 | 188 | // Hang time to within a tick and a half of 2v/g: 48 ticks against 192. |
| 178 | 189 | const hang = (t: MoveTuning): number => ((2 * t.jumpVelocity) / t.gravity) * 64; |
| 179 | expect(Math.abs(cs.ticks - hang(MOVE))).toBeLessThan(1.5); | |
| 190 | expect(Math.abs(cs.ticks - hang(CS_MOVE))).toBeLessThan(1.5); | |
| 180 | 191 | expect(Math.abs(moon.ticks - hang(MOON))).toBeLessThan(1.5); |
| 181 | 192 | }); |
| 182 | 193 | |
| 183 | 194 | test("the walk, duck and run speeds are the profile's own", () => { |
| 184 | 195 | const top = (w: Partial<Wish>, tuning: MoveTuning): number => { |
| 185 | 196 | const b = body(); |
| 186 | run(b, { ...wish(w), maxSpeed: tuning.maxSpeed }, 4, [ground], tuning, HULL); | |
| 197 | run(b, { ...wish(w), maxSpeed: tuning.maxSpeed }, 4, [ground], tuning, CS_HULL); | |
| 187 | 198 | return Math.hypot(b.vel[0], b.vel[1]); |
| 188 | 199 | }; |
| 189 | 200 | expect(top({ forward: 1 }, MOON)).toBeCloseTo(MOON.maxSpeed, 5); |
| 190 | 201 | expect(top({ forward: 1, walk: true }, MOON)).toBeCloseTo(MOON.walkSpeed, 5); |
| 191 | 202 | expect(top({ forward: 1, duck: true }, MOON)).toBeCloseTo(MOON.duckSpeed, 5); |
| 192 | expect(top({ forward: 1, walk: true }, MOVE)).toBeCloseTo(MOVE.walkSpeed, 5); | |
| 203 | expect(top({ forward: 1, walk: true }, CS_MOVE)).toBeCloseTo(CS_MOVE.walkSpeed, 5); | |
| 193 | 204 | }); |
| 194 | 205 | |
| 195 | 206 | test("the crouch closes at the rate the profile names", () => { |
| @@ -198,7 +209,7 @@ test("the crouch closes at the rate the profile names", () => { | ||
| 198 | 209 | const lazy = body(), quick = body(); |
| 199 | 210 | for (let i = 0; i < 20; i++) { |
| 200 | 211 | move(lazy, wish({ duck: true }), 1 / 64, [ground], MOON, GIANT); |
| 201 | move(quick, wish({ duck: true }), 1 / 64, [ground], MOVE, HULL); | |
| 212 | move(quick, wish({ duck: true }), 1 / 64, [ground], CS_MOVE, CS_HULL); | |
| 202 | 213 | } |
| 203 | 214 | expect(lazy.duckAmount).toBeCloseTo((20 / 64) * MOON.duckRate, 10); |
| 204 | 215 | expect(quick.duckAmount).toBe(1); |
| @@ -221,7 +232,7 @@ test("a 36-unit step climbs a ledge an 18-unit step refuses", () => { | ||
| 221 | 232 | const ledge: Box = { min: [100, -100, 0], max: [400, 100, 30], mat: 2 }; |
| 222 | 233 | const tall = body(), short = body(); |
| 223 | 234 | run(tall, { ...wish({ forward: 1 }), maxSpeed: MOON.maxSpeed }, 1, [ground, ledge], MOON, GIANT); |
| 224 | run(short, wish({ forward: 1 }), 1, [ground, ledge], MOVE, HULL); | |
| 235 | run(short, wish({ forward: 1 }), 1, [ground, ledge], CS_MOVE, CS_HULL); | |
| 225 | 236 | expect(tall.pos[2]).toBe(30); |
| 226 | 237 | expect(short.pos[2]).toBe(0); |
| 227 | 238 | expect(short.pos[0]).toBeLessThan(100); |
| @@ -241,24 +252,24 @@ test("the air cap and the air acceleration come from the tuning", () => { | ||
| 241 | 252 | test("acceleration and friction settle where the tuning says they do", () => { |
| 242 | 253 | // Running holds where what acceleration adds is what friction takes: |
| 243 | 254 | // accel × wishSpeed = speed × friction, so 2.8 × 250 / 5.2 ≈ 134.6 u/s. |
| 244 | const half: MoveTuning = { ...MOVE, accelerate: 2.8 }; | |
| 255 | const half: MoveTuning = { ...CS_MOVE, accelerate: 2.8 }; | |
| 245 | 256 | const b = body(); |
| 246 | run(b, wish({ forward: 1 }), 3, [ground], MOVE, HULL); | |
| 257 | run(b, wish({ forward: 1 }), 3, [ground], CS_MOVE, CS_HULL); | |
| 247 | 258 | const slower = body(); |
| 248 | run(slower, wish({ forward: 1 }), 3, [ground], half, HULL); | |
| 249 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(MOVE.maxSpeed, 5); | |
| 259 | run(slower, wish({ forward: 1 }), 3, [ground], half, CS_HULL); | |
| 260 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(CS_MOVE.maxSpeed, 5); | |
| 250 | 261 | expect(Math.hypot(slower.vel[0], slower.vel[1])) |
| 251 | .toBeCloseTo((half.accelerate * MOVE.maxSpeed) / half.friction, 2); | |
| 262 | .toBeCloseTo((half.accelerate * CS_MOVE.maxSpeed) / half.friction, 2); | |
| 252 | 263 | |
| 253 | 264 | // And a drifting body: no friction keeps every unit of its speed, while a |
| 254 | 265 | // stop speed five times CS's takes all of it inside three ticks. |
| 255 | 266 | const drift = (tuning: MoveTuning): number => { |
| 256 | 267 | const d = body(); |
| 257 | 268 | d.vel = [40, 0, 0]; |
| 258 | for (let i = 0; i < 3; i++) move(d, wish(), 1 / 64, [ground], tuning, HULL); | |
| 269 | for (let i = 0; i < 3; i++) move(d, wish(), 1 / 64, [ground], tuning, CS_HULL); | |
| 259 | 270 | return Math.hypot(d.vel[0], d.vel[1]); |
| 260 | 271 | }; |
| 261 | expect(drift({ ...MOVE, friction: 0 })).toBe(40); | |
| 262 | expect(drift({ ...MOVE, stopSpeed: 400 })).toBe(0); | |
| 263 | expect(drift(MOVE)).toBeCloseTo(20.5, 5); | |
| 272 | expect(drift({ ...CS_MOVE, friction: 0 })).toBe(40); | |
| 273 | expect(drift({ ...CS_MOVE, stopSpeed: 400 })).toBe(0); | |
| 274 | expect(drift(CS_MOVE)).toBeCloseTo(20.5, 5); | |
| 264 | 275 | }); |
src/client/movement.ts added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | /** | |
| 2 | * How Counter-Strike moves: the numbers the engine's solver is handed, and the | |
| 3 | * one curve that is this game's rather than the lineage's. | |
| 4 | * | |
| 5 | * The solver itself is `src/engine/physics.ts` — friction, acceleration, the air | |
| 6 | * cap, the step, the crouch — and it holds not one of the values below. Every | |
| 7 | * call in this game passes `CS_MOVE` and `CS_HULL`, so a reader who wants to | |
| 8 | * know how fast a player runs reads this file and no other, and a second game on | |
| 9 | * the same arithmetic writes its own pair instead of editing these. | |
| 10 | * | |
| 11 | * Units are Source units (1 unit ≈ 1.9 cm): 250 u/s running, 800 u/s² gravity, | |
| 12 | * a 32×32×72 hull, an 18-unit step, and a jump that clears 54 of them. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { Hull, MoveTuning } from "../engine/index.ts"; | |
| 16 | ||
| 17 | export const CS_HULL: Hull = { radius: 16, stand: 72, duck: 54, eyeStand: 64, eyeDuck: 46 }; | |
| 18 | ||
| 19 | export const CS_MOVE: MoveTuning = { | |
| 20 | gravity: 800, | |
| 21 | maxSpeed: 250, | |
| 22 | duckSpeed: 85, | |
| 23 | walkSpeed: 130, | |
| 24 | accelerate: 5.6, | |
| 25 | airAccelerate: 12, | |
| 26 | airSpeedCap: 30, | |
| 27 | friction: 5.2, | |
| 28 | stopSpeed: 80, | |
| 29 | jumpVelocity: 301.993, | |
| 30 | stepSize: 18, | |
| 31 | duckRate: 5, | |
| 32 | }; | |
| 33 | ||
| 34 | /** | |
| 35 | * Fall damage, CS-style: nothing under ~580 u/s, then it bites. This is a curve | |
| 36 | * this game chose and not a consequence of the solver, which is why it is here | |
| 37 | * and not behind the door — a landing hurts because of a rule, not because of | |
| 38 | * arithmetic. | |
| 39 | */ | |
| 40 | export const fallDamage = (impactSpeed: number): number => | |
| 41 | impactSpeed < 580 ? 0 : Math.round((impactSpeed - 580) * 0.0888 * 100) / 100; |
src/client/observe.ts +2 −2
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | |
| 15 | 15 | import { colOf, rowOf, walkable, type Level } from "./level.ts"; |
| 16 | 16 | import { angleDelta, clamp, dist, type Vec3 } from "../engine/index.ts"; |
| 17 | import { HULL } from "./physics.ts"; | |
| 17 | import { CS_HULL } from "./movement.ts"; | |
| 18 | 18 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 19 | 19 | import type { Player, World } from "./world.ts"; |
| 20 | 20 | import { aimAt, bodyCentre, currentWeapon, eyePos } from "./world.ts"; |
| @@ -69,7 +69,7 @@ export function observe( | ||
| 69 | 69 | theirHealth = target.health / 100; |
| 70 | 70 | theirSpeed = Math.hypot(target.vel[0], target.vel[1]) / 250; |
| 71 | 71 | // How squarely the enemy is looking back: 1 is down the barrel, -1 is away. |
| 72 | const back = aimAt(target, [bot.pos[0], bot.pos[1], bot.pos[2] + HULL.stand * 0.72]); | |
| 72 | const back = aimAt(target, [bot.pos[0], bot.pos[1], bot.pos[2] + CS_HULL.stand * 0.72]); | |
| 73 | 73 | theirFacing = Math.cos(angleDelta(target.yaw, back.yaw) * rad); |
| 74 | 74 | } |
| 75 | 75 |
src/client/scene.test.ts +2 −2
| @@ -14,7 +14,7 @@ import { expect, test } from "bun:test"; | ||
| 14 | 14 | import { existsSync, readFileSync } from "node:fs"; |
| 15 | 15 | import { join } from "node:path"; |
| 16 | 16 | import type { Camera } from "../engine/index.ts"; |
| 17 | import { HULL } from "./physics.ts"; | |
| 17 | import { CS_HULL } from "./movement.ts"; | |
| 18 | 18 | import { buildScene, createScene, LINE_CAPACITY, SKY } from "./scene.ts"; |
| 19 | 19 | import { DE_FORGE } from "./map.ts"; |
| 20 | 20 | import { SHORT } from "./match.ts"; |
| @@ -70,7 +70,7 @@ test("a duck squashes a player by the hull it has, a standing one by nothing", ( | ||
| 70 | 70 | other.duckAmount = 1; |
| 71 | 71 | buildScene(w, camera(), scene); |
| 72 | 72 | const ducking = scene.instances.slice(1, scene.instanceCount).find((i) => i.pos[0] === other.pos[0]); |
| 73 | expect(ducking?.scale).toBeCloseTo(HULL.duck / HULL.stand, 10); | |
| 73 | expect(ducking?.scale).toBeCloseTo(CS_HULL.duck / CS_HULL.stand, 10); | |
| 74 | 74 | expect(ducking?.scale).toBeCloseTo(54 / 72, 10); |
| 75 | 75 | }); |
| 76 | 76 |
src/client/scene.ts +3 −3
| @@ -18,10 +18,10 @@ | ||
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | 20 | import { |
| 21 | clamp, createScene as createPooledScene, nextInstance, pushLine, resetScene, | |
| 21 | clamp, createScene as createPooledScene, hullHeight, nextInstance, pushLine, resetScene, | |
| 22 | 22 | type Camera, type Scene as Frame, type Vec3, |
| 23 | 23 | } from "../engine/index.ts"; |
| 24 | import { HULL, hullHeight } from "./physics.ts"; | |
| 24 | import { CS_HULL } from "./movement.ts"; | |
| 25 | 25 | import { WEAPONS } from "./weapons.ts"; |
| 26 | 26 | import type { World } from "./world.ts"; |
| 27 | 27 | |
| @@ -131,7 +131,7 @@ export function buildScene(world: World, camera: Camera, into: Scene): Scene { | ||
| 131 | 131 | for (const p of world.players) { |
| 132 | 132 | // The eye is inside your own head: drawing it would fill the screen. |
| 133 | 133 | if (!p.alive || p === world.me) continue; |
| 134 | place(into, "player", p.pos, p.yaw, hullHeight(p, HULL) / HULL.stand, TEAM_TINT[p.team] ?? WHITE); | |
| 134 | place(into, "player", p.pos, p.yaw, hullHeight(p, CS_HULL) / CS_HULL.stand, TEAM_TINT[p.team] ?? WHITE); | |
| 135 | 135 | } |
| 136 | 136 | if (world.bomb.state === "planted" || world.bomb.state === "dropped") |
| 137 | 137 | place(into, "bomb", world.bomb.pos, 0, 1, WHITE); |
src/client/world.ts +10 −7
| @@ -9,9 +9,12 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { applyHit, traceBullet, type Actor } from "./combat.ts"; |
| 11 | 11 | import { siteAt, type Level } from "./level.ts"; |
| 12 | import { angleVectors, angleDelta, clamp, dist, normalize, rng, sub, visible, type Vec3 } from "../engine/index.ts"; | |
| 12 | import { | |
| 13 | angleVectors, angleDelta, clamp, dist, eyeHeight, hullHeight, move, normalize, rng, sub, visible, | |
| 14 | type Body, type Vec3, type Wish, | |
| 15 | } from "../engine/index.ts"; | |
| 13 | 16 | import * as rules from "./match.ts"; |
| 14 | import { eyeHeight, hullHeight, move, type Body, type Wish, HULL, MOVE } from "./physics.ts"; | |
| 17 | import { CS_HULL, CS_MOVE } from "./movement.ts"; | |
| 15 | 18 | import { |
| 16 | 19 | ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset, |
| 17 | 20 | type WeaponId, type Weapon, |
| @@ -172,7 +175,7 @@ export const teammates = (world: World, team: Team): Player[] => world.players.f | ||
| 172 | 175 | export const alive = (world: World, team: Team): Player[] => |
| 173 | 176 | world.players.filter((p) => p.team === team && p.alive); |
| 174 | 177 | |
| 175 | export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p, HULL)]; | |
| 178 | export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p, CS_HULL)]; | |
| 176 | 179 | |
| 177 | 180 | function say(world: World, text: string): void { |
| 178 | 181 | world.messages.push({ at: world.time, text }); |
| @@ -335,7 +338,7 @@ export function shoot(world: World, p: Player): boolean { | ||
| 335 | 338 | |
| 336 | 339 | const speed = Math.hypot(p.vel[0], p.vel[1]); |
| 337 | 340 | const spread = inaccuracy(w, { |
| 338 | moving: clamp(speed / MOVE.maxSpeed, 0, 1), | |
| 341 | moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1), | |
| 339 | 342 | inAir: !p.onGround, |
| 340 | 343 | ducking: p.duckAmount > 0.5, |
| 341 | 344 | scoped: p.scoped, |
| @@ -466,9 +469,9 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 466 | 469 | jump: input.jump && !frozen, |
| 467 | 470 | duck: input.duck, |
| 468 | 471 | walk: input.walk || p.scoped, |
| 469 | maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed), | |
| 472 | maxSpeed: Math.min(CS_MOVE.maxSpeed, w.carrySpeed), | |
| 470 | 473 | }; |
| 471 | move(p, wish, dt, world.level.boxes, MOVE, HULL); | |
| 474 | move(p, wish, dt, world.level.boxes, CS_MOVE, CS_HULL); | |
| 472 | 475 | finishReload(world, p); |
| 473 | 476 | if (input.reload) reload(world, p); |
| 474 | 477 | if (w.zoom !== undefined && input.scope !== p.scoped && world.time > p.nextShotAt - 0.05) p.scoped = input.scope; |
| @@ -550,4 +553,4 @@ export const aimAt = (p: Player, target: Vec3): { yaw: number; pitch: number } = | ||
| 550 | 553 | }; |
| 551 | 554 | |
| 552 | 555 | /** The middle of the chest: what a bot aims at before it aims higher. */ |
| 553 | export const bodyCentre = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + hullHeight(p, HULL) * 0.72]; | |
| 556 | export const bodyCentre = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + hullHeight(p, CS_HULL) * 0.72]; |
src/engine/GUIDE.md +48 −3
| @@ -26,8 +26,8 @@ always the example. | ||
| 26 | 26 | bare engine name here is exported, every line of every code block is a line of |
| 27 | 27 | the file above it, and every block is a run of lines of `src/engine/example.ts` |
| 28 | 28 | in the order that file has them. So when a group lands behind the door that this |
| 29 | page has no paragraph for — the physics of issue #3, one day — that test fails | |
| 30 | until the paragraph is written. That is the point. A name arrives with a page or | |
| 29 | page has no paragraph for — the mover of issue #3 was the last one — that test | |
| 30 | fails until the paragraph is written. That is the point. A name arrives with a page or | |
| 31 | 31 | it does not arrive. |
| 32 | 32 | |
| 33 | 33 | ## What a game brings |
| @@ -174,6 +174,51 @@ A shot in the example is one ray and one box, and it is the whole of hitscan: | ||
| 174 | 174 | if (rayBox(world.pos, to, world.target.min, world.target.max) === null) return world; |
| 175 | 175 | |
| 176 | 176 | |
| 177 | ### physics | |
| 178 | ||
| 179 | Walking, in the Quake lineage: ground friction, a capped ground acceleration, | |
| 180 | and an air acceleration that caps only the part of your wish speed along the new | |
| 181 | direction — which is why turning while you accelerate in the air makes you | |
| 182 | faster, and why strafe-jumping works. The example does not use it, which is why | |
| 183 | this section has no code block: it is a game with no gravity and no hull, and | |
| 184 | `src/client/world.ts` is the one to read instead. | |
| 185 | ||
| 186 | **What a game brings is three values and a list of boxes.** A `Body` is the | |
| 187 | mutable subject — position, velocity, whether it is on the ground, whether it is | |
| 188 | ducking and how far the crouch has closed — and `move` advances one of them by a | |
| 189 | step in seconds against the `Box` list you already trace bullets through. A `Wish` is the | |
| 190 | tick's intent: forward and sideways in -1..1, the yaw they are meant relative to, | |
| 191 | jump, duck, walk, and the top speed this tick allows, which is where a game whose | |
| 192 | heavy weapon slows you down puts that rule. A `MoveTuning` is the twelve numbers | |
| 193 | that decide the feel — gravity, the run, walk and duck speeds, the ground and air | |
| 194 | accelerations, the air cap, friction and stop speed, the jump velocity, the step | |
| 195 | size and how fast a crouch closes — and a `Hull` is the box that collides and the | |
| 196 | two eye heights it sees from. | |
| 197 | ||
| 198 | **Nothing in here has a default and nothing is read from module scope.** Every | |
| 199 | call takes the tuning and the hull, in that order, last — subject, situation, | |
| 200 | world, tuning, hull. So the same solver walks two games that share no number, | |
| 201 | and swapping a profile is an argument rather than an edit. The shooter's pair is | |
| 202 | `movement.CS_MOVE` and `movement.CS_HULL` in `src/client/movement.ts`, and | |
| 203 | `src/client/movement.test.ts` runs these functions on a second profile with half | |
| 204 | the gravity and a hull twice the size to prove the arithmetic never learned the | |
| 205 | first one. | |
| 206 | ||
| 207 | The pieces under `move` are exported because they are worth using and testing on | |
| 208 | their own: `accelerate` adds along a wish direction up to a wish speed and never | |
| 209 | past it, `airAccelerate` is the same with the tuning's air cap over the wish | |
| 210 | speed first, `stuck` asks whether a hull at a position overlaps anything, | |
| 211 | `groundUnder` is the highest box top within a reach below the feet — ask that | |
| 212 | rather than resolving a downward move, or a player under a low ceiling gets | |
| 213 | shoved through it — and `eyeHeight` and `hullHeight` interpolate a body's crouch | |
| 214 | between the hull's standing and ducked numbers, which is what a camera and a | |
| 215 | model scale read. | |
| 216 | ||
| 217 | What the mover does not know is anything about a game: no weapon, no round, no | |
| 218 | level module, no fall damage. Damage from a landing is a curve a game chose — | |
| 219 | `movement.fallDamage` is the shooter's — and it is on that side of the door for | |
| 220 | exactly that reason. | |
| 221 | ||
| 177 | 222 | ### grid |
| 178 | 223 | |
| 179 | 224 | A `Grid` of cells of any type — the cell type is your alphabet, not the |
| @@ -564,7 +609,7 @@ own tuning, and nothing else in this table at all. | ||
| 564 | 609 | | a frame as data | `src/client/scene.ts` | `src/range/scene.ts` | |
| 565 | 610 | | a pinned run | `src/client/replay.ts` — a script of held inputs, a round and a duel | `src/range/pin.ts` — a script of trigger pulls, one digest per drill | |
| 566 | 611 | | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays | |
| 567 | | the tuning | `src/client/physics.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` | | |
| 612 | | the tuning | `src/client/movement.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` | | |
| 568 | 613 | | a policy's observation, heads and weights | `src/client/observe.ts`, `src/client/action.ts`, `src/client/weights.ts` | none — the range has no agent and bundles none of the net | |
| 569 | 614 | |
| 570 | 615 | ## What the engine deliberately does not know |
src/engine/boundary.test.ts +14 −9
| @@ -203,7 +203,7 @@ test("the door rule is about the door and nothing else", () => { | ||
| 203 | 203 | expect(reachesPastTheDoor("src/client/world.ts", "../engine/math.ts")).toMatch(/door/); |
| 204 | 204 | expect(reachesPastTheDoor("src/rl/arena.ts", "../engine/sub/deep.ts")).toMatch(/door/); |
| 205 | 205 | expect(reachesPastTheDoor("src/client/world.ts", "../engine/index.ts")).toBeNull(); |
| 206 | expect(reachesPastTheDoor("src/client/world.ts", "./physics.ts")).toBeNull(); | |
| 206 | expect(reachesPastTheDoor("src/client/world.ts", "./movement.ts")).toBeNull(); | |
| 207 | 207 | expect(reachesPastTheDoor("src/client/world.ts", "bun:test")).toBeNull(); |
| 208 | 208 | expect(reachesPastTheDoor("scripts/build.ts", "../src/engine/trace.ts")).toMatch(/door/); |
| 209 | 209 | // A test may read another test's helpers; a module may not, test or not. |
| @@ -281,28 +281,33 @@ test("the example imports the door and nothing else, which is what it is for", ( | ||
| 281 | 281 | * digest — `QUANTUM`, `quantise`, `CHECKPOINT_CHARS`, `Fold`, `fold`, |
| 282 | 282 | * `Folded`, `Pin`, `Complaint`, `compare`. |
| 283 | 283 | * |
| 284 | * physics — `Body`, `Wish`, `MoveTuning`, `Hull`, `accelerate`, | |
| 285 | * `airAccelerate`, `move`, `groundUnder`, `stuck`, `eyeHeight`, `hullHeight`. | |
| 286 | * | |
| 284 | 287 | * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one |
| 285 | 288 | * copy of the surface, read twice, rather than two that drift. |
| 286 | 289 | */ |
| 287 | 290 | export const SURFACE = [ |
| 288 | "Activations", "Adam", "Box", "Builder", "Burst", "Button", "ButtonOptions", | |
| 291 | "Activations", "Adam", "Body", "Box", "Builder", "Burst", "Button", "ButtonOptions", | |
| 289 | 292 | "CHECKPOINT_CHARS", "Camera", "Complaint", "Controls", "Ctor", "DEFAULT_HYPER", "FakeCanvas", |
| 290 | 293 | "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", "Fold", |
| 291 | "Folded", "Frames", "Grads", "Grid", "Hit", "Host", "Hyper", "Instance", "KeyEventish", | |
| 292 | "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh", "MouseEventish", "Options", | |
| 294 | "Folded", "Frames", "Grads", "Grid", "Hit", "Host", "Hull", "Hyper", "Instance", "KeyEventish", | |
| 295 | "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh", "MouseEventish", "MoveTuning", "Options", | |
| 293 | 296 | "Overlay", "Packed", "PageOptions", "Paint", "Painter", "Pin", "Policy", "QUANTUM", |
| 294 | 297 | "Renderer", "RendererAssets", "Rollout", "Scene", "SceneCapacity", "Shader", "Shape", |
| 295 | 298 | "SoundDevice", "Stick", "Surface", "Tone", "TouchEventish", "Touchish", "UpdateStats", "Vec3", |
| 296 | "add", "addBox", "angleDelta", "angleVectors", "applyGrads", "attachButtons", "attenuate", | |
| 299 | "Wish", "accelerate", "add", "addBox", "airAccelerate", "angleDelta", "angleVectors", | |
| 300 | "applyGrads", "attachButtons", "attenuate", | |
| 297 | 301 | "backward", "builder", "cellAt", "clamp", "clearGrads", "colOf", "compare", |
| 298 | 302 | "computeAdvantages", "copyPolicy", "createAdam", "createControls", "createLoop", |
| 299 | 303 | "createPolicy", "createRenderer", "createRollout", "createScene", "createSoundDevice", |
| 300 | "cross", "deg", "dist", "dist2d", "dot", "fakePage", "fbm", "findPath", "finish", "fold", | |
| 301 | "forward", "greedy", "hash", "headOffset", "inside", "length", "length2d", "lerp", | |
| 302 | "logitCount", "mat4", "mergeSolid", "modelMatrix", "mul", "multiply", "newActivations", | |
| 304 | "cross", "deg", "dist", "dist2d", "dot", "eyeHeight", "fakePage", "fbm", "findPath", "finish", | |
| 305 | "fold", "forward", "greedy", "groundUnder", "hash", "headOffset", "hullHeight", "inside", | |
| 306 | "length", "length2d", "lerp", | |
| 307 | "logitCount", "mat4", "mergeSolid", "modelMatrix", "move", "mul", "multiply", "newActivations", | |
| 303 | 308 | "nextInstance", "noise", "normalize", "overlay", "pack", "paintAtlas", "painter", |
| 304 | 309 | "paramCount", "perspective", "pushLine", "quadMesh", "quantise", "rad", "rayBox", |
| 305 | "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength", "softmax", "sub", | |
| 310 | "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength", "softmax", "stuck", "sub", | |
| 306 | 311 | "tensorsOf", "traceWorld", "unpack", "update", "v3", "viewMatrix", "visible", "worldOf", |
| 307 | 312 | "zeroGrads", "zeroPolicy", |
| 308 | 313 | ]; |
src/engine/index.ts +9
| @@ -22,6 +22,15 @@ export type { Mat4, Vec3 } from "./math.ts"; | ||
| 22 | 22 | export { rayBox, segmentLength, traceWorld, visible } from "./trace.ts"; |
| 23 | 23 | export type { Box, Hit } from "./trace.ts"; |
| 24 | 24 | |
| 25 | /** | |
| 26 | * The mover: friction, acceleration, the air cap, walls, steps and the crouch, | |
| 27 | * in the Quake lineage. It reads no tuned number of its own — a `MoveTuning` and | |
| 28 | * a `Hull` arrive as arguments on every call — so the feel of a game is the pair | |
| 29 | * it hands in and never a constant in here. | |
| 30 | */ | |
| 31 | export { accelerate, airAccelerate, eyeHeight, groundUnder, hullHeight, move, stuck } from "./physics.ts"; | |
| 32 | export type { Body, Hull, MoveTuning, Wish } from "./physics.ts"; | |
| 33 | ||
| 25 | 34 | /** A grid of cells of any type: cell-to-world arithmetic, greedy meshing, A*. */ |
| 26 | 35 | export { cellAt, colOf, findPath, inside, mergeSolid, rowOf, worldOf } from "./grid.ts"; |
| 27 | 36 | export type { Grid } from "./grid.ts"; |
src/client/physics.ts → src/engine/physics.ts renamed +13 −33
| @@ -1,21 +1,22 @@ | ||
| 1 | 1 | /** |
| 2 | * Movement, in the Quake/Source lineage CS never left: ground friction, a | |
| 3 | * capped ground acceleration, and an air acceleration that only caps the part | |
| 4 | * of your wish speed along the new direction — which is why turning while you | |
| 5 | * accelerate in the air makes you faster, and why strafe-jumping works here. | |
| 2 | * Movement, in the Quake lineage: ground friction, a capped ground | |
| 3 | * acceleration, and an air acceleration that only caps the part of your wish | |
| 4 | * speed along the new direction — which is why turning while you accelerate in | |
| 5 | * the air makes you faster, and why strafe-jumping works here. | |
| 6 | 6 | * |
| 7 | * Units are Source units (1 unit ≈ 1.9 cm): 250 u/s running, 800 u/s² gravity, | |
| 8 | * a 32×32×72 hull, an 18-unit step. | |
| 7 | * The units are the caller's, throughout. A body, a hull and a tuning in metres | |
| 8 | * per second and a world of boxes in metres solve the same way as one in the | |
| 9 | * inch-ish units the lineage is usually written in; nothing below reads a scale. | |
| 9 | 10 | * |
| 10 | * The solver holds none of those numbers itself. Every tuned value reaches a | |
| 11 | * The solver holds no tuned number of its own. Every one of them reaches a | |
| 11 | 12 | * function as a required argument, in one boring order — subject, situation, |
| 12 | 13 | * tuning, hull — and no parameter has a default, so nothing can substitute a |
| 13 | * tuning quietly. `MOVE` and `HULL` below are Counter-Strike's answers, and a | |
| 14 | * caller that wants a different game hands in a different pair. | |
| 14 | * tuning quietly. A game brings a `MoveTuning` and a `Hull` and gets its own | |
| 15 | * feel out of the same arithmetic; `src/client/movement.ts` is one such pair. | |
| 15 | 16 | */ |
| 16 | 17 | |
| 17 | import type { Box } from "./level.ts"; | |
| 18 | import { clamp, dot, normalize, type Vec3 } from "../engine/index.ts"; | |
| 18 | import type { Box } from "./trace.ts"; | |
| 19 | import { clamp, dot, normalize, type Vec3 } from "./math.ts"; | |
| 19 | 20 | |
| 20 | 21 | /** The box a body occupies, and where it looks from, standing and ducked. */ |
| 21 | 22 | export type Hull = { |
| @@ -43,23 +44,6 @@ export type MoveTuning = { | ||
| 43 | 44 | duckRate: number; |
| 44 | 45 | }; |
| 45 | 46 | |
| 46 | export const HULL: Hull = { radius: 16, stand: 72, duck: 54, eyeStand: 64, eyeDuck: 46 }; | |
| 47 | ||
| 48 | export const MOVE: MoveTuning = { | |
| 49 | gravity: 800, | |
| 50 | maxSpeed: 250, | |
| 51 | duckSpeed: 85, | |
| 52 | walkSpeed: 130, | |
| 53 | accelerate: 5.6, | |
| 54 | airAccelerate: 12, | |
| 55 | airSpeedCap: 30, | |
| 56 | friction: 5.2, | |
| 57 | stopSpeed: 80, | |
| 58 | jumpVelocity: 301.993, | |
| 59 | stepSize: 18, | |
| 60 | duckRate: 5, | |
| 61 | }; | |
| 62 | ||
| 63 | 47 | /** |
| 64 | 48 | * How close under the feet a floor still counts as touched, in units. This is |
| 65 | 49 | * the solver's own tolerance — the slack a discrete step needs so a body that |
| @@ -216,7 +200,7 @@ export function move( | ||
| 216 | 200 | const blockedX = slideAxis(body.pos, 0, dx, height, boxes, hull); |
| 217 | 201 | const blockedY = slideAxis(body.pos, 1, dy, height, boxes, hull); |
| 218 | 202 | |
| 219 | // A step is a second try from 18 units higher, dropped back down after. | |
| 203 | // A step is a second try from `stepSize` higher, dropped back down after. | |
| 220 | 204 | if ((blockedX || blockedY) && body.onGround) { |
| 221 | 205 | const stepped: Vec3 = [before[0], before[1], before[2] + tuning.stepSize]; |
| 222 | 206 | if (!stuck(stepped, height, boxes, hull)) { |
| @@ -247,7 +231,3 @@ export function move( | ||
| 247 | 231 | body.onGround = false; |
| 248 | 232 | } |
| 249 | 233 | } |
| 250 | ||
| 251 | /** Fall damage, CS-style: nothing under ~580 u/s, then it bites. */ | |
| 252 | export const fallDamage = (impactSpeed: number): number => | |
| 253 | impactSpeed < 580 ? 0 : Math.round((impactSpeed - 580) * 0.0888 * 100) / 100; |
src/games.ts +14
| @@ -135,6 +135,20 @@ export type Game = { | ||
| 135 | 135 | * it is absent is a test rather than an inference: `src/engine/digest.test.ts` |
| 136 | 136 | * reads every committed bundle for the sentence that module carries, for the |
| 137 | 137 | * complaint it writes and for `CryptoHasher`, and finds none of them. |
| 138 | * #123 moved the mover behind the door — `src/engine/physics.ts`, the Quake-lineage | |
| 139 | * solver the shooter walks on, with Counter-Strike's numbers left in | |
| 140 | * `src/client/movement.ts` where a reader can find them — and no bundle gained | |
| 141 | * anything worth the name: app 72,128 → 72,132 raw and 33,546 → 33,552 gzipped, | |
| 142 | * range 28,978 → 28,980 and 12,319 → 12,323, example 8,754 raw unchanged to the | |
| 143 | * byte and 4,225 → 4,228 gzipped. Four bytes on the shooter is a rename and an | |
| 144 | * import line; the two other games gained the same handful because the minifier | |
| 145 | * hands out different short names over a module graph that changed shape, which | |
| 146 | * is what happened for #106, #107 and #110. The range and the example ship none | |
| 147 | * of the solver and that is a test rather than an inference: | |
| 148 | * `src/range/bundle.test.ts` reads the committed file for `airSpeedCap`, | |
| 149 | * `duckRate`, `jumpVelocity` and `stopSpeed` and finds none of them, while | |
| 150 | * `src/client/bundle.test.ts` finds all four and the jump velocity itself. | |
| 151 | * Measured 2026-09-20 after #123, on the bundles this branch commits. | |
| 138 | 152 | * The range's numbers here are #121's moving plates and #125's painter at once: |
| 139 | 153 | * both landed a range bundle and neither swept the other's, so static/ held two |
| 140 | 154 | * and every reader of it failed until a plain `bun run build` resolved them. |
src/independence.test.ts +1 −1
| @@ -154,7 +154,7 @@ test("the engine's door, the shell, a sibling and the app are what a game may re | ||
| 154 | 154 | expect(importsAnotherGame("src/range/main.ts", "../engine/index.ts")).toBeNull(); |
| 155 | 155 | expect(importsAnotherGame("src/client/main.ts", "../pages/shell.ts")).toBeNull(); |
| 156 | 156 | expect(importsAnotherGame("src/range/main.ts", "../pages/shell.ts")).toBeNull(); |
| 157 | expect(importsAnotherGame("src/client/world.ts", "./physics.ts")).toBeNull(); | |
| 157 | expect(importsAnotherGame("src/client/world.ts", "./movement.ts")).toBeNull(); | |
| 158 | 158 | expect(importsAnotherGame("src/range/main.ts", "./binding.ts")).toBeNull(); |
| 159 | 159 | expect(importsAnotherGame("src/client/main.test.ts", "../result.ts")).toBeNull(); |
| 160 | 160 | expect(importsAnotherGame("src/client/world.ts", "gl-matrix")).toBeNull(); |
src/pages/range.ts +3 −2
| @@ -101,8 +101,9 @@ export function rangePage(options: PageOptions): string { | ||
| 101 | 101 | the shot leaves a tracer for eighty milliseconds, and the atlas they are painted from is |
| 102 | 102 | three 64-pixel layers of this game's own — not a line of the shooter's map, models or |
| 103 | 103 | materials. What a frame looks like is plain data in <code>src/range/scene.ts</code>, read |
| 104 | by a test with no browser in the room. Still missing is the walking: the eye stands at the | |
| 105 | firing line until the engine's physics lands. If your browser has no WebGL2, the flat | |
| 104 | by a test with no browser in the room. Still missing is the walking: the mover is behind | |
| 105 | the engine's door now, and the eye stands at the firing line until this game hands it a | |
| 106 | tuning and a hull of its own. If your browser has no WebGL2, the flat | |
| 106 | 107 | horizon is still there and the panel says so.</p> |
| 107 | 108 | <p>You can hear it too, and that is the last part of the engine this page was not using: its |
| 108 | 109 | sound device, with five recipes of the range's own in <code>src/range/audio.ts</code> — the |
src/range/bundle.test.ts +12
| @@ -126,6 +126,18 @@ test("the range bundles none of the engine's policy network", () => { | ||
| 126 | 126 | for (const name of ["Math.tanh", "atob", "btoa"]) expect(code).not.toContain(name); |
| 127 | 127 | }); |
| 128 | 128 | |
| 129 | test("the range bundles none of the engine's mover either", () => { | |
| 130 | // The mover went behind the door in #123 and the range still walks nobody, so | |
| 131 | // the door widening cost this bundle nothing. Read as property names, which | |
| 132 | // the minifier cannot rename because a game's tuning literal writes them: a | |
| 133 | // bundle that carried the solver would carry the fields it reads out of a | |
| 134 | // `MoveTuning`. The shooter's bundle has all four — `src/client/bundle.test.ts` | |
| 135 | // is the other end of this claim. | |
| 136 | const code = readFileSync(bundlePath("range"), "utf8"); | |
| 137 | for (const field of ["airSpeedCap", "duckRate", "jumpVelocity", "stopSpeed"]) | |
| 138 | expect(code).not.toContain(field); | |
| 139 | }); | |
| 140 | ||
| 129 | 141 | test("the stub page leaves nothing of itself behind", () => { |
| 130 | 142 | // Every boot above undid its install, so no other test inherits a document. |
| 131 | 143 | expect("document" in globalThis).toBe(HAD_DOCUMENT); |
src/range/main.ts +1 −1
| @@ -181,7 +181,7 @@ export function boot(): void { | ||
| 181 | 181 | ticks += steps; |
| 182 | 182 | |
| 183 | 183 | // What was asked for this frame. The movement half of it is still only |
| 184 | // drawn — there is nothing to move until the engine's physics lands — and | |
| 184 | // drawn — the engine's mover is there, and nothing here hands it a tuning yet — and | |
| 185 | 185 | // the trigger and Enter half of it is the game. |
| 186 | 186 | const intent = readIntent(controls.held, controls.stick); |
| 187 | 187 |
static/app.1d36f87326.js added +58
Diff not shown — a line 57,194 characters wide (limit 2,000). +58
View filestatic/app.fc9923a472.js deleted −58
Diff not shown — a line 59,545 characters wide (limit 2,000). −58
View filestatic/example.4055b264fb.js added +1
Diff not shown — a line 8,748 characters wide (limit 2,000). +1
View filestatic/example.801f9085bb.js deleted −1
Diff not shown — a line 8,748 characters wide (limit 2,000). −1
View filestatic/range.b3648bb3ad.js added +58
Diff not shown — a line 17,032 characters wide (limit 2,000). +58
View filestatic/range.e5be537f51.js deleted −58
Diff not shown — a line 17,026 characters wide (limit 2,000). −58
View file