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

Closed opened this
Closed without merging

torque/cs-engine:torque/3-physics-moves-into-the-engine-and-takes → main 3 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
15 files changed +569 −420
README.md +12 −9
@@ -54,16 +54,19 @@ the rest.
5454
5555 - `src/engine/` — the part a second game could use, and the rule is that it never
5656 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.
6263 - `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
6770 for the engine's three asset tools.
6871 - `src/rl/` — training, which the game never imports: `arena` is the environment
6972 (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 @@
66
77 import type { Box } from "./map.ts";
88 import { dist, type Vec3 } from "../engine/math.ts";
9 import { HULL } from "./physics.ts";
9 import { CS_HULL } from "./movement.ts";
1010 import { rayBox, traceWorld } from "../engine/trace.ts";
1111 import { computeDamage, type Hitgroup, type Weapon } from "./weapons.ts";
1212
@@ -33,7 +33,7 @@ const PARTS: [Hitgroup, number, number, number][] = [
3333 ];
3434
3535 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;
3737 return PARTS.map(([group, lo, hi, w]) => ({
3838 group,
3939 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 @@
55 */
66
77 import { clamp } from "../engine/math.ts";
8 import { MOVE } from "./physics.ts";
8 import { CS_MOVE } from "./movement.ts";
99 import { inaccuracy, WEAPONS } from "./weapons.ts";
1010 import { currentWeapon, useProgress, type World } from "./world.ts";
1111
@@ -34,7 +34,7 @@ export function drawHud(
3434 if (me.alive) {
3535 const speed = Math.hypot(me.vel[0], me.vel[1]);
3636 const spread = inaccuracy(weapon, {
37 moving: clamp(speed / MOVE.maxSpeed, 0, 1),
37 moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1),
3838 inAir: !me.onGround,
3939 ducking: me.duckAmount > 0.5,
4040 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 @@
1414
1515 import * as map from "./map.ts";
1616 import { angleDelta, clamp, dist, type Vec3 } from "../engine/math.ts";
17 import { HULL } from "./physics.ts";
17 import { CS_HULL } from "./movement.ts";
1818 import { inaccuracy, WEAPONS } from "./weapons.ts";
1919 import type { Player, World } from "./world.ts";
2020 import { aimAt, bodyCentre, currentWeapon, eyePos } from "./world.ts";
@@ -69,7 +69,7 @@ export function observe(
6969 theirHealth = target.health / 100;
7070 theirSpeed = Math.hypot(target.vel[0], target.vel[1]) / 250;
7171 // 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]);
7373 theirFacing = Math.cos(angleDelta(target.yaw, back.yaw) * rad);
7474 }
7575
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";
1010 import { bombMesh, levelMesh, playerMesh, viewModelMesh } from "./models.ts";
1111 import { LAYERS, TEX_SIZE, materialTextures } from "./materials.ts";
1212 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";
1415 import { WEAPONS } from "./weapons.ts";
1516
1617 const SCENE_VS = `#version 300 es
@@ -234,7 +235,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null {
234235 gl.bindVertexArray(player.vao);
235236 for (const p of world.players) {
236237 if (!p.alive || p === world.me) continue;
237 const squash = hullHeight(p) / 72;
238 const squash = hullHeight(p, CS_HULL) / CS_HULL.stand;
238239 gl.uniformMatrix4fv(u.model, false, modelMatrix(p.pos, p.yaw, 1, 1, squash));
239240 const tint = TEAM_TINT[p.team] ?? [1, 1, 1];
240241 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", () => {
7171 expect(world.match.round >= 1).toBe(true);
7272 expect(world.players.some((p) => p.kills > 0)).toBe(true);
7373 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);
7577
7678 test("the terrorists plant, and a planted bomb keeps the round alive past the clock", () => {
7779 const world = createWorld(SHORT, "me", "t", 5, 11);
@@ -125,4 +127,4 @@ test("a match plays itself to the end inside its own time", () => {
125127 expect(world.match.phase).toBe("match_over");
126128 expect(world.match.matchWinner).not.toBe(null);
127129 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";
1111 import * as map from "./map.ts";
1212 import { angleVectors, angleDelta, clamp, dist, normalize, rng, sub, type Vec3 } from "../engine/math.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 } from "../engine/physics.ts";
15 import { CS_HULL, CS_MOVE } from "./movement.ts";
1516 import { visible } from "../engine/trace.ts";
1617 import {
1718 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
161162 export const alive = (world: World, team: Team): Player[] =>
162163 world.players.filter((p) => p.team === team && p.alive);
163164
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)];
165166
166167 function say(world: World, text: string): void {
167168 world.messages.push({ at: world.time, text });
@@ -323,7 +324,7 @@ export function shoot(world: World, p: Player): boolean {
323324
324325 const speed = Math.hypot(p.vel[0], p.vel[1]);
325326 const spread = inaccuracy(w, {
326 moving: clamp(speed / MOVE.maxSpeed, 0, 1),
327 moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1),
327328 inAir: !p.onGround,
328329 ducking: p.duckAmount > 0.5,
329330 scoped: p.scoped,
@@ -454,9 +455,9 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
454455 jump: input.jump && !frozen,
455456 duck: input.duck,
456457 walk: input.walk || p.scoped,
457 maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed),
458 maxSpeed: Math.min(CS_MOVE.maxSpeed, w.carrySpeed),
458459 };
459 move(p, wish, dt, world.boxes);
460 move(p, wish, dt, world.boxes, CS_MOVE, CS_HULL);
460461 finishReload(world, p);
461462 if (input.reload) reload(world, p);
462463 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 } =
538539 };
539540
540541 /** 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", () => {
5858 const files = sources();
5959 expect(files).toContain("math.ts");
6060 expect(files).toContain("trace.ts");
61 expect(files).toContain("physics.ts");
6162 });
6263
6364 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 file
static/app.c3680040c7.js added +58

Diff not shown — a line 56,130 characters wide (limit 2,000). +58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs