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

forked from trevato/cs

Physics moves behind the door, replayed on today’s main: the solver in the engine, CS’s numbers in the game #70

Closed opened this
Closed without merging

torque/cs-engine:torque/62-physics-moves-behind-the-door-replayed-o → main 2 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
17 files changed +653 −358
README.md +13 −4
@@ -112,7 +112,11 @@ subtends — and the panel says which of the two you are looking at.
112112
113113 - `src/engine/` — the part a second game could use, and the rule is that it never
114114 imports the game: `math` (vectors, matrices, angles, a seeded rng), `trace`
115 (rays against boxes, and the `Box` they are traced against), `grid` (a grid of
115 (rays against boxes, and the `Box` they are traced against), `physics` (the
116 Quake-lineage mover — friction, ground and air acceleration, sliding, steps
117 and crouching — which holds no number of its own: a caller hands in a
118 `MoveTuning` and a `Hull` on every call, and the only constant left in it is
119 the two-unit floor contact the solver needs), `grid` (a grid of
116120 cells of any type, the cell-to-world arithmetic, greedy meshing and A* with no
117121 corner-cutting — which cells are solid or walkable arrive as predicates, so it
118122 knows nothing about de_forge's alphabet), `mesh` (a box builder), `texture`
@@ -159,7 +163,9 @@ subtends — and the panel says which of the two you are looking at.
159163 `length`, `length2d`, `dist`, `dist2d`, `normalize`, `lerp`, `clamp`, `rad`,
160164 `deg`, `angleDelta`, `angleVectors`, `mat4`, `multiply`, `perspective`,
161165 `viewMatrix`, `modelMatrix`, `rng`, `Vec3` and `Mat4`; from `trace` `rayBox`,
162 `traceWorld`, `visible`, `segmentLength`, `Box` and `Hit`; from `grid`
166 `traceWorld`, `visible`, `segmentLength`, `Box` and `Hit`; from `physics`
167 `move`, `accelerate`, `airAccelerate`, `stuck`, `groundUnder`, `eyeHeight`,
168 `hullHeight`, `Body`, `Wish`, `MoveTuning` and `Hull`; from `grid`
163169 `inside`, `cellAt`, `worldOf`, `colOf`, `rowOf`, `mergeSolid`, `findPath` and
164170 `Grid`; from `mesh` `builder`, `addBox`, `finish`, `quadMesh`, `Builder` and
165171 `Mesh`; from `texture` `hash`, `noise`, `fbm`, `paintAtlas` and `Shader`; from
@@ -193,7 +199,9 @@ subtends — and the panel says which of the two you are looking at.
193199 second of elapsed time is sixty ticks however the frames are cut.
194200 - `src/client/` — the game, in modules named for one thing each: `map` (de_forge
195201 itself: its areas, its crates, its cell alphabet and its spawns and sites, laid
196 over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules),
202 over the engine's grid), `movement` (this game's tuning for the engine's
203 mover: `CS_MOVE`, `CS_HULL` and the fall-damage curve that goes with them),
204 `weapons`, `combat`, `match` (the rules),
197205 `world` (the simulation),
198206 `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`,
199207 `binding`, `scene`, `hud`, `audio`, `main`. Everything in here is headless and
@@ -293,7 +301,8 @@ subtends — and the panel says which of the two you are looking at.
293301 is imported by the entry, so the bundle has not grown by a byte; `level.test.ts`
294302 proves the shell has no seam a bullet can leave through and `paint.test.ts`
295303 pins the atlas byte for byte. No movement and no WebGL yet — those need the
296 physics and the renderer, and #13 builds the finale on this.
304 mover and the renderer, both of which are behind the door now, and #13 builds
305 the finale on this.
297306 `src/independence.test.ts` reads every source in here and fails on an import
298307 that leaves those three places; `binding.test.ts`,
299308 `targets.test.ts` and `readout.test.ts` pin the table, the intent, the game and
src/client/combat.ts +2 −2
@@ -6,7 +6,7 @@
66
77 import type { Box } from "./map.ts";
88 import { dist, rayBox, traceWorld, type Vec3 } from "../engine/index.ts";
9 import { HULL } from "./physics.ts";
9 import { CS_HULL } from "./movement.ts";
1010 import { computeDamage, type Hitgroup, type Weapon } from "./weapons.ts";
1111
1212 export type Actor = {
@@ -32,7 +32,7 @@ const PARTS: [Hitgroup, number, number, number][] = [
3232 ];
3333
3434 export function hitboxes(a: Actor): Part[] {
35 const height = HULL.stand + (HULL.duck - HULL.stand) * a.duckAmount;
35 const height = CS_HULL.stand + (CS_HULL.duck - CS_HULL.stand) * a.duckAmount;
3636 return PARTS.map(([group, lo, hi, w]) => ({
3737 group,
3838 min: [a.pos[0] - w, a.pos[1] - w, a.pos[2] + height * lo] as Vec3,
src/client/hud.ts +2 −2
@@ -11,7 +11,7 @@
1111 */
1212
1313 import { clamp } from "../engine/index.ts";
14 import { MOVE } from "./physics.ts";
14 import { CS_MOVE } from "./movement.ts";
1515 import { inaccuracy, WEAPONS } from "./weapons.ts";
1616 import { currentWeapon, useProgress, type World } from "./world.ts";
1717
@@ -61,7 +61,7 @@ export function drawHud(
6161 if (me.alive) {
6262 const speed = Math.hypot(me.vel[0], me.vel[1]);
6363 const spread = inaccuracy(weapon, {
64 moving: clamp(speed / MOVE.maxSpeed, 0, 1),
64 moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1),
6565 inAir: !me.onGround,
6666 ducking: me.duckAmount > 0.5,
6767 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 {
11 accelerate, airAccelerate, move,
12 type Body, type Hull, type MoveTuning, type Vec3, type Wish,
13 } from "../engine/index.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/index.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/index.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/index.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/index.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/scene.test.ts +2 −2
@@ -14,7 +14,7 @@ import { expect, test } from "bun:test";
1414 import { existsSync, readFileSync } from "node:fs";
1515 import { join } from "node:path";
1616 import type { Camera } from "../engine/index.ts";
17 import { HULL } from "./physics.ts";
17 import { CS_HULL } from "./movement.ts";
1818 import { buildScene, createScene, LINE_CAPACITY, SKY } from "./scene.ts";
1919 import { SHORT } from "./match.ts";
2020 import { createWorld, type Effect, type World } from "./world.ts";
@@ -69,7 +69,7 @@ test("a duck squashes a player by the hull it has, a standing one by nothing", (
6969 other.duckAmount = 1;
7070 buildScene(w, camera(), scene);
7171 const ducking = scene.instances.slice(1, scene.instanceCount).find((i) => i.pos[0] === other.pos[0]);
72 expect(ducking?.scale).toBeCloseTo(HULL.duck / HULL.stand, 10);
72 expect(ducking?.scale).toBeCloseTo(CS_HULL.duck / CS_HULL.stand, 10);
7373 expect(ducking?.scale).toBeCloseTo(54 / 72, 10);
7474 });
7575
src/client/scene.ts +6 −1
@@ -17,11 +17,16 @@
1717 * pool. The view model object is kept even on the frames where there is none.
1818 */
1919
20 <<<<<<< HEAD
21 import { clamp, hullHeight, type Camera, type Scene as Frame, type Vec3 } from "../engine/index.ts";
22 import { CS_HULL } from "./movement.ts";
23 =======
2024 import {
2125 clamp, createScene as createPooledScene, nextInstance, pushLine, resetScene,
2226 type Camera, type Scene as Frame, type Vec3,
2327 } from "../engine/index.ts";
2428 import { HULL, hullHeight } from "./physics.ts";
29 >>>>>>> origin/main
2530 import { WEAPONS } from "./weapons.ts";
2631 import type { World } from "./world.ts";
2732
@@ -131,7 +136,7 @@ export function buildScene(world: World, camera: Camera, into: Scene): Scene {
131136 for (const p of world.players) {
132137 // The eye is inside your own head: drawing it would fill the screen.
133138 if (!p.alive || p === world.me) continue;
134 place(into, "player", p.pos, p.yaw, hullHeight(p) / HULL.stand, TEAM_TINT[p.team] ?? WHITE);
139 place(into, "player", p.pos, p.yaw, hullHeight(p, CS_HULL) / CS_HULL.stand, TEAM_TINT[p.team] ?? WHITE);
135140 }
136141 if (world.bomb.state === "planted" || world.bomb.state === "dropped")
137142 place(into, "bomb", world.bomb.pos, 0, 1, WHITE);
src/client/world.ts +10 −7
@@ -9,9 +9,12 @@
99
1010 import { applyHit, traceBullet, type Actor } from "./combat.ts";
1111 import * as map from "./map.ts";
12 import { angleVectors, angleDelta, clamp, dist, normalize, rng, sub, visible, type Vec3 } from "../engine/index.ts";
12 import {
13 angleVectors, angleDelta, clamp, dist, eyeHeight, hullHeight, move, normalize, rng, sub,
14 visible, type Body, type Vec3, type Wish,
15 } from "../engine/index.ts";
1316 import * as rules from "./match.ts";
14 import { eyeHeight, hullHeight, move, type Body, type Wish, MOVE } from "./physics.ts";
17 import { CS_HULL, CS_MOVE } from "./movement.ts";
1518 import {
1619 ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset,
1720 type WeaponId, type Weapon,
@@ -160,7 +163,7 @@ export const teammates = (world: World, team: Team): Player[] => world.players.f
160163 export const alive = (world: World, team: Team): Player[] =>
161164 world.players.filter((p) => p.team === team && p.alive);
162165
163 export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p)];
166 export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p, CS_HULL)];
164167
165168 function say(world: World, text: string): void {
166169 world.messages.push({ at: world.time, text });
@@ -322,7 +325,7 @@ export function shoot(world: World, p: Player): boolean {
322325
323326 const speed = Math.hypot(p.vel[0], p.vel[1]);
324327 const spread = inaccuracy(w, {
325 moving: clamp(speed / MOVE.maxSpeed, 0, 1),
328 moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1),
326329 inAir: !p.onGround,
327330 ducking: p.duckAmount > 0.5,
328331 scoped: p.scoped,
@@ -453,9 +456,9 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
453456 jump: input.jump && !frozen,
454457 duck: input.duck,
455458 walk: input.walk || p.scoped,
456 maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed),
459 maxSpeed: Math.min(CS_MOVE.maxSpeed, w.carrySpeed),
457460 };
458 move(p, wish, dt, world.boxes);
461 move(p, wish, dt, world.boxes, CS_MOVE, CS_HULL);
459462 finishReload(world, p);
460463 if (input.reload) reload(world, p);
461464 if (w.zoom !== undefined && input.scope !== p.scoped && world.time > p.nextShotAt - 0.05) p.scoped = input.scope;
@@ -537,4 +540,4 @@ export const aimAt = (p: Player, target: Vec3): { yaw: number; pitch: number } =
537540 };
538541
539542 /** The middle of the chest: what a bot aims at before it aims higher. */
540 export const bodyCentre = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + hullHeight(p) * 0.72];
543 export const bodyCentre = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + hullHeight(p, CS_HULL) * 0.72];
src/engine/GUIDE.md +54
@@ -20,6 +20,14 @@ exported from `src/engine/index.ts`. A game's own name carries its module:
2020 code block names the file it came from on its first line, and that file is
2121 always the example.
2222
23 <<<<<<< HEAD
24 `guide.test.ts` holds all three: every name the door exports is named here,
25 every bare engine name here is exported, and every line of every code block is
26 a line of the file above it. So when a group lands behind the door that this
27 page has no paragraph for — the physics of issue #3, which is now the *physics*
28 section below — that test fails until the paragraph is written. That is the
29 point. A name arrives with a page or it does not arrive.
30 =======
2331 `guide.test.ts` holds all four: every name the door exports is named here, every
2432 bare engine name here is exported, every line of every code block is a line of
2533 the file above it, and every block is a run of lines of `src/engine/example.ts`
@@ -27,6 +35,7 @@ in the order that file has them. So when a group lands behind the door that this
2735 page has no paragraph for — the physics of issue #3, one day — that test fails
2836 until the paragraph is written. That is the point. A name arrives with a page or
2937 it does not arrive.
38 >>>>>>> origin/main
3039
3140 ## What a game brings
3241
@@ -124,6 +133,13 @@ and one plate, and never draws or sounds either.
124133
125134 Every number that decides how the game feels is the game's: eye height, hull
126135 size, gravity, how long a round lasts, what a plate is worth. The engine holds
136 <<<<<<< HEAD
137 no constants of its own beyond the loop's two safety clamps and the mover's
138 two-unit floor contact, and both of those are tolerances rather than feel. Put
139 yours at the top of a module — `src/client/movement.ts` is the shooter's whole
140 tuning for the mover — and a reader can tune the game without reading the
141 engine.
142 =======
127143 no constants of its own beyond the loop's two safety clamps. Put them at the top
128144 of a module, named, and a reader can tune the game without reading the engine.
129145 The example's whole tuning is one line, and that is the shape to copy:
@@ -133,6 +149,7 @@ The example's whole tuning is one line, and that is the shape to copy:
133149 /** The tuning, all of it: metres per second, degrees per second, metres, metres. */
134150 const SPEED = 4, TURN = 120, EYE = 1.6, REACH = 40;
135151 ```
152 >>>>>>> origin/main
136153
137154 ## What is behind the door
138155
@@ -170,6 +187,38 @@ A shot in the example is one ray and one box, and it is the whole of hitscan:
170187 if (rayBox(world.pos, to, world.target.min, world.target.max) === null) return world;
171188 ```
172189
190 ### physics
191
192 The mover, in the Quake/Source lineage: ground friction, a capped ground
193 acceleration, an air acceleration that caps only the part of your wish speed
194 along the new direction — which is why turning as you accelerate in the air
195 makes you faster — and a move that slides along walls and steps up small
196 ledges. `move` is one tick of all of it. Beside it are the pieces a game wants
197 on its own: `accelerate` and `airAccelerate` for the velocity change, `stuck`
198 for whether a hull at a place overlaps anything, `groundUnder` for the highest
199 box top within a reach below the feet, and `eyeHeight` and `hullHeight` for how
200 tall a body is right now.
201
202 What moves is a `Body` — a position, a velocity, whether it is on the ground and
203 how far through a crouch it is — and what it wants is a `Wish`: forward and
204 side in -1..1, a yaw, jump, duck, walk, and the top speed whatever it is
205 carrying allows. Neither carries a number of the game's: every tuned value
206 arrives as an argument, in one order everywhere — the thing that moves, then the
207 situation it is in (the step in seconds, the boxes), then the tuning, then the
208 hull. A `MoveTuning` is
209 gravity, the speeds, the accelerations, friction, the jump, `MoveTuning.stepSize`
210 and `MoveTuning.duckRate`; a `Hull` is the radius and the standing, ducked and
211 eye heights. Nothing has a default, so no call can substitute a tuning quietly,
212 and the shooter's Source numbers are `movement.CS_MOVE` and `movement.CS_HULL`
213 in `src/client/movement.ts`. The one constant left in the module is the two-unit
214 floor contact, which is the solver's tolerance rather than anything a designer
215 tunes.
216
217 ```ts
218 // src/client/world.ts
219 move(p, wish, dt, world.boxes, CS_MOVE, CS_HULL);
220 ```
221
173222 ### grid
174223
175224 A `Grid` of cells of any type — the cell type is your alphabet, not the
@@ -408,10 +457,15 @@ own tuning, and nothing else in this table at all.
408457 | mesh recipes | `src/client/models.ts` | `src/range/level.ts` — a room and a plate |
409458 | texture recipes | `src/client/materials.ts` | `src/range/paint.ts` — three 64² layers |
410459 | sound recipes | `src/client/audio.ts` | none yet |
460 <<<<<<< HEAD
461 | renderer assets | an atlas and eight meshes, from `src/client/main.ts` | none: it draws a 2D horizon in `src/range/readout.ts` |
462 | the tuning | `src/client/movement.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` |
463 =======
411464 | renderer assets | an atlas and eight meshes, from `src/client/main.ts` | an atlas and two meshes, from `src/range/scene.ts` |
412465 | a frame as data | `src/client/scene.ts` | `src/range/scene.ts` |
413466 | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays |
414467 | the tuning | `src/client/physics.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` |
468 >>>>>>> origin/main
415469
416470 ## What the engine deliberately does not know
417471
src/engine/boundary.test.ts +19 −1
@@ -186,7 +186,7 @@ test("the door rule is about the door and nothing else", () => {
186186 expect(reachesPastTheDoor("src/client/world.ts", "../engine/math.ts")).toMatch(/door/);
187187 expect(reachesPastTheDoor("src/rl/arena.ts", "../engine/sub/deep.ts")).toMatch(/door/);
188188 expect(reachesPastTheDoor("src/client/world.ts", "../engine/index.ts")).toBeNull();
189 expect(reachesPastTheDoor("src/client/world.ts", "./physics.ts")).toBeNull();
189 expect(reachesPastTheDoor("src/client/world.ts", "./movement.ts")).toBeNull();
190190 expect(reachesPastTheDoor("src/client/world.ts", "bun:test")).toBeNull();
191191 expect(reachesPastTheDoor("scripts/build.ts", "../src/engine/trace.ts")).toMatch(/door/);
192192 // A test may read another test's helpers; a module may not, test or not.
@@ -237,6 +237,23 @@ test("the example imports the door and nothing else, which is what it is for", (
237237 * copy of the surface, read twice, rather than two that drift.
238238 */
239239 export const SURFACE = [
240 <<<<<<< HEAD
241 "Body", "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera", "Controls", "Ctor",
242 "Frames", "Grid", "Hit", "Host", "Hull", "Instance", "KeyEventish", "Listens", "LockOwner",
243 "Loop", "LoopOptions", "Mat4", "Mesh", "MouseEventish", "MoveTuning", "Options", "Overlay",
244 "Paint", "Renderer", "RendererAssets",
245 "Scene", "Shader", "SoundDevice", "Stick", "Surface", "Tone",
246 "TouchEventish", "Touchish", "Vec3", "Wish", "accelerate", "add", "addBox", "airAccelerate",
247 "angleDelta", "angleVectors",
248 "attachButtons", "attenuate", "builder", "cellAt", "clamp", "colOf", "createControls",
249 "createLoop", "createRenderer", "createSoundDevice", "cross", "deg", "dist", "dist2d", "dot",
250 "eyeHeight", "fbm",
251 "findPath", "finish", "groundUnder", "hash", "hullHeight", "inside", "length", "length2d",
252 "lerp", "mat4", "mergeSolid",
253 "modelMatrix", "move", "mul", "multiply", "noise", "normalize", "overlay", "paintAtlas",
254 "perspective",
255 "quadMesh", "rad", "rayBox", "rng", "rowOf", "scale", "segmentLength", "stuck", "sub",
256 =======
240257 "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera", "Controls", "Ctor",
241258 "FakeCanvas", "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow",
242259 "Frames",
@@ -252,6 +269,7 @@ export const SURFACE = [
252269 "modelMatrix", "mul", "multiply", "nextInstance", "noise", "normalize", "overlay", "paintAtlas",
253270 "perspective", "pushLine",
254271 "quadMesh", "rad", "rayBox", "resetScene", "rng", "rowOf", "scale", "segmentLength", "sub",
272 >>>>>>> origin/main
255273 "traceWorld",
256274 "v3", "viewMatrix", "visible", "worldOf",
257275 ];
src/engine/index.ts +6
@@ -22,6 +22,12 @@ export type { Mat4, Vec3 } from "./math.ts";
2222 export { rayBox, segmentLength, traceWorld, visible } from "./trace.ts";
2323 export type { Box, Hit } from "./trace.ts";
2424
25 /** The Quake-lineage mover: a body through boxes, on a tuning and a hull given. */
26 export {
27 accelerate, airAccelerate, eyeHeight, groundUnder, hullHeight, move, stuck,
28 } from "./physics.ts";
29 export type { Body, Hull, MoveTuning, Wish } from "./physics.ts";
30
2531 /** A grid of cells of any type: cell-to-world arithmetic, greedy meshing, A*. */
2632 export { cellAt, colOf, findPath, inside, mergeSolid, rowOf, worldOf } from "./grid.ts";
2733 export type { Grid } from "./grid.ts";
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.d819b38eea.js added +58

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

View file
static/range.8657cc59c6.js added +1

Diff not shown — a line 12,533 characters wide (limit 2,000). +1

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs