Public: anyone can read this; the crew can change it.
forked from trevato/cs
Physics moves into the engine and takes its tuning as data #7
torque/cs-engine:torque/3-physics-moves-into-the-engine-and-takes → main 3 commits fork
15 files
changed +569
−420
README.md +12 −9
| @@ -54,16 +54,19 @@ the rest. | ||
| 54 | 54 | |
| 55 | 55 | - `src/engine/` — the part a second game could use, and the rule is that it never |
| 56 | 56 | imports the game: `math` (vectors, matrices, angles, a seeded rng), `trace` |
| 57 | (rays against boxes, and the `Box` they are traced against), `mesh` (a box | |
| 58 | builder), `texture` (hashed value noise, fbm and an atlas painter) and `sound` | |
| 59 | (a lazy `AudioContext` with noise bursts and tones). `boundary.test.ts` | |
| 60 | reads the imports of every file in here and fails if one of them points at | |
| 61 | `src/client/`, at `src/rl/` or at a package. | |
| 57 | (rays against boxes, and the `Box` they are traced against), `physics` (the | |
| 58 | Quake-lineage mover, which holds no number of its own: a caller hands in a | |
| 59 | `MoveTuning` and a `Hull`), `mesh` (a box builder), `texture` (hashed value | |
| 60 | noise, fbm and an atlas painter) and `sound` (a lazy `AudioContext` with noise | |
| 61 | bursts and tones). `boundary.test.ts` reads the imports of every file in here | |
| 62 | and fails if one of them points at `src/client/`, at `src/rl/` or at a package. | |
| 62 | 63 | - `src/client/` — the game, in modules named for one thing each: `map`, |
| 63 | `physics`, `weapons`, `combat`, `match` (the rules), `world` (the simulation), | |
| 64 | `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`, | |
| 65 | `renderer`, `hud`, `input`, `audio`, `main`. The last five aside, everything is | |
| 66 | headless and tested; `models`, `materials` and `audio` are this game's recipes | |
| 64 | `movement` (this game's `CS_MOVE`, `CS_HULL` and fall damage — the Source | |
| 65 | numbers the engine's mover is handed), `weapons`, `combat`, `match` (the rules), | |
| 66 | `world` (the simulation), `bots`, `observe`, `action`, `policy`, `weights`, | |
| 67 | `models`, `materials`, `renderer`, `hud`, `input`, `audio`, `main`. The last | |
| 68 | five aside, everything is headless and tested; `movement` is this game's tuning | |
| 69 | for the engine's mover, and `models`, `materials` and `audio` are its recipes | |
| 67 | 70 | for the engine's three asset tools. |
| 68 | 71 | - `src/rl/` — training, which the game never imports: `arena` is the environment |
| 69 | 72 | (a real duel on de_forge with the round and the economy taken out) and `ppo` is |
src/client/combat.ts +2 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Box } from "./map.ts"; |
| 8 | 8 | import { dist, type Vec3 } from "../engine/math.ts"; |
| 9 | import { HULL } from "./physics.ts"; | |
| 9 | import { CS_HULL } from "./movement.ts"; | |
| 10 | 10 | import { rayBox, traceWorld } from "../engine/trace.ts"; |
| 11 | 11 | import { computeDamage, type Hitgroup, type Weapon } from "./weapons.ts"; |
| 12 | 12 | |
| @@ -33,7 +33,7 @@ const PARTS: [Hitgroup, number, number, number][] = [ | ||
| 33 | 33 | ]; |
| 34 | 34 | |
| 35 | 35 | export function hitboxes(a: Actor): Part[] { |
| 36 | const height = HULL.stand + (HULL.duck - HULL.stand) * a.duckAmount; | |
| 36 | const height = CS_HULL.stand + (CS_HULL.duck - CS_HULL.stand) * a.duckAmount; | |
| 37 | 37 | return PARTS.map(([group, lo, hi, w]) => ({ |
| 38 | 38 | group, |
| 39 | 39 | min: [a.pos[0] - w, a.pos[1] - w, a.pos[2] + height * lo] as Vec3, |
src/client/hud.ts +2 −2
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { clamp } from "../engine/math.ts"; |
| 8 | import { MOVE } from "./physics.ts"; | |
| 8 | import { CS_MOVE } from "./movement.ts"; | |
| 9 | 9 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 10 | 10 | import { currentWeapon, useProgress, type World } from "./world.ts"; |
| 11 | 11 | |
| @@ -34,7 +34,7 @@ export function drawHud( | ||
| 34 | 34 | if (me.alive) { |
| 35 | 35 | const speed = Math.hypot(me.vel[0], me.vel[1]); |
| 36 | 36 | const spread = inaccuracy(weapon, { |
| 37 | moving: clamp(speed / MOVE.maxSpeed, 0, 1), | |
| 37 | moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1), | |
| 38 | 38 | inAir: !me.onGround, |
| 39 | 39 | ducking: me.duckAmount > 0.5, |
| 40 | 40 | scoped: me.scoped, |
src/client/movement.test.ts added +218
| @@ -0,0 +1,218 @@ | ||
| 1 | /** | |
| 2 | * The game's movement: the engine's solver run with this game's tuning. Every | |
| 3 | * number asserted here is a Counter-Strike number and lives in movement.ts — | |
| 4 | * the last block runs the same solver under a different profile, which is what | |
| 5 | * would fail if any of them crept back into `src/engine/physics.ts`. | |
| 6 | */ | |
| 7 | ||
| 8 | import { expect, test } from "bun:test"; | |
| 9 | import type { Box } from "./map.ts"; | |
| 10 | import type { Vec3 } from "../engine/math.ts"; | |
| 11 | import { | |
| 12 | accelerate, airAccelerate, move, type Body, type Hull, type MoveTuning, type Wish, | |
| 13 | } from "../engine/physics.ts"; | |
| 14 | import { CS_HULL, CS_MOVE, fallDamage } from "./movement.ts"; | |
| 15 | ||
| 16 | const ground: Box = { min: [-1000, -1000, -32], max: [1000, 1000, 0], mat: 0 }; | |
| 17 | ||
| 18 | const body = (pos: Vec3 = [0, 0, 0]): Body => ({ | |
| 19 | pos: [...pos] as Vec3, | |
| 20 | vel: [0, 0, 0], | |
| 21 | onGround: true, | |
| 22 | ducking: false, | |
| 23 | duckAmount: 0, | |
| 24 | }); | |
| 25 | ||
| 26 | const wish = (over: Partial<Wish> = {}): Wish => ({ | |
| 27 | forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: CS_MOVE.maxSpeed, ...over, | |
| 28 | }); | |
| 29 | ||
| 30 | const run = ( | |
| 31 | b: Body, | |
| 32 | w: Wish, | |
| 33 | seconds: number, | |
| 34 | boxes: Box[], | |
| 35 | tuning: MoveTuning = CS_MOVE, | |
| 36 | hull: Hull = CS_HULL, | |
| 37 | ): void => { | |
| 38 | for (let t = 0; t < seconds; t += 1 / 64) move(b, w, 1 / 64, boxes, tuning, hull); | |
| 39 | }; | |
| 40 | ||
| 41 | test("running forwards tops out at the weapon's carry speed", () => { | |
| 42 | const b = body(); | |
| 43 | run(b, wish({ forward: 1 }), 3, [ground]); | |
| 44 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(CS_MOVE.maxSpeed, 0); | |
| 45 | expect(b.pos[0]).toBeGreaterThan(500); | |
| 46 | }); | |
| 47 | ||
| 48 | test("letting go stops you, but not instantly", () => { | |
| 49 | const b = body(); | |
| 50 | run(b, wish({ forward: 1 }), 2, [ground]); | |
| 51 | move(b, wish(), 1 / 64, [ground], CS_MOVE, CS_HULL); | |
| 52 | const afterOneTick = Math.hypot(b.vel[0], b.vel[1]); | |
| 53 | expect(afterOneTick).toBeGreaterThan(200); | |
| 54 | run(b, wish(), 1, [ground]); | |
| 55 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(1); | |
| 56 | }); | |
| 57 | ||
| 58 | test("air acceleration is capped at 30 u/s along the wish direction", () => { | |
| 59 | const vel: Vec3 = [250, 0, 0]; | |
| 60 | airAccelerate(vel, [1, 0, 0], 250, 1, CS_MOVE); | |
| 61 | expect(vel[0]).toBeCloseTo(250, 5); // already faster than the air cap: nothing added | |
| 62 | const sideways: Vec3 = [250, 0, 0]; | |
| 63 | airAccelerate(sideways, [0, 1, 0], 250, 1, CS_MOVE); | |
| 64 | expect(sideways[1]).toBeCloseTo(CS_MOVE.airSpeedCap, 5); // across it: the full cap | |
| 65 | }); | |
| 66 | ||
| 67 | test("ground acceleration never overshoots the wish speed", () => { | |
| 68 | const vel: Vec3 = [0, 0, 0]; | |
| 69 | accelerate(vel, [1, 0, 0], 250, 5.6, 10); | |
| 70 | expect(vel[0]).toBeCloseTo(250, 5); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a wall stops you and you keep sliding along it", () => { | |
| 74 | const wall: Box = { min: [200, -1000, 0], max: [264, 1000, 384], mat: 1 }; | |
| 75 | const b = body(); | |
| 76 | run(b, wish({ forward: 1, side: -1 }), 3, [ground, wall]); | |
| 77 | expect(b.pos[0]).toBeLessThanOrEqual(200 - CS_HULL.radius + 0.01); | |
| 78 | expect(Math.abs(b.pos[1])).toBeGreaterThan(200); // still moving along the wall | |
| 79 | }); | |
| 80 | ||
| 81 | test("you step onto a 16-unit ledge and not onto a 40-unit one", () => { | |
| 82 | const low: Box = { min: [100, -100, 0], max: [400, 100, 16], mat: 2 }; | |
| 83 | const high: Box = { min: [100, -100, 0], max: [400, 100, 40], mat: 2 }; | |
| 84 | const stepper = body([0, 0, 0]); | |
| 85 | run(stepper, wish({ forward: 1 }), 1, [ground, low]); | |
| 86 | expect(stepper.pos[2]).toBeCloseTo(16, 1); | |
| 87 | expect(stepper.pos[0]).toBeGreaterThan(150); | |
| 88 | const blocked = body([0, 0, 0]); | |
| 89 | run(blocked, wish({ forward: 1 }), 1, [ground, high]); | |
| 90 | expect(blocked.pos[2]).toBe(0); | |
| 91 | expect(blocked.pos[0]).toBeLessThan(100); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a jump leaves the ground and gravity brings it back", () => { | |
| 95 | const b = body(); | |
| 96 | move(b, wish({ jump: true }), 1 / 64, [ground], CS_MOVE, CS_HULL); | |
| 97 | expect(b.onGround).toBe(false); | |
| 98 | expect(b.vel[2]).toBeGreaterThan(250); | |
| 99 | run(b, wish(), 2, [ground]); | |
| 100 | expect(b.onGround).toBe(true); | |
| 101 | expect(b.pos[2]).toBeCloseTo(0, 1); | |
| 102 | }); | |
| 103 | ||
| 104 | test("a jump clears 54 units, the height CS players count on", () => { | |
| 105 | const b = body(); | |
| 106 | let peak = 0; | |
| 107 | move(b, wish({ jump: true }), 1 / 64, [ground], CS_MOVE, CS_HULL); | |
| 108 | for (let t = 0; t < 1; t += 1 / 64) { | |
| 109 | move(b, wish(), 1 / 64, [ground], CS_MOVE, CS_HULL); | |
| 110 | peak = Math.max(peak, b.pos[2]); | |
| 111 | } | |
| 112 | expect(peak).toBeGreaterThan(54); | |
| 113 | expect(peak).toBeLessThan(60); | |
| 114 | }); | |
| 115 | ||
| 116 | test("crouching shrinks the hull and slows you down", () => { | |
| 117 | const b = body(); | |
| 118 | run(b, wish({ forward: 1, duck: true }), 2, [ground]); | |
| 119 | expect(b.duckAmount).toBeCloseTo(1, 2); | |
| 120 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(CS_MOVE.duckSpeed + 5); | |
| 121 | }); | |
| 122 | ||
| 123 | test("you cannot stand up under a low ceiling", () => { | |
| 124 | const ceiling: Box = { min: [-100, -100, 60], max: [100, 100, 200], mat: 1 }; | |
| 125 | const b = body(); | |
| 126 | b.duckAmount = 1; | |
| 127 | run(b, wish({ duck: true }), 1, [ground, ceiling]); | |
| 128 | run(b, wish({ duck: false }), 1, [ground, ceiling]); | |
| 129 | expect(b.duckAmount).toBeGreaterThan(0.2); | |
| 130 | }); | |
| 131 | ||
| 132 | test("strafing in the air gains speed a straight line cannot", () => { | |
| 133 | // Hold strafe-left and turn left through the jump: the classic gain. | |
| 134 | const b = body(); | |
| 135 | b.vel = [CS_MOVE.maxSpeed, 0, 0]; | |
| 136 | move(b, wish({ forward: 1, jump: true }), 1 / 64, [ground], CS_MOVE, CS_HULL); | |
| 137 | let yaw = 0; | |
| 138 | while (!b.onGround) { | |
| 139 | yaw += 0.55; | |
| 140 | move(b, wish({ side: -1, yaw }), 1 / 128, [ground], CS_MOVE, CS_HULL); | |
| 141 | } | |
| 142 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeGreaterThan(CS_MOVE.maxSpeed + 2); | |
| 143 | }); | |
| 144 | ||
| 145 | test("fall damage starts only after a long drop", () => { | |
| 146 | expect(fallDamage(400)).toBe(0); | |
| 147 | expect(fallDamage(1000)).toBeGreaterThan(30); | |
| 148 | }); | |
| 149 | ||
| 150 | // --- a second profile, which no baked-in constant could satisfy ------------- | |
| 151 | ||
| 152 | /** Half the gravity, a taller hull, a bigger step and twice the run speed. */ | |
| 153 | const MOON: MoveTuning = { ...CS_MOVE, gravity: 400, maxSpeed: 500, stepSize: 36 }; | |
| 154 | const TALL: Hull = { radius: 24, stand: 144, duck: 108, eyeStand: 128, eyeDuck: 92 }; | |
| 155 | ||
| 156 | const moonWish = (over: Partial<Wish> = {}): Wish => wish({ maxSpeed: MOON.maxSpeed, ...over }); | |
| 157 | ||
| 158 | test("half the gravity hangs a jump twice as long and twice as high", () => { | |
| 159 | // Source integrates gravity in two half steps, so the apex is | |
| 160 | // v²/2g = 301.993² / 1600 ≈ 57.0 units under CS and ≈ 114.0 under MOON, | |
| 161 | // and the hang time v/g doubles from 0.377 s to 0.755 s. | |
| 162 | const airTime = (tuning: MoveTuning): { peak: number; seconds: number } => { | |
| 163 | const b = body(); | |
| 164 | let peak = 0, seconds = 0; | |
| 165 | move(b, wish({ jump: true }), 1 / 64, [ground], tuning, CS_HULL); | |
| 166 | while (!b.onGround && seconds < 5) { | |
| 167 | move(b, wish(), 1 / 64, [ground], tuning, CS_HULL); | |
| 168 | seconds += 1 / 64; | |
| 169 | peak = Math.max(peak, b.pos[2]); | |
| 170 | } | |
| 171 | return { peak, seconds }; | |
| 172 | }; | |
| 173 | const cs = airTime(CS_MOVE); | |
| 174 | const moon = airTime(MOON); | |
| 175 | expect(cs.peak).toBeCloseTo(57.0, 0); | |
| 176 | expect(moon.peak).toBeCloseTo(114.0, 0); | |
| 177 | expect(moon.seconds / cs.seconds).toBeCloseTo(2, 1); | |
| 178 | }); | |
| 179 | ||
| 180 | test("a taller hull walks under what a CS hull walks under, and no lower", () => { | |
| 181 | // An overhang starting at x = 200 leaves a gap 100 units tall: the 72-unit | |
| 182 | // hull runs under it, the 144-unit one meets its edge and stops. | |
| 183 | const roof: Box = { min: [200, -1000, 100], max: [600, 1000, 200], mat: 1 }; | |
| 184 | const short = body(); | |
| 185 | run(short, wish({ forward: 1 }), 2, [ground, roof]); | |
| 186 | expect(short.pos[0]).toBeGreaterThan(400); | |
| 187 | const tall = body(); | |
| 188 | run(tall, moonWish({ forward: 1 }), 2, [ground, roof], MOON, TALL); | |
| 189 | expect(tall.pos[0]).toBeCloseTo(200 - TALL.radius, 1); | |
| 190 | }); | |
| 191 | ||
| 192 | test("the step size is the tuning's, not eighteen", () => { | |
| 193 | const ledge: Box = { min: [100, -100, 0], max: [400, 100, 30], mat: 2 }; | |
| 194 | const cs = body(); | |
| 195 | run(cs, wish({ forward: 1 }), 1, [ground, ledge]); | |
| 196 | expect(cs.pos[2]).toBe(0); // 30 > CS_MOVE.stepSize: blocked | |
| 197 | expect(cs.pos[0]).toBeLessThan(100); | |
| 198 | const moon = body(); | |
| 199 | run(moon, moonWish({ forward: 1 }), 1, [ground, ledge], MOON, TALL); | |
| 200 | expect(moon.pos[2]).toBeCloseTo(30, 1); // 30 < MOON.stepSize: climbed | |
| 201 | expect(moon.pos[0]).toBeGreaterThan(150); | |
| 202 | }); | |
| 203 | ||
| 204 | test("a wider hull stops further from a wall", () => { | |
| 205 | const wall: Box = { min: [200, -1000, 0], max: [264, 1000, 384], mat: 1 }; | |
| 206 | const cs = body(); | |
| 207 | run(cs, wish({ forward: 1 }), 2, [ground, wall]); | |
| 208 | expect(cs.pos[0]).toBeCloseTo(200 - CS_HULL.radius, 1); | |
| 209 | const wide = body(); | |
| 210 | run(wide, moonWish({ forward: 1 }), 2, [ground, wall], MOON, TALL); | |
| 211 | expect(wide.pos[0]).toBeCloseTo(200 - TALL.radius, 1); | |
| 212 | }); | |
| 213 | ||
| 214 | test("the run speed a profile names is the run speed it reaches", () => { | |
| 215 | const b = body(); | |
| 216 | run(b, moonWish({ forward: 1 }), 4, [ground], MOON, TALL); | |
| 217 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(MOON.maxSpeed, 0); | |
| 218 | }); |
src/client/movement.ts added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | /** | |
| 2 | * What this game hands the engine's mover: Counter-Strike's numbers, and the | |
| 3 | * fall-damage curve that goes with them. | |
| 4 | * | |
| 5 | * Units are Source units (1 unit ≈ 1.9 cm): 250 u/s running, 800 u/s² gravity, | |
| 6 | * a 32×32×72 hull, an 18-unit step. Nothing in `src/engine/physics.ts` knows | |
| 7 | * any of this — every one of these numbers reaches the solver as an argument, | |
| 8 | * so a second game can hand in its own and get its own feel. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { Hull, MoveTuning } from "../engine/physics.ts"; | |
| 12 | ||
| 13 | export const CS_HULL: Hull = { radius: 16, stand: 72, duck: 54, eyeStand: 64, eyeDuck: 46 }; | |
| 14 | ||
| 15 | export const CS_MOVE: MoveTuning = { | |
| 16 | gravity: 800, | |
| 17 | maxSpeed: 250, | |
| 18 | duckSpeed: 85, | |
| 19 | walkSpeed: 130, | |
| 20 | accelerate: 5.6, | |
| 21 | airAccelerate: 12, | |
| 22 | airSpeedCap: 30, | |
| 23 | friction: 5.2, | |
| 24 | stopSpeed: 80, | |
| 25 | jumpVelocity: 301.993, | |
| 26 | stepSize: 18, | |
| 27 | duckRate: 5, // a full crouch in 0.2 s | |
| 28 | }; | |
| 29 | ||
| 30 | /** Fall damage, CS-style: nothing under ~580 u/s, then it bites. */ | |
| 31 | export const fallDamage = (impactSpeed: number): number => | |
| 32 | 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 * as map from "./map.ts"; |
| 16 | 16 | import { angleDelta, clamp, dist, type Vec3 } from "../engine/math.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/physics.test.ts deleted −131
| @@ -1,131 +0,0 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import type { Box } from "./map.ts"; | |
| 3 | import type { Vec3 } from "../engine/math.ts"; | |
| 4 | import { accelerate, airAccelerate, fallDamage, HULL, MOVE, move, type Body, type Wish } from "./physics.ts"; | |
| 5 | ||
| 6 | const ground: Box = { min: [-1000, -1000, -32], max: [1000, 1000, 0], mat: 0 }; | |
| 7 | ||
| 8 | const body = (pos: Vec3 = [0, 0, 0]): Body => ({ | |
| 9 | pos: [...pos] as Vec3, | |
| 10 | vel: [0, 0, 0], | |
| 11 | onGround: true, | |
| 12 | ducking: false, | |
| 13 | duckAmount: 0, | |
| 14 | }); | |
| 15 | ||
| 16 | const wish = (over: Partial<Wish> = {}): Wish => ({ | |
| 17 | forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: MOVE.maxSpeed, ...over, | |
| 18 | }); | |
| 19 | ||
| 20 | const run = (b: Body, w: Wish, seconds: number, boxes: Box[]): void => { | |
| 21 | for (let t = 0; t < seconds; t += 1 / 64) move(b, w, 1 / 64, boxes); | |
| 22 | }; | |
| 23 | ||
| 24 | test("running forwards tops out at the weapon's carry speed", () => { | |
| 25 | const b = body(); | |
| 26 | run(b, wish({ forward: 1 }), 3, [ground]); | |
| 27 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(MOVE.maxSpeed, 0); | |
| 28 | expect(b.pos[0]).toBeGreaterThan(500); | |
| 29 | }); | |
| 30 | ||
| 31 | test("letting go stops you, but not instantly", () => { | |
| 32 | const b = body(); | |
| 33 | run(b, wish({ forward: 1 }), 2, [ground]); | |
| 34 | move(b, wish(), 1 / 64, [ground]); | |
| 35 | const afterOneTick = Math.hypot(b.vel[0], b.vel[1]); | |
| 36 | expect(afterOneTick).toBeGreaterThan(200); | |
| 37 | run(b, wish(), 1, [ground]); | |
| 38 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(1); | |
| 39 | }); | |
| 40 | ||
| 41 | test("air acceleration is capped at 30 u/s along the wish direction", () => { | |
| 42 | const vel: Vec3 = [250, 0, 0]; | |
| 43 | airAccelerate(vel, [1, 0, 0], 250, 1); | |
| 44 | expect(vel[0]).toBeCloseTo(250, 5); // already faster than the air cap: nothing added | |
| 45 | const sideways: Vec3 = [250, 0, 0]; | |
| 46 | airAccelerate(sideways, [0, 1, 0], 250, 1); | |
| 47 | expect(sideways[1]).toBeCloseTo(MOVE.airSpeedCap, 5); // across it: the full cap | |
| 48 | }); | |
| 49 | ||
| 50 | test("ground acceleration never overshoots the wish speed", () => { | |
| 51 | const vel: Vec3 = [0, 0, 0]; | |
| 52 | accelerate(vel, [1, 0, 0], 250, 5.6, 10); | |
| 53 | expect(vel[0]).toBeCloseTo(250, 5); | |
| 54 | }); | |
| 55 | ||
| 56 | test("a wall stops you and you keep sliding along it", () => { | |
| 57 | const wall: Box = { min: [200, -1000, 0], max: [264, 1000, 384], mat: 1 }; | |
| 58 | const b = body(); | |
| 59 | run(b, wish({ forward: 1, side: -1 }), 3, [ground, wall]); | |
| 60 | expect(b.pos[0]).toBeLessThanOrEqual(200 - HULL.radius + 0.01); | |
| 61 | expect(Math.abs(b.pos[1])).toBeGreaterThan(200); // still moving along the wall | |
| 62 | }); | |
| 63 | ||
| 64 | test("you step onto a 16-unit ledge and not onto a 40-unit one", () => { | |
| 65 | const low: Box = { min: [100, -100, 0], max: [400, 100, 16], mat: 2 }; | |
| 66 | const high: Box = { min: [100, -100, 0], max: [400, 100, 40], mat: 2 }; | |
| 67 | const stepper = body([0, 0, 0]); | |
| 68 | run(stepper, wish({ forward: 1 }), 1, [ground, low]); | |
| 69 | expect(stepper.pos[2]).toBeCloseTo(16, 1); | |
| 70 | expect(stepper.pos[0]).toBeGreaterThan(150); | |
| 71 | const blocked = body([0, 0, 0]); | |
| 72 | run(blocked, wish({ forward: 1 }), 1, [ground, high]); | |
| 73 | expect(blocked.pos[2]).toBe(0); | |
| 74 | expect(blocked.pos[0]).toBeLessThan(100); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a jump leaves the ground and gravity brings it back", () => { | |
| 78 | const b = body(); | |
| 79 | move(b, wish({ jump: true }), 1 / 64, [ground]); | |
| 80 | expect(b.onGround).toBe(false); | |
| 81 | expect(b.vel[2]).toBeGreaterThan(250); | |
| 82 | run(b, wish(), 2, [ground]); | |
| 83 | expect(b.onGround).toBe(true); | |
| 84 | expect(b.pos[2]).toBeCloseTo(0, 1); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a jump clears 54 units, the height CS players count on", () => { | |
| 88 | const b = body(); | |
| 89 | let peak = 0; | |
| 90 | move(b, wish({ jump: true }), 1 / 64, [ground]); | |
| 91 | for (let t = 0; t < 1; t += 1 / 64) { | |
| 92 | move(b, wish(), 1 / 64, [ground]); | |
| 93 | peak = Math.max(peak, b.pos[2]); | |
| 94 | } | |
| 95 | expect(peak).toBeGreaterThan(54); | |
| 96 | expect(peak).toBeLessThan(60); | |
| 97 | }); | |
| 98 | ||
| 99 | test("crouching shrinks the hull and slows you down", () => { | |
| 100 | const b = body(); | |
| 101 | run(b, wish({ forward: 1, duck: true }), 2, [ground]); | |
| 102 | expect(b.duckAmount).toBeCloseTo(1, 2); | |
| 103 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(MOVE.duckSpeed + 5); | |
| 104 | }); | |
| 105 | ||
| 106 | test("you cannot stand up under a low ceiling", () => { | |
| 107 | const ceiling: Box = { min: [-100, -100, 60], max: [100, 100, 200], mat: 1 }; | |
| 108 | const b = body(); | |
| 109 | b.duckAmount = 1; | |
| 110 | run(b, wish({ duck: true }), 1, [ground, ceiling]); | |
| 111 | run(b, wish({ duck: false }), 1, [ground, ceiling]); | |
| 112 | expect(b.duckAmount).toBeGreaterThan(0.2); | |
| 113 | }); | |
| 114 | ||
| 115 | test("strafing in the air gains speed a straight line cannot", () => { | |
| 116 | // Hold strafe-left and turn left through the jump: the classic gain. | |
| 117 | const b = body(); | |
| 118 | b.vel = [MOVE.maxSpeed, 0, 0]; | |
| 119 | move(b, wish({ forward: 1, jump: true }), 1 / 64, [ground]); | |
| 120 | let yaw = 0; | |
| 121 | while (!b.onGround) { | |
| 122 | yaw += 0.55; | |
| 123 | move(b, wish({ side: -1, yaw }), 1 / 128, [ground]); | |
| 124 | } | |
| 125 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeGreaterThan(MOVE.maxSpeed + 2); | |
| 126 | }); | |
| 127 | ||
| 128 | test("fall damage starts only after a long drop", () => { | |
| 129 | expect(fallDamage(400)).toBe(0); | |
| 130 | expect(fallDamage(1000)).toBeGreaterThan(30); | |
| 131 | }); |
src/client/physics.ts deleted −206
| @@ -1,206 +0,0 @@ | ||
| 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. | |
| 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. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { Box } from "./map.ts"; | |
| 12 | import { clamp, dot, normalize, type Vec3 } from "../engine/math.ts"; | |
| 13 | ||
| 14 | export const HULL = { radius: 16, stand: 72, duck: 54, eyeStand: 64, eyeDuck: 46 }; | |
| 15 | ||
| 16 | export const MOVE = { | |
| 17 | gravity: 800, | |
| 18 | maxSpeed: 250, | |
| 19 | duckSpeed: 85, | |
| 20 | walkSpeed: 130, | |
| 21 | accelerate: 5.6, | |
| 22 | airAccelerate: 12, | |
| 23 | airSpeedCap: 30, | |
| 24 | friction: 5.2, | |
| 25 | stopSpeed: 80, | |
| 26 | jumpVelocity: 301.993, | |
| 27 | stepSize: 18, | |
| 28 | }; | |
| 29 | ||
| 30 | export type Body = { | |
| 31 | pos: Vec3; | |
| 32 | vel: Vec3; | |
| 33 | onGround: boolean; | |
| 34 | ducking: boolean; | |
| 35 | duckAmount: number; | |
| 36 | }; | |
| 37 | ||
| 38 | export type Wish = { | |
| 39 | forward: number; // -1..1 along the view | |
| 40 | side: number; // -1..1 across it | |
| 41 | yaw: number; | |
| 42 | jump: boolean; | |
| 43 | duck: boolean; | |
| 44 | walk: boolean; | |
| 45 | maxSpeed: number; // the weapon's carry speed | |
| 46 | }; | |
| 47 | ||
| 48 | export const eyeHeight = (b: Body): number => | |
| 49 | HULL.eyeStand + (HULL.eyeDuck - HULL.eyeStand) * b.duckAmount; | |
| 50 | ||
| 51 | export const hullHeight = (b: Body): number => | |
| 52 | HULL.stand + (HULL.duck - HULL.stand) * b.duckAmount; | |
| 53 | ||
| 54 | function applyFriction(vel: Vec3, dt: number): void { | |
| 55 | const speed = Math.hypot(vel[0], vel[1], vel[2]); | |
| 56 | if (speed < 0.1) { vel[0] = 0; vel[1] = 0; return; } | |
| 57 | const control = Math.max(speed, MOVE.stopSpeed); | |
| 58 | const drop = control * MOVE.friction * dt; | |
| 59 | const scale = Math.max(speed - drop, 0) / speed; | |
| 60 | vel[0] *= scale; | |
| 61 | vel[1] *= scale; | |
| 62 | vel[2] *= scale; | |
| 63 | } | |
| 64 | ||
| 65 | /** Ground acceleration: add along the wish direction up to the wish speed. */ | |
| 66 | export function accelerate(vel: Vec3, wishDir: Vec3, wishSpeed: number, accel: number, dt: number): void { | |
| 67 | const current = dot(vel, wishDir); | |
| 68 | const add = wishSpeed - current; | |
| 69 | if (add <= 0) return; | |
| 70 | const step = Math.min(accel * wishSpeed * dt, add); | |
| 71 | vel[0] += wishDir[0] * step; | |
| 72 | vel[1] += wishDir[1] * step; | |
| 73 | vel[2] += wishDir[2] * step; | |
| 74 | } | |
| 75 | ||
| 76 | /** Air acceleration: the same, but the wish speed is capped at 30 u/s first. */ | |
| 77 | export function airAccelerate(vel: Vec3, wishDir: Vec3, wishSpeed: number, dt: number): void { | |
| 78 | accelerate(vel, wishDir, Math.min(wishSpeed, MOVE.airSpeedCap), MOVE.airAccelerate, dt); | |
| 79 | } | |
| 80 | ||
| 81 | const overlaps = (pos: Vec3, height: number, box: Box): boolean => | |
| 82 | pos[0] + HULL.radius > box.min[0] && pos[0] - HULL.radius < box.max[0] && | |
| 83 | pos[1] + HULL.radius > box.min[1] && pos[1] - HULL.radius < box.max[1] && | |
| 84 | pos[2] + height > box.min[2] && pos[2] < box.max[2]; | |
| 85 | ||
| 86 | export const stuck = (pos: Vec3, height: number, boxes: Box[]): boolean => | |
| 87 | boxes.some((b) => overlaps(pos, height, b)); | |
| 88 | ||
| 89 | /** Move one axis and push back out of whatever it ended up inside. */ | |
| 90 | function slideAxis(pos: Vec3, axis: 0 | 1 | 2, delta: number, height: number, boxes: Box[]): boolean { | |
| 91 | if (delta === 0) return false; | |
| 92 | pos[axis] += delta; | |
| 93 | let blocked = false; | |
| 94 | for (const box of boxes) { | |
| 95 | if (!overlaps(pos, height, box)) continue; | |
| 96 | blocked = true; | |
| 97 | const extent = axis === 2 ? 0 : HULL.radius; | |
| 98 | if (delta > 0) pos[axis] = (box.min[axis] as number) - extent - (axis === 2 ? height : 0); | |
| 99 | else pos[axis] = (box.max[axis] as number) + extent; | |
| 100 | } | |
| 101 | return blocked; | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * The floor holding a hull up: the highest box top within `reach` below the | |
| 106 | * feet. Asking this directly, rather than resolving a downward move, is what | |
| 107 | * keeps a player standing under a low ceiling from being shoved through it. | |
| 108 | */ | |
| 109 | export function groundUnder(pos: Vec3, height: number, boxes: Box[], reach: number): number | null { | |
| 110 | let best: number | null = null; | |
| 111 | for (const box of boxes) { | |
| 112 | if (pos[0] + HULL.radius <= box.min[0] || pos[0] - HULL.radius >= box.max[0]) continue; | |
| 113 | if (pos[1] + HULL.radius <= box.min[1] || pos[1] - HULL.radius >= box.max[1]) continue; | |
| 114 | const top = box.max[2] as number; | |
| 115 | if (top > pos[2] + 0.01 || top < pos[2] - reach) continue; | |
| 116 | if (best === null || top > best) best = top; | |
| 117 | } | |
| 118 | return best; | |
| 119 | } | |
| 120 | ||
| 121 | /** | |
| 122 | * One movement tick: friction or gravity, acceleration, then a move that | |
| 123 | * slides along walls and steps up onto anything 18 units or less. | |
| 124 | */ | |
| 125 | export function move(body: Body, wish: Wish, dt: number, boxes: Box[]): void { | |
| 126 | const duckTarget = wish.duck ? 1 : 0; | |
| 127 | const rate = dt * 5; | |
| 128 | const next = clamp( | |
| 129 | body.duckAmount < duckTarget | |
| 130 | ? Math.min(duckTarget, body.duckAmount + rate) | |
| 131 | : Math.max(duckTarget, body.duckAmount - rate), | |
| 132 | 0, | |
| 133 | 1, | |
| 134 | ); | |
| 135 | // Standing up is refused while something is over your head. | |
| 136 | const standing = next < body.duckAmount; | |
| 137 | const probe: Vec3 = [body.pos[0], body.pos[1], body.pos[2]]; | |
| 138 | const nextHeight = HULL.stand + (HULL.duck - HULL.stand) * next; | |
| 139 | if (!standing || !stuck(probe, nextHeight, boxes)) body.duckAmount = next; | |
| 140 | body.ducking = body.duckAmount > 0.5; | |
| 141 | ||
| 142 | const yawRad = (wish.yaw * Math.PI) / 180; | |
| 143 | const fx = Math.cos(yawRad), fy = Math.sin(yawRad); | |
| 144 | const wishDir = normalize([ | |
| 145 | fx * wish.forward + fy * wish.side, | |
| 146 | fy * wish.forward - fx * wish.side, | |
| 147 | 0, | |
| 148 | ]); | |
| 149 | let wishSpeed = Math.min(1, Math.hypot(wish.forward, wish.side)) * wish.maxSpeed; | |
| 150 | if (wish.walk) wishSpeed *= MOVE.walkSpeed / MOVE.maxSpeed; | |
| 151 | if (body.duckAmount > 0.5) wishSpeed *= MOVE.duckSpeed / MOVE.maxSpeed; | |
| 152 | ||
| 153 | if (body.onGround) { | |
| 154 | applyFriction(body.vel, dt); | |
| 155 | accelerate(body.vel, wishDir, wishSpeed, MOVE.accelerate, dt); | |
| 156 | body.vel[2] = 0; | |
| 157 | if (wish.jump) { | |
| 158 | body.vel[2] = MOVE.jumpVelocity; | |
| 159 | body.onGround = false; | |
| 160 | } | |
| 161 | } else { | |
| 162 | body.vel[2] -= MOVE.gravity * dt * 0.5; | |
| 163 | airAccelerate(body.vel, wishDir, wishSpeed, dt); | |
| 164 | } | |
| 165 | ||
| 166 | const height = hullHeight(body); | |
| 167 | const before: Vec3 = [body.pos[0], body.pos[1], body.pos[2]]; | |
| 168 | const dx = body.vel[0] * dt, dy = body.vel[1] * dt; | |
| 169 | const blockedX = slideAxis(body.pos, 0, dx, height, boxes); | |
| 170 | const blockedY = slideAxis(body.pos, 1, dy, height, boxes); | |
| 171 | ||
| 172 | // A step is a second try from 18 units higher, dropped back down after. | |
| 173 | if ((blockedX || blockedY) && body.onGround) { | |
| 174 | const stepped: Vec3 = [before[0], before[1], before[2] + MOVE.stepSize]; | |
| 175 | if (!stuck(stepped, height, boxes)) { | |
| 176 | slideAxis(stepped, 0, dx, height, boxes); | |
| 177 | slideAxis(stepped, 1, dy, height, boxes); | |
| 178 | const gained = Math.hypot(stepped[0] - before[0], stepped[1] - before[1]); | |
| 179 | const flat = Math.hypot(body.pos[0] - before[0], body.pos[1] - before[1]); | |
| 180 | if (gained > flat) { | |
| 181 | slideAxis(stepped, 2, -MOVE.stepSize, height, boxes); | |
| 182 | body.pos[0] = stepped[0]; | |
| 183 | body.pos[1] = stepped[1]; | |
| 184 | body.pos[2] = stepped[2]; | |
| 185 | } | |
| 186 | } | |
| 187 | } | |
| 188 | ||
| 189 | if (!body.onGround) body.vel[2] -= MOVE.gravity * dt * 0.5; | |
| 190 | if (slideAxis(body.pos, 2, body.vel[2] * dt, height, boxes)) body.vel[2] = 0; | |
| 191 | ||
| 192 | // On the ground means "a floor within two units of the feet" — and landing | |
| 193 | // snaps to it, so a player never rests a hair above the floor they touched. | |
| 194 | const support = groundUnder(body.pos, height, boxes, 2); | |
| 195 | if (support !== null && body.vel[2] <= 0) { | |
| 196 | body.pos[2] = support; | |
| 197 | body.vel[2] = 0; | |
| 198 | body.onGround = true; | |
| 199 | } else { | |
| 200 | body.onGround = false; | |
| 201 | } | |
| 202 | } | |
| 203 | ||
| 204 | /** Fall damage, CS-style: nothing under ~580 u/s, then it bites. */ | |
| 205 | export const fallDamage = (impactSpeed: number): number => | |
| 206 | impactSpeed < 580 ? 0 : Math.round((impactSpeed - 580) * 0.0888 * 100) / 100; |
src/client/renderer.ts +3 −2
| @@ -10,7 +10,8 @@ import type { Mesh } from "../engine/mesh.ts"; | ||
| 10 | 10 | import { bombMesh, levelMesh, playerMesh, viewModelMesh } from "./models.ts"; |
| 11 | 11 | import { LAYERS, TEX_SIZE, materialTextures } from "./materials.ts"; |
| 12 | 12 | import type { World } from "./world.ts"; |
| 13 | import { hullHeight } from "./physics.ts"; | |
| 13 | import { hullHeight } from "../engine/physics.ts"; | |
| 14 | import { CS_HULL } from "./movement.ts"; | |
| 14 | 15 | import { WEAPONS } from "./weapons.ts"; |
| 15 | 16 | |
| 16 | 17 | const SCENE_VS = `#version 300 es |
| @@ -234,7 +235,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null { | ||
| 234 | 235 | gl.bindVertexArray(player.vao); |
| 235 | 236 | for (const p of world.players) { |
| 236 | 237 | if (!p.alive || p === world.me) continue; |
| 237 | const squash = hullHeight(p) / 72; | |
| 238 | const squash = hullHeight(p, CS_HULL) / CS_HULL.stand; | |
| 238 | 239 | gl.uniformMatrix4fv(u.model, false, modelMatrix(p.pos, p.yaw, 1, 1, squash)); |
| 239 | 240 | const tint = TEAM_TINT[p.team] ?? [1, 1, 1]; |
| 240 | 241 | gl.uniform3f(u.tint, tint[0], tint[1], tint[2]); |
src/client/world.test.ts +4 −2
| @@ -71,7 +71,9 @@ test("a round is played out: bots meet, shoot, and one side wins it", () => { | ||
| 71 | 71 | expect(world.match.round >= 1).toBe(true); |
| 72 | 72 | expect(world.players.some((p) => p.kills > 0)).toBe(true); |
| 73 | 73 | expect(world.match.score.t + world.match.score.ct).toBeGreaterThan(0); |
| 74 | }); | |
| 74 | // Minutes of game at 64 Hz with nine policy bots in it: seconds of wall on a | |
| 75 | // busy machine, so the budget is stated rather than left at the default five. | |
| 76 | }, 60_000); | |
| 75 | 77 | |
| 76 | 78 | test("the terrorists plant, and a planted bomb keeps the round alive past the clock", () => { |
| 77 | 79 | const world = createWorld(SHORT, "me", "t", 5, 11); |
| @@ -125,4 +127,4 @@ test("a match plays itself to the end inside its own time", () => { | ||
| 125 | 127 | expect(world.match.phase).toBe("match_over"); |
| 126 | 128 | expect(world.match.matchWinner).not.toBe(null); |
| 127 | 129 | expect(Math.max(world.match.score.t, world.match.score.ct)).toBe(2); |
| 128 | }); | |
| 130 | }, 60_000); |
src/client/world.ts +7 −6
| @@ -11,7 +11,8 @@ import { applyHit, traceBullet, type Actor } from "./combat.ts"; | ||
| 11 | 11 | import * as map from "./map.ts"; |
| 12 | 12 | import { angleVectors, angleDelta, clamp, dist, normalize, rng, sub, type Vec3 } from "../engine/math.ts"; |
| 13 | 13 | import * as rules from "./match.ts"; |
| 14 | import { eyeHeight, hullHeight, move, type Body, type Wish, MOVE } from "./physics.ts"; | |
| 14 | import { eyeHeight, hullHeight, move, type Body, type Wish } from "../engine/physics.ts"; | |
| 15 | import { CS_HULL, CS_MOVE } from "./movement.ts"; | |
| 15 | 16 | import { visible } from "../engine/trace.ts"; |
| 16 | 17 | import { |
| 17 | 18 | ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset, |
| @@ -161,7 +162,7 @@ export const teammates = (world: World, team: Team): Player[] => world.players.f | ||
| 161 | 162 | export const alive = (world: World, team: Team): Player[] => |
| 162 | 163 | world.players.filter((p) => p.team === team && p.alive); |
| 163 | 164 | |
| 164 | export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p)]; | |
| 165 | export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p, CS_HULL)]; | |
| 165 | 166 | |
| 166 | 167 | function say(world: World, text: string): void { |
| 167 | 168 | world.messages.push({ at: world.time, text }); |
| @@ -323,7 +324,7 @@ export function shoot(world: World, p: Player): boolean { | ||
| 323 | 324 | |
| 324 | 325 | const speed = Math.hypot(p.vel[0], p.vel[1]); |
| 325 | 326 | const spread = inaccuracy(w, { |
| 326 | moving: clamp(speed / MOVE.maxSpeed, 0, 1), | |
| 327 | moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1), | |
| 327 | 328 | inAir: !p.onGround, |
| 328 | 329 | ducking: p.duckAmount > 0.5, |
| 329 | 330 | scoped: p.scoped, |
| @@ -454,9 +455,9 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 454 | 455 | jump: input.jump && !frozen, |
| 455 | 456 | duck: input.duck, |
| 456 | 457 | walk: input.walk || p.scoped, |
| 457 | maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed), | |
| 458 | maxSpeed: Math.min(CS_MOVE.maxSpeed, w.carrySpeed), | |
| 458 | 459 | }; |
| 459 | move(p, wish, dt, world.boxes); | |
| 460 | move(p, wish, dt, world.boxes, CS_MOVE, CS_HULL); | |
| 460 | 461 | finishReload(world, p); |
| 461 | 462 | if (input.reload) reload(world, p); |
| 462 | 463 | if (w.zoom !== undefined && input.scope !== p.scoped && world.time > p.nextShotAt - 0.05) p.scoped = input.scope; |
| @@ -538,4 +539,4 @@ export const aimAt = (p: Player, target: Vec3): { yaw: number; pitch: number } = | ||
| 538 | 539 | }; |
| 539 | 540 | |
| 540 | 541 | /** The middle of the chest: what a bot aims at before it aims higher. */ |
| 541 | export const bodyCentre = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + hullHeight(p) * 0.72]; | |
| 542 | export const bodyCentre = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + hullHeight(p, CS_HULL) * 0.72]; |
src/engine/boundary.test.ts +1
| @@ -58,6 +58,7 @@ test("the engine has the sources this test is about", () => { | ||
| 58 | 58 | const files = sources(); |
| 59 | 59 | expect(files).toContain("math.ts"); |
| 60 | 60 | expect(files).toContain("trace.ts"); |
| 61 | expect(files).toContain("physics.ts"); | |
| 61 | 62 | }); |
| 62 | 63 | |
| 63 | 64 | test("no engine module imports the game, the training, or a package", () => { |
src/engine/physics.ts added +228
| @@ -0,0 +1,228 @@ | ||
| 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. | |
| 6 | * | |
| 7 | * The rules live here; the numbers do not. A game hands in a `MoveTuning` (all | |
| 8 | * speeds in world units per second, accelerations in 1/s, gravity in u/s²) and | |
| 9 | * a `Hull` (world units), so the same solver moves a 250 u/s counter-terrorist | |
| 10 | * and whatever the next game wants. The arguments read the same way | |
| 11 | * everywhere: the thing that moves, then the situation it is in (`dt`, the | |
| 12 | * boxes), then the tuning, then the hull. | |
| 13 | * | |
| 14 | * The one number left in here is the two-unit floor contact below, which is a | |
| 15 | * solver tolerance rather than anything a designer tunes. | |
| 16 | */ | |
| 17 | ||
| 18 | import type { Box } from "./trace.ts"; | |
| 19 | import { clamp, dot, normalize, type Vec3 } from "./math.ts"; | |
| 20 | ||
| 21 | /** A standing/crouching capsule, squared off: a box `2·radius` across. */ | |
| 22 | export type Hull = { | |
| 23 | radius: number; | |
| 24 | stand: number; | |
| 25 | duck: number; | |
| 26 | eyeStand: number; | |
| 27 | eyeDuck: number; | |
| 28 | }; | |
| 29 | ||
| 30 | /** Everything the solver below reads that a designer gets to choose. */ | |
| 31 | export type MoveTuning = { | |
| 32 | gravity: number; // u/s², pulled down in two half steps per tick | |
| 33 | maxSpeed: number; // the speed `walkSpeed` and `duckSpeed` are fractions of | |
| 34 | duckSpeed: number; | |
| 35 | walkSpeed: number; | |
| 36 | accelerate: number; // 1/s, on the ground | |
| 37 | airAccelerate: number; // 1/s, in the air | |
| 38 | airSpeedCap: number; // u/s of wish speed the air may act on | |
| 39 | friction: number; // 1/s | |
| 40 | stopSpeed: number; // u/s below which friction stops scaling down with speed | |
| 41 | jumpVelocity: number; // u/s added straight up | |
| 42 | stepSize: number; // u of ledge a walk climbs for free | |
| 43 | duckRate: number; // 1/s: how fast the hull shrinks and grows back | |
| 44 | }; | |
| 45 | ||
| 46 | export type Body = { | |
| 47 | pos: Vec3; | |
| 48 | vel: Vec3; | |
| 49 | onGround: boolean; | |
| 50 | ducking: boolean; | |
| 51 | duckAmount: number; | |
| 52 | }; | |
| 53 | ||
| 54 | export type Wish = { | |
| 55 | forward: number; // -1..1 along the view | |
| 56 | side: number; // -1..1 across it | |
| 57 | yaw: number; | |
| 58 | jump: boolean; | |
| 59 | duck: boolean; | |
| 60 | walk: boolean; | |
| 61 | maxSpeed: number; // the weapon's carry speed | |
| 62 | }; | |
| 63 | ||
| 64 | /** How far a floor may be below the feet and still hold them up, in units. */ | |
| 65 | const CONTACT = 2; | |
| 66 | ||
| 67 | export const eyeHeight = (body: Body, hull: Hull): number => | |
| 68 | hull.eyeStand + (hull.eyeDuck - hull.eyeStand) * body.duckAmount; | |
| 69 | ||
| 70 | export const hullHeight = (body: Body, hull: Hull): number => | |
| 71 | hull.stand + (hull.duck - hull.stand) * body.duckAmount; | |
| 72 | ||
| 73 | function applyFriction(vel: Vec3, dt: number, tuning: MoveTuning): void { | |
| 74 | const speed = Math.hypot(vel[0], vel[1], vel[2]); | |
| 75 | if (speed < 0.1) { vel[0] = 0; vel[1] = 0; return; } | |
| 76 | const control = Math.max(speed, tuning.stopSpeed); | |
| 77 | const drop = control * tuning.friction * dt; | |
| 78 | const scale = Math.max(speed - drop, 0) / speed; | |
| 79 | vel[0] *= scale; | |
| 80 | vel[1] *= scale; | |
| 81 | vel[2] *= scale; | |
| 82 | } | |
| 83 | ||
| 84 | /** Ground acceleration: add along the wish direction up to the wish speed. */ | |
| 85 | export function accelerate(vel: Vec3, wishDir: Vec3, wishSpeed: number, accel: number, dt: number): void { | |
| 86 | const current = dot(vel, wishDir); | |
| 87 | const add = wishSpeed - current; | |
| 88 | if (add <= 0) return; | |
| 89 | const step = Math.min(accel * wishSpeed * dt, add); | |
| 90 | vel[0] += wishDir[0] * step; | |
| 91 | vel[1] += wishDir[1] * step; | |
| 92 | vel[2] += wishDir[2] * step; | |
| 93 | } | |
| 94 | ||
| 95 | /** Air acceleration: the same, but the wish speed meets `airSpeedCap` first. */ | |
| 96 | export function airAccelerate(vel: Vec3, wishDir: Vec3, wishSpeed: number, dt: number, tuning: MoveTuning): void { | |
| 97 | accelerate(vel, wishDir, Math.min(wishSpeed, tuning.airSpeedCap), tuning.airAccelerate, dt); | |
| 98 | } | |
| 99 | ||
| 100 | const overlaps = (pos: Vec3, height: number, box: Box, hull: Hull): boolean => | |
| 101 | pos[0] + hull.radius > box.min[0] && pos[0] - hull.radius < box.max[0] && | |
| 102 | pos[1] + hull.radius > box.min[1] && pos[1] - hull.radius < box.max[1] && | |
| 103 | pos[2] + height > box.min[2] && pos[2] < box.max[2]; | |
| 104 | ||
| 105 | export const stuck = (pos: Vec3, height: number, boxes: Box[], hull: Hull): boolean => | |
| 106 | boxes.some((b) => overlaps(pos, height, b, hull)); | |
| 107 | ||
| 108 | /** Move one axis and push back out of whatever it ended up inside. */ | |
| 109 | function slideAxis(pos: Vec3, axis: 0 | 1 | 2, delta: number, height: number, boxes: Box[], hull: Hull): boolean { | |
| 110 | if (delta === 0) return false; | |
| 111 | pos[axis] += delta; | |
| 112 | let blocked = false; | |
| 113 | for (const box of boxes) { | |
| 114 | if (!overlaps(pos, height, box, hull)) continue; | |
| 115 | blocked = true; | |
| 116 | const extent = axis === 2 ? 0 : hull.radius; | |
| 117 | if (delta > 0) pos[axis] = (box.min[axis] as number) - extent - (axis === 2 ? height : 0); | |
| 118 | else pos[axis] = (box.max[axis] as number) + extent; | |
| 119 | } | |
| 120 | return blocked; | |
| 121 | } | |
| 122 | ||
| 123 | /** | |
| 124 | * The floor holding a hull up: the highest box top within `reach` below the | |
| 125 | * feet. Asking this directly, rather than resolving a downward move, is what | |
| 126 | * keeps a player standing under a low ceiling from being shoved through it. | |
| 127 | */ | |
| 128 | export function groundUnder(pos: Vec3, height: number, boxes: Box[], reach: number, hull: Hull): number | null { | |
| 129 | let best: number | null = null; | |
| 130 | for (const box of boxes) { | |
| 131 | if (pos[0] + hull.radius <= box.min[0] || pos[0] - hull.radius >= box.max[0]) continue; | |
| 132 | if (pos[1] + hull.radius <= box.min[1] || pos[1] - hull.radius >= box.max[1]) continue; | |
| 133 | const top = box.max[2] as number; | |
| 134 | if (top > pos[2] + 0.01 || top < pos[2] - reach) continue; | |
| 135 | if (best === null || top > best) best = top; | |
| 136 | } | |
| 137 | return best; | |
| 138 | } | |
| 139 | ||
| 140 | /** | |
| 141 | * One movement tick: friction or gravity, acceleration, then a move that | |
| 142 | * slides along walls and steps up onto anything `tuning.stepSize` or less. | |
| 143 | */ | |
| 144 | export function move( | |
| 145 | body: Body, | |
| 146 | wish: Wish, | |
| 147 | dt: number, | |
| 148 | boxes: Box[], | |
| 149 | tuning: MoveTuning, | |
| 150 | hull: Hull, | |
| 151 | ): void { | |
| 152 | const duckTarget = wish.duck ? 1 : 0; | |
| 153 | const rate = dt * tuning.duckRate; | |
| 154 | const next = clamp( | |
| 155 | body.duckAmount < duckTarget | |
| 156 | ? Math.min(duckTarget, body.duckAmount + rate) | |
| 157 | : Math.max(duckTarget, body.duckAmount - rate), | |
| 158 | 0, | |
| 159 | 1, | |
| 160 | ); | |
| 161 | // Standing up is refused while something is over your head. | |
| 162 | const standing = next < body.duckAmount; | |
| 163 | const probe: Vec3 = [body.pos[0], body.pos[1], body.pos[2]]; | |
| 164 | const nextHeight = hull.stand + (hull.duck - hull.stand) * next; | |
| 165 | if (!standing || !stuck(probe, nextHeight, boxes, hull)) body.duckAmount = next; | |
| 166 | body.ducking = body.duckAmount > 0.5; | |
| 167 | ||
| 168 | const yawRad = (wish.yaw * Math.PI) / 180; | |
| 169 | const fx = Math.cos(yawRad), fy = Math.sin(yawRad); | |
| 170 | const wishDir = normalize([ | |
| 171 | fx * wish.forward + fy * wish.side, | |
| 172 | fy * wish.forward - fx * wish.side, | |
| 173 | 0, | |
| 174 | ]); | |
| 175 | let wishSpeed = Math.min(1, Math.hypot(wish.forward, wish.side)) * wish.maxSpeed; | |
| 176 | if (wish.walk) wishSpeed *= tuning.walkSpeed / tuning.maxSpeed; | |
| 177 | if (body.duckAmount > 0.5) wishSpeed *= tuning.duckSpeed / tuning.maxSpeed; | |
| 178 | ||
| 179 | if (body.onGround) { | |
| 180 | applyFriction(body.vel, dt, tuning); | |
| 181 | accelerate(body.vel, wishDir, wishSpeed, tuning.accelerate, dt); | |
| 182 | body.vel[2] = 0; | |
| 183 | if (wish.jump) { | |
| 184 | body.vel[2] = tuning.jumpVelocity; | |
| 185 | body.onGround = false; | |
| 186 | } | |
| 187 | } else { | |
| 188 | body.vel[2] -= tuning.gravity * dt * 0.5; | |
| 189 | airAccelerate(body.vel, wishDir, wishSpeed, dt, tuning); | |
| 190 | } | |
| 191 | ||
| 192 | const height = hullHeight(body, hull); | |
| 193 | const before: Vec3 = [body.pos[0], body.pos[1], body.pos[2]]; | |
| 194 | const dx = body.vel[0] * dt, dy = body.vel[1] * dt; | |
| 195 | const blockedX = slideAxis(body.pos, 0, dx, height, boxes, hull); | |
| 196 | const blockedY = slideAxis(body.pos, 1, dy, height, boxes, hull); | |
| 197 | ||
| 198 | // A step is a second try from `stepSize` higher, dropped back down after. | |
| 199 | if ((blockedX || blockedY) && body.onGround) { | |
| 200 | const stepped: Vec3 = [before[0], before[1], before[2] + tuning.stepSize]; | |
| 201 | if (!stuck(stepped, height, boxes, hull)) { | |
| 202 | slideAxis(stepped, 0, dx, height, boxes, hull); | |
| 203 | slideAxis(stepped, 1, dy, height, boxes, hull); | |
| 204 | const gained = Math.hypot(stepped[0] - before[0], stepped[1] - before[1]); | |
| 205 | const flat = Math.hypot(body.pos[0] - before[0], body.pos[1] - before[1]); | |
| 206 | if (gained > flat) { | |
| 207 | slideAxis(stepped, 2, -tuning.stepSize, height, boxes, hull); | |
| 208 | body.pos[0] = stepped[0]; | |
| 209 | body.pos[1] = stepped[1]; | |
| 210 | body.pos[2] = stepped[2]; | |
| 211 | } | |
| 212 | } | |
| 213 | } | |
| 214 | ||
| 215 | if (!body.onGround) body.vel[2] -= tuning.gravity * dt * 0.5; | |
| 216 | if (slideAxis(body.pos, 2, body.vel[2] * dt, height, boxes, hull)) body.vel[2] = 0; | |
| 217 | ||
| 218 | // On the ground means "a floor within two units of the feet" — and landing | |
| 219 | // snaps to it, so a player never rests a hair above the floor they touched. | |
| 220 | const support = groundUnder(body.pos, height, boxes, CONTACT, hull); | |
| 221 | if (support !== null && body.vel[2] <= 0) { | |
| 222 | body.pos[2] = support; | |
| 223 | body.vel[2] = 0; | |
| 224 | body.onGround = true; | |
| 225 | } else { | |
| 226 | body.onGround = false; | |
| 227 | } | |
| 228 | } |
static/app.6864d577a4.js deleted −58
Diff not shown — a line 56,044 characters wide (limit 2,000). −58
View filestatic/app.c3680040c7.js added +58
Diff not shown — a line 56,130 characters wide (limit 2,000). +58
View file
new above
offline