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

Merged opened this

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

Merged

vesper/cs-engine:vesper/155-players-read-as-people-when-they-move-a → main 1 commit fork

  • Merged. vesper/155-players-read-as-people-when-they-move-a was merged into main as 760c60e.

Review

Sign in to review this change.
4 files changed +245 −5
README.md +6 −1
@@ -104,7 +104,12 @@ yet.
104104 — the bomb beeping faster as its fuse runs down, the plant, the cut wire and a
105105 body going down, each placed where it happened and heard from the eye the
106106 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`.
108113 - **Two kinds of map.** `de_forge` is drawn by hand, cell by cell; the other
109114 kind is carved from a number by `src/client/generate.ts` — pick *generated*
110115 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 @@
1313 import { expect, test } from "bun:test";
1414 import { existsSync, readFileSync } from "node:fs";
1515 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";
1717 import { CS_HULL } from "./movement.ts";
1818 import { digest, fold, reader, TICK, TICKS_PER_SECOND } from "./replay.ts";
1919 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,
2122 } from "./scene.ts";
2223 import { DE_FORGE } from "./map.ts";
2324 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
443444 expect(still.viewModel?.offset[2]).toBeCloseTo(standing.viewModel?.offset[2] ?? NaN, 12);
444445 expect(still.viewModel?.offset[2]).toBeGreaterThan(0);
445446 });
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 & {
8080 fell: number[];
8181 /** False until the first frame is built: a body already down then does not fall again. */
8282 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[];
8390 };
8491 };
8592
@@ -141,6 +148,39 @@ export const BODY_FALL = 0.4;
141148 export const BODY_REACH = 512;
142149 const BODY_SHADE = 0.66;
143150
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
144184 /** A body's shadow on the ground it stands on: a touch wider than the 32-unit hull. */
145185 const BODY_SHADOW = 22, BODY_DARK = 0.55;
146186 /** A thrown thing is eight units across; the bomb's case is twenty-two by fourteen. */
@@ -215,7 +255,10 @@ export function createScene(): Scene {
215255 shadows: SHADOW_CAPACITY,
216256 }),
217257 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 },
219262 };
220263 }
221264
@@ -271,6 +314,57 @@ function lay(scene: Scene, world: World, p: Player, fell: number): void {
271314 place(scene, "player", FROM, p.yaw, 1 + (BODY_FLATTEN - 1) * fall, BODY_TINT[p.team] ?? WHITE, 1, BODY_TIP * fall);
272315 }
273316
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
274368 /**
275369 * Fill `into` with what this world looks like from this camera, and hand the
276370 * same object back. The order the instances go in is the order they are drawn:
@@ -308,6 +402,7 @@ export function buildScene(
308402 const fell = into.keep.fell;
309403 for (let seat = 0; seat < world.players.length; seat++) {
310404 const p = world.players[seat] as Player;
405 walk(into.keep, seat, p, world.time, settled);
311406 if (!p.alive) {
312407 const since = fell[seat] ?? -1;
313408 if (since === -1 || settled) fell[seat] = settled ? -Infinity : world.time;
@@ -321,7 +416,7 @@ export function buildScene(
321416 // The eye is inside this head: drawing it would fill the screen. Whose head
322417 // that is moves when you die, which is why it is `view` and not `world.me`.
323418 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);
325420 }
326421 if (world.bomb.state === "planted" || world.bomb.state === "dropped") {
327422 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"
9797 } else {
9898 world.bomb.state = "carried";
9999 }
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 }
100108 buildScene(world, camera(), scene);
101109 busiest = Math.max(busiest, scene.lineCount);
102110 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs