Split The code is public; the app answers only to the crew.

Players that read as players: legs that swing on the stride the footsteps already count #122

Merged opened this

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

Merged

vesper/cs:vesper/88-players-that-read-as-players-legs-that-s → main 5 commits fork 3 threads · 3 unresolved

  • Merged. vesper/88-players-that-read-as-players-legs-that-s was merged into main as 979c388.

Review

Sign in to review this change.
10 files changed +1016 −40
README.md +20 −1
@@ -100,7 +100,22 @@ wants one, but the running server never reads it.
100100 view and fades with distance — a shot or a footstep to your left plays from
101101 the left channel, to your right the right — and a running player who is not
102102 walking, ducking or airborne leaves footsteps this way too, so someone
103 closing in from behind is something you hear before you see. A planted bomb
103 closing in from behind is something you hear before you see. **Legs swing on
104 that same stride**: the ground a player has covered is the phase the other
105 nine bodies are posed from, so an enemy crossing a doorway reads as someone
106 running and a player at a dead stop is still. One stride of ground is one
107 *step* — a cycle of legs is two of them, right foot then left — so the legs
108 alternate at exactly the cadence you hear and the swing is widest at the
109 footfall. It is one draw each, hinged in the vertex shader, and the
110 simulation neither sets it nor reads it — a walker's legs move on a stride
111 nobody hears. The gun rides the upper body, so a running player's muzzle
112 flash comes off the end of the barrel their lean has moved rather than where
113 the gun was modelled. The pose is a picture and nothing else: hitboxes are
114 axis-aligned on a player's position and know nothing about it, so a sprinting
115 enemy's swung foot is drawn further out than the box a bullet finds it in.
116 The head is the exception, because the head is the shot worth taking — it is
117 left out of the lean, so what you aim at and what you hit are the same box.
118 A planted bomb
104119 beeps: about once a second right after the plant, quickening to a few a
105120 second — and rising in pitch — as the fuse runs out, so a CT can walk to the
106121 site by ear with no map open. The plant, the defuse, the detonation and a
@@ -379,6 +394,10 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
379394 HUD's minimap draws, and nothing it draws them with),
380395 `project` (a world point as a screen point, from the same camera the
381396 renderer draws with, or null when there is no pixel for it),
397 `stride` (what a body's limbs are doing, as three angles: the leg swing, the
398 free arm against it and the lean over the hips, off the ground a player has
399 covered — one footfall per step of it, the same step the ear is counting —
400 a picture, with no reading of it in the simulation),
382401 `summary` (the end of
383402 the match as numbers), `ears` (a sound's pan and volume, as plain data),
384403 `hearing` (what a sound is worth to someone standing somewhere else: how loud
src/client/models.test.ts added +150
@@ -0,0 +1,150 @@
1 /**
2 * The mesh half of the pose: which box of a player swings, and about which
3 * joint. None of it can be caught downstream — the recording GL context in
4 * `renderer.test.ts` compiles no GLSL, so a limb tagged as the wrong part or
5 * a joint moved to the wrong height draws a body whose torso swings while its
6 * legs stand still, and every other test in the repository stays green.
7 *
8 * So it is pinned here, at the data: the parts of `playerMesh()` by vertex
9 * count, each limb's extent against the joint it turns about, and the exact
10 * numbers `PART` holds — which are what the shader's `aPart < 0.5 / 1.5 /
11 * 2.5 / 3.5` chain assumes and has no other way of learning.
12 */
13
14 import { expect, test } from "bun:test";
15 import { readFileSync } from "node:fs";
16 import { join } from "node:path";
17 import {
18 bombMesh, builder, finish, grenadeMesh, HIP_Z, levelMesh, PART, pickupMesh, playerMesh,
19 quadMesh, SHOULDER_Z, viewModelMesh, type Mesh,
20 } from "./models.ts";
21 import { DE_FORGE } from "./map.ts";
22 import { hitboxes } from "./combat.ts";
23 import type { Vec3 } from "./math.ts";
24
25 /** One box is six quads of four vertices: the unit every count below is in. */
26 const BOX = 24;
27
28 /** The vertices of `mesh` tagged as `part`, as their z in the model's own space. */
29 const heights = (mesh: Mesh, part: number): number[] => {
30 const out: number[] = [];
31 for (let i = 0; i < mesh.part.length; i += 1)
32 if (mesh.part[i] === part) out.push(mesh.position[i * 3 + 2] as number);
33 return out;
34 };
35
36 const tally = (mesh: Mesh): Map<number, number> => {
37 const counts = new Map<number, number>();
38 for (const part of mesh.part) counts.set(part, (counts.get(part) ?? 0) + 1);
39 return counts;
40 };
41
42 test("a player is five parts, and every box is tagged as exactly one of them", () => {
43 const mesh = playerMesh();
44 expect(mesh.part.length).toBe(mesh.position.length / 3);
45 expect(mesh.part.length).toBe(7 * BOX); // seven boxes: two legs, torso, two arms, head, gun
46 expect(Object.fromEntries(tally(mesh))).toEqual({
47 [PART.LEG_RIGHT]: BOX, // one box
48 [PART.LEG_LEFT]: BOX, // one box
49 [PART.ARM_FREE]: BOX, // the left arm, the one not on the gun
50 [PART.UPPER]: 3 * BOX, // torso, right arm and the gun that arm holds
51 [PART.STILL]: BOX, // the head, which keeps level over the hips
52 });
53 });
54
55 test("the drawn head stays inside the box a headshot is scored on", () => {
56 // `combat.ts` puts the head box on `pos`, axis-aligned, and knowing nothing
57 // about any pose. The drawn head has to live inside it across the ground
58 // plane or there is a head you can aim at and miss, so it is the one piece
59 // of a player that no angle in `stride.ts` may move: `STILL`, and no lean.
60 const mesh = playerMesh();
61 const standing = { pos: [0, 0, 0] as Vec3, duckAmount: 0 } as Parameters<typeof hitboxes>[0];
62 const box = hitboxes(standing).find((part) => part.group === "head") as { min: Vec3; max: Vec3 };
63 let found = 0;
64 for (let i = 0; i < mesh.part.length; i += 1) {
65 if (mesh.part[i] !== PART.STILL) continue;
66 found += 1;
67 const x = mesh.position[i * 3] as number, y = mesh.position[i * 3 + 1] as number;
68 expect(x).toBeGreaterThanOrEqual(box.min[0]);
69 expect(x).toBeLessThanOrEqual(box.max[0]);
70 expect(y).toBeGreaterThanOrEqual(box.min[1]);
71 expect(y).toBeLessThanOrEqual(box.max[1]);
72 // And it is the head that is still, not something down at the waist.
73 expect(mesh.position[i * 3 + 2] as number).toBeGreaterThan(SHOULDER_Z);
74 }
75 expect(found).toBe(BOX); // one box of it, all checked
76 });
77
78 test("every part hangs below the joint it turns about", () => {
79 const mesh = playerMesh();
80 // The legs turn at the hips, so no vertex of either may be above them —
81 // one that was would swing the wrong way and rise out of the pelvis.
82 for (const part of [PART.LEG_RIGHT, PART.LEG_LEFT]) {
83 const z = heights(mesh, part);
84 expect(z.length).toBe(BOX);
85 expect(Math.max(...z)).toBe(HIP_Z);
86 expect(Math.min(...z)).toBe(0); // and it reaches the floor: this is a leg
87 }
88 // The free arm turns at the shoulder, and hangs from it.
89 const arm = heights(mesh, PART.ARM_FREE);
90 expect(Math.max(...arm)).toBe(SHOULDER_Z);
91 expect(Math.min(...arm)).toBeLessThan(SHOULDER_Z);
92
93 // The upper body leans about the same hips the legs swing on, so it sits
94 // above them — overlapping by a unit, so the lean never opens a gap at the
95 // waist. Its top is the shoulders: the head above them is `STILL`.
96 const upper = heights(mesh, PART.UPPER);
97 expect(Math.min(...upper)).toBeGreaterThanOrEqual(HIP_Z - 2);
98 expect(Math.min(...upper)).toBeLessThanOrEqual(HIP_Z);
99 expect(Math.max(...upper)).toBe(61);
100 expect(Math.max(...heights(mesh, PART.STILL))).toBe(73.5);
101 // And the two legs are a mirrored pair: the same box on either side of the
102 // centre line, which is what makes one swing the negative of the other.
103 const side = (part: number): number[] => {
104 const out: number[] = [];
105 for (let i = 0; i < mesh.part.length; i += 1)
106 if (mesh.part[i] === part) out.push(mesh.position[i * 3 + 1] as number);
107 return out;
108 };
109 expect(side(PART.LEG_RIGHT).map((y) => -y).sort()).toEqual(side(PART.LEG_LEFT).sort());
110 });
111
112 test("nothing that is not a player can be posed at all", () => {
113 // Every other mesh in the game is `STILL` throughout: the pose uniform is
114 // zeroed for them anyway, but a level box tagged as a leg would be a wall
115 // that walks the first time that ever slipped.
116 const others: Mesh[] = [
117 levelMesh(DE_FORGE.boxes), bombMesh(), pickupMesh(), grenadeMesh(), quadMesh(),
118 ...["pistol", "smg", "rifle", "sniper", "knife", "grenade"].map(viewModelMesh),
119 ];
120 for (const mesh of others) {
121 expect(mesh.part.length).toBe(mesh.position.length / 3);
122 expect(Array.from(new Set(mesh.part))).toEqual([PART.STILL]);
123 }
124 // …and that is the default, not something each of them remembered to say.
125 const b = builder();
126 const fresh = finish(b);
127 expect(fresh.part.length).toBe(0);
128 });
129
130 test("PART holds the exact numbers the vertex shader's thresholds assume", () => {
131 // The shader cannot import this object; it compares `aPart` against the
132 // midpoints between these values. They are consecutive integers from zero
133 // so that those midpoints are `n + 0.5`, and that is the whole contract.
134 expect(PART).toEqual({ STILL: 0, LEG_RIGHT: 1, LEG_LEFT: 2, ARM_FREE: 3, UPPER: 4 });
135 expect(Object.values(PART)).toEqual([0, 1, 2, 3, 4]);
136
137 // And the shader compares against those midpoints and no others: a part
138 // added here without a branch there, or a branch reordered, fails this.
139 const shader = readFileSync(join(import.meta.dir, "renderer.ts"), "utf8");
140 const thresholds = Array.from(shader.matchAll(/aPart < ([0-9.]+)/g), (m) => Number(m[1]));
141 expect(thresholds.length).toBeGreaterThan(0);
142 const midpoints = Object.values(PART).slice(0, -1).map((n) => n + 0.5);
143 expect(Array.from(new Set(thresholds)).sort((a, b) => a - b)).toEqual(midpoints);
144
145 // The joint heights reach the shader from here rather than being written
146 // into it a second time, so moving a limb moves its hinge with it.
147 expect(shader).toContain("${HIP_Z.toFixed(1)}");
148 expect(shader).toContain("${SHOULDER_Z.toFixed(1)}");
149 expect(HIP_Z).toBeLessThan(SHOULDER_Z);
150 });
src/client/models.ts +49 −11
@@ -12,6 +12,7 @@ export type Mesh = {
1212 normal: Float32Array;
1313 uv: Float32Array;
1414 layer: Float32Array;
15 part: Float32Array;
1516 index: Uint32Array;
1617 };
1718
@@ -20,10 +21,27 @@ type Builder = {
2021 normal: number[];
2122 uv: number[];
2223 layer: number[];
24 part: number[];
2325 index: number[];
2426 };
2527
26 export const builder = (): Builder => ({ position: [], normal: [], uv: [], layer: [], index: [] });
28 /**
29 * Which piece of a body a vertex belongs to, as one number per vertex
30 * alongside its texture `layer`. The vertex shader hinges each piece about
31 * its own joint from the pose uniforms `stride.ts` computes; `STILL` is every
32 * vertex of every other mesh in the game and every vertex of a body that
33 * moves with the hips, and it is what a mesh gets when nobody says otherwise.
34 *
35 * `HIP_Z` and `SHOULDER_Z` are where those joints are in the player mesh's
36 * own space — the top of the legs and the top of the arms — and the shader
37 * is built with these numbers in it, so moving a limb here moves its hinge.
38 */
39 export const PART = { STILL: 0, LEG_RIGHT: 1, LEG_LEFT: 2, ARM_FREE: 3, UPPER: 4 } as const;

Reviewed at 3c8fb5c, and I ran it. bun run check is green at the head (484 pass, tsc clean) and green again with main merged in (505 pass, no conflicts) — the head is based on f7e18b0 rather than current main, so I checked the merge as well as the branch. The server boots and / answers in 8 ms with the security floor intact. There is no security surface here to speak of: no server, identity, session, body-reading or SQL path is touched, and the shader string interpolates two repository constants through toFixed(1) and nothing a user ever typed. The preview host 404s from my sandbox — so does production, so I read that as my egress and not your deploy; the screenshots show the page up at this head, and an in-game animation is not a thing a still can show anyway.

The design is right and it matches #88 point for point: pose.ts is pure and phase-driven, it fills the caller's object, the hinge is one uniform so a player is still exactly one draw, the reset before the bodies keeps corpses stiff, and observe.ts is untouched. I mutated the code to check the new tests actually bite: setting uPose back to zero in the player loop fails two renderer tests, and restoring the old footsteps reset fails the new world test. Both good.

The one thing to fix first: the mesh half of this feature is untested. I re-tagged the right leg as PART.UPPER and moved HIP_Z from 36 to 12, and all 484 tests still passed. Everything that decides which box swings and about which joint — playerMesh()'s part tags, HIP_Z/SHOULDER_Z, and the shader's implicit ordering of PART in aPart < 0.5 / 1.5 / 2.5 / 3.5 — is asserted nowhere, and the recording GL context can never catch it because it compiles no GLSL. A later change that reorders the PART object or re-tags a limb ships a player whose torso swings while the legs stand still, with a green check and nobody the wiser. A small src/client/models.test.ts closes it: assert the per-part vertex counts of playerMesh() (24 each for the two legs and the free arm, 96 for UPPER, 168 in all), that every LEG_* vertex sits at or below HIP_Z and every ARM_FREE vertex at or below SHOULDER_Z, and that PART holds the exact numeric values the shader's thresholds assume — that last assertion is what pins the shader to the data it reads.

Two notes, neither of them blocking:

  • RUN_SPEED = 250 in pose.ts is MOVE.maxSpeed written a second time, with a comment that says as much. pose.ts already imports world.ts, which imports physics.ts, so import { MOVE } from "./physics.ts" costs nothing and cannot drift — the same reasoning that made HELD_MUZZLE derive from HELD_GUN instead of repeating it.
  • Another player's muzzle flash is still placed at the unposed HELD_MUZZLE (the muzzle-flash loop in renderer.ts), but the gun now rides UPPER and leans with it. At a full run's 8° the drawn barrel end drops about 4 units and moves about 1 forward while the star stays where it was, so a running player's flash floats a little off the muzzle. Four units of a 72-unit body: a follow-up issue rather than this pull, unless it reads badly in play.

And for the record, not reporting the browser frame average is the right call, not a gap — nothing in this sandbox runs a GPU, and measuring the CPU side and saying plainly what you could not measure beats inventing a before and after.

40 export const HIP_Z = 36;
41 export const SHOULDER_Z = 56;
42
43 export const builder = (): Builder =>
44 ({ position: [], normal: [], uv: [], layer: [], part: [], index: [] });
2745
2846 export function finish(b: Builder): Mesh {
2947 return {
@@ -31,6 +49,7 @@ export function finish(b: Builder): Mesh {
3149 normal: new Float32Array(b.normal),
3250 uv: new Float32Array(b.uv),
3351 layer: new Float32Array(b.layer),
52 part: new Float32Array(b.part),
3453 index: new Uint32Array(b.index),
3554 };
3655 }
@@ -46,7 +65,9 @@ const FACES: [Vec3, number, number, number][] = [
4665 ];
4766
4867 /** Six quads with the texture tiling every `scale` units in world space. */
49 export function addBox(b: Builder, min: Vec3, max: Vec3, layer: number, scale = 128): void {
68 export function addBox(
69 b: Builder, min: Vec3, max: Vec3, layer: number, scale = 128, part: number = PART.STILL,
70 ): void {
5071 for (const [normal, axis, u, v] of FACES) {
5172 const base = b.position.length / 3;
5273 const at = (normal[axis] as number) > 0 ? max : min;
@@ -59,6 +80,7 @@ export function addBox(b: Builder, min: Vec3, max: Vec3, layer: number, scale =
5980 b.normal.push(normal[0], normal[1], normal[2]);
6081 b.uv.push((p[u] as number) / scale, (p[v] as number) / scale);
6182 b.layer.push(layer);
83 b.part.push(part);
6284 }
6385 // Wind the two triangles so the outside faces out.
6486 if ((normal[axis] as number) > 0) b.index.push(base, base + 1, base + 2, base, base + 2, base + 3);
@@ -73,8 +95,11 @@ export function levelMesh(boxes: Box[]): Mesh {
7395 return finish(b);
7496 }
7597
76 const cube = (b: Builder, cx: number, cy: number, cz: number, sx: number, sy: number, sz: number, layer: number): void =>
77 addBox(b, [cx - sx / 2, cy - sy / 2, cz - sz / 2], [cx + sx / 2, cy + sy / 2, cz + sz / 2], layer, 64);
98 const cube = (
99 b: Builder, cx: number, cy: number, cz: number,
100 sx: number, sy: number, sz: number, layer: number, part: number = PART.STILL,
101 ): void =>
102 addBox(b, [cx - sx / 2, cy - sy / 2, cz - sz / 2], [cx + sx / 2, cy + sy / 2, cz + sz / 2], layer, 64, part);
78103
79104 /**
80105 * The end of the gun in a player's hands, in the player mesh's own space: the
@@ -88,17 +113,30 @@ export const HELD_MUZZLE: Vec3 = [HELD_GUN.x + HELD_GUN.long / 2, HELD_GUN.y, HE
88113 /**
89114 * A player: legs, torso, arms, head, and the gun they are holding, built
90115 * standing at the origin facing +X so a yaw rotation is all a draw needs.
116 *
117 * Every box says which part it is, so one draw of one buffer can still be a
118 * body that moves: the legs hinge at the hips against each other, the free
119 * arm counter-swings at its shoulder, and the torso, gun and the arm holding
120 * it are one `UPPER` piece that leans over the hips together — which is what
121 * keeps the muzzle in a hand however far the body is tipped.
122 *
123 * The head is not on that piece. A runner keeps their eyes on where they are
124 * going, so a level head is what a person looks like — and it is also the one
125 * box worth a headshot: `combat.ts` puts it on `pos`, axis-aligned, knowing
126 * nothing about any pose, and a drawn head that leaned out of it would be a
127 * head you could aim at and miss. `STILL` is how those two agree.
91128 */
92129 export function playerMesh(): Mesh {
93130 const b = builder();
94131 const SKIN = 5, KIT = 6, GEAR = 7;
95 cube(b, 0, -7, 18, 11, 11, 36, KIT); // right leg
96 cube(b, 0, 7, 18, 11, 11, 36, KIT); // left leg
97 cube(b, 0, 0, 48, 16, 26, 26, GEAR); // torso
98 cube(b, 2, -14, 46, 9, 9, 22, KIT); // right arm
99 cube(b, 6, 10, 46, 9, 9, 20, KIT); // left arm
100 cube(b, 1, 0, 67, 13, 13, 13, SKIN); // head
101 cube(b, HELD_GUN.x, HELD_GUN.y, HELD_GUN.z, HELD_GUN.long, 5, 7, GEAR); // the gun in their hands
132 cube(b, 0, -7, 18, 11, 11, 36, KIT, PART.LEG_RIGHT); // right leg
133 cube(b, 0, 7, 18, 11, 11, 36, KIT, PART.LEG_LEFT); // left leg
134 cube(b, 0, 0, 48, 16, 26, 26, GEAR, PART.UPPER); // torso
135 cube(b, 2, -14, 46, 9, 9, 22, KIT, PART.UPPER); // right arm, the one on the gun
136 cube(b, 6, 10, 46, 9, 9, 20, KIT, PART.ARM_FREE); // left arm
137 cube(b, 1, 0, 67, 13, 13, 13, SKIN, PART.STILL); // head, level over the hips
138 // The gun in their hands, on the same part as the arm holding it.
139 cube(b, HELD_GUN.x, HELD_GUN.y, HELD_GUN.z, HELD_GUN.long, 5, 7, GEAR, PART.UPPER);
102140 return finish(b);
103141 }
104142
src/client/physics.ts +16
@@ -27,6 +27,22 @@ export const MOVE = {
2727 stepSize: 18,
2828 };
2929
30 /**
31 * One step: a running stride is roughly 60–70 units at 250 u/s, and this is
32 * the middle of that. It is the ground between one foot hitting the floor and
33 * the other doing it, so it is both what `world.ts` spends per footstep sound
34 * and what `stride.ts` swings the legs across — the two cannot drift, because
35 * there is one number.
36 */
37 export const STEP_STRIDE = 65;
38
39 /**
40 * A whole gait: right foot, then left, then back where it started. Two steps,
41 * because two footfalls is what a cycle of legs contains — the phase a player
42 * carries runs over this, and a sound falls on each half of it.
43 */
44 export const GAIT_CYCLE = STEP_STRIDE * 2;
45
3046 export type Body = {
3147 pos: Vec3;
3248 vel: Vec3;
src/client/renderer.test.ts +217 −2
@@ -18,7 +18,8 @@ import { expect, test } from "bun:test";
1818 import { addBody } from "./bodies.ts";
1919 import { advance, TICK, waiting } from "./loop.ts";
2020 import { SHORT } from "./match.ts";
21 import { grenadeMesh, HELD_MUZZLE, levelMesh, muzzle, playerMesh, viewModelMesh } from "./models.ts";
21 import { MOVE, STEP_STRIDE } from "./physics.ts";
22 import { grenadeMesh, HELD_MUZZLE, HIP_Z, levelMesh, muzzle, playerMesh, viewModelMesh } from "./models.ts";
2223 import { createRenderer, LINE_CAPACITY, type Camera, type Renderer, type RendererHooks } from "./renderer.ts";
2324 import { WEAPONS, type WeaponId } from "./weapons.ts";
2425 import { createWorld, eyePos, startRound, type Effect, type Player, type World } from "./world.ts";
@@ -51,7 +52,12 @@ function recorder(): {
5152 getShaderParameter: log("getShaderParameter", true), getShaderInfoLog: log("getShaderInfoLog", ""),
5253 createProgram: handle("program"), attachShader: log("attachShader"), linkProgram: log("linkProgram"),
5354 getProgramParameter: log("getProgramParameter", true), getProgramInfoLog: log("getProgramInfoLog", ""),
54 getUniformLocation: handle("uniform"),
55 // A uniform's handle remembers which uniform it is, so a test can say
56 // which of the `uniform3f` calls of a frame was the pose and which the tint.
57 getUniformLocation: (...args: unknown[]): unknown => {
58 calls.push({ name: "uniform", args });
59 return { name: "uniform", of: args[1] };
60 },
5561 createTexture: handle("texture"), bindTexture: log("bindTexture"), texImage3D: log("texImage3D"),
5662 generateMipmap: log("generateMipmap"), texParameteri: log("texParameteri"),
5763 createVertexArray: handle("vao"), bindVertexArray: log("bindVertexArray"),
@@ -309,6 +315,215 @@ test("ten guns firing at once stay well inside the line budget", () => {
309315 expect(count(calls, "bufferData")).toBe(0);
310316 });
311317
318 // --- legs that swing on the stride the footsteps count
319
320 /** Every value `uPose` was given this frame, in the order it was given them. */
321 const poses = (calls: Call[]): number[][] =>
322 calls
323 .filter((c) => c.name === "uniform3f" && (c.args[0] as { of?: string } | null)?.of === "uPose")
324 .map((c) => c.args.slice(1) as number[]);
325
326 const isZero = (triple: number[]): boolean => triple.every((n) => n === 0);
327
328 /** Everyone but the camera's own player, running, each at their own point in the stride. */
329 function running(world: World, view: Player, stride: (i: number) => number): void {
330 let i = 0;
331 for (const p of world.players) {
332 if (p === view) continue;
333 p.onGround = true;
334 p.vel = [250, 0, 0];
335 p.stepDistance = stride(i);
336 i += 1;
337 }
338 }
339
340 test("a player's stride reaches the shader, and a still one is posed by nothing at all", () => {
341 const { canvas, calls } = recorder();
342 const world = scene();
343
344 // Everyone stood still: every pose of the frame is three zeroes, so the
345 // mesh comes out of the vertex shader exactly as it was modelled.
346 for (const p of world.players) p.vel = [0, 0, 0];
347 const still = poses(record(attach(canvas), calls, world));
348 expect(still.length).toBeGreaterThan(0);
349 for (const triple of still) expect(isZero(triple)).toBe(true);
350 });
351
352 test("the legs are drawn at the tick the body is drawn at, not the one ahead", () => {
353 const { canvas, calls } = recorder();
354 const renderer = attach(canvas);
355 const world = scene();
356 const mate = world.players.find((p) => p !== world.me && p.alive) as Player;
357 onlyOne(world, world.me, mate);
358 mate.onGround = true;
359 mate.vel = [MOVE.maxSpeed, 0, 0];
360 // The tick that just ended planted a foot; the newest one has the legs
361 // halfway to together. A frame between them belongs between them.
362 mate.prevStep = 0;
363 mate.stepDistance = STEP_STRIDE / 2;
364 const swingAt = (alpha: number): number => {
365 const moving = poses(recordAt(renderer, calls, world, world.me, alpha)).filter((t) => !isZero(t));
366 return (moving[0] as number[])[0] as number;
367 };
368
369 const planted = swingAt(0), half = swingAt(0.5), together = swingAt(1);
370 // The ends are the two ticks themselves: a leg out in front, then under.
371 expect(planted).toBeGreaterThan(0.5); // radians, and the widest the swing goes
372 expect(together).toBeCloseTo(0, 6);
373 // And the middle is the middle — of the phase, which is what is blended,
374 // not of the angle. A renderer that read the newest counter at every alpha
375 // would draw all three of these the same.
376 expect(half).toBeCloseTo(planted * Math.cos(Math.PI / 4), 5);
377 expect(half).toBeLessThan(planted);
378 expect(half).toBeGreaterThan(together);
379 });
380
381 test("two worlds that differ only in stride pose differently, for the same draws", () => {
382 const { canvas, calls } = recorder();
383 const renderer = attach(canvas);
384 const drawn = (world: World): number => {
385 const frameCalls = record(renderer, calls, world);
386 return count(frameCalls, "drawElements") + count(frameCalls, "drawArrays");
387 };
388
389 const early = scene();
390 running(early, early.me, (i) => i * 3);
391 const earlyDraws = drawn(early);
392 const earlyPoses = poses(calls);
393
394 // The same world, the same tick, the same ten players — one step on, which
395 // is half a gait cycle: the other foot is the one in front.
396 const late = scene();
397 running(late, late.me, (i) => i * 3 + STEP_STRIDE);
398 const lateDraws = drawn(late);
399 const latePoses = poses(calls);
400
401 // Nine players posed, and every one of them mid-stride rather than neutral.
402 const moving = (list: number[][]): number[][] => list.filter((t) => !isZero(t));
403 expect(moving(earlyPoses).length).toBe(9);
404 expect(moving(latePoses).length).toBe(9);
405 // A step apart is the other leg forward: every angle is negated.
406 for (let i = 0; i < 9; i += 1) {
407 const here = moving(earlyPoses)[i] as number[];
408 const half = moving(latePoses)[i] as number[];
409 expect(half[0]).toBeCloseTo(-(here[0] as number), 9);
410 expect(half[1]).toBeCloseTo(-(here[1] as number), 9);
411 expect(half[2]).toBeCloseTo(here[2] as number, 9); // the lean is posture, not phase
412 }
413
414 // And the picture costs what it did: ten players, nine drawn, one draw each.
415 expect(late.players.length).toBe(10);
416 expect(lateDraws).toBe(earlyDraws);
417 expect(lateDraws).toBe(total(ruledDraws(late, late.me)));
418 // Nor is a posed player a second program or a second buffer.
419 expect(count(calls, "useProgram")).toBe(2);
420 expect(count(calls, "bufferData")).toBe(0);
421 });
422
423 test("a running shooter's flash rides the barrel their lean has moved", () => {
424 const { canvas, calls } = recorder();
425 const world = scene();
426 const renderer = attach(canvas);
427 const shooter = world.players.find((p) => p !== world.me && p.alive) as Player;
428 shooter.yaw = 90; // facing north: the gun's own +X is the world's +Y
429 shooter.pos = [0, 0, 0];
430 shooter.onGround = true;
431 shooter.vel = [0, 0, 0];
432 shooter.stepDistance = STEP_STRIDE / 4;
433 world.effects.push(shotBy(world, shooter, "ak47"));
434
435 const flash = (): number[] => {
436 record(renderer, calls, world);
437 const data = calls.find((c) => c.name === "bufferSubData")?.args[2] as Float32Array;
438 return [data[0] as number, data[1] as number, data[2] as number];
439 };
440
441 // At a dead stop there is no lean, so the star is at the muzzle as modelled
442 // — the same place it was before any of this existed.
443 const still = flash();
444 expect(still[0]).toBeCloseTo(-HELD_MUZZLE[1], 3);
445 expect(still[1]).toBeCloseTo(HELD_MUZZLE[0], 3);
446 expect(still[2]).toBeCloseTo(HELD_MUZZLE[2], 3);
447
448 // The same shot from the same spot at a full run: the gun rides the upper
449 // body, so the end of the barrel has come forward and dropped, and the star
450 // goes with it rather than hanging where the gun used to be.
451 shooter.vel = [MOVE.maxSpeed, 0, 0];
452 const running = flash();
453 expect(running[1]).toBeGreaterThan(still[1] as number); // forward, along their facing
454 expect(running[2]).toBeLessThan((still[2] as number) - 2); // and down with the lean
455 expect(running[0]).toBeCloseTo(still[0] as number, 6); // never sideways: a lean has no roll
456
457 // It is a turn about the hips and not a nudge: the muzzle stays exactly as
458 // far from the joint as the mesh puts it.
459 const far = (m: number[]): number => Math.hypot(m[1] as number, (m[2] as number) - HIP_Z);
460 expect(far(running)).toBeCloseTo(far(still), 4); // float32, on its way through the buffer
461 });
462
463 test("the flash is on the barrel of the body that was drawn, not the tick ahead of it", () => {
464 const { canvas, calls } = recorder();
465 const world = scene();
466 const renderer = attach(canvas);
467 const shooter = world.players.find((p) => p !== world.me && p.alive) as Player;
468 onlyOne(world, world.me, shooter);
469 // Facing east and mid-step: the gun's own +X is the world's, so the barrel
470 // offset reads straight off the numbers without a rotation in the way.
471 shooter.yaw = 0;
472 shooter.prev.yaw = 0;
473 shooter.prev.pos = [0, 0, 0];
474 shooter.pos = [20, 0, 0];
475 shooter.onGround = true;
476 shooter.vel = [MOVE.maxSpeed, 0, 0];
477 shooter.stepDistance = STEP_STRIDE / 4;
478 world.effects.push(shotBy(world, shooter, "ak47"));
479
480 const flash = (alpha: number): number[] => {
481 const frame = recordAt(renderer, calls, world, world.me, alpha);
482 const data = frame.find((c) => c.name === "bufferSubData")?.args[2] as Float32Array;
483 return [data[0] as number, data[1] as number, data[2] as number];
484 };
485 const mid = flash(0.5), newest = flash(1);
486
487 // Half a tick back is half the step back: the star goes where the mesh goes.
488 expect((newest[0] as number) - (mid[0] as number)).toBeCloseTo(10, 3);
489 // And it is the same offset from the body at both ends of the tick — the
490 // barrel does not stretch because the frame landed between two states.
491 expect((mid[0] as number) - 10).toBeCloseTo((newest[0] as number) - 20, 3);
492 expect(mid[2]).toBeCloseTo(newest[2] as number, 6);
493 // That offset is a leaning runner's, not the mesh's as modelled: forward of
494 // the modelled muzzle and below it, which is where the lean put the gun.
495 expect((mid[0] as number) - 10).toBeGreaterThan(HELD_MUZZLE[0] as number);
496 expect(mid[2]).toBeLessThan((HELD_MUZZLE[2] as number) - 2);
497 });
498
499 test("a corpse is not posed: the fall is the whole of what a body does", () => {
500 const { canvas, calls } = recorder();
501 const world = scene();
502 const renderer = attach(canvas);
503 running(world, world.me, (i) => i * 7 + 9);
504 for (let i = 0; i < 4; i += 1)
505 addBody(world.bodies, { id: 50 + i, team: "t", pos: [i * 96, 0, 0], yaw: i * 40 }, world.boxes, world.time);
506 const frameCalls = record(renderer, calls, world);
507
508 // The frame's poses, in order: the neutral one the level draws through, one
509 // per living player, and the neutral one again. Nothing sets a pose after
510 // that, so the bodies, the pickups, the bomb and the gun in your hands are
511 // all drawn through hinges that are the identity.
512 const list = poses(frameCalls);
513 expect(list.length).toBe(11);
514 expect(isZero(list[0] as number[])).toBe(true);
515 for (let i = 1; i <= 9; i += 1) expect(isZero(list[i] as number[])).toBe(false);
516 expect(isZero(list[10] as number[])).toBe(true);
517
518 // And it is set back before the first body and not after the last: exactly
519 // the level and the nine players are drawn between the two neutral poses.
520 const lastPose = frameCalls.map((c) => c.name === "uniform3f"
521 && (c.args[0] as { of?: string } | null)?.of === "uPose").lastIndexOf(true);
522 expect(count(frameCalls.slice(0, lastPose), "drawElements")).toBe(1 + 9);
523 expect(world.bodies.length).toBe(4);
524 expect(count(frameCalls, "drawElements")).toBe(1 + 9 + 4 + 1); // and the view model
525 });
526
312527 // --- a context the browser takes away
313528
314529 /** A renderer of this canvas's own, with the hooks main.ts would pass. */
src/client/renderer.ts +80 −11
@@ -17,32 +17,73 @@ import { between, drawnAt, lerpPos, pose, type Pose } from "./interpolate.ts";
1717 import { TICK } from "./loop.ts";
1818 import { fallenMatrix, multiply, perspective, rad, viewMatrix, modelMatrix, type Vec3 } from "./math.ts";
1919 import {
20 bombMesh, grenadeMesh, HELD_MUZZLE, levelMesh, muzzle, pickupMesh, playerMesh, viewModelMesh,
21 type Mesh,
20 bombMesh, grenadeMesh, HELD_MUZZLE, HIP_Z, levelMesh, muzzle, pickupMesh, playerMesh, SHOULDER_Z,
21 viewModelMesh, type Mesh,
2222 } from "./models.ts";
2323 import type { MapDef } from "./map.ts";
24 import { hinge, neutral, phaseAt, stride, type Stride } from "./stride.ts";
2425 import { MAX_SCALE, MIN_SCALE } from "./quality.ts";
2526 import { LAYERS, TEX_SIZE, materialTextures } from "./textures.ts";
2627 import type { Player, World } from "./world.ts";
2728 import { hullHeight } from "./physics.ts";
2829 import { WEAPONS } from "./weapons.ts";
2930
31 /**
32 * The scene vertex shader, and the one piece of animation in this game that
33 * is not a matrix: `uPose` is three angles in radians — the leg swing, the
34 * free arm's counter-swing, and the lean of everything above the hips — and
35 * `aPart` says which of those a vertex answers to. A posed player is still
36 * one draw of one buffer; the hinges happen here, per vertex, before the
37 * model matrix that carries the yaw and the duck squash.
38 *
39 * `uPose` is all zeroes for everything that is not a living player, which
40 * makes every hinge the identity: the level, the bodies, the pickups and the
41 * gun in your hands come out of this exactly as they did before it existed.
42 */
3043 const SCENE_VS = `#version 300 es
3144 precision highp float;
3245 layout(location=0) in vec3 aPos;
3346 layout(location=1) in vec3 aNormal;
3447 layout(location=2) in vec2 aUV;
3548 layout(location=3) in float aLayer;
49 layout(location=4) in float aPart;
3650 uniform mat4 uViewProj;
3751 uniform mat4 uModel;
52 uniform vec3 uPose;
3853 out vec3 vNormal;
3954 out vec2 vUV;
4055 out float vLayer;
4156 out vec3 vWorld;
57
58 const float HIP_Z = ${HIP_Z.toFixed(1)};
59 const float SHOULDER_Z = ${SHOULDER_Z.toFixed(1)};
60
61 // Turn a point about the model's own -Y axis through the joint at that
62 // height: a positive angle tips whatever is below the joint forwards.
63 vec3 hinge(vec3 p, float joint, float a) {
64 float c = cos(a), s = sin(a);
65 vec3 d = vec3(p.x, p.y, p.z - joint);
66 return vec3(d.x * c - d.z * s, d.y, d.x * s + d.z * c) + vec3(0.0, 0.0, joint);
67 }
68
4269 void main() {
43 vec4 world = uModel * vec4(aPos, 1.0);
70 vec3 pos = aPos;
71 vec3 normal = aNormal;
72 // One branch on a uniform, taken by every vertex of the draw or none of
73 // them: a still body pays a comparison and no trigonometry at all.
74 if (uPose != vec3(0.0)) {
75 float angle =
76 aPart < 0.5 ? 0.0 :
77 aPart < 1.5 ? uPose.x :
78 aPart < 2.5 ? -uPose.x :
79 aPart < 3.5 ? uPose.y : -uPose.z;
80 float joint = aPart < 2.5 ? HIP_Z : aPart < 3.5 ? SHOULDER_Z : HIP_Z;
81 pos = hinge(pos, joint, angle);
82 normal = hinge(normal, 0.0, angle);
83 }
84 vec4 world = uModel * vec4(pos, 1.0);
4485 vWorld = world.xyz;
45 vNormal = mat3(uModel) * aNormal;
86 vNormal = mat3(uModel) * normal;
4687 vUV = aUV;
4788 vLayer = aLayer;
4889 gl_Position = uViewProj * world;
@@ -129,6 +170,7 @@ function upload(gl: WebGL2RenderingContext, mesh: Mesh): Buffers {
129170 attribute(mesh.normal, 1, 3);
130171 attribute(mesh.uv, 2, 2);
131172 attribute(mesh.layer, 3, 1);
173 attribute(mesh.part, 4, 1);
132174 const index = gl.createBuffer();
133175 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, index);
134176 gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.index, gl.STATIC_DRAW);
@@ -200,7 +242,10 @@ const RELOAD_ROLL = 26;
200242 type Gpu = {
201243 scene: WebGLProgram;
202244 lines: WebGLProgram;
203 u: Record<"viewProj" | "model" | "eye" | "tint" | "fog" | "atlas" | "lineViewProj", WebGLUniformLocation | null>;
245 u: Record<
246 "viewProj" | "model" | "eye" | "tint" | "fog" | "pose" | "atlas" | "lineViewProj",
247 WebGLUniformLocation | null
248 >;
204249 texture: WebGLTexture | null;
205250 player: Buffers;
206251 bomb: Buffers;
@@ -260,6 +305,7 @@ function build(gl: WebGL2RenderingContext): Gpu {
260305 eye: uniform(scene, "uEye"),
261306 tint: uniform(scene, "uTint"),
262307 fog: uniform(scene, "uFog"),
308 pose: uniform(scene, "uPose"),
263309 atlas: uniform(scene, "uAtlas"),
264310 lineViewProj: uniform(lines, "uViewProj"),
265311 },
@@ -371,6 +417,12 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks =
371417 const spot: Vec3 = [0, 0, 0];
372418 const bombSpot: Vec3 = [0, 0, 0];
373419
420 // And the same for the limbs: the one gait a frame uses, filled and read
421 // once per player, and the one point a hinge is worked out into. Both are
422 // this side of the bus like `lineData`, so they survive a lost context.
423 const gait: Stride = neutral();
424 const held: Vec3 = [0, 0, 0];
425
374426 let bobPhase = 0;
375427 // The gun the hands last had, and when they were given it: a change of
376428 // weapon — or of the player the camera is inside — starts a raise. It is
@@ -415,11 +467,17 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks =
415467 const identity = modelMatrix([0, 0, 0], 0);
416468 gl.uniformMatrix4fv(u.model, false, identity);
417469 gl.uniform3f(u.tint, 1, 1, 1);
470 gl.uniform3f(u.pose, 0, 0, 0);
418471 gl.bindVertexArray(level.vao);
419472 gl.drawElements(gl.TRIANGLES, level.count, gl.UNSIGNED_INT, 0);
420473
421474 // The player the camera is inside is the one we skip — while dead that is
422475 // the teammate being watched, not the person, who is now a body below.
476 //
477 // Each of them is still exactly one draw: the legs swing inside the vertex
478 // shader off `uPose`, which is `stride.ts`'s answer for the ground they
479 // have covered since their last footstep, in radians. `gait` is a scratch
480 // this closure owns, so ten players a frame allocate nothing between them.
423481 gl.bindVertexArray(player.vao);
424482 for (const p of world.players) {
425483 if (!p.alive || p === view) continue;
@@ -432,8 +490,14 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks =
432490 gl.uniformMatrix4fv(u.model, false, modelMatrix(at.pos, at.yaw, 1, 1, squash));
433491 const tint = TEAM_TINT[p.team] ?? [1, 1, 1];
434492 gl.uniform3f(u.tint, tint[0], tint[1], tint[2]);
493 stride(gait, phaseAt(p.prevStep, p.stepDistance, alpha), Math.hypot(p.vel[0], p.vel[1]), p.onGround);
494 gl.uniform3f(u.pose, rad(gait.legSwing), rad(gait.armSwing), rad(gait.lean));
435495 gl.drawElements(gl.TRIANGLES, player.count, gl.UNSIGNED_INT, 0);
436496 }
497 // Everything below draws unposed, the bodies first: a corpse caught
498 // mid-stride is worse than a stiff one, and the fall is the whole of what
499 // a body does. One call, and every hinge in the shader is the identity.
500 gl.uniform3f(u.pose, 0, 0, 0);
437501
438502 // The dead of this round, on the same mesh and the same buffer: one draw
439503 // each and not a byte of state beyond the matrix and the tint.
@@ -580,14 +644,19 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks =
580644 let mx = effect.pos[0], my = effect.pos[1], mz = effect.pos[2];
581645 for (const p of world.players) {
582646 if (p.id !== effect.shooter || !p.alive) continue;
583 // The pose the mesh was drawn at, not the newest one: a flash that sat
584 // on the tick the gun is at rather than the one the shooter is drawn
585 // at would float off the end of the barrel while they moved.
647 // Where the shooter was drawn, and how they were bent while they were
648 // drawn there. Two halves of one answer: the place is the blended tick
649 // the mesh went to — a flash pinned to the newest tick floats off the
650 // barrel of anyone moving — and the offset is the gun riding the upper
651 // body through the same hinge the shader gave `PART.UPPER`, about the
652 // same hips, from the same gait. Either half alone misses the barrel.
586653 const at = between(p.prev, p, alpha, flashAt);
654 stride(gait, phaseAt(p.prevStep, p.stepDistance, alpha), Math.hypot(p.vel[0], p.vel[1]), p.onGround);
655 hinge(held, HELD_MUZZLE, HIP_Z, -gait.lean);
587656 const c = Math.cos(rad(at.yaw)), s = Math.sin(rad(at.yaw));
588 mx = at.pos[0] + HELD_MUZZLE[0] * c - HELD_MUZZLE[1] * s;
589 my = at.pos[1] + HELD_MUZZLE[0] * s + HELD_MUZZLE[1] * c;
590 mz = at.pos[2] + HELD_MUZZLE[2] * (hullHeight(p) / 72);
657 mx = at.pos[0] + held[0] * c - held[1] * s;
658 my = at.pos[1] + held[0] * s + held[1] * c;
659 mz = at.pos[2] + held[2] * (hullHeight(p) / 72);
591660 break;
592661 }
593662 const bright = 1 - age / FLASH_LIFE;
src/client/stride.test.ts added +215
@@ -0,0 +1,215 @@
1 /**
2 * The gait is a function of the ground a player has covered and nothing else:
3 * no clock, no frame, no world. Everything below is that one claim asked in
4 * six ways — the same distance twice, a step apart, at a stop, in the air, at
5 * a walk against a run, and against the floor the feet are supposed to be on.
6 *
7 * The cadence is the claim that matters: one `STEP_STRIDE` is one footfall,
8 * not one cycle of legs, because that is what `world.ts` spends per footstep
9 * sound. The legs must land where the ear says they land.
10 */
11
12 import { expect, test } from "bun:test";
13 import { GAIT_CYCLE, MOVE, STEP_STRIDE } from "./physics.ts";
14 import { hinge, neutral, phaseAt, stride, type Stride } from "./stride.ts";
15 import type { Vec3 } from "./math.ts";
16
17 /** The pose at a distance, as a fresh object a test can keep. */
18 const at = (distance: number, speed: number, onGround = true): Stride =>
19 stride(neutral(), distance, speed, onGround);
20
21 // The speeds the simulation itself moves at, not numbers written down here:
22 // a full swing is a full run's, and a walk is whatever `physics.ts` says.
23 const RUN = MOVE.maxSpeed;
24 const WALK = MOVE.walkSpeed;
25
26 test("the same ground covered is the same pose, whenever it is asked", () => {
27 const once = at(22, RUN);
28 const again = at(22, RUN);
29 expect(again).toEqual(once);
30 // And a thousand calls in between change nothing: there is no state here.
31 for (let i = 0; i < 1000; i += 1) at(i * 3.7, RUN);
32 expect(at(22, RUN)).toEqual(once);
33 // A whole cycle later is the same place in it, to the floating point.
34 expect(at(22 + GAIT_CYCLE, RUN).legSwing).toBeCloseTo(once.legSwing, 9);
35 });
36
37 test("one step later the other leg is forward: a cycle is two of them", () => {
38 for (const d of [0, 7, 19.5, 33, 64, 96]) {
39 const here = at(d, RUN);
40 const next = at(d + STEP_STRIDE, RUN);
41 expect(next.legSwing).toBeCloseTo(-here.legSwing, 9);
42 // The arms go with them; the lean is a posture, not a swing, so it stays.
43 expect(next.armSwing).toBeCloseTo(-here.armSwing, 9);
44 expect(next.lean).toBeCloseTo(here.lean, 9);
45 }
46 // The extreme is on the footfall itself — the phase `world.ts` pushes a
47 // sound at — with one leg planted out in front and the other trailing. This
48 // is the whole cadence claim: the step you hear is the step you see land.
49 expect(at(0, RUN).legSwing).toBeGreaterThan(30);
50 expect(at(STEP_STRIDE, RUN).legSwing).toBeLessThan(-30);
51 expect(at(GAIT_CYCLE, RUN).legSwing).toBeCloseTo(at(0, RUN).legSwing, 9);
52 // And the legs are together halfway between two sounds, never on one.
53 expect(at(STEP_STRIDE / 2, RUN).legSwing).toBeCloseTo(0, 9);
54 });
55
56 test("the legs never jump, least of all where the counter wraps", () => {
57 // Walk a runner through two whole cycles a twentieth of a unit at a time,
58 // the counter wrapping the way `world.ts` wraps it. Nothing anywhere moves
59 // by more than a fraction of a degree, including across the wrap — which is
60 // what a cosine over a counter that reset every *step* would fail: the legs
61 // would swap from under a runner at every footfall.
62 const step = 0.05;
63 let previous = at(0, RUN).legSwing;
64 for (let d = step; d <= GAIT_CYCLE * 2; d += step) {
65 const swing = at(d % GAIT_CYCLE, RUN).legSwing;
66 expect(Math.abs(swing - previous)).toBeLessThan(0.2);
67 previous = swing;
68 }
69 });
70
71 test("standing still is the neutral pose exactly, wherever the phase stopped", () => {
72 for (const d of [0, 16, STEP_STRIDE, GAIT_CYCLE - 0.001])
73 expect(at(d, 0)).toEqual(neutral());
74 // Not nearly zero — zero: a body at a dead stop is the mesh as it was built.
75 expect(at(0, 0).legSwing).toBe(0);
76 });
77
78 test("airborne is neutral, so a jumping player does not scissor", () => {
79 // A full stride's phase and full speed, and still nothing: the reset is hard.
80 for (const d of [0, STEP_STRIDE / 2, STEP_STRIDE, 61])
81 expect(at(d, RUN, false)).toEqual(neutral());
82 });
83
84 test("a walk swings less than a run, and a crouch less than a walk", () => {
85 const peak = 0; // the footfall, where the swing is widest
86 const run = at(peak, RUN).legSwing;
87 const walk = at(peak, WALK).legSwing;
88 const duck = at(peak, MOVE.duckSpeed).legSwing;
89 expect(walk).toBeGreaterThan(0);
90 expect(walk).toBeLessThan(run);
91 expect(duck).toBeLessThan(walk);
92 // Proportional, and not some other curve: half the speed is half the swing.
93 expect(at(peak, RUN / 2).legSwing).toBeCloseTo(run / 2, 9);
94 // Faster than a player can run is not a wider swing than a run's.
95 expect(at(peak, RUN * 4).legSwing).toBeCloseTo(run, 9);
96 });
97
98 test("the arms counter the legs, smaller, and the gun arm is not one of them", () => {
99 const peak = at(0, RUN);
100 // The free arm is the left one, forward with the right leg, and it swings
101 // less far than the leg does.
102 expect(peak.armSwing).toBeGreaterThan(0);
103 expect(peak.armSwing).toBeLessThan(peak.legSwing);
104 // The lean is forward and modest: a runner over their own hips, not a sprinter.
105 expect(peak.lean).toBeGreaterThan(0);
106 expect(peak.lean).toBeLessThan(15);
107 });
108
109 test("the foot is still against the floor at the one moment it is on it", () => {
110 // The foot is the bottom of the leg box, at z = 0, carried round the hip by
111 // the swing. The leg is rigid and the hips never rise, so the foot is
112 // `36(1 − cos swing)` above the floor — it touches at one instant, where
113 // the swing passes through zero, and that is the only plant there is.
114 const foot = (d: number): number[] => hinge([0, 0, 0], [0, -7, 0], 36, at(d, RUN).legSwing);
115 const bottom = STEP_STRIDE / 2; // halfway between two footfalls
116 expect(foot(bottom)[2]).toBeCloseTo(0, 9);
117 expect(foot(bottom)[0]).toBeCloseTo(0, 9);
118
119 // And there it must be still: the leg sweeps backwards through the floor at
120 // exactly the speed the body goes forwards, so a foot on the ground stays
121 // on the spot instead of skating along it. A unit of ground covered is a
122 // unit of foot carried back, to within a fiftieth of it.
123 const step = 0.5;
124 const slide = (foot(bottom + step)[0] as number) - (foot(bottom - step)[0] as number);
125 expect(slide / (2 * step)).toBeCloseTo(-1, 1.7);
126
127 // The feet stay under the body: at the extreme — the footfall itself — the
128 // swing lifts a foot a few units and not a fifth of a leg. A swing wide
129 // enough to sweep the whole 65 units of a step would be a runner doing the
130 // splits in mid-air, and would cross the floor at twice the ground's speed.
131 expect(foot(0)[2]).toBeGreaterThan(3);
132 expect(foot(0)[2]).toBeLessThan(8);
133 });
134
135 test("the phase is blended between two ticks, the short way round the wrap", () => {
136 // The same `alpha` the place is drawn at: the ends are the two ticks exactly.
137 expect(phaseAt(10, 14, 0)).toBe(10);
138 expect(phaseAt(10, 14, 1)).toBe(14);
139 expect(phaseAt(10, 14, 0.5)).toBeCloseTo(12, 9);
140 // Across the wrap it is the four units of ground it really was, not a whole
141 // gait run backwards: 128 to 2 is forward through `GAIT_CYCLE`.
142 expect(phaseAt(GAIT_CYCLE - 2, 2, 0.5)).toBeCloseTo(GAIT_CYCLE, 9);
143 expect(at(phaseAt(GAIT_CYCLE - 2, 2, 0.5), RUN).legSwing).toBeCloseTo(at(0, RUN).legSwing, 9);
144 // A backlog does not draw the future, and nothing that is not a number
145 // blends at all: the newest tick is what a frame falls back to.
146 expect(phaseAt(10, 14, 9)).toBe(14);
147 expect(phaseAt(10, 14, -3)).toBe(10);
148 expect(phaseAt(10, 14, NaN)).toBe(14);
149 expect(phaseAt(NaN, 14, 0.5)).toBe(14);
150 });
151
152 test("a pose is filled into the caller's object, so a frame of ten allocates one", () => {
153 const scratch = neutral();
154 expect(stride(scratch, 10, RUN, true)).toBe(scratch);
155 const first = scratch.legSwing;
156 stride(scratch, 10 + STEP_STRIDE, RUN, true);
157 expect(scratch.legSwing).toBeCloseTo(-first, 9);
158 });
159
160 // --- the hinge, the one piece of this the CPU also has to do
161
162 const turn = (point: Vec3, joint: number, degrees: number): Vec3 =>
163 hinge([0, 0, 0], point, joint, degrees);
164
165 test("a hinge is a turn about the joint: nothing moves but what is away from it", () => {
166 // At the joint itself, and at no angle at all, a point is where it was.
167 expect(turn([0, 5, 36], 36, 40)).toEqual([0, 5, 36]);
168 expect(turn([27, -6, 46], 36, 0)).toEqual([27, -6, 46]);
169
170 // A quarter turn puts what was above the joint out in front of it.
171 const up: Vec3 = [0, 0, 46];
172 const quarter = turn(up, 36, -90);
173 expect(quarter[0]).toBeCloseTo(10, 9);
174 expect(quarter[2]).toBeCloseTo(36, 9);
175
176 // And it is a rotation, so the distance from the joint is kept and the
177 // sideways axis — the one it turns about — is not touched at all.
178 const far = (p: Vec3): number => Math.hypot(p[0], p[2] - 36);
179 for (const degrees of [-31, -8, 3, 17, 90, 180]) {
180 const point: Vec3 = [27, -6, 46];
181 const turned = turn(point, 36, degrees);
182 expect(far(turned)).toBeCloseTo(far(point), 9);
183 expect(turned[1]).toBe(point[1]);
184 }
185 // Back again is back exactly: two hinges that cancel are no hinge.
186 const there = turn([27, -6, 46], 36, 23);
187 const back = turn(there, 36, -23);
188 expect(back[0]).toBeCloseTo(27, 9);
189 expect(back[2]).toBeCloseTo(46, 9);
190 });
191
192 test("a lean tips the top of a body forwards, which is what the gun rides", () => {
193 // The upper body is hinged through the negative of the lean, the way the
194 // renderer asks for it: a point above the hips comes forward and drops.
195 const muzzle: Vec3 = [27, -6, 46];
196 const leaned = turn(muzzle, 36, -at(0, RUN).lean);
197 expect(leaned[0]).toBeGreaterThan(muzzle[0]);
198 expect(leaned[2]).toBeLessThan(muzzle[2]);
199 // A body at a stop leans by nothing, so the barrel is where it was modelled.
200 expect(turn(muzzle, 36, -at(0, 0).lean)).toEqual(muzzle);
201 });
202
203 test("a hinge writes into the caller's point, and may be the point itself", () => {
204 const scratch: Vec3 = [27, -6, 46];
205 expect(hinge(scratch, scratch, 36, -12)).toBe(scratch); // a lean's own sign
206 expect(scratch[2]).toBeLessThan(46);
207 expect(Math.hypot(scratch[0], scratch[2] - 36)).toBeCloseTo(Math.hypot(27, 10), 9);
208 });
209
210 test("a distance or a speed that is not a number is the neutral pose", () => {
211 expect(at(NaN, RUN)).toEqual(neutral());
212 expect(at(10, NaN)).toEqual(neutral());
213 expect(at(Infinity, RUN)).toEqual(neutral());
214 expect(at(10, -5)).toEqual(neutral());
215 });
src/client/stride.ts added +178
@@ -0,0 +1,178 @@
1 /**
2 * What a player's limbs are doing, from what the simulation already counts.
3 *
4 * This is the shape of a body, not the place of one: where a player is drawn
5 * and which way they face is `interpolate.ts`, which owns the word `pose` for
6 * it. Here it is the gait — the swing of the legs, the counter-swing of the
7 * free arm, and the lean of everything above the hips.
8 *
9 * The phase is `p.stepDistance` — ground covered since the gait began, in
10 * world units — so the legs swing on exactly the stride the footsteps are
11 * counted on, and a player who speeds up takes faster steps for the same
12 * reason they are heard taking faster steps.
13 *
14 * One `STEP_STRIDE` of ground is one *step*, which is half a cycle: a gait
15 * contains two footfalls, right then left, so the whole cycle is
16 * `GAIT_CYCLE`. That is why the swing is a cosine of half a turn per step and
17 * not a sine of a whole one. It puts the extreme — one leg planted out in
18 * front, the other trailing — exactly where `world.ts` pushes the sound, so
19 * the step you hear is the step you see land. A sine over the same number
20 * would run the legs at twice the audible cadence and cross them at the
21 * moment the foot is heard hitting the floor.
22 *
23 * It is a function of phase and not of wall-clock time: the same distance at
24 * the same speed is the same pose, this frame and the next, so nothing here
25 * drifts against the sound of the steps and a paused match does not walk on
26 * the spot. Amplitude scales with speed and is exactly zero at a dead stop,
27 * so a player standing still is the mesh as it was modelled.
28 *
29 * Nothing in here touches GL, the DOM, a `World` or the module that holds one:
30 * the stride it measures against lives in `physics.ts` beside the speed it is
31 * measured against, so `world.ts` and this file both read it from there and
32 * neither is in the other's import graph. It fills a `Stride` the
33 * caller owns rather than returning a new one, so a frame that poses ten
34 * players allocates nothing — the arena steps ten players a tick and never
35 * renders, and this must stay cheap enough that calling it there would still
36 * be free.
37 */
38
39 import { clamp, type Vec3 } from "./math.ts";
40 import { GAIT_CYCLE, MOVE, STEP_STRIDE } from "./physics.ts";
41
42 /**
43 * The three numbers the vertex shader needs, in degrees.
44 *
45 * `legSwing` is positive with the right leg forward and the left one back.
46 * `armSwing` is the free arm — the left one — counter-swung against the leg
47 * it shares a side with, which is how a person walks: left arm forward with
48 * the right leg. The arm that holds the gun does not move independently of
49 * the gun; it leans with the torso and nothing else, so the muzzle stays in
50 * a hand however far the body is tipped.
51 */
52 export type Stride = {
53 /** Right leg forward, left leg back, in degrees about the hips. */
54 legSwing: number;
55 /** The free arm, against its own leg, in degrees about the shoulder. */
56 armSwing: number;
57 /** How far the upper body is tipped forward over the hips, in degrees. */
58 lean: number;
59 };
60
61 /** The pose of a player standing still: the mesh exactly as it was modelled. */
62 export const neutral = (): Stride => ({ legSwing: 0, armSwing: 0, lean: 0 });
63
64 /**
65 * The speed the swing is measured against is the one the simulation runs at,
66 * taken from `physics.ts` rather than written down a second time: a change of
67 * pace there is a change of stride here and cannot drift out of step with it.
68 * Walking and ducking are fractions of the same number, so both come out as a
69 * fraction of a run's swing without this file knowing either.
70 */
71 const RUN_SPEED = MOVE.maxSpeed;
72
73 /**
74 * A full run's swing, in degrees, and it is geometry rather than taste.
75 *
76 * A leg here is a rigid bar: 36 units of it below the joint it turns about,
77 * no knee to fold and hips that never rise. A swing of θ therefore holds the
78 * foot `36(1 − cos θ)` *above* the floor, and the one instant in a step when
79 * a foot is really on the ground is the bottom of its arc, where the swing
80 * passes through zero. That is the only plant there is to get right, and
81 * getting it right means the foot does not travel across the floor while it
82 * is on it: the leg has to sweep backwards through that point at exactly the
83 * speed the body is going forwards.
84 *
85 * Differentiate the swing there and it is one number — `36·θ·π / STEP_STRIDE`
86 * = 1, or 32.9°. At 33° the foot is still against the ground to within a
87 * fifth of a per cent as it passes through, and never lifts more than 6 units
88 * at the extreme, so the feet stay under the body.
89 *
90 * The other sum — make the foot's whole sweep, `72·sin θ`, cover the 65 units
outdated

Reviewed the head at 5a4a068 — read every changed line, ran the check on a throwaway worktree of that commit (bun run check: tsc clean, 561 pass), and mutated three things to confirm the new tests bite: reverting the footsteps gate fails "a walker's stride keeps turning", dropping gl.uniform3f(u.pose, ...) from the player loop fails two renderer cases, and neutering the muzzle lean fails "the flash is on the barrel of the body that was drawn". The security surface is untouched — no src/server.ts, no src/tty.ts, no identity, no input path, no new dependency — stride() even rejects NaN/Infinity at its own boundary, and one draw per player, unposed corpses and the restored-context path are all asserted rather than claimed. The shape is right: a pure module, a per-vertex part, one uPose uniform, and the CPU knowing about a hinge in exactly one place.

The blocker is the animation's cadence, which is the one thing the title promises.

1 (high) — the legs run at twice the rate of the footsteps they are supposed to be counted on, and the step sound lands where the legs are together. footsteps pushes one sound per STEP_STRIDE (65 units), so at 250 u/s a runner is heard at 250/65 = 3.85 steps a second — which is about right for a person. stride() makes distance / STEP_STRIDE one whole sine cycle, and a gait cycle contains two footfalls, so the legs alternate 7.7 times a second: double the audible cadence, and double a human's. Worse, the counter wraps to ~0 at the moment the sound fires, and Math.sin(0) is zero — the legs are crossed, mid-swing, exactly when the foot is heard hitting the floor. The extremes fall at 16.25 and 48.75 units, between the sounds, which your own stride.test.ts pins as "a quarter of the way in is the extreme of the swing". So the claim in the README — "legs swing on that same stride" — is off by a factor of two and a quarter cycle.

Fix it in the one expression: one STEP_STRIDE is one step, not one cycle, and the extreme belongs on the wrap.

const swing = Math.cos((distance / STEP_STRIDE) * Math.PI) * LEG_SWING * amount;

Now d=0 is the right leg planted forward, d=65 (the next sound) is the left, d=130 is the right again: one footfall per audible step, feet alternating with the ear, 3.85 a second. Two test claims move with it — "half a stride later the other leg is forward" becomes a full STEP_STRIDE later (stride.test.ts), and the same in "two worlds that differ only in stride" in renderer.test.ts, where STEP_STRIDE / 2 becomes STEP_STRIDE. That contradicts the literal checkbox in #88 ("half the stride gives the opposite leg forward"); the checkbox is what is wrong, because it reads STEP_STRIDE as a gait cycle when footsteps spends it as a single step. Say so in the pull and I will not ask again.

While you are in there, check the skate: with the cycle doubled, a foot travels 2·36·sin(32°) = 38 units of the 65 the body covers between plants, so the feet slide 40% of the way. LEG_SWING wants to come up toward 50–65° (sin θ = 32.5/36 is 64°for zero skate) — pick it by eye, but pick it deliberately and say what you saw.

2 (medium) — the lean moves what a player aims at and nothing that a bullet hits. combat.ts builds hitboxes axis-aligned on pos: head is pos±9, legs pos±13. At full run the 8° lean carries the drawn head sin(8°)·31 = 4.3 units forward, so ~1.8 units of a 13-wide head box sticks out past the 18-wide head hitbox, and a swung foot reaches 36·sin(32°) = 19 units forward of a leg box that stops at 13. "Shooting with clear hit feedback" is in the direction, and a pose that moves the target away from the hitbox spends some of it. It is inherent to posing without animated hitboxes, and I am not asking you to animate them — but with the amplitude raised in (1) the legs go further out, so either keep LEAN small enough that the head stays inside its own box or write the trade into the README beside the sentence that says the simulation neither sets the pose nor reads it.

3 (low) — the pose is read a tick ahead of the body it is drawn on. The loop takes at = between(p.prev, p, alpha, drawn) for the place and then p.stepDistance / p.vel — the newest tick — for the shape. That is the exact inconsistency the flash comment is proud of having fixed, half a tick of phase off (~4 units, ~2° of swing). Cheap to close if prev ever carries stepDistance; noted, not required.

4 (low) — stride.ts says "Nothing in here touches GL, the DOM or a World" and then imports world.ts for STEP_STRIDE, which drags the whole simulation module into the import graph of the pure module — including for the arena the same doc comment says should be able to call it. STEP_STRIDE belongs next to MOVE in physics.ts, with world.ts and stride.ts both reading it from there. Same paragraph: the first line of the footsteps doc still reads "never while ducking, walking (Shift) or airborne", which is now true of the sound only.

One note on what I could not do: both https://cs--122--forge-trevato-apps.open-platform.sh/ and the production host answer 404 from this sandbox, and the two screenshots are the landing page — the game lives behind a canvas, so no picture at this head shows a leg moving. I booted 5a4a068 locally instead (/ in 5.8 ms, the bundle serves, the posed shader is in it and parses as GLSL ES 3.00), but nobody has yet watched this run on a GPU. When you push the fix, say what the legs looked like when you did.

Fix (1) and this is a yes.

91 * of ground between two plants — wants 64.5°, and it is the wrong sum for
92 * this leg: it assumes a foot that stays down across the step, when this one
93 * touches at a point. Drawn, that is a runner doing the splits with both feet
94 * 20 units in the air at every footfall, and the foot crossing the floor at
95 * twice the ground's speed on the way through.
96 */
97 const LEG_SWING = 33;
98 /** The free arm swings less than the leg it opposes — a run, not a march. */
99 const ARM_SHARE = 0.55;
100 /**
101 * How far a runner is tipped over their own hips at full speed. It stays
102 * small on purpose: the hitboxes in `combat.ts` are axis-aligned on `pos` and
103 * know nothing about any of this, so every degree of lean is a degree the
104 * drawn body and the thing a bullet hits disagree by. The head is exempt —
105 * `models.ts` leaves it on `PART.STILL`, so the one box worth a headshot does
106 * not move with the lean at all.
107 */
108 const LEAN = 8;
109
110 const clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n);
111
112 /**
113 * Fills `out` with the pose of a player whose gait is `distance` into its
114 * cycle, who is moving at `speed` units a second across the ground and is or
115 * is not `onGround`, and returns it.
116 *
117 * Airborne is the neutral pose with no phase in it at all: a player mid-jump
118 * whose legs kept scissoring reads as a glitch, and `world.ts` zeroes the
119 * distance on the way up anyway — this is the second half of that promise,
120 * so a stale distance cannot pose a body in the air.
121 */
122 export function stride(out: Stride, distance: number, speed: number, onGround: boolean): Stride {
123 const amount = onGround && Number.isFinite(speed) ? clamp01(speed / RUN_SPEED) : 0;
124 if (amount === 0 || !Number.isFinite(distance)) {
125 out.legSwing = 0;
126 out.armSwing = 0;
127 out.lean = 0;
128 return out;
129 }
130 // Half a turn per step, so a whole one per `GAIT_CYCLE`: the right leg is
131 // planted forward where the phase is 0, the left where it is `STEP_STRIDE`,
132 // and both of those are where `world.ts` pushes a footstep sound.
133 const swing = Math.cos((distance / STEP_STRIDE) * Math.PI) * LEG_SWING * amount;

Approved at f966129. The cadence is fixed and fixed properly: one STEP_STRIDE is a step, GAIT_CYCLE is two, the counter in world.ts runs the whole cycle and fires a sound at each half of it, and stride.ts is a cosine of half a turn per step — so the legs alternate 3.85 times a second at a run against 3.85 sounds, instead of the 7.7 they did at 5a4a068. phaseAt closes the tick the pose was read at, the short way round the wrap, and STEP_STRIDE now lives in physics.ts beside the speed it is measured against, so stride.ts imports no World and the doc comment that says so is true.

What I checked rather than took on trust:

  • bun run check on a throwaway worktree of f966129: tsc clean, 567 pass. Booted it — / answers in 19 ms, the bundle builds, the posed shader is in it.
  • Four mutations, each caught by a new test and only by new tests: putting the sine back (5 failures in stride.test.ts), reading p.stepDistance instead of phaseAt(...) in the player loop (fails "the legs are drawn at the tick the body is drawn at"), tagging the head PART.UPPER again (3 failures in models.test.ts, including the head-box one), and wrapping the counter every STEP_STRIDE (fails "a sound every step, and the phase that carries it is two steps long"). Restored, green again.
  • LEG_SWING = 33 is the number the derivation says it is: 36·θ·π / 65 = 1 gives 32.93°, and the foot really does track the floor — at the bottom of the arc it sweeps back at 1.002 units per unit of ground, which is the right answer to the skate I asked about last round, and a better one than the 50–65° I suggested. You were right to push back and right to write down why.
  • Both existing footstep tests are untouched and pass, the hit path is untouched, and observe.ts is not in the diff.
  • Security is a short paragraph because there is nothing in it: no src/server.ts, no src/tty.ts, no identity, session, body-reading, rate limit, SQL or shell in the diff. stride() still refuses NaN, Infinity and a negative speed at its own boundary, and phaseAt does the same with alpha. No new dependency.
  • The head on PART.STILL is the right answer to the hitbox note, and the README says the rest plainly instead of hiding it. The drawn head sits inside the box combat.ts scores a headshot on, across the ground plane, and there is a test that fails if it ever leaves.

Two things I am leaving as notes rather than asking you to change, because they are honest trade-offs and the code already says so:

  1. Where the sound lands in the arc is a convention, not geometry. With a rigid leg and hips that do not rise, the foot is lowest — actually touching — at the midpoint between two sounds, which is exactly what "the foot is still against the floor at the one moment it is on it" asserts at STEP_STRIDE / 2. The sound fires at the extreme, where the foot is 5.8 units up. That is what I asked for and I think it reads better on a boxy rig, because the extreme is the frame a person parses as a step and the zero crossing has both legs in the same column. But the README's "the swing is widest at the footfall" is the convention talking, and a reader who trusts it will be surprised by the test. One clause would square them.
  2. The legs and torso still leave their hitboxes at a sprint — a swung foot reaches 19 units forward of a leg box that stops at 13. The README says so in as many words, the head is exempt, and animated hitboxes are not what the direction asks for. Fine as it stands.

Nothing left of this pull for an issue. One thing I found beside it and filed instead: the leaderboard's seven-column table runs off a 360px phone and drops wins — visible in the forge's screenshots of this head and of main alike, so it is not yours. That is #141.

Still nobody's eyes on this running on a GPU: the preview host and production both 404 from my sandbox, and the screenshots are the landing page, with the game behind a canvas. The shader parses as GLSL ES 3.00 and the ordering of the pose uniform is pinned by tests, which is as far as a sandbox reaches. Worth someone clicking Play before it merges.

134 out.legSwing = swing;
135 out.armSwing = swing * ARM_SHARE;
136 out.lean = LEAN * amount;
137 return out;
138 }
139
140 /**
141 * The phase to draw at, between the two ticks the frame lands between: the
142 * same `alpha` the place is blended by, so a body's shape is read at the tick
143 * its position is read at rather than the one ahead of it. Half a tick of
144 * phase at a run is two units of ground, which is a couple of degrees of
145 * swing — small, but it is the same inconsistency the muzzle flash refuses.
146 *
147 * The counter wraps at `GAIT_CYCLE`, so the step between two ticks is taken
148 * the short way forward round that circle: 129 to 1 is two units of ground,
149 * not a gait run backwards. Nothing that is not a number blends at all.
150 */
151 export function phaseAt(prev: number, now: number, alpha: number): number {
152 if (!Number.isFinite(prev) || !Number.isFinite(now)) return now;
153 const step = (((now - prev) % GAIT_CYCLE) + GAIT_CYCLE) % GAIT_CYCLE;
154 return prev + step * clamp(Number.isFinite(alpha) ? alpha : 1, 0, 1);
155 }
156
157 /**
158 * `point` turned about the model's own -Y axis through the joint at height
159 * `joint`, in degrees, written into `out` — the same rotation the vertex
160 * shader applies to a part, in the one place the CPU has to know where a
161 * posed piece of a body ended up.
162 *
163 * That place is a muzzle flash: the gun rides the upper body, so a running
164 * player's barrel leans with it, and a star drawn at the gun's modelled
165 * muzzle would hang in the air a few units off the end of it. Everything
166 * else about a pose happens on the GPU and stays there.
167 *
168 * `out` may be `point`; nothing is allocated either way.
169 */
170 export function hinge(out: Vec3, point: Vec3, joint: number, degrees: number): Vec3 {
171 const a = (degrees * Math.PI) / 180;
172 const c = Math.cos(a), s = Math.sin(a);
173 const dx = point[0], dz = point[2] - joint;
174 out[0] = dx * c - dz * s;
175 out[1] = point[1];
176 out[2] = dx * s + dz * c + joint;
177 return out;
178 }
src/client/world.test.ts +55
@@ -4,6 +4,7 @@ import { teleported } from "./interpolate.ts";
44 import { dist } from "./math.ts";
55 import { DE_FORGE, siteAt } from "./map.ts";
66 import { SHORT, winReward } from "./match.ts";
7 import { GAIT_CYCLE, STEP_STRIDE } from "./physics.ts";
78 import { WEAPONS } from "./weapons.ts";
89 import {
910 aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, createWorld, currentWeapon, damageDealt,
@@ -575,6 +576,60 @@ test("half the speed leaves about half the footsteps", () => {
575576 expect(stepsIn(world)).toBeLessThanOrEqual(2);
576577 });
577578
579 test("a walker's stride keeps turning even though nobody hears it", () => {
580 // The phase the legs swing on is the one the footsteps are counted on, so a
581 // player creeping across a doorway has to keep covering ground even while
582 // they are silent — otherwise they read as sliding.
583 const walking = createWorld(SHORT, "me", "ct", 5, 71);
584 walkOnGround(walking, 1, { ...NO_INPUT, forward: 1, walk: true });
585 expect(stepsIn(walking)).toBe(0);
586 expect(walking.me.stepDistance).toBeGreaterThan(0);
587
588 const ducking = createWorld(SHORT, "me", "ct", 5, 73);
589 walkOnGround(ducking, 1.5, { ...NO_INPUT, forward: 1, duck: true });
590 expect(stepsIn(ducking)).toBe(0);
591 expect(ducking.me.stepDistance).toBeGreaterThan(0);
592
593 // Off the ground it is zero and stays zero: feet in the air cover none.
594 const jumping = createWorld(SHORT, "me", "ct", 5, 79);
595 walkOnGround(jumping, 0.5, { ...NO_INPUT, forward: 1 });
596 expect(jumping.me.stepDistance).toBeGreaterThan(0);
597 for (let t = 0; t < 0.2; t += TICK) {
598 jumping.time += TICK;
599 applyInput(jumping, jumping.me, { ...NO_INPUT, forward: 1, jump: true }, TICK);
600 }
601 expect(jumping.me.onGround).toBe(false);
602 expect(jumping.me.stepDistance).toBe(0);
603 });
604
605 test("a sound every step, and the phase that carries it is two steps long", () => {
606 // The cadence the ear gets: one `STEP_STRIDE` of ground per footstep, at
607 // whatever speed the player is doing it. Four seconds of running is four
608 // times the one-second count and not eight — this is what the legs are
609 // swung on, so if it moves, the animation moves with it.
610 const world = createWorld(SHORT, "me", "ct", 5, 83);
611 walkOnGround(world, 4, { ...NO_INPUT, forward: 1 });
612 const covered = Math.hypot(world.me.pos[0], world.me.pos[1]);
613 const expected = covered / STEP_STRIDE;
614 expect(stepsIn(world)).toBeGreaterThanOrEqual(Math.floor(expected) - 1);
615 expect(stepsIn(world)).toBeLessThanOrEqual(Math.ceil(expected));
616
617 // And the phase runs over a whole gait — two of those steps — rather than
618 // resetting at each one: a counter that wrapped every step would swap a
619 // runner's legs from under them every time a foot landed. Over four
620 // seconds it passes through the far half of the cycle many times.
621 expect(world.me.stepDistance).toBeGreaterThanOrEqual(0);
622 expect(world.me.stepDistance).toBeLessThan(GAIT_CYCLE);
623 const phases: number[] = [];
624 for (let t = 0; t < 1; t += TICK) {
625 world.time += TICK;
626 applyInput(world, world.me, { ...NO_INPUT, forward: 1 }, TICK);
627 phases.push(world.me.stepDistance);
628 }
629 expect(phases.some((d) => d > STEP_STRIDE)).toBe(true);
630 expect(phases.every((d) => d < GAIT_CYCLE)).toBe(true);
631 });
632
578633 test("a match plays itself to the end inside its own time", () => {
579634 const world = createWorld({ ...SHORT, winScore: 2, freezeTime: 1, roundTime: 40, endDelay: 1 }, "me", "ct", 5, 23);
580635 world.me.alive = false;
src/client/world.ts +36 −15
@@ -17,7 +17,7 @@ import { pose, remember, rememberPos, type Pose } from "./interpolate.ts";
1717 import { DE_FORGE, siteAt, type MapDef } from "./map.ts";
1818 import { angleVectors, angleDelta, clamp, dist, dist2d, normalize, rng, sub, type Vec3 } from "./math.ts";
1919 import * as rules from "./match.ts";
20 import { eyeHeight, hullHeight, move, type Body, type Wish, MOVE } from "./physics.ts";
20 import { eyeHeight, GAIT_CYCLE, hullHeight, move, STEP_STRIDE, type Body, type Wish, MOVE } from "./physics.ts";
2121 import { callLabel, isCall, roundPlan, type Call, type Plan, type RadioCall } from "./plan.ts";
2222 import { visible } from "./trace.ts";
2323 import {
@@ -59,7 +59,9 @@ export type Player = Actor & Body & {
5959 headshots: number;
6060 damage: number;
6161 spawnAt: number;
62 stepDistance: number; // ground covered since the last footstep, for cadence
62 stepDistance: number; // ground covered into the gait cycle: the footsteps' cadence and the legs' phase
63 /** That same phase when the previous tick ended, for the reason `prev` exists. */
64 prevStep: number;
6365 /**
6466 * Where this player was when the previous tick ended. Written once a tick by
6567 * `update` and read by nothing but the renderer, which draws between it and
@@ -230,7 +232,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player
230232 defuseKit: false, hasBomb: false,
231233 nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false, firedThisPress: false, emptyThisPress: false,
232234 punch: [0, 0], nextPickupAt: 0,
233 kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0, stepDistance: 0,
235 kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0, stepDistance: 0, prevStep: 0,
234236 prev: pose(),
235237 };
236238 }
@@ -338,6 +340,7 @@ export function startRound(world: World): void {
338340 p.punch = [0, 0];
339341 p.scoped = false;
340342 p.stepDistance = 0;
343 p.prevStep = 0;
341344 p.spawnAt = world.time;
342345 p.nextPickupAt = 0;
343346 p.yaw = p.team === "t" ? 90 : -90;
@@ -930,24 +933,38 @@ export function useProgress(world: World, p: Player): { label: string; fraction:
930933
931934 // --- footsteps --------------------------------------------------------------
932935
933 /** A running stride is roughly 60–70 units at 250 u/s; the middle of that. */
934 const STEP_STRIDE = 65;
935
936936 /**
937 * A footstep every stride's worth of ground covered — never while ducking,
938 * walking (Shift) or airborne, the ways CS itself keeps you quiet. Distance
939 * drives the cadence rather than a timer, so it speeds up with the player;
940 * pushing one `Effect` a few times a second, not one a tick, is what keeps
941 * this affordable in the training arena that steps every player every tick.
937 * A footstep every `STEP_STRIDE` of ground covered, and the phase that carries
938 * it. Distance drives the cadence rather than a timer, so it speeds up with
939 * the player; pushing one `Effect` a few times a second, not one a tick, is
940 * what keeps this affordable in the training arena that steps every player
941 * every tick.
942 *
943 * The counter runs over a whole `GAIT_CYCLE` — two steps — rather than
944 * resetting at each one, because a cycle of legs contains two footfalls: the
945 * right foot lands at 0 and the left at `STEP_STRIDE`, and `stride.ts` reads
946 * the same number to put the right leg forward at one and the left at the
947 * other. A counter that wrapped every step would swap a runner's legs from
948 * under them each time a foot landed.
949 *
950 * It is the sound that is silenced while ducking or walking (Shift), the ways
951 * CS itself keeps you quiet — the phase keeps turning either way, because a
952 * crouched player whose legs froze would read as sliding. Only the air resets
953 * it: feet that are not on the ground are not covering any.
942954 */
943955 function footsteps(world: World, p: Player, wish: Wish, before: Vec3): void {
944 if (!p.onGround || p.ducking || wish.walk) {
956 if (!p.onGround) {
945957 p.stepDistance = 0;
946958 return;
947959 }
960 const was = p.stepDistance;
948961 p.stepDistance += dist2d(before, p.pos);
949 if (p.stepDistance < STEP_STRIDE) return;
950 p.stepDistance -= STEP_STRIDE;
962 // A step is a multiple of `STEP_STRIDE` crossed — one per step at either
963 // half of the cycle — and the cycle itself wraps after the second of them.
964 const stepped = Math.floor(p.stepDistance / STEP_STRIDE) > Math.floor(was / STEP_STRIDE);
965 if (p.stepDistance >= GAIT_CYCLE) p.stepDistance -= GAIT_CYCLE;
966 if (!stepped) return;
967 if (p.ducking || wish.walk) return; // the stride turns; nobody hears it
951968 world.effects.push({ kind: "sound", at: world.time, pos: [p.pos[0], p.pos[1], p.pos[2]], name: "step", by: p.id });
952969 }
953970
@@ -1013,7 +1030,11 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
10131030 * is the code that moves them.
10141031 */
10151032 function rememberTick(world: World): void {
1016 for (const p of world.players) remember(p.prev, p);
1033 for (const p of world.players) {
1034 remember(p.prev, p);
1035 // The gait is a pose too, and it is blended by the same `alpha`.
1036 p.prevStep = p.stepDistance;
1037 }
10171038 rememberPos(world.bomb.prev, world.bomb.pos);
10181039 }
10191040

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs