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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/88-players-that-read-as-players-legs-that-s → main 5 commits fork 3 threads · 3 unresolved
README.md +20 −1
| @@ -100,7 +100,22 @@ wants one, but the running server never reads it. | ||
| 100 | 100 | view and fades with distance — a shot or a footstep to your left plays from |
| 101 | 101 | the left channel, to your right the right — and a running player who is not |
| 102 | 102 | 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 | |
| 104 | 119 | beeps: about once a second right after the plant, quickening to a few a |
| 105 | 120 | second — and rising in pitch — as the fuse runs out, so a CT can walk to the |
| 106 | 121 | 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. | ||
| 379 | 394 | HUD's minimap draws, and nothing it draws them with), |
| 380 | 395 | `project` (a world point as a screen point, from the same camera the |
| 381 | 396 | 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), | |
| 382 | 401 | `summary` (the end of |
| 383 | 402 | the match as numbers), `ears` (a sound's pan and volume, as plain data), |
| 384 | 403 | `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 = { | ||
| 12 | 12 | normal: Float32Array; |
| 13 | 13 | uv: Float32Array; |
| 14 | 14 | layer: Float32Array; |
| 15 | part: Float32Array; | |
| 15 | 16 | index: Uint32Array; |
| 16 | 17 | }; |
| 17 | 18 | |
| @@ -20,10 +21,27 @@ type Builder = { | ||
| 20 | 21 | normal: number[]; |
| 21 | 22 | uv: number[]; |
| 22 | 23 | layer: number[]; |
| 24 | part: number[]; | |
| 23 | 25 | index: number[]; |
| 24 | 26 | }; |
| 25 | 27 | |
| 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; | |
| 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: [] }); | |
| 27 | 45 | |
| 28 | 46 | export function finish(b: Builder): Mesh { |
| 29 | 47 | return { |
| @@ -31,6 +49,7 @@ export function finish(b: Builder): Mesh { | ||
| 31 | 49 | normal: new Float32Array(b.normal), |
| 32 | 50 | uv: new Float32Array(b.uv), |
| 33 | 51 | layer: new Float32Array(b.layer), |
| 52 | part: new Float32Array(b.part), | |
| 34 | 53 | index: new Uint32Array(b.index), |
| 35 | 54 | }; |
| 36 | 55 | } |
| @@ -46,7 +65,9 @@ const FACES: [Vec3, number, number, number][] = [ | ||
| 46 | 65 | ]; |
| 47 | 66 | |
| 48 | 67 | /** 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 { | |
| 50 | 71 | for (const [normal, axis, u, v] of FACES) { |
| 51 | 72 | const base = b.position.length / 3; |
| 52 | 73 | 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 = | ||
| 59 | 80 | b.normal.push(normal[0], normal[1], normal[2]); |
| 60 | 81 | b.uv.push((p[u] as number) / scale, (p[v] as number) / scale); |
| 61 | 82 | b.layer.push(layer); |
| 83 | b.part.push(part); | |
| 62 | 84 | } |
| 63 | 85 | // Wind the two triangles so the outside faces out. |
| 64 | 86 | 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 { | ||
| 73 | 95 | return finish(b); |
| 74 | 96 | } |
| 75 | 97 | |
| 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); | |
| 78 | 103 | |
| 79 | 104 | /** |
| 80 | 105 | * 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 | ||
| 88 | 113 | /** |
| 89 | 114 | * A player: legs, torso, arms, head, and the gun they are holding, built |
| 90 | 115 | * 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. | |
| 91 | 128 | */ |
| 92 | 129 | export function playerMesh(): Mesh { |
| 93 | 130 | const b = builder(); |
| 94 | 131 | 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); | |
| 102 | 140 | return finish(b); |
| 103 | 141 | } |
| 104 | 142 |
src/client/physics.ts +16
| @@ -27,6 +27,22 @@ export const MOVE = { | ||
| 27 | 27 | stepSize: 18, |
| 28 | 28 | }; |
| 29 | 29 | |
| 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 | ||
| 30 | 46 | export type Body = { |
| 31 | 47 | pos: Vec3; |
| 32 | 48 | vel: Vec3; |
src/client/renderer.test.ts +217 −2
| @@ -18,7 +18,8 @@ import { expect, test } from "bun:test"; | ||
| 18 | 18 | import { addBody } from "./bodies.ts"; |
| 19 | 19 | import { advance, TICK, waiting } from "./loop.ts"; |
| 20 | 20 | 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"; | |
| 22 | 23 | import { createRenderer, LINE_CAPACITY, type Camera, type Renderer, type RendererHooks } from "./renderer.ts"; |
| 23 | 24 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 24 | 25 | import { createWorld, eyePos, startRound, type Effect, type Player, type World } from "./world.ts"; |
| @@ -51,7 +52,12 @@ function recorder(): { | ||
| 51 | 52 | getShaderParameter: log("getShaderParameter", true), getShaderInfoLog: log("getShaderInfoLog", ""), |
| 52 | 53 | createProgram: handle("program"), attachShader: log("attachShader"), linkProgram: log("linkProgram"), |
| 53 | 54 | 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 | }, | |
| 55 | 61 | createTexture: handle("texture"), bindTexture: log("bindTexture"), texImage3D: log("texImage3D"), |
| 56 | 62 | generateMipmap: log("generateMipmap"), texParameteri: log("texParameteri"), |
| 57 | 63 | createVertexArray: handle("vao"), bindVertexArray: log("bindVertexArray"), |
| @@ -309,6 +315,215 @@ test("ten guns firing at once stay well inside the line budget", () => { | ||
| 309 | 315 | expect(count(calls, "bufferData")).toBe(0); |
| 310 | 316 | }); |
| 311 | 317 | |
| 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 | ||
| 312 | 527 | // --- a context the browser takes away |
| 313 | 528 | |
| 314 | 529 | /** 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"; | ||
| 17 | 17 | import { TICK } from "./loop.ts"; |
| 18 | 18 | import { fallenMatrix, multiply, perspective, rad, viewMatrix, modelMatrix, type Vec3 } from "./math.ts"; |
| 19 | 19 | 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, | |
| 22 | 22 | } from "./models.ts"; |
| 23 | 23 | import type { MapDef } from "./map.ts"; |
| 24 | import { hinge, neutral, phaseAt, stride, type Stride } from "./stride.ts"; | |
| 24 | 25 | import { MAX_SCALE, MIN_SCALE } from "./quality.ts"; |
| 25 | 26 | import { LAYERS, TEX_SIZE, materialTextures } from "./textures.ts"; |
| 26 | 27 | import type { Player, World } from "./world.ts"; |
| 27 | 28 | import { hullHeight } from "./physics.ts"; |
| 28 | 29 | import { WEAPONS } from "./weapons.ts"; |
| 29 | 30 | |
| 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 | */ | |
| 30 | 43 | const SCENE_VS = `#version 300 es |
| 31 | 44 | precision highp float; |
| 32 | 45 | layout(location=0) in vec3 aPos; |
| 33 | 46 | layout(location=1) in vec3 aNormal; |
| 34 | 47 | layout(location=2) in vec2 aUV; |
| 35 | 48 | layout(location=3) in float aLayer; |
| 49 | layout(location=4) in float aPart; | |
| 36 | 50 | uniform mat4 uViewProj; |
| 37 | 51 | uniform mat4 uModel; |
| 52 | uniform vec3 uPose; | |
| 38 | 53 | out vec3 vNormal; |
| 39 | 54 | out vec2 vUV; |
| 40 | 55 | out float vLayer; |
| 41 | 56 | 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 | ||
| 42 | 69 | 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); | |
| 44 | 85 | vWorld = world.xyz; |
| 45 | vNormal = mat3(uModel) * aNormal; | |
| 86 | vNormal = mat3(uModel) * normal; | |
| 46 | 87 | vUV = aUV; |
| 47 | 88 | vLayer = aLayer; |
| 48 | 89 | gl_Position = uViewProj * world; |
| @@ -129,6 +170,7 @@ function upload(gl: WebGL2RenderingContext, mesh: Mesh): Buffers { | ||
| 129 | 170 | attribute(mesh.normal, 1, 3); |
| 130 | 171 | attribute(mesh.uv, 2, 2); |
| 131 | 172 | attribute(mesh.layer, 3, 1); |
| 173 | attribute(mesh.part, 4, 1); | |
| 132 | 174 | const index = gl.createBuffer(); |
| 133 | 175 | gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, index); |
| 134 | 176 | gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.index, gl.STATIC_DRAW); |
| @@ -200,7 +242,10 @@ const RELOAD_ROLL = 26; | ||
| 200 | 242 | type Gpu = { |
| 201 | 243 | scene: WebGLProgram; |
| 202 | 244 | 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 | >; | |
| 204 | 249 | texture: WebGLTexture | null; |
| 205 | 250 | player: Buffers; |
| 206 | 251 | bomb: Buffers; |
| @@ -260,6 +305,7 @@ function build(gl: WebGL2RenderingContext): Gpu { | ||
| 260 | 305 | eye: uniform(scene, "uEye"), |
| 261 | 306 | tint: uniform(scene, "uTint"), |
| 262 | 307 | fog: uniform(scene, "uFog"), |
| 308 | pose: uniform(scene, "uPose"), | |
| 263 | 309 | atlas: uniform(scene, "uAtlas"), |
| 264 | 310 | lineViewProj: uniform(lines, "uViewProj"), |
| 265 | 311 | }, |
| @@ -371,6 +417,12 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks = | ||
| 371 | 417 | const spot: Vec3 = [0, 0, 0]; |
| 372 | 418 | const bombSpot: Vec3 = [0, 0, 0]; |
| 373 | 419 | |
| 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 | ||
| 374 | 426 | let bobPhase = 0; |
| 375 | 427 | // The gun the hands last had, and when they were given it: a change of |
| 376 | 428 | // 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 = | ||
| 415 | 467 | const identity = modelMatrix([0, 0, 0], 0); |
| 416 | 468 | gl.uniformMatrix4fv(u.model, false, identity); |
| 417 | 469 | gl.uniform3f(u.tint, 1, 1, 1); |
| 470 | gl.uniform3f(u.pose, 0, 0, 0); | |
| 418 | 471 | gl.bindVertexArray(level.vao); |
| 419 | 472 | gl.drawElements(gl.TRIANGLES, level.count, gl.UNSIGNED_INT, 0); |
| 420 | 473 | |
| 421 | 474 | // The player the camera is inside is the one we skip — while dead that is |
| 422 | 475 | // 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. | |
| 423 | 481 | gl.bindVertexArray(player.vao); |
| 424 | 482 | for (const p of world.players) { |
| 425 | 483 | if (!p.alive || p === view) continue; |
| @@ -432,8 +490,14 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks = | ||
| 432 | 490 | gl.uniformMatrix4fv(u.model, false, modelMatrix(at.pos, at.yaw, 1, 1, squash)); |
| 433 | 491 | const tint = TEAM_TINT[p.team] ?? [1, 1, 1]; |
| 434 | 492 | 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)); | |
| 435 | 495 | gl.drawElements(gl.TRIANGLES, player.count, gl.UNSIGNED_INT, 0); |
| 436 | 496 | } |
| 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); | |
| 437 | 501 | |
| 438 | 502 | // The dead of this round, on the same mesh and the same buffer: one draw |
| 439 | 503 | // each and not a byte of state beyond the matrix and the tint. |
| @@ -580,14 +644,19 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks = | ||
| 580 | 644 | let mx = effect.pos[0], my = effect.pos[1], mz = effect.pos[2]; |
| 581 | 645 | for (const p of world.players) { |
| 582 | 646 | 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. | |
| 586 | 653 | 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); | |
| 587 | 656 | 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); | |
| 591 | 660 | break; |
| 592 | 661 | } |
| 593 | 662 | 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 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. Fix it in the one expression: one
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 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. 2 (medium) — the lean moves what a player aims at and nothing that a bullet hits. 3 (low) — the pose is read a tick ahead of the body it is drawn on. The loop takes 4 (low) — One note on what I could not do: both 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 What I checked rather than took on trust:
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:
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 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"; | ||
| 4 | 4 | import { dist } from "./math.ts"; |
| 5 | 5 | import { DE_FORGE, siteAt } from "./map.ts"; |
| 6 | 6 | import { SHORT, winReward } from "./match.ts"; |
| 7 | import { GAIT_CYCLE, STEP_STRIDE } from "./physics.ts"; | |
| 7 | 8 | import { WEAPONS } from "./weapons.ts"; |
| 8 | 9 | import { |
| 9 | 10 | aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, createWorld, currentWeapon, damageDealt, |
| @@ -575,6 +576,60 @@ test("half the speed leaves about half the footsteps", () => { | ||
| 575 | 576 | expect(stepsIn(world)).toBeLessThanOrEqual(2); |
| 576 | 577 | }); |
| 577 | 578 | |
| 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 | ||
| 578 | 633 | test("a match plays itself to the end inside its own time", () => { |
| 579 | 634 | const world = createWorld({ ...SHORT, winScore: 2, freezeTime: 1, roundTime: 40, endDelay: 1 }, "me", "ct", 5, 23); |
| 580 | 635 | world.me.alive = false; |
src/client/world.ts +36 −15
| @@ -17,7 +17,7 @@ import { pose, remember, rememberPos, type Pose } from "./interpolate.ts"; | ||
| 17 | 17 | import { DE_FORGE, siteAt, type MapDef } from "./map.ts"; |
| 18 | 18 | import { angleVectors, angleDelta, clamp, dist, dist2d, normalize, rng, sub, type Vec3 } from "./math.ts"; |
| 19 | 19 | 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"; | |
| 21 | 21 | import { callLabel, isCall, roundPlan, type Call, type Plan, type RadioCall } from "./plan.ts"; |
| 22 | 22 | import { visible } from "./trace.ts"; |
| 23 | 23 | import { |
| @@ -59,7 +59,9 @@ export type Player = Actor & Body & { | ||
| 59 | 59 | headshots: number; |
| 60 | 60 | damage: number; |
| 61 | 61 | 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; | |
| 63 | 65 | /** |
| 64 | 66 | * Where this player was when the previous tick ended. Written once a tick by |
| 65 | 67 | * `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 | ||
| 230 | 232 | defuseKit: false, hasBomb: false, |
| 231 | 233 | nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false, firedThisPress: false, emptyThisPress: false, |
| 232 | 234 | 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, | |
| 234 | 236 | prev: pose(), |
| 235 | 237 | }; |
| 236 | 238 | } |
| @@ -338,6 +340,7 @@ export function startRound(world: World): void { | ||
| 338 | 340 | p.punch = [0, 0]; |
| 339 | 341 | p.scoped = false; |
| 340 | 342 | p.stepDistance = 0; |
| 343 | p.prevStep = 0; | |
| 341 | 344 | p.spawnAt = world.time; |
| 342 | 345 | p.nextPickupAt = 0; |
| 343 | 346 | p.yaw = p.team === "t" ? 90 : -90; |
| @@ -930,24 +933,38 @@ export function useProgress(world: World, p: Player): { label: string; fraction: | ||
| 930 | 933 | |
| 931 | 934 | // --- footsteps -------------------------------------------------------------- |
| 932 | 935 | |
| 933 | /** A running stride is roughly 60–70 units at 250 u/s; the middle of that. */ | |
| 934 | const STEP_STRIDE = 65; | |
| 935 | ||
| 936 | 936 | /** |
| 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. | |
| 942 | 954 | */ |
| 943 | 955 | function footsteps(world: World, p: Player, wish: Wish, before: Vec3): void { |
| 944 | if (!p.onGround || p.ducking || wish.walk) { | |
| 956 | if (!p.onGround) { | |
| 945 | 957 | p.stepDistance = 0; |
| 946 | 958 | return; |
| 947 | 959 | } |
| 960 | const was = p.stepDistance; | |
| 948 | 961 | 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 | |
| 951 | 968 | world.effects.push({ kind: "sound", at: world.time, pos: [p.pos[0], p.pos[1], p.pos[2]], name: "step", by: p.id }); |
| 952 | 969 | } |
| 953 | 970 | |
| @@ -1013,7 +1030,11 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 1013 | 1030 | * is the code that moves them. |
| 1014 | 1031 | */ |
| 1015 | 1032 | 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 | } | |
| 1017 | 1038 | rememberPos(world.bomb.prev, world.bomb.pos); |
| 1018 | 1039 | } |
| 1019 | 1040 |
Reviewed at 3c8fb5c, and I ran it.
bun run checkis green at the head (484 pass, tsc clean) and green again withmainmerged in (505 pass, no conflicts) — the head is based on f7e18b0 rather than currentmain, 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 throughtoFixed(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.tsis 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, andobserve.tsis untouched. I mutated the code to check the new tests actually bite: settinguPoseback to zero in the player loop fails two renderer tests, and restoring the oldfootstepsreset 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.UPPERand movedHIP_Zfrom 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 ofPARTinaPart < 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 thePARTobject 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 smallsrc/client/models.test.tscloses it: assert the per-part vertex counts ofplayerMesh()(24 each for the two legs and the free arm, 96 forUPPER, 168 in all), that everyLEG_*vertex sits at or belowHIP_Zand everyARM_FREEvertex at or belowSHOULDER_Z, and thatPARTholds 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 = 250inpose.tsisMOVE.maxSpeedwritten a second time, with a comment that says as much.pose.tsalready importsworld.ts, which importsphysics.ts, soimport { MOVE } from "./physics.ts"costs nothing and cannot drift — the same reasoning that madeHELD_MUZZLEderive fromHELD_GUNinstead of repeating it.HELD_MUZZLE(the muzzle-flash loop inrenderer.ts), but the gun now ridesUPPERand 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.