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 #120
torque/cs-engine:torque/62-physics-moves-behind-the-door-replayed-o → main 2 commits fork
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. | ||
| 112 | 112 | |
| 113 | 113 | - `src/engine/` — the part a second game could use, and the rule is that it never |
| 114 | 114 | 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 | |
| 116 | 120 | cells of any type, the cell-to-world arithmetic, greedy meshing and A* with no |
| 117 | 121 | corner-cutting — which cells are solid or walkable arrive as predicates, so it |
| 118 | 122 | 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. | ||
| 159 | 163 | `length`, `length2d`, `dist`, `dist2d`, `normalize`, `lerp`, `clamp`, `rad`, |
| 160 | 164 | `deg`, `angleDelta`, `angleVectors`, `mat4`, `multiply`, `perspective`, |
| 161 | 165 | `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` | |
| 163 | 169 | `inside`, `cellAt`, `worldOf`, `colOf`, `rowOf`, `mergeSolid`, `findPath` and |
| 164 | 170 | `Grid`; from `mesh` `builder`, `addBox`, `finish`, `quadMesh`, `Builder` and |
| 165 | 171 | `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. | ||
| 193 | 199 | second of elapsed time is sixty ticks however the frames are cut. |
| 194 | 200 | - `src/client/` — the game, in modules named for one thing each: `map` (de_forge |
| 195 | 201 | 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), | |
| 197 | 205 | `world` (the simulation), |
| 198 | 206 | `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`, |
| 199 | 207 | `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. | ||
| 293 | 301 | is imported by the entry, so the bundle has not grown by a byte; `level.test.ts` |
| 294 | 302 | proves the shell has no seam a bullet can leave through and `paint.test.ts` |
| 295 | 303 | 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. | |
| 297 | 306 | `src/independence.test.ts` reads every source in here and fails on an import |
| 298 | 307 | that leaves those three places; `binding.test.ts`, |
| 299 | 308 | `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 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Box } from "./map.ts"; |
| 8 | 8 | import { dist, rayBox, traceWorld, type Vec3 } from "../engine/index.ts"; |
| 9 | import { HULL } from "./physics.ts"; | |
| 9 | import { CS_HULL } from "./movement.ts"; | |
| 10 | 10 | import { computeDamage, type Hitgroup, type Weapon } from "./weapons.ts"; |
| 11 | 11 | |
| 12 | 12 | export type Actor = { |
| @@ -32,7 +32,7 @@ const PARTS: [Hitgroup, number, number, number][] = [ | ||
| 32 | 32 | ]; |
| 33 | 33 | |
| 34 | 34 | 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; | |
| 36 | 36 | return PARTS.map(([group, lo, hi, w]) => ({ |
| 37 | 37 | group, |
| 38 | 38 | 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 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { clamp } from "../engine/index.ts"; |
| 14 | import { MOVE } from "./physics.ts"; | |
| 14 | import { CS_MOVE } from "./movement.ts"; | |
| 15 | 15 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 16 | 16 | import { currentWeapon, useProgress, type World } from "./world.ts"; |
| 17 | 17 | |
| @@ -61,7 +61,7 @@ export function drawHud( | ||
| 61 | 61 | if (me.alive) { |
| 62 | 62 | const speed = Math.hypot(me.vel[0], me.vel[1]); |
| 63 | 63 | const spread = inaccuracy(weapon, { |
| 64 | moving: clamp(speed / MOVE.maxSpeed, 0, 1), | |
| 64 | moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1), | |
| 65 | 65 | inAir: !me.onGround, |
| 66 | 66 | ducking: me.duckAmount > 0.5, |
| 67 | 67 | 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 @@ | ||
| 14 | 14 | |
| 15 | 15 | import * as map from "./map.ts"; |
| 16 | 16 | import { angleDelta, clamp, dist, type Vec3 } from "../engine/index.ts"; |
| 17 | import { HULL } from "./physics.ts"; | |
| 17 | import { CS_HULL } from "./movement.ts"; | |
| 18 | 18 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 19 | 19 | import type { Player, World } from "./world.ts"; |
| 20 | 20 | import { aimAt, bodyCentre, currentWeapon, eyePos } from "./world.ts"; |
| @@ -69,7 +69,7 @@ export function observe( | ||
| 69 | 69 | theirHealth = target.health / 100; |
| 70 | 70 | theirSpeed = Math.hypot(target.vel[0], target.vel[1]) / 250; |
| 71 | 71 | // 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]); | |
| 73 | 73 | theirFacing = Math.cos(angleDelta(target.yaw, back.yaw) * rad); |
| 74 | 74 | } |
| 75 | 75 |
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"; | ||
| 14 | 14 | import { existsSync, readFileSync } from "node:fs"; |
| 15 | 15 | import { join } from "node:path"; |
| 16 | 16 | import type { Camera } from "../engine/index.ts"; |
| 17 | import { HULL } from "./physics.ts"; | |
| 17 | import { CS_HULL } from "./movement.ts"; | |
| 18 | 18 | import { buildScene, createScene, LINE_CAPACITY, SKY } from "./scene.ts"; |
| 19 | 19 | import { SHORT } from "./match.ts"; |
| 20 | 20 | 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", ( | ||
| 69 | 69 | other.duckAmount = 1; |
| 70 | 70 | buildScene(w, camera(), scene); |
| 71 | 71 | 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); | |
| 73 | 73 | expect(ducking?.scale).toBeCloseTo(54 / 72, 10); |
| 74 | 74 | }); |
| 75 | 75 |
src/client/scene.ts +6 −1
| @@ -17,11 +17,16 @@ | ||
| 17 | 17 | * pool. The view model object is kept even on the frames where there is none. |
| 18 | 18 | */ |
| 19 | 19 | |
| 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 | ======= | |
| 20 | 24 | import { |
| 21 | 25 | clamp, createScene as createPooledScene, nextInstance, pushLine, resetScene, |
| 22 | 26 | type Camera, type Scene as Frame, type Vec3, |
| 23 | 27 | } from "../engine/index.ts"; |
| 24 | 28 | import { HULL, hullHeight } from "./physics.ts"; |
| 29 | >>>>>>> origin/main | |
| 25 | 30 | import { WEAPONS } from "./weapons.ts"; |
| 26 | 31 | import type { World } from "./world.ts"; |
| 27 | 32 | |
| @@ -131,7 +136,7 @@ export function buildScene(world: World, camera: Camera, into: Scene): Scene { | ||
| 131 | 136 | for (const p of world.players) { |
| 132 | 137 | // The eye is inside your own head: drawing it would fill the screen. |
| 133 | 138 | 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); | |
| 135 | 140 | } |
| 136 | 141 | if (world.bomb.state === "planted" || world.bomb.state === "dropped") |
| 137 | 142 | place(into, "bomb", world.bomb.pos, 0, 1, WHITE); |
src/client/world.ts +10 −7
| @@ -9,9 +9,12 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { applyHit, traceBullet, type Actor } from "./combat.ts"; |
| 11 | 11 | 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"; | |
| 13 | 16 | 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"; | |
| 15 | 18 | import { |
| 16 | 19 | ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset, |
| 17 | 20 | type WeaponId, type Weapon, |
| @@ -160,7 +163,7 @@ export const teammates = (world: World, team: Team): Player[] => world.players.f | ||
| 160 | 163 | export const alive = (world: World, team: Team): Player[] => |
| 161 | 164 | world.players.filter((p) => p.team === team && p.alive); |
| 162 | 165 | |
| 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)]; | |
| 164 | 167 | |
| 165 | 168 | function say(world: World, text: string): void { |
| 166 | 169 | world.messages.push({ at: world.time, text }); |
| @@ -322,7 +325,7 @@ export function shoot(world: World, p: Player): boolean { | ||
| 322 | 325 | |
| 323 | 326 | const speed = Math.hypot(p.vel[0], p.vel[1]); |
| 324 | 327 | const spread = inaccuracy(w, { |
| 325 | moving: clamp(speed / MOVE.maxSpeed, 0, 1), | |
| 328 | moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1), | |
| 326 | 329 | inAir: !p.onGround, |
| 327 | 330 | ducking: p.duckAmount > 0.5, |
| 328 | 331 | scoped: p.scoped, |
| @@ -453,9 +456,9 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 453 | 456 | jump: input.jump && !frozen, |
| 454 | 457 | duck: input.duck, |
| 455 | 458 | walk: input.walk || p.scoped, |
| 456 | maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed), | |
| 459 | maxSpeed: Math.min(CS_MOVE.maxSpeed, w.carrySpeed), | |
| 457 | 460 | }; |
| 458 | move(p, wish, dt, world.boxes); | |
| 461 | move(p, wish, dt, world.boxes, CS_MOVE, CS_HULL); | |
| 459 | 462 | finishReload(world, p); |
| 460 | 463 | if (input.reload) reload(world, p); |
| 461 | 464 | 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 } = | ||
| 537 | 540 | }; |
| 538 | 541 | |
| 539 | 542 | /** 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: | ||
| 20 | 20 | code block names the file it came from on its first line, and that file is |
| 21 | 21 | always the example. |
| 22 | 22 | |
| 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 | ======= | |
| 23 | 31 | `guide.test.ts` holds all four: every name the door exports is named here, every |
| 24 | 32 | bare engine name here is exported, every line of every code block is a line of |
| 25 | 33 | 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 | ||
| 27 | 35 | page has no paragraph for — the physics of issue #3, one day — that test fails |
| 28 | 36 | until the paragraph is written. That is the point. A name arrives with a page or |
| 29 | 37 | it does not arrive. |
| 38 | >>>>>>> origin/main | |
| 30 | 39 | |
| 31 | 40 | ## What a game brings |
| 32 | 41 | |
| @@ -124,6 +133,13 @@ and one plate, and never draws or sounds either. | ||
| 124 | 133 | |
| 125 | 134 | Every number that decides how the game feels is the game's: eye height, hull |
| 126 | 135 | 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 | ======= | |
| 127 | 143 | no constants of its own beyond the loop's two safety clamps. Put them at the top |
| 128 | 144 | of a module, named, and a reader can tune the game without reading the engine. |
| 129 | 145 | 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: | ||
| 133 | 149 | /** The tuning, all of it: metres per second, degrees per second, metres, metres. */ |
| 134 | 150 | const SPEED = 4, TURN = 120, EYE = 1.6, REACH = 40; |
| 135 | 151 | |
| 152 | >>>>>>> origin/main | |
| 136 | 153 | |
| 137 | 154 | ## What is behind the door |
| 138 | 155 | |
| @@ -170,6 +187,38 @@ A shot in the example is one ray and one box, and it is the whole of hitscan: | ||
| 170 | 187 | if (rayBox(world.pos, to, world.target.min, world.target.max) === null) return world; |
| 171 | 188 | |
| 172 | 189 | |
| 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 | ||
| 173 | 222 | ### grid |
| 174 | 223 | |
| 175 | 224 | 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. | ||
| 408 | 457 | | mesh recipes | `src/client/models.ts` | `src/range/level.ts` — a room and a plate | |
| 409 | 458 | | texture recipes | `src/client/materials.ts` | `src/range/paint.ts` — three 64² layers | |
| 410 | 459 | | 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 | ======= | |
| 411 | 464 | | renderer assets | an atlas and eight meshes, from `src/client/main.ts` | an atlas and two meshes, from `src/range/scene.ts` | |
| 412 | 465 | | a frame as data | `src/client/scene.ts` | `src/range/scene.ts` | |
| 413 | 466 | | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays | |
| 414 | 467 | | the tuning | `src/client/physics.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` | |
| 468 | >>>>>>> origin/main | |
| 415 | 469 | |
| 416 | 470 | ## What the engine deliberately does not know |
| 417 | 471 |
src/engine/boundary.test.ts +19 −1
| @@ -186,7 +186,7 @@ test("the door rule is about the door and nothing else", () => { | ||
| 186 | 186 | expect(reachesPastTheDoor("src/client/world.ts", "../engine/math.ts")).toMatch(/door/); |
| 187 | 187 | expect(reachesPastTheDoor("src/rl/arena.ts", "../engine/sub/deep.ts")).toMatch(/door/); |
| 188 | 188 | 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(); | |
| 190 | 190 | expect(reachesPastTheDoor("src/client/world.ts", "bun:test")).toBeNull(); |
| 191 | 191 | expect(reachesPastTheDoor("scripts/build.ts", "../src/engine/trace.ts")).toMatch(/door/); |
| 192 | 192 | // 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", ( | ||
| 237 | 237 | * copy of the surface, read twice, rather than two that drift. |
| 238 | 238 | */ |
| 239 | 239 | 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 | ======= | |
| 240 | 257 | "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera", "Controls", "Ctor", |
| 241 | 258 | "FakeCanvas", "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", |
| 242 | 259 | "Frames", |
| @@ -252,6 +269,7 @@ export const SURFACE = [ | ||
| 252 | 269 | "modelMatrix", "mul", "multiply", "nextInstance", "noise", "normalize", "overlay", "paintAtlas", |
| 253 | 270 | "perspective", "pushLine", |
| 254 | 271 | "quadMesh", "rad", "rayBox", "resetScene", "rng", "rowOf", "scale", "segmentLength", "sub", |
| 272 | >>>>>>> origin/main | |
| 255 | 273 | "traceWorld", |
| 256 | 274 | "v3", "viewMatrix", "visible", "worldOf", |
| 257 | 275 | ]; |
src/engine/index.ts +6
| @@ -22,6 +22,12 @@ export type { Mat4, Vec3 } from "./math.ts"; | ||
| 22 | 22 | export { rayBox, segmentLength, traceWorld, visible } from "./trace.ts"; |
| 23 | 23 | export type { Box, Hit } from "./trace.ts"; |
| 24 | 24 | |
| 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 | ||
| 25 | 31 | /** A grid of cells of any type: cell-to-world arithmetic, greedy meshing, A*. */ |
| 26 | 32 | export { cellAt, colOf, findPath, inside, mergeSolid, rowOf, worldOf } from "./grid.ts"; |
| 27 | 33 | 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 filestatic/range.8657cc59c6.js added +1
Diff not shown — a line 12,533 characters wide (limit 2,000). +1
View file
new above
offline