Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
Physics takes its tuning as arguments, in place: no constant read from module scope, and the digest does not move #131
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/122-physics-takes-its-tuning-as-arguments-in → main 1 commit fork
7 files
changed +317
−133
README.md +5 −1
| @@ -250,7 +250,11 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 250 | 250 | `siteAt`, `findPath`), `map` (de_forge itself: its areas, its crates, its cell |
| 251 | 251 | alphabet and its spawns and sites, laid over the engine's grid, exported as |
| 252 | 252 | `DE_FORGE` — one value of `Level` and the only one that ships), |
| 253 | `physics`, `weapons`, `combat`, `match` (the rules), | |
| 253 | `physics` (the Quake-lineage mover, which reads no number out of its own | |
| 254 | module scope: every function takes what it is tuned by as a required | |
| 255 | argument — subject, situation, tuning, hull — and `MOVE` and `HULL` are the | |
| 256 | Counter-Strike answers its callers hand in), `weapons`, `combat`, `match` | |
| 257 | (the rules), | |
| 254 | 258 | `world` (the simulation), |
| 255 | 259 | `bots`, `observe`, `action`, `weights`, `models`, `materials`, |
| 256 | 260 | `binding`, `scene`, `hud`, `audio`, `main`. Everything in here is headless and |
src/client/physics.test.ts +154 −21
| @@ -1,7 +1,10 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import type { Box } from "./level.ts"; |
| 3 | 3 | import type { Vec3 } from "../engine/index.ts"; |
| 4 | import { accelerate, airAccelerate, fallDamage, HULL, MOVE, move, type Body, type Wish } from "./physics.ts"; | |
| 4 | import { | |
| 5 | accelerate, airAccelerate, eyeHeight, fallDamage, HULL, hullHeight, MOVE, move, | |
| 6 | type Body, type Hull, type MoveTuning, type Wish, | |
| 7 | } from "./physics.ts"; | |
| 5 | 8 | |
| 6 | 9 | const ground: Box = { min: [-1000, -1000, -32], max: [1000, 1000, 0], mat: 0 }; |
| 7 | 10 | |
| @@ -17,33 +20,33 @@ const wish = (over: Partial<Wish> = {}): Wish => ({ | ||
| 17 | 20 | forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: MOVE.maxSpeed, ...over, |
| 18 | 21 | }); |
| 19 | 22 | |
| 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); | |
| 23 | const run = (b: Body, w: Wish, seconds: number, boxes: Box[], tuning: MoveTuning, hull: Hull): void => { | |
| 24 | for (let t = 0; t < seconds; t += 1 / 64) move(b, w, 1 / 64, boxes, tuning, hull); | |
| 22 | 25 | }; |
| 23 | 26 | |
| 24 | 27 | test("running forwards tops out at the weapon's carry speed", () => { |
| 25 | 28 | const b = body(); |
| 26 | run(b, wish({ forward: 1 }), 3, [ground]); | |
| 29 | run(b, wish({ forward: 1 }), 3, [ground], MOVE, HULL); | |
| 27 | 30 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(MOVE.maxSpeed, 0); |
| 28 | 31 | expect(b.pos[0]).toBeGreaterThan(500); |
| 29 | 32 | }); |
| 30 | 33 | |
| 31 | 34 | test("letting go stops you, but not instantly", () => { |
| 32 | 35 | const b = body(); |
| 33 | run(b, wish({ forward: 1 }), 2, [ground]); | |
| 34 | move(b, wish(), 1 / 64, [ground]); | |
| 36 | run(b, wish({ forward: 1 }), 2, [ground], MOVE, HULL); | |
| 37 | move(b, wish(), 1 / 64, [ground], MOVE, HULL); | |
| 35 | 38 | const afterOneTick = Math.hypot(b.vel[0], b.vel[1]); |
| 36 | 39 | expect(afterOneTick).toBeGreaterThan(200); |
| 37 | run(b, wish(), 1, [ground]); | |
| 40 | run(b, wish(), 1, [ground], MOVE, HULL); | |
| 38 | 41 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(1); |
| 39 | 42 | }); |
| 40 | 43 | |
| 41 | 44 | test("air acceleration is capped at 30 u/s along the wish direction", () => { |
| 42 | 45 | const vel: Vec3 = [250, 0, 0]; |
| 43 | airAccelerate(vel, [1, 0, 0], 250, 1); | |
| 46 | airAccelerate(vel, [1, 0, 0], 250, 1, MOVE); | |
| 44 | 47 | expect(vel[0]).toBeCloseTo(250, 5); // already faster than the air cap: nothing added |
| 45 | 48 | const sideways: Vec3 = [250, 0, 0]; |
| 46 | airAccelerate(sideways, [0, 1, 0], 250, 1); | |
| 49 | airAccelerate(sideways, [0, 1, 0], 250, 1, MOVE); | |
| 47 | 50 | expect(sideways[1]).toBeCloseTo(MOVE.airSpeedCap, 5); // across it: the full cap |
| 48 | 51 | }); |
| 49 | 52 | |
| @@ -56,7 +59,7 @@ test("ground acceleration never overshoots the wish speed", () => { | ||
| 56 | 59 | test("a wall stops you and you keep sliding along it", () => { |
| 57 | 60 | const wall: Box = { min: [200, -1000, 0], max: [264, 1000, 384], mat: 1 }; |
| 58 | 61 | const b = body(); |
| 59 | run(b, wish({ forward: 1, side: -1 }), 3, [ground, wall]); | |
| 62 | run(b, wish({ forward: 1, side: -1 }), 3, [ground, wall], MOVE, HULL); | |
| 60 | 63 | expect(b.pos[0]).toBeLessThanOrEqual(200 - HULL.radius + 0.01); |
| 61 | 64 | expect(Math.abs(b.pos[1])).toBeGreaterThan(200); // still moving along the wall |
| 62 | 65 | }); |
| @@ -65,21 +68,21 @@ test("you step onto a 16-unit ledge and not onto a 40-unit one", () => { | ||
| 65 | 68 | const low: Box = { min: [100, -100, 0], max: [400, 100, 16], mat: 2 }; |
| 66 | 69 | const high: Box = { min: [100, -100, 0], max: [400, 100, 40], mat: 2 }; |
| 67 | 70 | const stepper = body([0, 0, 0]); |
| 68 | run(stepper, wish({ forward: 1 }), 1, [ground, low]); | |
| 71 | run(stepper, wish({ forward: 1 }), 1, [ground, low], MOVE, HULL); | |
| 69 | 72 | expect(stepper.pos[2]).toBeCloseTo(16, 1); |
| 70 | 73 | expect(stepper.pos[0]).toBeGreaterThan(150); |
| 71 | 74 | const blocked = body([0, 0, 0]); |
| 72 | run(blocked, wish({ forward: 1 }), 1, [ground, high]); | |
| 75 | run(blocked, wish({ forward: 1 }), 1, [ground, high], MOVE, HULL); | |
| 73 | 76 | expect(blocked.pos[2]).toBe(0); |
| 74 | 77 | expect(blocked.pos[0]).toBeLessThan(100); |
| 75 | 78 | }); |
| 76 | 79 | |
| 77 | 80 | test("a jump leaves the ground and gravity brings it back", () => { |
| 78 | 81 | const b = body(); |
| 79 | move(b, wish({ jump: true }), 1 / 64, [ground]); | |
| 82 | move(b, wish({ jump: true }), 1 / 64, [ground], MOVE, HULL); | |
| 80 | 83 | expect(b.onGround).toBe(false); |
| 81 | 84 | expect(b.vel[2]).toBeGreaterThan(250); |
| 82 | run(b, wish(), 2, [ground]); | |
| 85 | run(b, wish(), 2, [ground], MOVE, HULL); | |
| 83 | 86 | expect(b.onGround).toBe(true); |
| 84 | 87 | expect(b.pos[2]).toBeCloseTo(0, 1); |
| 85 | 88 | }); |
| @@ -87,9 +90,9 @@ test("a jump leaves the ground and gravity brings it back", () => { | ||
| 87 | 90 | test("a jump clears 54 units, the height CS players count on", () => { |
| 88 | 91 | const b = body(); |
| 89 | 92 | let peak = 0; |
| 90 | move(b, wish({ jump: true }), 1 / 64, [ground]); | |
| 93 | move(b, wish({ jump: true }), 1 / 64, [ground], MOVE, HULL); | |
| 91 | 94 | for (let t = 0; t < 1; t += 1 / 64) { |
| 92 | move(b, wish(), 1 / 64, [ground]); | |
| 95 | move(b, wish(), 1 / 64, [ground], MOVE, HULL); | |
| 93 | 96 | peak = Math.max(peak, b.pos[2]); |
| 94 | 97 | } |
| 95 | 98 | expect(peak).toBeGreaterThan(54); |
| @@ -98,7 +101,7 @@ test("a jump clears 54 units, the height CS players count on", () => { | ||
| 98 | 101 | |
| 99 | 102 | test("crouching shrinks the hull and slows you down", () => { |
| 100 | 103 | const b = body(); |
| 101 | run(b, wish({ forward: 1, duck: true }), 2, [ground]); | |
| 104 | run(b, wish({ forward: 1, duck: true }), 2, [ground], MOVE, HULL); | |
| 102 | 105 | expect(b.duckAmount).toBeCloseTo(1, 2); |
| 103 | 106 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(MOVE.duckSpeed + 5); |
| 104 | 107 | }); |
| @@ -107,8 +110,8 @@ test("you cannot stand up under a low ceiling", () => { | ||
| 107 | 110 | const ceiling: Box = { min: [-100, -100, 60], max: [100, 100, 200], mat: 1 }; |
| 108 | 111 | const b = body(); |
| 109 | 112 | b.duckAmount = 1; |
| 110 | run(b, wish({ duck: true }), 1, [ground, ceiling]); | |
| 111 | run(b, wish({ duck: false }), 1, [ground, ceiling]); | |
| 113 | run(b, wish({ duck: true }), 1, [ground, ceiling], MOVE, HULL); | |
| 114 | run(b, wish({ duck: false }), 1, [ground, ceiling], MOVE, HULL); | |
| 112 | 115 | expect(b.duckAmount).toBeGreaterThan(0.2); |
| 113 | 116 | }); |
| 114 | 117 | |
| @@ -116,11 +119,11 @@ test("strafing in the air gains speed a straight line cannot", () => { | ||
| 116 | 119 | // Hold strafe-left and turn left through the jump: the classic gain. |
| 117 | 120 | const b = body(); |
| 118 | 121 | b.vel = [MOVE.maxSpeed, 0, 0]; |
| 119 | move(b, wish({ forward: 1, jump: true }), 1 / 64, [ground]); | |
| 122 | move(b, wish({ forward: 1, jump: true }), 1 / 64, [ground], MOVE, HULL); | |
| 120 | 123 | let yaw = 0; |
| 121 | 124 | while (!b.onGround) { |
| 122 | 125 | yaw += 0.55; |
| 123 | move(b, wish({ side: -1, yaw }), 1 / 128, [ground]); | |
| 126 | move(b, wish({ side: -1, yaw }), 1 / 128, [ground], MOVE, HULL); | |
| 124 | 127 | } |
| 125 | 128 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeGreaterThan(MOVE.maxSpeed + 2); |
| 126 | 129 | }); |
| @@ -129,3 +132,133 @@ test("fall damage starts only after a long drop", () => { | ||
| 129 | 132 | expect(fallDamage(400)).toBe(0); |
| 130 | 133 | expect(fallDamage(1000)).toBeGreaterThan(30); |
| 131 | 134 | }); |
| 135 | ||
| 136 | // --- a second tuning ------------------------------------------------------ | |
| 137 | // | |
| 138 | // Everything above runs the solver on Counter-Strike's numbers, which is also | |
| 139 | // what it would do if those numbers were still read out of module scope. The | |
| 140 | // tests below run the same functions on a profile that shares none of the | |
| 141 | // values under test, so re-baking any one of them — gravity, the walk and duck | |
| 142 | // ratios, the duck rate, the air cap, the acceleration, the friction, the stop | |
| 143 | // speed, the step size, the hull — fails one of them. I checked by doing it. | |
| 144 | ||
| 145 | /** Half the gravity, a higher jump, twice the step, a lazier crouch. */ | |
| 146 | const MOON: MoveTuning = { | |
| 147 | ...MOVE, | |
| 148 | gravity: 400, jumpVelocity: 600, maxSpeed: 500, duckSpeed: 200, walkSpeed: 250, | |
| 149 | airAccelerate: 6, airSpeedCap: 60, stepSize: 36, duckRate: 2.5, | |
| 150 | }; | |
| 151 | ||
| 152 | /** Three times as wide and twice as tall as a CS player. */ | |
| 153 | const GIANT: Hull = { radius: 48, stand: 144, duck: 108, eyeStand: 128, eyeDuck: 92 }; | |
| 154 | ||
| 155 | const jump = (tuning: MoveTuning, hull: Hull): { peak: number; ticks: number } => { | |
| 156 | const b = body(); | |
| 157 | move(b, wish({ jump: true }), 1 / 64, [ground], tuning, hull); | |
| 158 | // The first tick leaves the ground at the jump velocity, less the half step | |
| 159 | // of gravity the integrator takes on the way out — both of them arguments. | |
| 160 | expect(b.vel[2]).toBeCloseTo(tuning.jumpVelocity - tuning.gravity / 128, 10); | |
| 161 | let peak = 0, ticks = 0; | |
| 162 | while (!b.onGround) { | |
| 163 | move(b, wish(), 1 / 64, [ground], tuning, hull); | |
| 164 | peak = Math.max(peak, b.pos[2]); | |
| 165 | ticks += 1; | |
| 166 | } | |
| 167 | return { peak, ticks }; | |
| 168 | }; | |
| 169 | ||
| 170 | test("the apex and the hang time are the profile's gravity and jump velocity", () => { | |
| 171 | // v²/2g and 2v/g, by hand: 301.993² / 1600 ≈ 57.0 units and 0.755 s under CS, | |
| 172 | // 600² / 800 = 450 units and 3 s under half gravity and a 600 u/s jump. | |
| 173 | const cs = jump(MOVE, HULL); | |
| 174 | const moon = jump(MOON, GIANT); | |
| 175 | expect(cs.peak).toBeCloseTo(MOVE.jumpVelocity ** 2 / (2 * MOVE.gravity), 1); | |
| 176 | expect(moon.peak).toBeCloseTo(MOON.jumpVelocity ** 2 / (2 * MOON.gravity), 1); | |
| 177 | // Hang time to within a tick and a half of 2v/g: 48 ticks against 192. | |
| 178 | const hang = (t: MoveTuning): number => ((2 * t.jumpVelocity) / t.gravity) * 64; | |
| 179 | expect(Math.abs(cs.ticks - hang(MOVE))).toBeLessThan(1.5); | |
| 180 | expect(Math.abs(moon.ticks - hang(MOON))).toBeLessThan(1.5); | |
| 181 | }); | |
| 182 | ||
| 183 | test("the walk, duck and run speeds are the profile's own", () => { | |
| 184 | const top = (w: Partial<Wish>, tuning: MoveTuning): number => { | |
| 185 | const b = body(); | |
| 186 | run(b, { ...wish(w), maxSpeed: tuning.maxSpeed }, 4, [ground], tuning, HULL); | |
| 187 | return Math.hypot(b.vel[0], b.vel[1]); | |
| 188 | }; | |
| 189 | expect(top({ forward: 1 }, MOON)).toBeCloseTo(MOON.maxSpeed, 5); | |
| 190 | expect(top({ forward: 1, walk: true }, MOON)).toBeCloseTo(MOON.walkSpeed, 5); | |
| 191 | expect(top({ forward: 1, duck: true }, MOON)).toBeCloseTo(MOON.duckSpeed, 5); | |
| 192 | expect(top({ forward: 1, walk: true }, MOVE)).toBeCloseTo(MOVE.walkSpeed, 5); | |
| 193 | }); | |
| 194 | ||
| 195 | test("the crouch closes at the rate the profile names", () => { | |
| 196 | // Twenty ticks of duck: 20/64 s × 2.5 a second is 0.78125 closed, and CS's | |
| 197 | // 5 a second is shut in half that. | |
| 198 | const lazy = body(), quick = body(); | |
| 199 | for (let i = 0; i < 20; i++) { | |
| 200 | move(lazy, wish({ duck: true }), 1 / 64, [ground], MOON, GIANT); | |
| 201 | move(quick, wish({ duck: true }), 1 / 64, [ground], MOVE, HULL); | |
| 202 | } | |
| 203 | expect(lazy.duckAmount).toBeCloseTo((20 / 64) * MOON.duckRate, 10); | |
| 204 | expect(quick.duckAmount).toBe(1); | |
| 205 | }); | |
| 206 | ||
| 207 | test("the hull handed in is the hull that collides and the eyes that see", () => { | |
| 208 | const wall: Box = { min: [200, -1000, 0], max: [264, 1000, 384], mat: 1 }; | |
| 209 | const giant = body(); | |
| 210 | run(giant, { ...wish({ forward: 1 }), maxSpeed: MOON.maxSpeed }, 3, [ground, wall], MOON, GIANT); | |
| 211 | expect(giant.pos[0]).toBeCloseTo(200 - GIANT.radius, 10); | |
| 212 | ||
| 213 | const standing = body(), ducked = { ...body(), duckAmount: 1 }; | |
| 214 | expect(eyeHeight(standing, GIANT)).toBe(GIANT.eyeStand); | |
| 215 | expect(eyeHeight(ducked, GIANT)).toBe(GIANT.eyeDuck); | |
| 216 | expect(hullHeight(standing, GIANT)).toBe(GIANT.stand); | |
| 217 | expect(hullHeight(ducked, GIANT)).toBe(GIANT.duck); | |
| 218 | }); | |
| 219 | ||
| 220 | test("a 36-unit step climbs a ledge an 18-unit step refuses", () => { | |
| 221 | const ledge: Box = { min: [100, -100, 0], max: [400, 100, 30], mat: 2 }; | |
| 222 | const tall = body(), short = body(); | |
| 223 | run(tall, { ...wish({ forward: 1 }), maxSpeed: MOON.maxSpeed }, 1, [ground, ledge], MOON, GIANT); | |
| 224 | run(short, wish({ forward: 1 }), 1, [ground, ledge], MOVE, HULL); | |
| 225 | expect(tall.pos[2]).toBe(30); | |
| 226 | expect(short.pos[2]).toBe(0); | |
| 227 | expect(short.pos[0]).toBeLessThan(100); | |
| 228 | }); | |
| 229 | ||
| 230 | test("the air cap and the air acceleration come from the tuning", () => { | |
| 231 | // A whole second across the wish direction: the full cap, whatever it is. | |
| 232 | const vel: Vec3 = [250, 0, 0]; | |
| 233 | airAccelerate(vel, [0, 1, 0], 250, 1, MOON); | |
| 234 | expect(vel[1]).toBeCloseTo(MOON.airSpeedCap, 5); | |
| 235 | // One tick of it: accel × the capped wish speed × dt, short of the cap. | |
| 236 | const tick: Vec3 = [250, 0, 0]; | |
| 237 | airAccelerate(tick, [0, 1, 0], 250, 1 / 64, MOON); | |
| 238 | expect(tick[1]).toBeCloseTo((MOON.airAccelerate * MOON.airSpeedCap) / 64, 10); | |
| 239 | }); | |
| 240 | ||
| 241 | test("acceleration and friction settle where the tuning says they do", () => { | |
| 242 | // Running holds where what acceleration adds is what friction takes: | |
| 243 | // accel × wishSpeed = speed × friction, so 2.8 × 250 / 5.2 ≈ 134.6 u/s. | |
| 244 | const half: MoveTuning = { ...MOVE, accelerate: 2.8 }; | |
| 245 | const b = body(); | |
| 246 | run(b, wish({ forward: 1 }), 3, [ground], MOVE, HULL); | |
| 247 | 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); | |
| 250 | expect(Math.hypot(slower.vel[0], slower.vel[1])) | |
| 251 | .toBeCloseTo((half.accelerate * MOVE.maxSpeed) / half.friction, 2); | |
| 252 | ||
| 253 | // And a drifting body: no friction keeps every unit of its speed, while a | |
| 254 | // stop speed five times CS's takes all of it inside three ticks. | |
| 255 | const drift = (tuning: MoveTuning): number => { | |
| 256 | const d = body(); | |
| 257 | d.vel = [40, 0, 0]; | |
| 258 | for (let i = 0; i < 3; i++) move(d, wish(), 1 / 64, [ground], tuning, HULL); | |
| 259 | return Math.hypot(d.vel[0], d.vel[1]); | |
| 260 | }; | |
| 261 | expect(drift({ ...MOVE, friction: 0 })).toBe(40); | |
| 262 | expect(drift({ ...MOVE, stopSpeed: 400 })).toBe(0); | |
| 263 | expect(drift(MOVE)).toBeCloseTo(20.5, 5); | |
| 264 | }); |
src/client/physics.ts +95 −48
| @@ -6,14 +6,46 @@ | ||
| 6 | 6 | * |
| 7 | 7 | * Units are Source units (1 unit ≈ 1.9 cm): 250 u/s running, 800 u/s² gravity, |
| 8 | 8 | * a 32×32×72 hull, an 18-unit step. |
| 9 | * | |
| 10 | * The solver holds none of those numbers itself. Every tuned value reaches a | |
| 11 | * function as a required argument, in one boring order — subject, situation, | |
| 12 | * 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. | |
| 9 | 15 | */ |
| 10 | 16 | |
| 11 | 17 | import type { Box } from "./level.ts"; |
| 12 | 18 | import { clamp, dot, normalize, type Vec3 } from "../engine/index.ts"; |
| 13 | 19 | |
| 14 | export const HULL = { radius: 16, stand: 72, duck: 54, eyeStand: 64, eyeDuck: 46 }; | |
| 20 | /** The box a body occupies, and where it looks from, standing and ducked. */ | |
| 21 | export type Hull = { | |
| 22 | radius: number; | |
| 23 | stand: number; | |
| 24 | duck: number; | |
| 25 | eyeStand: number; | |
| 26 | eyeDuck: number; | |
| 27 | }; | |
| 15 | 28 | |
| 16 | export const MOVE = { | |
| 29 | /** What a mover is tuned by: speeds in u/s, accelerations per second. */ | |
| 30 | export type MoveTuning = { | |
| 31 | gravity: number; | |
| 32 | maxSpeed: number; | |
| 33 | duckSpeed: number; | |
| 34 | walkSpeed: number; | |
| 35 | accelerate: number; | |
| 36 | airAccelerate: number; | |
| 37 | airSpeedCap: number; | |
| 38 | friction: number; | |
| 39 | stopSpeed: number; | |
| 40 | jumpVelocity: number; | |
| 41 | stepSize: number; | |
| 42 | /** How fast a crouch closes and opens, in duck fractions per second. */ | |
| 43 | duckRate: number; | |
| 44 | }; | |
| 45 | ||
| 46 | export const HULL: Hull = { radius: 16, stand: 72, duck: 54, eyeStand: 64, eyeDuck: 46 }; | |
| 47 | ||
| 48 | export const MOVE: MoveTuning = { | |
| 17 | 49 | gravity: 800, |
| 18 | 50 | maxSpeed: 250, |
| 19 | 51 | duckSpeed: 85, |
| @@ -25,8 +57,17 @@ export const MOVE = { | ||
| 25 | 57 | stopSpeed: 80, |
| 26 | 58 | jumpVelocity: 301.993, |
| 27 | 59 | stepSize: 18, |
| 60 | duckRate: 5, | |
| 28 | 61 | }; |
| 29 | 62 | |
| 63 | /** | |
| 64 | * How close under the feet a floor still counts as touched, in units. This is | |
| 65 | * the solver's own tolerance — the slack a discrete step needs so a body that | |
| 66 | * landed does not spend the next tick a hair above what it landed on — and not | |
| 67 | * a number any game chose, so it stays here rather than in a `MoveTuning`. | |
| 68 | */ | |
| 69 | const CONTACT = 2; | |
| 70 | ||
| 30 | 71 | export type Body = { |
| 31 | 72 | pos: Vec3; |
| 32 | 73 | vel: Vec3; |
| @@ -45,17 +86,17 @@ export type Wish = { | ||
| 45 | 86 | maxSpeed: number; // the weapon's carry speed |
| 46 | 87 | }; |
| 47 | 88 | |
| 48 | export const eyeHeight = (b: Body): number => | |
| 49 | HULL.eyeStand + (HULL.eyeDuck - HULL.eyeStand) * b.duckAmount; | |
| 89 | export const eyeHeight = (b: Body, hull: Hull): number => | |
| 90 | hull.eyeStand + (hull.eyeDuck - hull.eyeStand) * b.duckAmount; | |
| 50 | 91 | |
| 51 | export const hullHeight = (b: Body): number => | |
| 52 | HULL.stand + (HULL.duck - HULL.stand) * b.duckAmount; | |
| 92 | export const hullHeight = (b: Body, hull: Hull): number => | |
| 93 | hull.stand + (hull.duck - hull.stand) * b.duckAmount; | |
| 53 | 94 | |
| 54 | function applyFriction(vel: Vec3, dt: number): void { | |
| 95 | function applyFriction(vel: Vec3, dt: number, tuning: MoveTuning): void { | |
| 55 | 96 | const speed = Math.hypot(vel[0], vel[1], vel[2]); |
| 56 | 97 | 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; | |
| 98 | const control = Math.max(speed, tuning.stopSpeed); | |
| 99 | const drop = control * tuning.friction * dt; | |
| 59 | 100 | const scale = Math.max(speed - drop, 0) / speed; |
| 60 | 101 | vel[0] *= scale; |
| 61 | 102 | vel[1] *= scale; |
| @@ -73,28 +114,30 @@ export function accelerate(vel: Vec3, wishDir: Vec3, wishSpeed: number, accel: n | ||
| 73 | 114 | vel[2] += wishDir[2] * step; |
| 74 | 115 | } |
| 75 | 116 | |
| 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); | |
| 117 | /** Air acceleration: the same, but the wish speed meets the air cap first. */ | |
| 118 | export function airAccelerate(vel: Vec3, wishDir: Vec3, wishSpeed: number, dt: number, tuning: MoveTuning): void { | |
| 119 | accelerate(vel, wishDir, Math.min(wishSpeed, tuning.airSpeedCap), tuning.airAccelerate, dt); | |
| 79 | 120 | } |
| 80 | 121 | |
| 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] && | |
| 122 | const overlaps = (pos: Vec3, height: number, box: Box, hull: Hull): boolean => | |
| 123 | pos[0] + hull.radius > box.min[0] && pos[0] - hull.radius < box.max[0] && | |
| 124 | pos[1] + hull.radius > box.min[1] && pos[1] - hull.radius < box.max[1] && | |
| 84 | 125 | pos[2] + height > box.min[2] && pos[2] < box.max[2]; |
| 85 | 126 | |
| 86 | export const stuck = (pos: Vec3, height: number, boxes: Box[]): boolean => | |
| 87 | boxes.some((b) => overlaps(pos, height, b)); | |
| 127 | export const stuck = (pos: Vec3, height: number, boxes: Box[], hull: Hull): boolean => | |
| 128 | boxes.some((b) => overlaps(pos, height, b, hull)); | |
| 88 | 129 | |
| 89 | 130 | /** 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 { | |
| 131 | function slideAxis( | |
| 132 | pos: Vec3, axis: 0 | 1 | 2, delta: number, height: number, boxes: Box[], hull: Hull, | |
| 133 | ): boolean { | |
| 91 | 134 | if (delta === 0) return false; |
| 92 | 135 | pos[axis] += delta; |
| 93 | 136 | let blocked = false; |
| 94 | 137 | for (const box of boxes) { |
| 95 | if (!overlaps(pos, height, box)) continue; | |
| 138 | if (!overlaps(pos, height, box, hull)) continue; | |
| 96 | 139 | blocked = true; |
| 97 | const extent = axis === 2 ? 0 : HULL.radius; | |
| 140 | const extent = axis === 2 ? 0 : hull.radius; | |
| 98 | 141 | if (delta > 0) pos[axis] = (box.min[axis] as number) - extent - (axis === 2 ? height : 0); |
| 99 | 142 | else pos[axis] = (box.max[axis] as number) + extent; |
| 100 | 143 | } |
| @@ -106,11 +149,13 @@ function slideAxis(pos: Vec3, axis: 0 | 1 | 2, delta: number, height: number, bo | ||
| 106 | 149 | * feet. Asking this directly, rather than resolving a downward move, is what |
| 107 | 150 | * keeps a player standing under a low ceiling from being shoved through it. |
| 108 | 151 | */ |
| 109 | export function groundUnder(pos: Vec3, height: number, boxes: Box[], reach: number): number | null { | |
| 152 | export function groundUnder( | |
| 153 | pos: Vec3, height: number, boxes: Box[], reach: number, hull: Hull, | |
| 154 | ): number | null { | |
| 110 | 155 | let best: number | null = null; |
| 111 | 156 | 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; | |
| 157 | if (pos[0] + hull.radius <= box.min[0] || pos[0] - hull.radius >= box.max[0]) continue; | |
| 158 | if (pos[1] + hull.radius <= box.min[1] || pos[1] - hull.radius >= box.max[1]) continue; | |
| 114 | 159 | const top = box.max[2] as number; |
| 115 | 160 | if (top > pos[2] + 0.01 || top < pos[2] - reach) continue; |
| 116 | 161 | if (best === null || top > best) best = top; |
| @@ -120,11 +165,13 @@ export function groundUnder(pos: Vec3, height: number, boxes: Box[], reach: numb | ||
| 120 | 165 | |
| 121 | 166 | /** |
| 122 | 167 | * One movement tick: friction or gravity, acceleration, then a move that |
| 123 | * slides along walls and steps up onto anything 18 units or less. | |
| 168 | * slides along walls and steps up onto anything the step size clears. | |
| 124 | 169 | */ |
| 125 | export function move(body: Body, wish: Wish, dt: number, boxes: Box[]): void { | |
| 170 | export function move( | |
| 171 | body: Body, wish: Wish, dt: number, boxes: Box[], tuning: MoveTuning, hull: Hull, | |
| 172 | ): void { | |
| 126 | 173 | const duckTarget = wish.duck ? 1 : 0; |
| 127 | const rate = dt * 5; | |
| 174 | const rate = dt * tuning.duckRate; | |
| 128 | 175 | const next = clamp( |
| 129 | 176 | body.duckAmount < duckTarget |
| 130 | 177 | ? Math.min(duckTarget, body.duckAmount + rate) |
| @@ -135,8 +182,8 @@ export function move(body: Body, wish: Wish, dt: number, boxes: Box[]): void { | ||
| 135 | 182 | // Standing up is refused while something is over your head. |
| 136 | 183 | const standing = next < body.duckAmount; |
| 137 | 184 | 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; | |
| 185 | const nextHeight = hull.stand + (hull.duck - hull.stand) * next; | |
| 186 | if (!standing || !stuck(probe, nextHeight, boxes, hull)) body.duckAmount = next; | |
| 140 | 187 | body.ducking = body.duckAmount > 0.5; |
| 141 | 188 | |
| 142 | 189 | const yawRad = (wish.yaw * Math.PI) / 180; |
| @@ -147,38 +194,38 @@ export function move(body: Body, wish: Wish, dt: number, boxes: Box[]): void { | ||
| 147 | 194 | 0, |
| 148 | 195 | ]); |
| 149 | 196 | 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; | |
| 197 | if (wish.walk) wishSpeed *= tuning.walkSpeed / tuning.maxSpeed; | |
| 198 | if (body.duckAmount > 0.5) wishSpeed *= tuning.duckSpeed / tuning.maxSpeed; | |
| 152 | 199 | |
| 153 | 200 | if (body.onGround) { |
| 154 | applyFriction(body.vel, dt); | |
| 155 | accelerate(body.vel, wishDir, wishSpeed, MOVE.accelerate, dt); | |
| 201 | applyFriction(body.vel, dt, tuning); | |
| 202 | accelerate(body.vel, wishDir, wishSpeed, tuning.accelerate, dt); | |
| 156 | 203 | body.vel[2] = 0; |
| 157 | 204 | if (wish.jump) { |
| 158 | body.vel[2] = MOVE.jumpVelocity; | |
| 205 | body.vel[2] = tuning.jumpVelocity; | |
| 159 | 206 | body.onGround = false; |
| 160 | 207 | } |
| 161 | 208 | } else { |
| 162 | body.vel[2] -= MOVE.gravity * dt * 0.5; | |
| 163 | airAccelerate(body.vel, wishDir, wishSpeed, dt); | |
| 209 | body.vel[2] -= tuning.gravity * dt * 0.5; | |
| 210 | airAccelerate(body.vel, wishDir, wishSpeed, dt, tuning); | |
| 164 | 211 | } |
| 165 | 212 | |
| 166 | const height = hullHeight(body); | |
| 213 | const height = hullHeight(body, hull); | |
| 167 | 214 | const before: Vec3 = [body.pos[0], body.pos[1], body.pos[2]]; |
| 168 | 215 | 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); | |
| 216 | const blockedX = slideAxis(body.pos, 0, dx, height, boxes, hull); | |
| 217 | const blockedY = slideAxis(body.pos, 1, dy, height, boxes, hull); | |
| 171 | 218 | |
| 172 | 219 | // A step is a second try from 18 units higher, dropped back down after. |
| 173 | 220 | 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); | |
| 221 | const stepped: Vec3 = [before[0], before[1], before[2] + tuning.stepSize]; | |
| 222 | if (!stuck(stepped, height, boxes, hull)) { | |
| 223 | slideAxis(stepped, 0, dx, height, boxes, hull); | |
| 224 | slideAxis(stepped, 1, dy, height, boxes, hull); | |
| 178 | 225 | const gained = Math.hypot(stepped[0] - before[0], stepped[1] - before[1]); |
| 179 | 226 | const flat = Math.hypot(body.pos[0] - before[0], body.pos[1] - before[1]); |
| 180 | 227 | if (gained > flat) { |
| 181 | slideAxis(stepped, 2, -MOVE.stepSize, height, boxes); | |
| 228 | slideAxis(stepped, 2, -tuning.stepSize, height, boxes, hull); | |
| 182 | 229 | body.pos[0] = stepped[0]; |
| 183 | 230 | body.pos[1] = stepped[1]; |
| 184 | 231 | body.pos[2] = stepped[2]; |
| @@ -186,12 +233,12 @@ export function move(body: Body, wish: Wish, dt: number, boxes: Box[]): void { | ||
| 186 | 233 | } |
| 187 | 234 | } |
| 188 | 235 | |
| 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; | |
| 236 | if (!body.onGround) body.vel[2] -= tuning.gravity * dt * 0.5; | |
| 237 | if (slideAxis(body.pos, 2, body.vel[2] * dt, height, boxes, hull)) body.vel[2] = 0; | |
| 191 | 238 | |
| 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); | |
| 239 | // On the ground means "a floor within `CONTACT` units of the feet" — and | |
| 240 | // landing snaps to it, so a player never rests a hair above what they touched. | |
| 241 | const support = groundUnder(body.pos, height, boxes, CONTACT, hull); | |
| 195 | 242 | if (support !== null && body.vel[2] <= 0) { |
| 196 | 243 | body.pos[2] = support; |
| 197 | 244 | body.vel[2] = 0; |
src/client/scene.ts +1 −1
| @@ -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.stand, TEAM_TINT[p.team] ?? WHITE); | |
| 134 | place(into, "player", p.pos, p.yaw, hullHeight(p, HULL) / 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 +4 −4
| @@ -11,7 +11,7 @@ import { applyHit, traceBullet, type Actor } from "./combat.ts"; | ||
| 11 | 11 | import { siteAt, type Level } from "./level.ts"; |
| 12 | 12 | import { angleVectors, angleDelta, clamp, dist, normalize, rng, sub, visible, type Vec3 } from "../engine/index.ts"; |
| 13 | 13 | import * as rules from "./match.ts"; |
| 14 | import { eyeHeight, hullHeight, move, type Body, type Wish, MOVE } from "./physics.ts"; | |
| 14 | import { eyeHeight, hullHeight, move, type Body, type Wish, HULL, MOVE } from "./physics.ts"; | |
| 15 | 15 | import { |
| 16 | 16 | ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset, |
| 17 | 17 | type WeaponId, type Weapon, |
| @@ -172,7 +172,7 @@ export const teammates = (world: World, team: Team): Player[] => world.players.f | ||
| 172 | 172 | export const alive = (world: World, team: Team): Player[] => |
| 173 | 173 | world.players.filter((p) => p.team === team && p.alive); |
| 174 | 174 | |
| 175 | export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p)]; | |
| 175 | export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p, HULL)]; | |
| 176 | 176 | |
| 177 | 177 | function say(world: World, text: string): void { |
| 178 | 178 | world.messages.push({ at: world.time, text }); |
| @@ -468,7 +468,7 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 468 | 468 | walk: input.walk || p.scoped, |
| 469 | 469 | maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed), |
| 470 | 470 | }; |
| 471 | move(p, wish, dt, world.level.boxes); | |
| 471 | move(p, wish, dt, world.level.boxes, MOVE, HULL); | |
| 472 | 472 | finishReload(world, p); |
| 473 | 473 | if (input.reload) reload(world, p); |
| 474 | 474 | if (w.zoom !== undefined && input.scope !== p.scoped && world.time > p.nextShotAt - 0.05) p.scoped = input.scope; |
| @@ -550,4 +550,4 @@ export const aimAt = (p: Player, target: Vec3): { yaw: number; pitch: number } = | ||
| 550 | 550 | }; |
| 551 | 551 | |
| 552 | 552 | /** 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) * 0.72]; | |
| 553 | export const bodyCentre = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + hullHeight(p, HULL) * 0.72]; |
static/app.436c7e87eb.js added +58
Diff not shown — a line 59,545 characters wide (limit 2,000). +58
View filestatic/app.522be2f0cc.js deleted −58
Diff not shown — a line 59,465 characters wide (limit 2,000). −58
View file
new above
offline