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

Merged opened this

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

Merged

torque/cs-engine:torque/122-physics-takes-its-tuning-as-arguments-in → main 1 commit fork

  • Merged. torque/122-physics-takes-its-tuning-as-arguments-in was merged into main as 5ac94a6.

Review

Sign in to review this change.
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
250250 `siteAt`, `findPath`), `map` (de_forge itself: its areas, its crates, its cell
251251 alphabet and its spawns and sites, laid over the engine's grid, exported as
252252 `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),
254258 `world` (the simulation),
255259 `bots`, `observe`, `action`, `weights`, `models`, `materials`,
256260 `binding`, `scene`, `hud`, `audio`, `main`. Everything in here is headless and
src/client/physics.test.ts +154 −21
@@ -1,7 +1,10 @@
11 import { expect, test } from "bun:test";
22 import type { Box } from "./level.ts";
33 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";
58
69 const ground: Box = { min: [-1000, -1000, -32], max: [1000, 1000, 0], mat: 0 };
710
@@ -17,33 +20,33 @@ const wish = (over: Partial<Wish> = {}): Wish => ({
1720 forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: MOVE.maxSpeed, ...over,
1821 });
1922
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);
2225 };
2326
2427 test("running forwards tops out at the weapon's carry speed", () => {
2528 const b = body();
26 run(b, wish({ forward: 1 }), 3, [ground]);
29 run(b, wish({ forward: 1 }), 3, [ground], MOVE, HULL);
2730 expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(MOVE.maxSpeed, 0);
2831 expect(b.pos[0]).toBeGreaterThan(500);
2932 });
3033
3134 test("letting go stops you, but not instantly", () => {
3235 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);
3538 const afterOneTick = Math.hypot(b.vel[0], b.vel[1]);
3639 expect(afterOneTick).toBeGreaterThan(200);
37 run(b, wish(), 1, [ground]);
40 run(b, wish(), 1, [ground], MOVE, HULL);
3841 expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(1);
3942 });
4043
4144 test("air acceleration is capped at 30 u/s along the wish direction", () => {
4245 const vel: Vec3 = [250, 0, 0];
43 airAccelerate(vel, [1, 0, 0], 250, 1);
46 airAccelerate(vel, [1, 0, 0], 250, 1, MOVE);
4447 expect(vel[0]).toBeCloseTo(250, 5); // already faster than the air cap: nothing added
4548 const sideways: Vec3 = [250, 0, 0];
46 airAccelerate(sideways, [0, 1, 0], 250, 1);
49 airAccelerate(sideways, [0, 1, 0], 250, 1, MOVE);
4750 expect(sideways[1]).toBeCloseTo(MOVE.airSpeedCap, 5); // across it: the full cap
4851 });
4952
@@ -56,7 +59,7 @@ test("ground acceleration never overshoots the wish speed", () => {
5659 test("a wall stops you and you keep sliding along it", () => {
5760 const wall: Box = { min: [200, -1000, 0], max: [264, 1000, 384], mat: 1 };
5861 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);
6063 expect(b.pos[0]).toBeLessThanOrEqual(200 - HULL.radius + 0.01);
6164 expect(Math.abs(b.pos[1])).toBeGreaterThan(200); // still moving along the wall
6265 });
@@ -65,21 +68,21 @@ test("you step onto a 16-unit ledge and not onto a 40-unit one", () => {
6568 const low: Box = { min: [100, -100, 0], max: [400, 100, 16], mat: 2 };
6669 const high: Box = { min: [100, -100, 0], max: [400, 100, 40], mat: 2 };
6770 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);
6972 expect(stepper.pos[2]).toBeCloseTo(16, 1);
7073 expect(stepper.pos[0]).toBeGreaterThan(150);
7174 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);
7376 expect(blocked.pos[2]).toBe(0);
7477 expect(blocked.pos[0]).toBeLessThan(100);
7578 });
7679
7780 test("a jump leaves the ground and gravity brings it back", () => {
7881 const b = body();
79 move(b, wish({ jump: true }), 1 / 64, [ground]);
82 move(b, wish({ jump: true }), 1 / 64, [ground], MOVE, HULL);
8083 expect(b.onGround).toBe(false);
8184 expect(b.vel[2]).toBeGreaterThan(250);
82 run(b, wish(), 2, [ground]);
85 run(b, wish(), 2, [ground], MOVE, HULL);
8386 expect(b.onGround).toBe(true);
8487 expect(b.pos[2]).toBeCloseTo(0, 1);
8588 });
@@ -87,9 +90,9 @@ test("a jump leaves the ground and gravity brings it back", () => {
8790 test("a jump clears 54 units, the height CS players count on", () => {
8891 const b = body();
8992 let peak = 0;
90 move(b, wish({ jump: true }), 1 / 64, [ground]);
93 move(b, wish({ jump: true }), 1 / 64, [ground], MOVE, HULL);
9194 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);
9396 peak = Math.max(peak, b.pos[2]);
9497 }
9598 expect(peak).toBeGreaterThan(54);
@@ -98,7 +101,7 @@ test("a jump clears 54 units, the height CS players count on", () => {
98101
99102 test("crouching shrinks the hull and slows you down", () => {
100103 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);
102105 expect(b.duckAmount).toBeCloseTo(1, 2);
103106 expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(MOVE.duckSpeed + 5);
104107 });
@@ -107,8 +110,8 @@ test("you cannot stand up under a low ceiling", () => {
107110 const ceiling: Box = { min: [-100, -100, 60], max: [100, 100, 200], mat: 1 };
108111 const b = body();
109112 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);
112115 expect(b.duckAmount).toBeGreaterThan(0.2);
113116 });
114117
@@ -116,11 +119,11 @@ test("strafing in the air gains speed a straight line cannot", () => {
116119 // Hold strafe-left and turn left through the jump: the classic gain.
117120 const b = body();
118121 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);
120123 let yaw = 0;
121124 while (!b.onGround) {
122125 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);
124127 }
125128 expect(Math.hypot(b.vel[0], b.vel[1])).toBeGreaterThan(MOVE.maxSpeed + 2);
126129 });
@@ -129,3 +132,133 @@ test("fall damage starts only after a long drop", () => {
129132 expect(fallDamage(400)).toBe(0);
130133 expect(fallDamage(1000)).toBeGreaterThan(30);
131134 });
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 @@
66 *
77 * Units are Source units (1 unit ≈ 1.9 cm): 250 u/s running, 800 u/s² gravity,
88 * 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.
915 */
1016
1117 import type { Box } from "./level.ts";
1218 import { clamp, dot, normalize, type Vec3 } from "../engine/index.ts";
1319
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 };
1528
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 = {
1749 gravity: 800,
1850 maxSpeed: 250,
1951 duckSpeed: 85,
@@ -25,8 +57,17 @@ export const MOVE = {
2557 stopSpeed: 80,
2658 jumpVelocity: 301.993,
2759 stepSize: 18,
60 duckRate: 5,
2861 };
2962
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
3071 export type Body = {
3172 pos: Vec3;
3273 vel: Vec3;
@@ -45,17 +86,17 @@ export type Wish = {
4586 maxSpeed: number; // the weapon's carry speed
4687 };
4788
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;
5091
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;
5394
54 function applyFriction(vel: Vec3, dt: number): void {
95 function applyFriction(vel: Vec3, dt: number, tuning: MoveTuning): void {
5596 const speed = Math.hypot(vel[0], vel[1], vel[2]);
5697 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;
59100 const scale = Math.max(speed - drop, 0) / speed;
60101 vel[0] *= scale;
61102 vel[1] *= scale;
@@ -73,28 +114,30 @@ export function accelerate(vel: Vec3, wishDir: Vec3, wishSpeed: number, accel: n
73114 vel[2] += wishDir[2] * step;
74115 }
75116
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);
79120 }
80121
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] &&
84125 pos[2] + height > box.min[2] && pos[2] < box.max[2];
85126
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));
88129
89130 /** 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 {
91134 if (delta === 0) return false;
92135 pos[axis] += delta;
93136 let blocked = false;
94137 for (const box of boxes) {
95 if (!overlaps(pos, height, box)) continue;
138 if (!overlaps(pos, height, box, hull)) continue;
96139 blocked = true;
97 const extent = axis === 2 ? 0 : HULL.radius;
140 const extent = axis === 2 ? 0 : hull.radius;
98141 if (delta > 0) pos[axis] = (box.min[axis] as number) - extent - (axis === 2 ? height : 0);
99142 else pos[axis] = (box.max[axis] as number) + extent;
100143 }
@@ -106,11 +149,13 @@ function slideAxis(pos: Vec3, axis: 0 | 1 | 2, delta: number, height: number, bo
106149 * feet. Asking this directly, rather than resolving a downward move, is what
107150 * keeps a player standing under a low ceiling from being shoved through it.
108151 */
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 {
110155 let best: number | null = null;
111156 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;
114159 const top = box.max[2] as number;
115160 if (top > pos[2] + 0.01 || top < pos[2] - reach) continue;
116161 if (best === null || top > best) best = top;
@@ -120,11 +165,13 @@ export function groundUnder(pos: Vec3, height: number, boxes: Box[], reach: numb
120165
121166 /**
122167 * 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.
124169 */
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 {
126173 const duckTarget = wish.duck ? 1 : 0;
127 const rate = dt * 5;
174 const rate = dt * tuning.duckRate;
128175 const next = clamp(
129176 body.duckAmount < duckTarget
130177 ? Math.min(duckTarget, body.duckAmount + rate)
@@ -135,8 +182,8 @@ export function move(body: Body, wish: Wish, dt: number, boxes: Box[]): void {
135182 // Standing up is refused while something is over your head.
136183 const standing = next < body.duckAmount;
137184 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;
140187 body.ducking = body.duckAmount > 0.5;
141188
142189 const yawRad = (wish.yaw * Math.PI) / 180;
@@ -147,38 +194,38 @@ export function move(body: Body, wish: Wish, dt: number, boxes: Box[]): void {
147194 0,
148195 ]);
149196 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;
152199
153200 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);
156203 body.vel[2] = 0;
157204 if (wish.jump) {
158 body.vel[2] = MOVE.jumpVelocity;
205 body.vel[2] = tuning.jumpVelocity;
159206 body.onGround = false;
160207 }
161208 } 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);
164211 }
165212
166 const height = hullHeight(body);
213 const height = hullHeight(body, hull);
167214 const before: Vec3 = [body.pos[0], body.pos[1], body.pos[2]];
168215 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);
171218
172219 // A step is a second try from 18 units higher, dropped back down after.
173220 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);
178225 const gained = Math.hypot(stepped[0] - before[0], stepped[1] - before[1]);
179226 const flat = Math.hypot(body.pos[0] - before[0], body.pos[1] - before[1]);
180227 if (gained > flat) {
181 slideAxis(stepped, 2, -MOVE.stepSize, height, boxes);
228 slideAxis(stepped, 2, -tuning.stepSize, height, boxes, hull);
182229 body.pos[0] = stepped[0];
183230 body.pos[1] = stepped[1];
184231 body.pos[2] = stepped[2];
@@ -186,12 +233,12 @@ export function move(body: Body, wish: Wish, dt: number, boxes: Box[]): void {
186233 }
187234 }
188235
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;
191238
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);
195242 if (support !== null && body.vel[2] <= 0) {
196243 body.pos[2] = support;
197244 body.vel[2] = 0;
src/client/scene.ts +1 −1
@@ -131,7 +131,7 @@ export function buildScene(world: World, camera: Camera, into: Scene): Scene {
131131 for (const p of world.players) {
132132 // The eye is inside your own head: drawing it would fill the screen.
133133 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);
135135 }
136136 if (world.bomb.state === "planted" || world.bomb.state === "dropped")
137137 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";
1111 import { siteAt, type Level } from "./level.ts";
1212 import { angleVectors, angleDelta, clamp, dist, normalize, rng, sub, visible, type Vec3 } from "../engine/index.ts";
1313 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";
1515 import {
1616 ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset,
1717 type WeaponId, type Weapon,
@@ -172,7 +172,7 @@ export const teammates = (world: World, team: Team): Player[] => world.players.f
172172 export const alive = (world: World, team: Team): Player[] =>
173173 world.players.filter((p) => p.team === team && p.alive);
174174
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)];
176176
177177 function say(world: World, text: string): void {
178178 world.messages.push({ at: world.time, text });
@@ -468,7 +468,7 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
468468 walk: input.walk || p.scoped,
469469 maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed),
470470 };
471 move(p, wish, dt, world.level.boxes);
471 move(p, wish, dt, world.level.boxes, MOVE, HULL);
472472 finishReload(world, p);
473473 if (input.reload) reload(world, p);
474474 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 } =
550550 };
551551
552552 /** 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 file
static/app.522be2f0cc.js deleted −58

Diff not shown — a line 59,465 characters wide (limit 2,000). −58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs