Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

A second profile proves the solver is general: half gravity, a taller hull, a bigger step — and one apex checked by hand #152

Merged opened this

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

Merged

torque/cs-engine:torque/124-a-second-profile-proves-the-solver-is-ge → main 1 commit fork

  • Merged. torque/124-a-second-profile-proves-the-solver-is-ge was merged into main as 0b385c0.

Review

Sign in to review this change.
4 files changed +421 −138
README.md +8 −2
@@ -186,7 +186,11 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
186186 Quake-lineage mover: friction, a capped ground acceleration, the air
187187 acceleration that makes strafe-jumping work, walls, steps and the crouch — it
188188 reads no tuned number of its own, because a `MoveTuning` and a `Hull` arrive as
189 arguments on every call, so the feel of a game is the pair it hands in),
189 arguments on every call, so the feel of a game is the pair it hands in, and
190 `physics.test.ts` walks two profiles that share no number to say so: half the
191 gravity, a taller and wider hull, twice the step and a different top speed,
192 every assertion computed from the tuning it was handed and the apex checked
193 against `v²/2g` by hand),
190194 `grid` (a grid of
191195 cells of any type, the cell-to-world arithmetic, greedy meshing and A* with no
192196 corner-cutting — which cells are solid or walkable arrive as predicates, so it
@@ -295,7 +299,9 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
295299 refuses a layout a round cannot be played on),
296300 `movement` (Counter-Strike's numbers, and nothing else: `CS_MOVE`, `CS_HULL`
297301 and `fallDamage`, handed to the engine's mover on every call — the solver
298 itself went behind the door in #123 and holds none of them), `weapons`,
302 itself went behind the door in #123 and holds none of them, and
303 `movement.test.ts` is now only about whether this game still feels like this
304 game, the generality having moved to `src/engine/physics.test.ts`), `weapons`,
299305 `combat`, `match`
300306 (the rules),
301307 `world` (the simulation),
src/client/movement.test.ts +32 −132
@@ -6,8 +6,11 @@
66 * game's, because every assertion below is about the pair `src/client/
77 * movement.ts` hands in.
88 *
9 * The second half runs the same functions on a profile that shares none of the
10 * values under test, which is what says the tuning is really an argument.
9 * What is deliberately not here is the proof that the solver is general. That
10 * is `src/engine/physics.test.ts`, which runs the same functions on two
11 * profiles of its own — half the gravity, a taller hull, twice the step — with
12 * no game and none of the numbers below anywhere in it. This file is only ever
13 * about whether Counter-Strike still feels like Counter-Strike.
1114 */
1215
1316 import { expect, test } from "bun:test";
@@ -139,137 +142,34 @@ test("strafing in the air gains speed a straight line cannot", () => {
139142 expect(Math.hypot(b.vel[0], b.vel[1])).toBeGreaterThan(CS_MOVE.maxSpeed + 2);
140143 });
141144
145 test("the eyes are 64 units up standing and 46 crouched", () => {
146 const standing = body(), ducked = { ...body(), duckAmount: 1 };
147 expect(eyeHeight(standing, CS_HULL)).toBe(64);
148 expect(eyeHeight(ducked, CS_HULL)).toBe(46);
149 expect(hullHeight(standing, CS_HULL)).toBe(72);
150 expect(hullHeight(ducked, CS_HULL)).toBe(54);
151 });
152
153 test("walking is 130 u/s and crouching is 85, the two speeds CS players know", () => {
154 const top = (over: Partial<Wish>): number => {
155 const b = body();
156 run(b, wish(over), 4, [ground], CS_MOVE, CS_HULL);
157 return Math.hypot(b.vel[0], b.vel[1]);
158 };
159 expect(top({ forward: 1, walk: true })).toBeCloseTo(130, 5);
160 expect(top({ forward: 1, duck: true })).toBeCloseTo(85, 5);
161 });
162
163 test("a body that lets go of everything is halved in three ticks", () => {
164 // 40 u/s is under CS's 80 u/s stop speed, so friction bites at the stop
165 // speed rather than at the speed: 40 − 3 × 80 × 5.2/64 ≈ 20.5 u/s.
166 const b = body();
167 b.vel = [40, 0, 0];
168 for (let i = 0; i < 3; i++) move(b, wish(), 1 / 64, [ground], CS_MOVE, CS_HULL);
169 expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(20.5, 5);
170 });
171
142172 test("fall damage starts only after a long drop", () => {
143173 expect(fallDamage(400)).toBe(0);
144174 expect(fallDamage(1000)).toBeGreaterThan(30);
145175 });
146
147 // --- a second tuning ------------------------------------------------------
148 //
149 // Everything above runs the solver on Counter-Strike's numbers, which is also
150 // what it would do if those numbers were still read out of module scope. The
151 // tests below run the same functions on a profile that shares none of the
152 // values under test, so re-baking any one of them — gravity, the walk and duck
153 // ratios, the duck rate, the air cap, the acceleration, the friction, the stop
154 // speed, the step size, the hull — fails one of them. I checked by doing it.
155
156 /** Half the gravity, a higher jump, twice the step, a lazier crouch. */
157 const MOON: MoveTuning = {
158 ...CS_MOVE,
159 gravity: 400, jumpVelocity: 600, maxSpeed: 500, duckSpeed: 200, walkSpeed: 250,
160 airAccelerate: 6, airSpeedCap: 60, stepSize: 36, duckRate: 2.5,
161 };
162
163 /** Three times as wide and twice as tall as a CS player. */
164 const GIANT: Hull = { radius: 48, stand: 144, duck: 108, eyeStand: 128, eyeDuck: 92 };
165
166 const jump = (tuning: MoveTuning, hull: Hull): { peak: number; ticks: number } => {
167 const b = body();
168 move(b, wish({ jump: true }), 1 / 64, [ground], tuning, hull);
169 // The first tick leaves the ground at the jump velocity, less the half step
170 // of gravity the integrator takes on the way out — both of them arguments.
171 expect(b.vel[2]).toBeCloseTo(tuning.jumpVelocity - tuning.gravity / 128, 10);
172 let peak = 0, ticks = 0;
173 while (!b.onGround) {
174 move(b, wish(), 1 / 64, [ground], tuning, hull);
175 peak = Math.max(peak, b.pos[2]);
176 ticks += 1;
177 }
178 return { peak, ticks };
179 };
180
181 test("the apex and the hang time are the profile's gravity and jump velocity", () => {
182 // v²/2g and 2v/g, by hand: 301.993² / 1600 ≈ 57.0 units and 0.755 s under CS,
183 // 600² / 800 = 450 units and 3 s under half gravity and a 600 u/s jump.
184 const cs = jump(CS_MOVE, CS_HULL);
185 const moon = jump(MOON, GIANT);
186 expect(cs.peak).toBeCloseTo(CS_MOVE.jumpVelocity ** 2 / (2 * CS_MOVE.gravity), 1);
187 expect(moon.peak).toBeCloseTo(MOON.jumpVelocity ** 2 / (2 * MOON.gravity), 1);
188 // Hang time to within a tick and a half of 2v/g: 48 ticks against 192.
189 const hang = (t: MoveTuning): number => ((2 * t.jumpVelocity) / t.gravity) * 64;
190 expect(Math.abs(cs.ticks - hang(CS_MOVE))).toBeLessThan(1.5);
191 expect(Math.abs(moon.ticks - hang(MOON))).toBeLessThan(1.5);
192 });
193
194 test("the walk, duck and run speeds are the profile's own", () => {
195 const top = (w: Partial<Wish>, tuning: MoveTuning): number => {
196 const b = body();
197 run(b, { ...wish(w), maxSpeed: tuning.maxSpeed }, 4, [ground], tuning, CS_HULL);
198 return Math.hypot(b.vel[0], b.vel[1]);
199 };
200 expect(top({ forward: 1 }, MOON)).toBeCloseTo(MOON.maxSpeed, 5);
201 expect(top({ forward: 1, walk: true }, MOON)).toBeCloseTo(MOON.walkSpeed, 5);
202 expect(top({ forward: 1, duck: true }, MOON)).toBeCloseTo(MOON.duckSpeed, 5);
203 expect(top({ forward: 1, walk: true }, CS_MOVE)).toBeCloseTo(CS_MOVE.walkSpeed, 5);
204 });
205
206 test("the crouch closes at the rate the profile names", () => {
207 // Twenty ticks of duck: 20/64 s × 2.5 a second is 0.78125 closed, and CS's
208 // 5 a second is shut in half that.
209 const lazy = body(), quick = body();
210 for (let i = 0; i < 20; i++) {
211 move(lazy, wish({ duck: true }), 1 / 64, [ground], MOON, GIANT);
212 move(quick, wish({ duck: true }), 1 / 64, [ground], CS_MOVE, CS_HULL);
213 }
214 expect(lazy.duckAmount).toBeCloseTo((20 / 64) * MOON.duckRate, 10);
215 expect(quick.duckAmount).toBe(1);
216 });
217
218 test("the hull handed in is the hull that collides and the eyes that see", () => {
219 const wall: Box = { min: [200, -1000, 0], max: [264, 1000, 384], mat: 1 };
220 const giant = body();
221 run(giant, { ...wish({ forward: 1 }), maxSpeed: MOON.maxSpeed }, 3, [ground, wall], MOON, GIANT);
222 expect(giant.pos[0]).toBeCloseTo(200 - GIANT.radius, 10);
223
224 const standing = body(), ducked = { ...body(), duckAmount: 1 };
225 expect(eyeHeight(standing, GIANT)).toBe(GIANT.eyeStand);
226 expect(eyeHeight(ducked, GIANT)).toBe(GIANT.eyeDuck);
227 expect(hullHeight(standing, GIANT)).toBe(GIANT.stand);
228 expect(hullHeight(ducked, GIANT)).toBe(GIANT.duck);
229 });
230
231 test("a 36-unit step climbs a ledge an 18-unit step refuses", () => {
232 const ledge: Box = { min: [100, -100, 0], max: [400, 100, 30], mat: 2 };
233 const tall = body(), short = body();
234 run(tall, { ...wish({ forward: 1 }), maxSpeed: MOON.maxSpeed }, 1, [ground, ledge], MOON, GIANT);
235 run(short, wish({ forward: 1 }), 1, [ground, ledge], CS_MOVE, CS_HULL);
236 expect(tall.pos[2]).toBe(30);
237 expect(short.pos[2]).toBe(0);
238 expect(short.pos[0]).toBeLessThan(100);
239 });
240
241 test("the air cap and the air acceleration come from the tuning", () => {
242 // A whole second across the wish direction: the full cap, whatever it is.
243 const vel: Vec3 = [250, 0, 0];
244 airAccelerate(vel, [0, 1, 0], 250, 1, MOON);
245 expect(vel[1]).toBeCloseTo(MOON.airSpeedCap, 5);
246 // One tick of it: accel × the capped wish speed × dt, short of the cap.
247 const tick: Vec3 = [250, 0, 0];
248 airAccelerate(tick, [0, 1, 0], 250, 1 / 64, MOON);
249 expect(tick[1]).toBeCloseTo((MOON.airAccelerate * MOON.airSpeedCap) / 64, 10);
250 });
251
252 test("acceleration and friction settle where the tuning says they do", () => {
253 // Running holds where what acceleration adds is what friction takes:
254 // accel × wishSpeed = speed × friction, so 2.8 × 250 / 5.2 ≈ 134.6 u/s.
255 const half: MoveTuning = { ...CS_MOVE, accelerate: 2.8 };
256 const b = body();
257 run(b, wish({ forward: 1 }), 3, [ground], CS_MOVE, CS_HULL);
258 const slower = body();
259 run(slower, wish({ forward: 1 }), 3, [ground], half, CS_HULL);
260 expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(CS_MOVE.maxSpeed, 5);
261 expect(Math.hypot(slower.vel[0], slower.vel[1]))
262 .toBeCloseTo((half.accelerate * CS_MOVE.maxSpeed) / half.friction, 2);
263
264 // And a drifting body: no friction keeps every unit of its speed, while a
265 // stop speed five times CS's takes all of it inside three ticks.
266 const drift = (tuning: MoveTuning): number => {
267 const d = body();
268 d.vel = [40, 0, 0];
269 for (let i = 0; i < 3; i++) move(d, wish(), 1 / 64, [ground], tuning, CS_HULL);
270 return Math.hypot(d.vel[0], d.vel[1]);
271 };
272 expect(drift({ ...CS_MOVE, friction: 0 })).toBe(40);
273 expect(drift({ ...CS_MOVE, stopSpeed: 400 })).toBe(0);
274 expect(drift(CS_MOVE)).toBeCloseTo(20.5, 5);
275 });
src/engine/GUIDE.md +13 −4
@@ -200,9 +200,17 @@ call takes the tuning and the hull, in that order, last — subject, situation,
200200 world, tuning, hull. So the same solver walks two games that share no number,
201201 and swapping a profile is an argument rather than an edit. The shooter's pair is
202202 `movement.CS_MOVE` and `movement.CS_HULL` in `src/client/movement.ts`, and
203 `src/client/movement.test.ts` runs these functions on a second profile with half
204 the gravity and a hull twice the size to prove the arithmetic never learned the
205 first one.
203 `src/client/movement.test.ts` holds those to what a player feels. The proof that
204 the arithmetic never learned them is `src/engine/physics.test.ts`: two profiles
205 of the engine's own, one with half the other's gravity, a taller and wider hull,
206 twice the step and a different top speed, walking a world of plain boxes with no
207 game in it. Every assertion in there is computed from the tuning it was handed —
208 the apex against `v²/2g` and the climb against `v/g`, written out with their
209 units — so a number baked back into the solver fails arithmetic rather than a
210 snapshot. The one number `physics.ts` does own is its contact tolerance, a
211 couple of units of slack under the feet, and it is in the caller's units: two
212 profiles of one scale share it, and a profile at another scale would want it to
213 move with them.
206214
207215 The pieces under `move` are exported because they are worth using and testing on
208216 their own: `accelerate` adds along a wish direction up to a wish speed and never
@@ -632,7 +640,8 @@ own tuning, and nothing else in this table at all.
632640 | a frame as data | `src/client/scene.ts` | `src/range/scene.ts` |
633641 | a pinned run | `src/client/replay.ts` — a script of held inputs, a round and a duel | `src/range/pin.ts` — a script of trigger pulls, one digest per drill |
634642 | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays |
635 | the tuning | `src/client/movement.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` |
643 | the mover's tuning and hull | `movement.CS_MOVE` and `movement.CS_HULL` in `src/client/movement.ts` | none — nobody walks in the range, the eye stands still |
644 | the rest of the tuning | `src/client/weapons.ts`, and `movement.fallDamage` | its own: `targets.STANDARD`, `targets.EYE` and the drills beside them in `src/range/targets.ts` |
636645 | a policy's observation, heads and weights | `src/client/observe.ts`, `src/client/action.ts`, `src/client/weights.ts` | none — the range has no agent and bundles none of the net |
637646
638647 ## What the engine deliberately does not know
src/engine/physics.test.ts added +368
@@ -0,0 +1,368 @@
1 /**
2 * The solver on two profiles that share no number, in a world of plain boxes.
3 *
4 * Every test below runs `move` and its pieces twice — once under `WALKER` and
5 * once under `DRIFTER`, which has half the gravity, a taller and wider hull, a
6 * higher jump, twice the step and a different top speed — and asserts the
7 * result against arithmetic on the tuning it was handed, never against a number
8 * typed twice. A constant baked back into the module would pass under one
9 * profile and fail under the other, which is the whole point of the file.
10 *
11 * **Units are the caller's.** These two profiles are written in the units the
12 * lineage is usually written in — u for length, u/s for speed, u/s² for
13 * gravity — and the tick is 1/64 s throughout, so a hand calculation in this
14 * file reads: `v = 300 u/s`, `g = 600 u/s²`, apex `v²/2g = 75 u` at `t = v/g =
15 * 0.5 s`. The solver reads no scale except one: its contact tolerance, the
16 * couple of units of slack under the feet that keeps a body that landed from
17 * spending the next tick a hair above the floor, is in the caller's units and
18 * is the solver's own — `physics.ts` says why it is not in a `MoveTuning`. A
19 * profile at a wildly different scale (metres, say) would want that number to
20 * move with it; the two here are of one scale, so it does not come up.
21 *
22 * No game appears in this file: no weapon, no round, no map, and no tuning any
23 * game here ships. The shooter's own numbers, and what a player feels them as,
24 * are `src/client/movement.test.ts`.
25 */
26
27 import { expect, test } from "bun:test";
28 import {
29 accelerate, airAccelerate, eyeHeight, groundUnder, hullHeight, move, stuck,
30 type Body, type Box, type Hull, type MoveTuning, type Vec3, type Wish,
31 } from "./index.ts";
32
33 /** The step every run below takes, in seconds: 64 a second. */
34 const TICK = 1 / 64;
35
36 /** A floor at z = 0 wide enough that nothing in here walks off it. */
37 const FLOOR: Box = { min: [-4000, -4000, -64], max: [4000, 4000, 0], mat: 0 };
38
39 /** The reference profile: speeds in u/s, gravity in u/s², a 20-unit step. */
40 const WALKER: MoveTuning = {
41 gravity: 600,
42 maxSpeed: 200,
43 duckSpeed: 70,
44 walkSpeed: 120,
45 accelerate: 6,
46 airAccelerate: 10,
47 airSpeedCap: 25,
48 friction: 4,
49 stopSpeed: 60,
50 jumpVelocity: 300,
51 stepSize: 20,
52 duckRate: 4,
53 };
54
55 /** Half the gravity, a higher jump, a faster body, twice the step. */
56 const DRIFTER: MoveTuning = {
57 gravity: WALKER.gravity / 2,
58 maxSpeed: 320,
59 duckSpeed: 110,
60 walkSpeed: 180,
61 accelerate: 8,
62 airAccelerate: 5,
63 airSpeedCap: 50,
64 friction: 2,
65 stopSpeed: 30,
66 jumpVelocity: 450,
67 stepSize: WALKER.stepSize * 2,
68 duckRate: 2,
69 };
70
71 /** A 40×40×64 hull. */
72 const SHORT: Hull = { radius: 20, stand: 64, duck: 40, eyeStand: 56, eyeDuck: 34 };
73
74 /** A 60×60×120 hull: half again as wide as `SHORT` and nearly twice as tall. */
75 const TALL: Hull = { radius: 30, stand: 120, duck: 76, eyeStand: 108, eyeDuck: 66 };
76
77 type Profile = { name: string; tuning: MoveTuning; hull: Hull };
78
79 const PROFILES: Profile[] = [
80 { name: "the walker", tuning: WALKER, hull: SHORT },
81 { name: "the drifter", tuning: DRIFTER, hull: TALL },
82 ];
83
84 const body = (pos: Vec3 = [0, 0, 0]): Body => ({
85 pos: [...pos] as Vec3,
86 vel: [0, 0, 0],
87 onGround: true,
88 ducking: false,
89 duckAmount: 0,
90 });
91
92 /** A tick's intent. The carry speed defaults to the profile's own top speed. */
93 const wish = (tuning: MoveTuning, over: Partial<Wish> = {}): Wish => ({
94 forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false,
95 maxSpeed: tuning.maxSpeed, ...over,
96 });
97
98 const run = (b: Body, w: Wish, seconds: number, boxes: Box[], tuning: MoveTuning, hull: Hull): void => {
99 for (let t = 0; t < seconds; t += TICK) move(b, w, TICK, boxes, tuning, hull);
100 };
101
102 // --- the arc, checked by hand ---------------------------------------------
103
104 /** One jump from the floor: its peak, the tick it peaked on, and the flight. */
105 function jumpArc(tuning: MoveTuning, hull: Hull): { peak: number; peakTick: number; ticks: number } {
106 const b = body();
107 move(b, wish(tuning, { jump: true }), TICK, [FLOOR], tuning, hull);
108 expect(b.onGround).toBe(false);
109 // Leaving the ground: the jump velocity less the half step of gravity the
110 // integrator takes on the way out — v₀ − g·dt/2, both of them arguments.
111 expect(b.vel[2]).toBeCloseTo(tuning.jumpVelocity - (tuning.gravity * TICK) / 2, 10);
112 let peak = b.pos[2], peakTick = 1, ticks = 1;
113 while (!b.onGround) {
114 move(b, wish(tuning), TICK, [FLOOR], tuning, hull);
115 ticks += 1;
116 if (b.pos[2] > peak) { peak = b.pos[2]; peakTick = ticks; }
117 }
118 return { peak, peakTick, ticks };
119 }
120
121 test("the apex is v²/2g and the climb is v/g, in the profile's own units", () => {
122 // By hand, from the two numbers each profile brings and nothing else:
123 //
124 // walker v₀ = 300 u/s, g = 600 u/s²
125 // apex v₀²/2g = 90000 / 1200 = 75 u
126 // climb v₀/g = 300 / 600 = 0.5 s = 32 ticks at 64 Hz
127 // flight 2v₀/g = 1.0 s = 64 ticks
128 // drifter v₀ = 450 u/s, g = 300 u/s² — half the gravity
129 // apex v₀²/2g = 202500 / 600 = 337.5 u
130 // climb v₀/g = 1.5 s = 96 ticks
131 // flight 2v₀/g = 3.0 s = 192 ticks
132 //
133 // The half-step integrator samples the parabola v₀t − ½gt² exactly at every
134 // tick boundary, and both climbs above are whole numbers of ticks, so the
135 // sampled peak *is* the analytic apex rather than a value just under it.
136 // The assertions compute all six from the tuning; the six numbers above are
137 // the arithmetic, written out so the test can be checked without running it.
138 for (const { name, tuning, hull } of PROFILES) {
139 const arc = jumpArc(tuning, hull);
140 const apex = tuning.jumpVelocity ** 2 / (2 * tuning.gravity);
141 const climb = tuning.jumpVelocity / tuning.gravity;
142 expect(arc.peak, `${name} reaches v²/2g`).toBeCloseTo(apex, 6);
143 expect(arc.peakTick, `${name} reaches it at v/g`).toBe(Math.round(climb / TICK));
144 expect(arc.ticks, `${name} is back down at 2v/g`).toBe(Math.round((2 * climb) / TICK));
145 }
146 });
147
148 test("half the gravity is twice the hang time, and the jump that is 1.5× as fast is 4.5× as high", () => {
149 // The apex ratio is (v'/v)² × (g/g') = (450/300)² × (600/300) = 2.25 × 2 =
150 // 4.5, and the flight ratio is (v'/v) × (g/g') = 1.5 × 2 = 3: 337.5 u over
151 // 75 u, and 3.0 s over 1.0 s. Ratios, so a solver that had learned either
152 // profile's numbers fails one side of them.
153 const walker = jumpArc(WALKER, SHORT);
154 const drifter = jumpArc(DRIFTER, TALL);
155 expect(drifter.peak / walker.peak).toBeCloseTo(4.5, 9);
156 expect(drifter.ticks / walker.ticks).toBe(3);
157 });
158
159 // --- friction, the air cap and the step, each under both profiles ----------
160
161 for (const { name, tuning, hull } of PROFILES) {
162 test(`${name} accelerates at the rate it was handed and stops at the carry speed`, () => {
163 // From a standstill, one tick adds accel × wishSpeed × dt and friction
164 // takes nothing, because a body under a tenth of a unit a second is stopped.
165 const first = body();
166 move(first, wish(tuning, { forward: 1 }), TICK, [FLOOR], tuning, hull);
167 expect(first.vel[0]).toBeCloseTo(tuning.accelerate * tuning.maxSpeed * TICK, 9);
168
169 // Left running, it settles where what acceleration adds is what friction
170 // takes — accel × wishSpeed / friction — or at the wish speed if that comes
171 // first, which it does for both of these profiles.
172 const settle = (t: MoveTuning): number => {
173 const b = body();
174 run(b, wish(t, { forward: 1 }), 6, [FLOOR], t, hull);
175 return Math.hypot(b.vel[0], b.vel[1]);
176 };
177 expect(settle(tuning)).toBeCloseTo(tuning.maxSpeed, 6);
178 // Halve the acceleration against the friction and the equilibrium is the
179 // one the formula names — half the carry speed — approached rather than
180 // reached, which is why this one is held to a hundredth of a unit.
181 const sluggish: MoveTuning = { ...tuning, accelerate: tuning.friction / 2 };
182 expect(settle(sluggish)).toBeCloseTo((sluggish.accelerate * tuning.maxSpeed) / tuning.friction, 2);
183 });
184
185 test(`${name} loses friction × dt of its speed each tick, and stop speed is the floor under that`, () => {
186 // One tick of drift with nothing held: the drop is control × friction × dt,
187 // where control is the speed itself, or the stop speed when the body is
188 // slower than that — which is what makes the last few units go quickly.
189 const drift = (from: number): number => {
190 const b = body();
191 b.vel = [from, 0, 0];
192 move(b, wish(tuning), TICK, [FLOOR], tuning, hull);
193 return b.vel[0];
194 };
195 const fast = tuning.stopSpeed * 2, slow = tuning.stopSpeed / 2;
196 expect(drift(fast)).toBeCloseTo(fast - fast * tuning.friction * TICK, 9);
197 expect(drift(slow)).toBeCloseTo(slow - tuning.stopSpeed * tuning.friction * TICK, 9);
198 // And with friction switched off, a drifting body keeps every unit of it.
199 const free = body();
200 free.vel = [fast, 0, 0];
201 move(free, wish(tuning), TICK, [FLOOR], { ...tuning, friction: 0 }, hull);
202 expect(free.vel[0]).toBe(fast);
203 });
204
205 test(`${name} gains at most its air cap across the direction it is already going`, () => {
206 // Along the way it is already moving, faster than the cap: nothing to add.
207 const along: Vec3 = [tuning.maxSpeed, 0, 0];
208 airAccelerate(along, [1, 0, 0], tuning.maxSpeed, 1, tuning);
209 expect(along[0]).toBe(tuning.maxSpeed);
210 // Across it, given a whole second: the cap, whatever the wish speed was.
211 const across: Vec3 = [tuning.maxSpeed, 0, 0];
212 airAccelerate(across, [0, 1, 0], tuning.maxSpeed, 1, tuning);
213 expect(across[1]).toBeCloseTo(tuning.airSpeedCap, 9);
214 // And one tick of it: airAccelerate × the capped wish speed × dt, which is
215 // short of the cap and is where the turn-while-accelerating gain comes from.
216 const tick: Vec3 = [tuning.maxSpeed, 0, 0];
217 airAccelerate(tick, [0, 1, 0], tuning.maxSpeed, TICK, tuning);
218 expect(tick[1]).toBeCloseTo(tuning.airAccelerate * tuning.airSpeedCap * TICK, 10);
219 expect(tick[1]).toBeLessThan(tuning.airSpeedCap);
220 });
221
222 test(`${name} steps onto a ledge inside its step size and is stopped by one above it`, () => {
223 const ledge = (height: number): Box => ({ min: [150, -200, -64], max: [600, 200, height], mat: 1 });
224 const low = body(), high = body();
225 run(low, wish(tuning, { forward: 1 }), 2, [FLOOR, ledge(tuning.stepSize - 5)], tuning, hull);
226 run(high, wish(tuning, { forward: 1 }), 2, [FLOOR, ledge(tuning.stepSize + 10)], tuning, hull);
227 // Under the step size: up and onto it, still walking.
228 expect(low.pos[2]).toBe(tuning.stepSize - 5);
229 expect(low.pos[0]).toBeGreaterThan(150);
230 // Over it: stopped flat against the face, a hull's radius short of it.
231 expect(high.pos[2]).toBe(0);
232 expect(high.pos[0]).toBeCloseTo(150 - hull.radius, 9);
233 });
234
235 test(`${name} walks, ducks and runs at the three speeds its profile names`, () => {
236 const top = (over: Partial<Wish>): number => {
237 const b = body();
238 run(b, wish(tuning, over), 6, [FLOOR], tuning, hull);
239 return Math.hypot(b.vel[0], b.vel[1]);
240 };
241 expect(top({ forward: 1 })).toBeCloseTo(tuning.maxSpeed, 6);
242 expect(top({ forward: 1, walk: true })).toBeCloseTo(tuning.walkSpeed, 6);
243 expect(top({ forward: 1, duck: true })).toBeCloseTo(tuning.duckSpeed, 6);
244 });
245
246 test(`${name} closes its crouch at the rate its profile names`, () => {
247 // Sixteen ticks is a quarter of a second, so the crouch is duckRate/4
248 // closed — 1.0 at 4 a second, 0.5 at 2 — and clamped at shut either way.
249 const b = body();
250 for (let i = 0; i < 16; i++) move(b, wish(tuning, { duck: true }), TICK, [FLOOR], tuning, hull);
251 expect(b.duckAmount).toBeCloseTo(Math.min(1, (16 / 64) * tuning.duckRate), 10);
252 });
253 }
254
255 // --- the hull is an argument too -------------------------------------------
256
257 test("the hull handed in is the box that collides, and a wider one stops sooner", () => {
258 const wall: Box = { min: [300, -400, -64], max: [400, 400, 500], mat: 1 };
259 const stops = (tuning: MoveTuning, hull: Hull): number => {
260 const b = body();
261 run(b, wish(tuning, { forward: 1 }), 4, [FLOOR, wall], tuning, hull);
262 return b.pos[0];
263 };
264 for (const { tuning, hull } of PROFILES) expect(stops(tuning, hull)).toBeCloseTo(300 - hull.radius, 9);
265 // Same profile, two hulls: the wide one comes to rest 10 units further back.
266 expect(stops(WALKER, SHORT) - stops(WALKER, TALL)).toBeCloseTo(TALL.radius - SHORT.radius, 9);
267 });
268
269 test("stuck reads the hull it is given, not one it remembers", () => {
270 // A post 25 units out: the 30-unit radius is inside it, the 20-unit one is not.
271 const post: Box = { min: [25, -50, -64], max: [100, 50, 10], mat: 1 };
272 expect(stuck([0, 0, 0], TALL.stand, [post], TALL)).toBe(true);
273 expect(stuck([0, 0, 0], SHORT.stand, [post], SHORT)).toBe(false);
274 // A slab from 100 to 140 up: the tall hull's head is in it standing (120 > 100)
275 // and clear of it crouched (76 < 100), which is the same box read two ways.
276 const slab: Box = { min: [-100, -100, 100], max: [100, 100, 140], mat: 1 };
277 expect(stuck([0, 0, 0], TALL.stand, [slab], TALL)).toBe(true);
278 expect(stuck([0, 0, 0], TALL.duck, [slab], TALL)).toBe(false);
279 expect(stuck([0, 0, 0], SHORT.stand, [slab], SHORT)).toBe(false);
280 });
281
282 test("groundUnder is the highest top within the reach it is asked for", () => {
283 const under: Box = { min: [-50, -50, -200], max: [50, 50, -15], mat: 0 };
284 const higher: Box = { min: [-50, -50, -200], max: [50, 50, -5], mat: 0 };
285 const boxes = [under, higher];
286 expect(groundUnder([0, 0, 0], TALL.stand, boxes, 20, TALL)).toBe(-5);
287 expect(groundUnder([0, 0, 0], TALL.stand, [under], 20, TALL)).toBe(-15);
288 // Nothing within three units of the feet: nothing holding this hull up.
289 expect(groundUnder([0, 0, 0], TALL.stand, boxes, 3, TALL)).toBeNull();
290 // A shelf whose near edge is 25 units out: the wide hull stands on it and the
291 // narrow one is over thin air, with every other argument the same.
292 const shelf: Box = { min: [25, -50, -200], max: [400, 50, -2], mat: 0 };
293 expect(groundUnder([0, 0, 0], TALL.stand, [shelf], 10, TALL)).toBe(-2);
294 expect(groundUnder([0, 0, 0], SHORT.stand, [shelf], 10, SHORT)).toBeNull();
295 });
296
297 test("the eyes and the hull height interpolate the hull's own two numbers", () => {
298 const standing = body(), half = { ...body(), duckAmount: 0.5 }, ducked = { ...body(), duckAmount: 1 };
299 for (const hull of [SHORT, TALL]) {
300 expect(eyeHeight(standing, hull)).toBe(hull.eyeStand);
301 expect(eyeHeight(ducked, hull)).toBe(hull.eyeDuck);
302 expect(eyeHeight(half, hull)).toBeCloseTo((hull.eyeStand + hull.eyeDuck) / 2, 9);
303 expect(hullHeight(standing, hull)).toBe(hull.stand);
304 expect(hullHeight(ducked, hull)).toBe(hull.duck);
305 expect(hullHeight(half, hull)).toBeCloseTo((hull.stand + hull.duck) / 2, 9);
306 }
307 });
308
309 test("a hull opens only as far as the headroom over it allows", () => {
310 // 90 units of headroom: the tall hull fits crouched (76 u) and not standing
311 // (120 u), so the crouch opens to where the hull is 90 tall and stops —
312 // (120 − 90) / (120 − 76) = 0.682 closed — rather than to 0 or staying shut.
313 const headroom = 90;
314 const slab: Box = { min: [-200, -200, headroom], max: [200, 200, 300], mat: 1 };
315 const b = body();
316 b.duckAmount = 1;
317 run(b, wish(DRIFTER, { duck: false }), 2, [FLOOR, slab], DRIFTER, TALL);
318 const fits = (TALL.stand - headroom) / (TALL.stand - TALL.duck);
319 expect(hullHeight(b, TALL)).toBeLessThanOrEqual(headroom);
320 expect(b.duckAmount).toBeGreaterThanOrEqual(fits);
321 // And it got there: no more than one tick's worth of crouch short of it.
322 expect(b.duckAmount - fits).toBeLessThan(DRIFTER.duckRate * TICK);
323 // Step out from under the slab and the same profile stands straight back up.
324 run(b, wish(DRIFTER, { duck: false }), 2, [FLOOR], DRIFTER, TALL);
325 expect(b.duckAmount).toBe(0);
326 });
327
328 // --- the lineage's signature, under a profile that never saw a shooter ------
329
330 test("ground acceleration adds along the wish direction and never past the wish speed", () => {
331 // One enormous step: the add is clamped to what is left, so it lands on the
332 // wish speed exactly rather than overshooting it.
333 const far: Vec3 = [0, 0, 0];
334 accelerate(far, [1, 0, 0], 250, 5, 10);
335 expect(far[0]).toBe(250);
336 // One tick: accel × wishSpeed × dt, and nothing across the direction asked for.
337 const tick: Vec3 = [0, 0, 0];
338 accelerate(tick, [1, 0, 0], 250, 5, TICK);
339 expect(tick[0]).toBeCloseTo(5 * 250 * TICK, 10);
340 expect(tick[1]).toBe(0);
341 // Already faster along it than asked: nothing added, and nothing taken.
342 const fast: Vec3 = [300, 0, 0];
343 accelerate(fast, [1, 0, 0], 250, 5, TICK);
344 expect(fast[0]).toBe(300);
345 });
346
347 test("turning while accelerating in the air gains speed a straight line cannot", () => {
348 const gain = (tuning: MoveTuning, hull: Hull): number => {
349 const b = body();
350 b.vel = [tuning.maxSpeed, 0, 0];
351 move(b, wish(tuning, { forward: 1, jump: true }), TICK, [FLOOR], tuning, hull);
352 let yaw = 0;
353 while (!b.onGround) {
354 yaw += 0.35;
355 move(b, wish(tuning, { side: -1, yaw }), TICK, [FLOOR], tuning, hull);
356 }
357 return Math.hypot(b.vel[0], b.vel[1]) - tuning.maxSpeed;
358 };
359 // Held strafe, turning into it the whole flight: faster on landing than the
360 // profile's own top speed, which is the air cap applying to the new direction
361 // rather than to the speed. Straight ahead, the same flight gains nothing.
362 for (const { name, tuning, hull } of PROFILES) expect(gain(tuning, hull), name).toBeGreaterThan(2);
363 const straight = body();
364 straight.vel = [WALKER.maxSpeed, 0, 0];
365 move(straight, wish(WALKER, { forward: 1, jump: true }), TICK, [FLOOR], WALKER, SHORT);
366 while (!straight.onGround) move(straight, wish(WALKER, { forward: 1 }), TICK, [FLOOR], WALKER, SHORT);
367 expect(Math.hypot(straight.vel[0], straight.vel[1])).toBeCloseTo(WALKER.maxSpeed, 6);
368 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs