Split The code is public; the app answers only to the crew.
A 64-unit crate is 7 units too tall to jump onto: nothing on either map can be climbed, and crouch-jumping does nothing #234
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/197-a-64-unit-crate-is-7-units-too-tall-to-j → main 1 commit fork
6 files
changed +199
−6
README.md +10 −1
| @@ -44,7 +44,16 @@ wants one, but the running server never reads it. | ||
| 44 | 44 | ## What is real and what is not |
| 45 | 45 | |
| 46 | 46 | - **Real.** The game: WebGL2 written by hand, a fixed 64 Hz simulation, Source-style |
| 47 | movement (ground friction, air acceleration, an 18-unit step, strafe jumping), | |
| 47 | movement (ground friction, air acceleration, an 18-unit step, strafe jumping, | |
| 48 | and the **crouch-jump**: a jump peaks at 57 units, 7 short of the 64-unit | |
| 49 | crates both maps are full of, but duck in the air and the legs tuck up under | |
| 50 | a head that stays put, so the feet clear 75 and you land on top — jump | |
| 51 | without ducking and the crate is still in your way. The jump itself is no | |
| 52 | higher; standing back up in the air is refused while the legs have nowhere | |
| 53 | to go, the same way it is under a low ceiling. **The bots do not jump**, so | |
| 54 | a crate top is a place a bot will not follow you to: one sent after | |
| 55 | something up there, the bomb included, walks to the foot of the crate and | |
| 56 | waits), | |
| 48 | 57 | CS damage with range falloff, armour and hitgroups, a knife that pays for a flank |
| 49 | 58 | — 55 in the chest, 180 in the back, where "the back" is CS's own test of standing |
| 50 | 59 | within 61.65° of straight behind someone and armour stops the same share either |
src/client/combat.test.ts +24
| @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; | ||
| 2 | 2 | import { applyHit, hitboxes, traceBullet, type Actor } from "./combat.ts"; |
| 3 | 3 | import { MAT, type Box } from "./map.ts"; |
| 4 | 4 | import type { Vec3 } from "./math.ts"; |
| 5 | import { HULL, hullHeight, move, type Body, type Wish } from "./physics.ts"; | |
| 5 | 6 | import { WEAPONS, type Weapon } from "./weapons.ts"; |
| 6 | 7 | |
| 7 | 8 | const actor = (over: Partial<Actor> = {}): Actor => ({ |
| @@ -154,3 +155,26 @@ test("a rifle in the back is still a rifle", () => { | ||
| 154 | 155 | expect(behind.result.damage).toBe(ahead.result.damage); |
| 155 | 156 | expect(behind.result.killed).toBe(false); |
| 156 | 157 | }); |
| 158 | ||
| 159 | // --- a crouch-jumper ------------------------------------------------------- | |
| 160 | ||
| 161 | test("a crouch-jumper's head is where a bullet finds it: the hitboxes lift with the feet", () => { | |
| 162 | const floor: Box = { min: [-1000, -1000, -32], max: [1000, 1000, 0], mat: MAT.FLOOR }; | |
| 163 | const body: Body = { pos: [500, 0, 0], vel: [0, 0, 0], onGround: true, ducking: false, duckAmount: 0, landSpeed: 0 }; | |
| 164 | const still: Wish = { forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: 250 }; | |
| 165 | move(body, { ...still, jump: true }, 1 / 64, [floor]); | |
| 166 | for (let i = 0; i < 20; i++) move(body, { ...still, duck: true }, 1 / 64, [floor]); | |
| 167 | expect(body.duckAmount).toBe(1); | |
| 168 | expect(body.onGround).toBe(false); | |
| 169 | const target = actor({ pos: [...body.pos] as Vec3, duckAmount: body.duckAmount }); | |
| 170 | const top = body.pos[2] + hullHeight(body); | |
| 171 | const head = hitboxes(target).find((p) => p.group === "head") as { max: Vec3 }; | |
| 172 | expect(head.max[2]).toBeCloseTo(top, 6); | |
| 173 | // A shot a hand under the top of the hull is a headshot; one where the head | |
| 174 | // of a crouched body standing on the floor would be goes under the tucked feet. | |
| 175 | const high = traceBullet([0, 0, top - 3], [1000, 0, top - 3], [], [target], 0, WEAPONS.ak47); | |
| 176 | expect(high.kind === "actor" && high.group).toBe("head"); | |
| 177 | const low = traceBullet([0, 0, HULL.duck - 3], [1000, 0, HULL.duck - 3], [], [target], 0, WEAPONS.ak47); | |
| 178 | expect(body.pos[2]).toBeGreaterThan(HULL.duck); | |
| 179 | expect(low.kind).toBe("none"); | |
| 180 | }); |
src/client/map.test.ts +22
| @@ -3,6 +3,7 @@ import { | ||
| 3 | 3 | buildMap, CELL, cellAt, colOf, DE_CINDER, DE_FORGE, DEFAULT_MAP, findPath, MAP_NAMES, mapFor, MAPS, |
| 4 | 4 | placeAt, placeLabel, rowOf, siteAt, walkable, worldOf, type Layout, type MapDef, |
| 5 | 5 | } from "./map.ts"; |
| 6 | import type { Vec3 } from "./math.ts"; | |
| 6 | 7 | |
| 7 | 8 | /** |
| 8 | 9 | * Every structural claim below holds for every map in the catalogue, not just |
| @@ -308,3 +309,24 @@ test("placeLabel says letters as letters and leaves every other word alone", () | ||
| 308 | 309 | expect(placeLabel("lower tunnel")).toBe("lower tunnel"); |
| 309 | 310 | expect(placeLabel("catwalk")).toBe("catwalk"); |
| 310 | 311 | }); |
| 312 | ||
| 313 | test("a goal on top of any crate paths to the floor beside it, not to nothing", () => { | |
| 314 | for (const map of [DE_FORGE, DE_CINDER]) { | |
| 315 | let crates = 0; | |
| 316 | for (let row = 0; row < map.gridH; row++) { | |
| 317 | for (let col = 0; col < map.gridW; col++) { | |
| 318 | const c = cellAt(map, col, row); | |
| 319 | if (c !== "x" && c !== "X") continue; | |
| 320 | crates++; | |
| 321 | const top = worldOf(map, col, row, c === "x" ? 64 : 128); | |
| 322 | const path = findPath(map, map.spawns.t[0] as Vec3, top); | |
| 323 | expect(path.length).toBeGreaterThan(0); | |
| 324 | const end = path[path.length - 1] as Vec3; | |
| 325 | const endCol = colOf(map, end[0]), endRow = rowOf(map, end[1]); | |
| 326 | expect(walkable(map, endCol, endRow)).toBe(true); | |
| 327 | expect(Math.max(Math.abs(endCol - col), Math.abs(endRow - row))).toBeLessThanOrEqual(2); | |
| 328 | } | |
| 329 | } | |
| 330 | expect(crates).toBeGreaterThan(0); | |
| 331 | } | |
| 332 | }); |
src/client/map.ts +27
| @@ -358,12 +358,39 @@ export function placeAt(map: MapDef, pos: Vec3): string | null { | ||
| 358 | 358 | |
| 359 | 359 | // --- pathfinding ---------------------------------------------------------- |
| 360 | 360 | |
| 361 | /** | |
| 362 | * A goal on top of a crate — a person crouch-jumped up there, or died there | |
| 363 | * with the bomb — is a place a bot cannot walk to and does not jump to. The | |
| 364 | * nearest floor beside it is the next best thing: the foot of the crate, | |
| 365 | * rather than a path of nothing and a bot standing still. Null when the goal | |
| 366 | * is not on a crate, or the crate has no floor within two cells of it. | |
| 367 | */ | |
| 368 | function besideCrate(map: MapDef, to: Vec3): Vec3 | null { | |
| 369 | const col = colOf(map, to[0]), row = rowOf(map, to[1]); | |
| 370 | const c = cellAt(map, col, row); | |
| 371 | if (c !== "x" && c !== "X") return null; | |
| 372 | let best: Vec3 | null = null, bestDistance = Infinity; | |
| 373 | for (let ring = 1; ring <= 2 && best === null; ring++) { | |
| 374 | for (let dr = -ring; dr <= ring; dr++) { | |
| 375 | for (let dc = -ring; dc <= ring; dc++) { | |
| 376 | if (Math.max(Math.abs(dc), Math.abs(dr)) !== ring || !walkable(map, col + dc, row + dr)) continue; | |
| 377 | const at = worldOf(map, col + dc, row + dr); | |
| 378 | const d = Math.hypot(at[0] - to[0], at[1] - to[1]); | |
| 379 | if (d < bestDistance) { best = at; bestDistance = d; } | |
| 380 | } | |
| 381 | } | |
| 382 | } | |
| 383 | return best; | |
| 384 | } | |
| 385 | ||
| 361 | 386 | /** |
| 362 | 387 | * A* over the walkable cells, eight ways, no corner-cutting. de_forge's grid is |
| 363 | 388 | * 1936 cells; a full search is well under a millisecond, and bots only ask for |
| 364 | 389 | * one when their goal changes. |
| 365 | 390 | */ |
| 366 | 391 | export function findPath(map: MapDef, from: Vec3, to: Vec3): Vec3[] { |
| 392 | const beside = besideCrate(map, to); | |
| 393 | if (beside !== null) return findPath(map, from, beside); | |
| 367 | 394 | const start = rowOf(map, from[1]) * map.gridW + colOf(map, from[0]); |
| 368 | 395 | const goal = rowOf(map, to[1]) * map.gridW + colOf(map, to[0]); |
| 369 | 396 | if (start === goal) return [to]; |
src/client/physics.test.ts +94 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import type { Box } from "./map.ts"; |
| 3 | 3 | import type { Vec3 } from "./math.ts"; |
| 4 | import { accelerate, airAccelerate, fallDamage, HULL, MOVE, move, type Body, type Wish } from "./physics.ts"; | |
| 4 | import { accelerate, airAccelerate, fallDamage, HULL, hullHeight, legLift, MOVE, move, stuck, type Body, type Wish } from "./physics.ts"; | |
| 5 | 5 | |
| 6 | 6 | const ground: Box = { min: [-1000, -1000, -32], max: [1000, 1000, 0], mat: 0 }; |
| 7 | 7 | |
| @@ -176,3 +176,96 @@ test("fall damage is exactly its formula past the threshold", () => { | ||
| 176 | 176 | expect(fallDamage(579.99)).toBe(0); |
| 177 | 177 | expect(fallDamage(680)).toBeCloseTo(8.88, 5); |
| 178 | 178 | }); |
| 179 | ||
| 180 | // --- the crouch-jump -------------------------------------------------------- | |
| 181 | ||
| 182 | /** A 64-unit crate, its near face 100 units ahead of a body standing at the origin. */ | |
| 183 | const crate: Box = { min: [100, -200, 0], max: [400, 200, 64], mat: 2 }; | |
| 184 | ||
| 185 | /** | |
| 186 | * A run-up from 200 units back, a jump at the origin, and forward held until | |
| 187 | * the body is on the ground again — with duck held from the jump on, or not. | |
| 188 | * Returns the body, and whether any tick left it inside a box. | |
| 189 | */ | |
| 190 | const leap = (duck: boolean, boxes: Box[]): { b: Body; clipped: boolean } => { | |
| 191 | const b = body([-200, 0, 0]); | |
| 192 | let clipped = false; | |
| 193 | while (b.pos[0] < 0) move(b, wish({ forward: 1 }), 1 / 64, boxes); | |
| 194 | move(b, wish({ forward: 1, jump: true }), 1 / 64, boxes); | |
| 195 | for (let i = 0; i < 128 && !b.onGround; i++) { | |
| 196 | move(b, wish({ forward: 1, duck }), 1 / 64, boxes); | |
| 197 | if (stuck(b.pos, hullHeight(b), boxes)) clipped = true; | |
| 198 | } | |
| 199 | return { b, clipped }; | |
| 200 | }; | |
| 201 | ||
| 202 | test("a crouch-jump lands on top of a 64-unit crate", () => { | |
| 203 | const { b, clipped } = leap(true, [ground, crate]); | |
| 204 | expect(b.onGround).toBe(true); | |
| 205 | expect(b.pos[2]).toBe(64); | |
| 206 | expect(b.pos[0]).toBeGreaterThan(crate.min[0]); | |
| 207 | expect(clipped).toBe(false); | |
| 208 | }); | |
| 209 | ||
| 210 | test("a plain jump does not: the crate is still cover for anyone who does not know the trick", () => { | |
| 211 | const { b } = leap(false, [ground, crate]); | |
| 212 | expect(b.onGround).toBe(true); | |
| 213 | expect(b.pos[2]).toBe(0); | |
| 214 | expect(b.pos[0]).toBeLessThan(crate.min[0]); | |
| 215 | }); | |
| 216 | ||
| 217 | test("the crouch-jump is the legs, not the jump: velocity and the head's apex are unchanged", () => { | |
| 218 | const apex = (duck: boolean): { head: number; vz: number } => { | |
| 219 | const b = body(); | |
| 220 | move(b, wish({ jump: true }), 1 / 64, [ground]); | |
| 221 | const vz = b.vel[2]; | |
| 222 | let head = 0; | |
| 223 | for (let i = 0; i < 64; i++) { | |
| 224 | move(b, wish({ duck }), 1 / 64, [ground]); | |
| 225 | head = Math.max(head, b.pos[2] + hullHeight(b)); | |
| 226 | } | |
| 227 | return { head, vz }; | |
| 228 | }; | |
| 229 | const plain = apex(false), tucked = apex(true); | |
| 230 | expect(tucked.vz).toBe(plain.vz); | |
| 231 | // The top of the hull goes exactly as high either way; only the feet rise. | |
| 232 | expect(tucked.head).toBeCloseTo(plain.head, 6); | |
| 233 | }); | |
| 234 | ||
| 235 | test("the feet only lift in the air: ducking on the ground lowers the head, not the floor", () => { | |
| 236 | const b = body(); | |
| 237 | run(b, wish({ duck: true }), 1, [ground]); | |
| 238 | expect(b.pos[2]).toBe(0); | |
| 239 | expect(b.duckAmount).toBe(1); | |
| 240 | expect(legLift(true, HULL.stand, HULL.duck)).toBe(0); | |
| 241 | expect(legLift(false, HULL.stand, HULL.duck)).toBe(HULL.stand - HULL.duck); | |
| 242 | }); | |
| 243 | ||
| 244 | test("a crouch-jump into a 72-unit gap never leaves the body inside the wall or the floor", () => { | |
| 245 | // A slot in a wall: a 64-unit sill, 72 units of air, and the wall again. | |
| 246 | const sill: Box = { min: [100, -200, 0], max: [400, 200, 64], mat: 1 }; | |
| 247 | const lintel: Box = { min: [100, -200, 136], max: [400, 200, 400], mat: 1 }; | |
| 248 | const boxes = [ground, sill, lintel]; | |
| 249 | const { b, clipped } = leap(true, boxes); | |
| 250 | expect(clipped).toBe(false); | |
| 251 | expect(b.onGround).toBe(true); | |
| 252 | expect(b.pos[2]).toBe(64); | |
| 253 | // Letting go of duck in there stands up only as far as the slot allows. | |
| 254 | for (let i = 0; i < 64; i++) { | |
| 255 | move(b, wish(), 1 / 64, boxes); | |
| 256 | expect(stuck(b.pos, hullHeight(b), boxes)).toBe(false); | |
| 257 | } | |
| 258 | }); | |
| 259 | ||
| 260 | test("standing up in the air is refused while the legs would go through the floor", () => { | |
| 261 | const b = body([0, 0, 6]); | |
| 262 | b.onGround = false; | |
| 263 | b.duckAmount = 1; | |
| 264 | // Falling from six units up: stretching out would put the feet 12 under the floor. | |
| 265 | for (let i = 0; i < 64; i++) { | |
| 266 | move(b, wish(), 1 / 64, [ground]); | |
| 267 | expect(stuck(b.pos, hullHeight(b), [ground])).toBe(false); | |
| 268 | } | |
| 269 | expect(b.onGround).toBe(true); | |
| 270 | expect(b.pos[2]).toBe(0); | |
| 271 | }); |
src/client/physics.ts +22 −4
| @@ -5,7 +5,8 @@ | ||
| 5 | 5 | * accelerate in the air makes you faster, and why strafe-jumping works here. |
| 6 | 6 | * |
| 7 | 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. | |
| 8 | * a 32×32×72 hull (54 ducked), an 18-unit step, a 57-unit jump — 75 with the | |
| 9 | * legs tucked, which is what puts a 64-unit crate in reach of a crouch-jump. | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | import type { Box } from "./map.ts"; |
| @@ -140,6 +141,18 @@ export function groundUnder(pos: Vec3, height: number, boxes: Box[], reach: numb | ||
| 140 | 141 | return best; |
| 141 | 142 | } |
| 142 | 143 | |
| 144 | /** | |
| 145 | * How far the feet move when the hull changes height: on the ground the floor | |
| 146 | * holds the feet and the head is what drops; in the air nothing holds them, so | |
| 147 | * a duck pulls the legs up under a head that stays where it was. That is the | |
| 148 | * crouch-jump — it is the legs, not the gravity, so a standing jump still | |
| 149 | * peaks at 57 units, but tucked feet clear a 64-unit crate by the 18 units the | |
| 150 | * hull gave up. Standing back up in the air pushes the legs down again, and | |
| 151 | * that is refused like standing under a ceiling is when there is no room. | |
| 152 | */ | |
| 153 | export const legLift = (onGround: boolean, fromHeight: number, toHeight: number): number => | |
| 154 | onGround ? 0 : fromHeight - toHeight; | |
| 155 | ||
| 143 | 156 | /** |
| 144 | 157 | * One movement tick: friction or gravity, acceleration, then a move that |
| 145 | 158 | * slides along walls and steps up onto anything 18 units or less. |
| @@ -154,11 +167,16 @@ export function move(body: Body, wish: Wish, dt: number, boxes: Box[]): void { | ||
| 154 | 167 | 0, |
| 155 | 168 | 1, |
| 156 | 169 | ); |
| 157 | // Standing up is refused while something is over your head. | |
| 170 | // Standing up is refused while something is over your head — or, in the | |
| 171 | // air, under your feet. Ducking never is: the new hull is inside the old. | |
| 158 | 172 | const standing = next < body.duckAmount; |
| 159 | const probe: Vec3 = [body.pos[0], body.pos[1], body.pos[2]]; | |
| 160 | 173 | const nextHeight = HULL.stand + (HULL.duck - HULL.stand) * next; |
| 161 | if (!standing || !stuck(probe, nextHeight, boxes)) body.duckAmount = next; | |
| 174 | const lift = legLift(body.onGround, hullHeight(body), nextHeight); | |
| 175 | const probe: Vec3 = [body.pos[0], body.pos[1], body.pos[2] + lift]; | |
| 176 | if (!standing || !stuck(probe, nextHeight, boxes)) { | |
| 177 | body.duckAmount = next; | |
| 178 | body.pos[2] += lift; | |
| 179 | } | |
| 162 | 180 | body.ducking = body.duckAmount > 0.5; |
| 163 | 181 | |
| 164 | 182 | const yawRad = (wish.yaw * Math.PI) / 180; |