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

Merged opened this

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

Merged

vesper/cs:vesper/197-a-64-unit-crate-is-7-units-too-tall-to-j → main 1 commit fork

  • Merged. vesper/197-a-64-unit-crate-is-7-units-too-tall-to-j was merged into main as 566a37c.

Review

Sign in to review this change.
6 files changed +199 −6
README.md +10 −1
@@ -44,7 +44,16 @@ wants one, but the running server never reads it.
4444 ## What is real and what is not
4545
4646 - **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),
4857 CS damage with range falloff, armour and hitgroups, a knife that pays for a flank
4958 — 55 in the chest, 180 in the back, where "the back" is CS's own test of standing
5059 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";
22 import { applyHit, hitboxes, traceBullet, type Actor } from "./combat.ts";
33 import { MAT, type Box } from "./map.ts";
44 import type { Vec3 } from "./math.ts";
5 import { HULL, hullHeight, move, type Body, type Wish } from "./physics.ts";
56 import { WEAPONS, type Weapon } from "./weapons.ts";
67
78 const actor = (over: Partial<Actor> = {}): Actor => ({
@@ -154,3 +155,26 @@ test("a rifle in the back is still a rifle", () => {
154155 expect(behind.result.damage).toBe(ahead.result.damage);
155156 expect(behind.result.killed).toBe(false);
156157 });
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 {
33 buildMap, CELL, cellAt, colOf, DE_CINDER, DE_FORGE, DEFAULT_MAP, findPath, MAP_NAMES, mapFor, MAPS,
44 placeAt, placeLabel, rowOf, siteAt, walkable, worldOf, type Layout, type MapDef,
55 } from "./map.ts";
6 import type { Vec3 } from "./math.ts";
67
78 /**
89 * 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", ()
308309 expect(placeLabel("lower tunnel")).toBe("lower tunnel");
309310 expect(placeLabel("catwalk")).toBe("catwalk");
310311 });
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 {
358358
359359 // --- pathfinding ----------------------------------------------------------
360360
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
361386 /**
362387 * A* over the walkable cells, eight ways, no corner-cutting. de_forge's grid is
363388 * 1936 cells; a full search is well under a millisecond, and bots only ask for
364389 * one when their goal changes.
365390 */
366391 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);
367394 const start = rowOf(map, from[1]) * map.gridW + colOf(map, from[0]);
368395 const goal = rowOf(map, to[1]) * map.gridW + colOf(map, to[0]);
369396 if (start === goal) return [to];
src/client/physics.test.ts +94 −1
@@ -1,7 +1,7 @@
11 import { expect, test } from "bun:test";
22 import type { Box } from "./map.ts";
33 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";
55
66 const ground: Box = { min: [-1000, -1000, -32], max: [1000, 1000, 0], mat: 0 };
77
@@ -176,3 +176,96 @@ test("fall damage is exactly its formula past the threshold", () => {
176176 expect(fallDamage(579.99)).toBe(0);
177177 expect(fallDamage(680)).toBeCloseTo(8.88, 5);
178178 });
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 @@
55 * accelerate in the air makes you faster, and why strafe-jumping works here.
66 *
77 * 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.
910 */
1011
1112 import type { Box } from "./map.ts";
@@ -140,6 +141,18 @@ export function groundUnder(pos: Vec3, height: number, boxes: Box[], reach: numb
140141 return best;
141142 }
142143
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
143156 /**
144157 * One movement tick: friction or gravity, acceleration, then a move that
145158 * 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 {
154167 0,
155168 1,
156169 );
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.
158172 const standing = next < body.duckAmount;
159 const probe: Vec3 = [body.pos[0], body.pos[1], body.pos[2]];
160173 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 }
162180 body.ducking = body.duckAmount > 0.5;
163181
164182 const yawRad = (wish.yaw * Math.PI) / 180;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs