Public: anyone can read this; the crew can change it.
forked from trevato/cs
Players read as people when they move: a gait you can see across the room, at no cost to the frame #395
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/155-players-read-as-people-when-they-move-a → main 1 commit fork
4 files
changed +245
−5
README.md +6 −1
| @@ -104,7 +104,12 @@ yet. | ||
| 104 | 104 | — the bomb beeping faster as its fuse runs down, the plant, the cut wire and a |
| 105 | 105 | body going down, each placed where it happened and heard from the eye the |
| 106 | 106 | camera is behind. A footstep is at full volume inside 256 Source units and a |
| 107 | quarter of that at 1,024; your own boots are a third of somebody else's. The leaderboard is a real SQLite table under `FORGE_APP_DATA`. | |
| 107 | quarter of that at 1,024; your own boots are a third of somebody else's. Bodies | |
| 108 | read as people when they move: a player running bobs once a footfall, phased by | |
| 109 | the floor they have crossed rather than the clock, twists at the hips and leans | |
| 110 | into the run; one who lands after a jump sinks into it; one who dies goes over | |
| 111 | and stays down. All of it is drawing, worked out from the round's own numbers, | |
| 112 | and none of it is in the simulation. The leaderboard is a real SQLite table under `FORGE_APP_DATA`. | |
| 108 | 113 | - **Two kinds of map.** `de_forge` is drawn by hand, cell by cell; the other |
| 109 | 114 | kind is carved from a number by `src/client/generate.ts` — pick *generated* |
| 110 | 115 | on the menu and the match id is the seed, so every match is a layout nobody |
src/client/scene.test.ts +134 −2
| @@ -13,11 +13,12 @@ | ||
| 13 | 13 | import { expect, test } from "bun:test"; |
| 14 | 14 | import { existsSync, readFileSync } from "node:fs"; |
| 15 | 15 | import { join } from "node:path"; |
| 16 | import { groundUnder, pushEffect, resetEffects, type Camera } from "../engine/index.ts"; | |
| 16 | import { groundUnder, modelMatrix, pushEffect, resetEffects, type Camera, type Instance } from "../engine/index.ts"; | |
| 17 | 17 | import { CS_HULL } from "./movement.ts"; |
| 18 | 18 | import { digest, fold, reader, TICK, TICKS_PER_SECOND } from "./replay.ts"; |
| 19 | 19 | import { |
| 20 | BODY_FALL, BODY_FLATTEN, BODY_LIFT, BODY_TIP, buildScene, createScene, LINE_CAPACITY, RAISE_DROP, raiseLeft, SKY, | |
| 20 | BODY_FALL, BODY_FLATTEN, BODY_LIFT, BODY_TIP, buildScene, createScene, GAIT_BOB, GAIT_LEAN, GAIT_STEP, | |
| 21 | LAND_AIRTIME, LAND_SETTLE, LAND_SQUASH, LINE_CAPACITY, RAISE_DROP, raiseLeft, SKY, | |
| 21 | 22 | } from "./scene.ts"; |
| 22 | 23 | import { DE_FORGE } from "./map.ts"; |
| 23 | 24 | import { SHORT } from "./match.ts"; |
| @@ -443,3 +444,134 @@ test("reduced motion stills the walk's sway on the gun, and leaves the kick and | ||
| 443 | 444 | expect(still.viewModel?.offset[2]).toBeCloseTo(standing.viewModel?.offset[2] ?? NaN, 12); |
| 444 | 445 | expect(still.viewModel?.offset[2]).toBeGreaterThan(0); |
| 445 | 446 | }); |
| 447 | ||
| 448 | /** The numbers the renderer builds an instance's matrix from, and nothing else. */ | |
| 449 | const placed = (i: Instance): number[] => [...i.pos, i.yaw, i.scale, i.size ?? 1, i.tip ?? 0]; | |
| 450 | ||
| 451 | /** The instance drawn for this player, found where they stand. */ | |
| 452 | const drawnAt = (scene: ReturnType<typeof createScene>, p: Player): Instance | undefined => | |
| 453 | scene.instances.slice(1, scene.instanceCount).find((i) => i.pos[0] === p.pos[0] && i.pos[1] === p.pos[1]); | |
| 454 | ||
| 455 | /** Run `p` down +X at `speed` for `seconds`, one frame every 1/`fps` s, and draw each. */ | |
| 456 | function run(w: World, scene: ReturnType<typeof createScene>, p: Player, speed: number, seconds: number, fps: number): void { | |
| 457 | p.vel = [speed, 0, 0]; | |
| 458 | p.yaw = 0; | |
| 459 | const frames = Math.round(seconds * fps); | |
| 460 | const x0 = p.pos[0]; | |
| 461 | const t0 = w.time; | |
| 462 | for (let f = 1; f <= frames; f++) { | |
| 463 | w.time = t0 + f / fps; | |
| 464 | p.pos[0] = x0 + (speed * f) / fps; | |
| 465 | buildScene(w, camera(), scene); | |
| 466 | } | |
| 467 | } | |
| 468 | ||
| 469 | test("a standing player is placed by exactly the matrix a player always was", () => { | |
| 470 | const w = world(); | |
| 471 | const other = w.players.find((p) => p !== w.me) as Player; | |
| 472 | const scene = createScene(); | |
| 473 | for (let f = 0; f < 10; f++) { | |
| 474 | w.time += 1 / 60; | |
| 475 | buildScene(w, camera(), scene); | |
| 476 | } | |
| 477 | const i = drawnAt(scene, other) as Instance; | |
| 478 | expect([i.pos[2], i.yaw, i.scale, i.tip]).toEqual([other.pos[2], other.yaw, 1, 0]); | |
| 479 | // Untipped, the renderer builds it with `modelMatrix` — the call it always made for a player. | |
| 480 | expect(modelMatrix(i.pos, i.yaw, i.size ?? 1, i.size ?? 1, (i.size ?? 1) * i.scale)) | |
| 481 | .toEqual(modelMatrix(other.pos, other.yaw, 1, 1, 1)); | |
| 482 | }); | |
| 483 | ||
| 484 | test("a running player bobs and leans, and is where their stride says whatever the frame rate", () => { | |
| 485 | const at30 = world(), at144 = world(); | |
| 486 | const a = at30.players.find((p) => p !== at30.me) as Player; | |
| 487 | const b = at144.players.find((p) => p !== at144.me) as Player; | |
| 488 | const slow = createScene(), fast = createScene(); | |
| 489 | buildScene(at30, camera(), slow); | |
| 490 | buildScene(at144, camera(), fast); | |
| 491 | // Half a second at 250 u/s is 125 units: most of the way through a third footfall, mid-bob. | |
| 492 | run(at30, slow, a, 250, 0.5, 30); | |
| 493 | run(at144, fast, b, 250, 0.5, 144); | |
| 494 | expect(a.pos[0]).toBeCloseTo(b.pos[0], 6); | |
| 495 | const ran = drawnAt(slow, a) as Instance; | |
| 496 | const also = [...fast.instances.slice(1, fast.instanceCount)].find((i) => Math.abs(i.pos[0] - b.pos[0]) < 1e-6) as Instance; | |
| 497 | expect(ran.tip).toBeCloseTo(GAIT_LEAN, 10); | |
| 498 | const expected = GAIT_BOB * Math.abs(Math.sin((Math.PI * 125) / GAIT_STEP)); | |
| 499 | expect(ran.pos[2] - a.pos[2]).toBeCloseTo(expected, 6); | |
| 500 | expect(ran.pos[2] - a.pos[2]).toBeGreaterThan(0.5); | |
| 501 | expect(ran.yaw).not.toBe(0); | |
| 502 | const one = placed(ran), two = placed(also); | |
| 503 | for (let k = 0; k < one.length; k++) expect(one[k]).toBeCloseTo(two[k] as number, 6); | |
| 504 | }); | |
| 505 | ||
| 506 | test("a player pushing into a wall goes nowhere and so does their stride: no walking on the spot", () => { | |
| 507 | const w = world(); | |
| 508 | const other = w.players.find((p) => p !== w.me) as Player; | |
| 509 | const scene = createScene(); | |
| 510 | buildScene(w, camera(), scene); | |
| 511 | other.vel = [250, 0, 0]; | |
| 512 | other.yaw = 0; | |
| 513 | for (let f = 0; f < 30; f++) { | |
| 514 | w.time += 1 / 60; | |
| 515 | buildScene(w, camera(), scene); | |
| 516 | const i = drawnAt(scene, other) as Instance; | |
| 517 | expect(i.pos[2]).toBe(other.pos[2]); | |
| 518 | } | |
| 519 | }); | |
| 520 | ||
| 521 | test("a backpedal leans back and a strafe leans neither way", () => { | |
| 522 | const w = world(); | |
| 523 | const other = w.players.find((p) => p !== w.me) as Player; | |
| 524 | const scene = createScene(); | |
| 525 | other.yaw = 0; | |
| 526 | other.vel = [-250, 0, 0]; | |
| 527 | buildScene(w, camera(), scene); | |
| 528 | expect(drawnAt(scene, other)?.tip).toBeCloseTo(-GAIT_LEAN, 10); | |
| 529 | other.vel = [0, 250, 0]; | |
| 530 | buildScene(w, camera(), scene); | |
| 531 | expect(drawnAt(scene, other)?.tip).toBeCloseTo(0, 10); | |
| 532 | }); | |
| 533 | ||
| 534 | test("a landing presses the body down and lets it back up; a tick off a stair edge does not", () => { | |
| 535 | const w = world(); | |
| 536 | const other = w.players.find((p) => p !== w.me) as Player; | |
| 537 | const scene = createScene(); | |
| 538 | buildScene(w, camera(), scene); | |
| 539 | other.onGround = false; | |
| 540 | buildScene(w, camera(), scene); | |
| 541 | w.time += LAND_AIRTIME + 0.1; | |
| 542 | buildScene(w, camera(), scene); | |
| 543 | expect(drawnAt(scene, other)?.scale).toBe(1); | |
| 544 | other.onGround = true; | |
| 545 | buildScene(w, camera(), scene); | |
| 546 | expect(drawnAt(scene, other)?.scale).toBe(1); | |
| 547 | w.time += LAND_SETTLE / 2; | |
| 548 | buildScene(w, camera(), scene); | |
| 549 | expect(drawnAt(scene, other)?.scale).toBeCloseTo(1 - LAND_SQUASH, 10); | |
| 550 | w.time += LAND_SETTLE; | |
| 551 | buildScene(w, camera(), scene); | |
| 552 | expect(drawnAt(scene, other)?.scale).toBe(1); | |
| 553 | ||
| 554 | other.onGround = false; | |
| 555 | buildScene(w, camera(), scene); | |
| 556 | w.time += 1 / 64; | |
| 557 | buildScene(w, camera(), scene); | |
| 558 | other.onGround = true; | |
| 559 | w.time += 1 / 64; | |
| 560 | buildScene(w, camera(), scene); | |
| 561 | w.time += LAND_SETTLE / 2; | |
| 562 | buildScene(w, camera(), scene); | |
| 563 | expect(drawnAt(scene, other)?.scale).toBe(1); | |
| 564 | }); | |
| 565 | ||
| 566 | test("a respawn across the map is not a stride", () => { | |
| 567 | const w = world(); | |
| 568 | const other = w.players.find((p) => p !== w.me) as Player; | |
| 569 | const scene = createScene(); | |
| 570 | buildScene(w, camera(), scene); | |
| 571 | other.pos[0] += GAIT_STEP * 20.5; | |
| 572 | other.vel = [250, 0, 0]; | |
| 573 | other.yaw = 0; | |
| 574 | w.time += 1 / 60; | |
| 575 | buildScene(w, camera(), scene); | |
| 576 | expect(drawnAt(scene, other)?.pos[2]).toBe(other.pos[2]); | |
| 577 | }); |
src/client/scene.ts +97 −2
| @@ -80,6 +80,13 @@ export type Scene = Frame & { | ||
| 80 | 80 | fell: number[]; |
| 81 | 81 | /** False until the first frame is built: a body already down then does not fall again. */ |
| 82 | 82 | drawn: boolean; |
| 83 | /** | |
| 84 | * The gait's memory, by seat like `fell`: how far each body has walked | |
| 85 | * across the floor (the phase its bob is read off), where it stood last | |
| 86 | * frame, when it last left the ground (-1 while it is on it) and when it | |
| 87 | * last came down. The frame's memory again, never the round's. | |
| 88 | */ | |
| 89 | stride: number[]; lastX: number[]; lastY: number[]; left: number[]; landed: number[]; | |
| 83 | 90 | }; |
| 84 | 91 | }; |
| 85 | 92 | |
| @@ -141,6 +148,39 @@ export const BODY_FALL = 0.4; | ||
| 141 | 148 | export const BODY_REACH = 512; |
| 142 | 149 | const BODY_SHADE = 0.66; |
| 143 | 150 | |
| 151 | /** | |
| 152 | * A gait, read off the world's own numbers: a body in motion bobs once a | |
| 153 | * footfall, twists its hips a little either way, and leans into the way it is | |
| 154 | * running, and a body that has been in the air sinks into its landing and comes | |
| 155 | * back up. The bob and the twist are phased by distance walked, never by time, | |
| 156 | * so a body is at the same point of its stride at the same point of the floor | |
| 157 | * whatever the frame rate — and one pushed against a wall, going nowhere, does | |
| 158 | * not walk on the spot. Below `GAIT_STILL` a body is standing, and a standing | |
| 159 | * body's instance is exactly the one it always was. | |
| 160 | * | |
| 161 | * `GAIT_RUN` is a rifle's run in units a second: the speed at which the whole | |
| 162 | * of each is drawn. A shift-walk is about half of it. | |
| 163 | */ | |
| 164 | export const GAIT_STEP = 44; | |
| 165 | export const GAIT_BOB = 2.5; | |
| 166 | export const GAIT_TWIST = 4; | |
| 167 | export const GAIT_LEAN = 7; | |
| 168 | export const GAIT_RUN = 250; | |
| 169 | export const GAIT_STILL = 5; | |
| 170 | /** | |
| 171 | * A landing: how long it takes, how far down it presses the body at its | |
| 172 | * deepest, and how long a body has to have been off the floor for one. A step | |
| 173 | * up a stair edge that loses the ground for a tick is not a landing. | |
| 174 | */ | |
| 175 | export const LAND_SETTLE = 0.22; | |
| 176 | export const LAND_SQUASH = 0.12; | |
| 177 | export const LAND_AIRTIME = 0.15; | |
| 178 | /** | |
| 179 | * A body that moved further than this between two frames did not walk there — | |
| 180 | * it spawned, or a replay was seeked — and the distance is not stride. | |
| 181 | */ | |
| 182 | const GAIT_JUMP = 128; | |
| 183 | ||
| 144 | 184 | /** A body's shadow on the ground it stands on: a touch wider than the 32-unit hull. */ |
| 145 | 185 | const BODY_SHADOW = 22, BODY_DARK = 0.55; |
| 146 | 186 | /** A thrown thing is eight units across; the bomb's case is twenty-two by fourteen. */ |
| @@ -215,7 +255,10 @@ export function createScene(): Scene { | ||
| 215 | 255 | shadows: SHADOW_CAPACITY, |
| 216 | 256 | }), |
| 217 | 257 | sky: [SKY[0], SKY[1], SKY[2]], |
| 218 | keep: { bobPhase: 0, time: 0, viewModel: { mesh: "rifle", offset: [0, 0, 0] }, fell: [], drawn: false }, | |
| 258 | keep: { | |
| 259 | bobPhase: 0, time: 0, viewModel: { mesh: "rifle", offset: [0, 0, 0] }, fell: [], drawn: false, | |
| 260 | stride: [], lastX: [], lastY: [], left: [], landed: [], | |
| 261 | }, | |
| 219 | 262 | }; |
| 220 | 263 | } |
| 221 | 264 | |
| @@ -271,6 +314,57 @@ function lay(scene: Scene, world: World, p: Player, fell: number): void { | ||
| 271 | 314 | place(scene, "player", FROM, p.yaw, 1 + (BODY_FLATTEN - 1) * fall, BODY_TINT[p.team] ?? WHITE, 1, BODY_TIP * fall); |
| 272 | 315 | } |
| 273 | 316 | |
| 317 | /** | |
| 318 | * What a body did since the last frame, into the gait's memory: the floor it | |
| 319 | * crossed, and whether it left the ground or came back down onto it. A frame | |
| 320 | * that is `settled` — the first, or one whose clock went back — has no last | |
| 321 | * frame to measure against, so it only writes down where everybody is. | |
| 322 | */ | |
| 323 | function walk(keep: Scene["keep"], seat: number, p: Player, time: number, settled: boolean): void { | |
| 324 | const x = p.pos[0], y = p.pos[1]; | |
| 325 | const known = !settled && keep.lastX[seat] !== undefined; | |
| 326 | const moved = known ? Math.hypot(x - (keep.lastX[seat] as number), y - (keep.lastY[seat] as number)) : 0; | |
| 327 | keep.stride[seat] = (known ? (keep.stride[seat] ?? 0) : 0) + (moved <= GAIT_JUMP ? moved : 0); | |
| 328 | keep.lastX[seat] = x; | |
| 329 | keep.lastY[seat] = y; | |
| 330 | const left = known ? (keep.left[seat] ?? -1) : -1; | |
| 331 | if (!known) keep.landed[seat] = -Infinity; | |
| 332 | if (p.onGround) { | |
| 333 | if (left !== -1 && time - left >= LAND_AIRTIME) keep.landed[seat] = time; | |
| 334 | keep.left[seat] = -1; | |
| 335 | } else { | |
| 336 | keep.left[seat] = left === -1 ? time : left; | |
| 337 | } | |
| 338 | } | |
| 339 | ||
| 340 | /** | |
| 341 | * One living body, standing, running or landing: the player mesh squashed by | |
| 342 | * the hull it has, then moved by its gait. Everything the gait adds is zero for | |
| 343 | * a body standing on the floor, so that body is placed by exactly the numbers | |
| 344 | * it always was. | |
| 345 | */ | |
| 346 | function stand(scene: Scene, p: Player, seat: number, time: number): void { | |
| 347 | const keep = scene.keep; | |
| 348 | let scale = hullHeight(p, CS_HULL) / CS_HULL.stand; | |
| 349 | let yaw = p.yaw, tip = 0, lift = 0; | |
| 350 | const speed = Math.hypot(p.vel[0], p.vel[1]); | |
| 351 | if (speed >= GAIT_STILL) { | |
| 352 | const pace = Math.min(speed / GAIT_RUN, 1); | |
| 353 | const a = (p.yaw * Math.PI) / 180; | |
| 354 | // Leaning into the run and back off a backpedal; a strafe leans neither way. | |
| 355 | tip = GAIT_LEAN * clamp((p.vel[0] * Math.cos(a) + p.vel[1] * Math.sin(a)) / GAIT_RUN, -1, 1); | |
| 356 | if (p.onGround) { | |
| 357 | const phase = (Math.PI * (keep.stride[seat] ?? 0)) / GAIT_STEP; | |
| 358 | lift = GAIT_BOB * pace * Math.abs(Math.sin(phase)); | |
| 359 | yaw += GAIT_TWIST * pace * Math.sin(phase); | |
| 360 | } | |
| 361 | } | |
| 362 | const settle = (time - (keep.landed[seat] ?? -Infinity)) / LAND_SETTLE; | |
| 363 | if (settle >= 0 && settle < 1) scale *= 1 - LAND_SQUASH * Math.sin(Math.PI * settle); | |
| 364 | place(scene, "player", lift === 0 ? p.pos : at(FROM, p.pos[0], p.pos[1], p.pos[2] + lift), | |
| 365 | yaw, scale, TEAM_TINT[p.team] ?? WHITE, 1, tip); | |
| 366 | } | |
| 367 | ||
| 274 | 368 | /** |
| 275 | 369 | * Fill `into` with what this world looks like from this camera, and hand the |
| 276 | 370 | * same object back. The order the instances go in is the order they are drawn: |
| @@ -308,6 +402,7 @@ export function buildScene( | ||
| 308 | 402 | const fell = into.keep.fell; |
| 309 | 403 | for (let seat = 0; seat < world.players.length; seat++) { |
| 310 | 404 | const p = world.players[seat] as Player; |
| 405 | walk(into.keep, seat, p, world.time, settled); | |
| 311 | 406 | if (!p.alive) { |
| 312 | 407 | const since = fell[seat] ?? -1; |
| 313 | 408 | if (since === -1 || settled) fell[seat] = settled ? -Infinity : world.time; |
| @@ -321,7 +416,7 @@ export function buildScene( | ||
| 321 | 416 | // The eye is inside this head: drawing it would fill the screen. Whose head |
| 322 | 417 | // that is moves when you die, which is why it is `view` and not `world.me`. |
| 323 | 418 | if (p === view) continue; |
| 324 | place(into, "player", p.pos, p.yaw, hullHeight(p, CS_HULL) / CS_HULL.stand, TEAM_TINT[p.team] ?? WHITE); | |
| 419 | stand(into, p, seat, world.time); | |
| 325 | 420 | } |
| 326 | 421 | if (world.bomb.state === "planted" || world.bomb.state === "dropped") { |
| 327 | 422 | place(into, "bomb", world.bomb.pos, 0, 1, WHITE); |
src/frames.test.ts +8
| @@ -97,6 +97,14 @@ test("two hundred frames of the shooter leave the same pool and the same buffer" | ||
| 97 | 97 | } else { |
| 98 | 98 | world.bomb.state = "carried"; |
| 99 | 99 | } |
| 100 | // Everybody running, jumping every so often and landing: the gait's | |
| 101 | // memory is written every frame and is grown only on the first. | |
| 102 | world.time += 1 / 60; | |
| 103 | for (const p of world.players) { | |
| 104 | p.vel[0] = 250; | |
| 105 | p.pos[0] += 250 / 60; | |
| 106 | p.onGround = frame % 40 > 20; | |
| 107 | } | |
| 100 | 108 | buildScene(world, camera(), scene); |
| 101 | 109 | busiest = Math.max(busiest, scene.lineCount); |
| 102 | 110 | } |