Public: anyone can read this; the crew can change it.

forked from trevato/cs

Bodies stay where they fell: a corpse until the round ends, and a camera that is not inside it #389

Merged opened this

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

Merged

vesper/cs-engine:vesper/296-bodies-stay-where-they-fell-a-corpse-unt → main 2 commits fork

  • Merged. vesper/296-bodies-stay-where-they-fell-a-corpse-unt was merged into main as ecc12dc.

Review

Sign in to review this change.
13 files changed +417 −30
README.md +14 −2
@@ -610,7 +610,18 @@ the map — and from there it eases along with them instead of snapping around
610610 with their aim, and it stays on them until they die or you ask for someone else. With your whole side down and
611611 the bomb still ticking it watches whoever is left alive rather than your own
612612 body. `src/client/spectate.ts` is that camera, and nothing else in the game
613 reads it.
613 reads it. When nobody at all is left standing, the camera rises out of your
614 body — up and back along the way you faced, stopped short of any wall — and
615 looks down at where you fell.
616
617 Whoever dies stays on the floor where they fell until the next round stands
618 them up: the player model tipped onto its face along the way they were facing,
619 dropped onto the floor if they died in the air, and tinted their side, dimmed.
620 It topples over 0.4 s from exactly where the living model stood, so a kill
621 reads as a fall and not a disappearance. A body is drawing and nothing else —
622 one more instance of the player mesh in `src/client/scene.ts`, turned over by
623 the engine's `fallenMatrix` — so bullets go through it, it blocks nobody, no
624 bot has ever seen one, and no pinned replay moved.
614625
615626 The guns are in `src/client/weapons.ts` and follow CS's shape: damage falls off
616627 with range, armour eats a share of it, a helmet is the only thing that helps a
@@ -1018,7 +1029,8 @@ never checked itself.
10181029 And nothing is *slow*: `src/budget.test.ts` is the ceiling on a frame. It
10191030 measures a 64 Hz tick of ten bodies with nine bots thinking (**0.078 ms**
10201031 today), `buildScene` on a hungry frame of the shooter — ten bodies, the bomb
1021 down and a firefight still in the effect ring (**0.0096 ms**) — and
1032 down and a firefight still in the effect ring (**0.0096 ms**), and again
1033 with all ten of them lying on the floor — and
10221034 `buildRangeScene` on a full lane (**0.0003 ms**), against a 15.625 ms frame,
10231035 and fails if any of the three passes a ceiling ten to sixty times those
10241036 numbers. It prints what it measured, so a green run says what a frame costs;
src/budget.test.ts +33 −1
@@ -27,7 +27,8 @@
2727 * this is the number that decides how many rooms one box holds.
2828 * - `buildScene` on the world that tick left, with a firefight's worth of
2929 * effects in the ring — bodies, the bomb, tracers, sparks and blood — which
30 * is a hungry frame rather than a quiet one.
30 * is a hungry frame rather than a quiet one; and again with all ten players
31 * dead, each one a body lying on the floor it fell to (#296).
3132 * - `buildRangeScene` on a live run: the room, the plates standing in it and a
3233 * tracer still fading. The range's frame is a thirtieth of the shooter's and
3334 * is measured anyway, because a game with no ceiling is where the cost goes.
@@ -423,6 +424,37 @@ test("the shooter's scene is built in a fraction of what a tick costs", () => {
423424 expect(scene.lineCount).toBeGreaterThan(100);
424425 }, PATIENCE);
425426
427 test("the shooter's scene holds the same ceiling with ten bodies on the floor", () => {
428 useShipped();
429 const world = liveRound(11);
430 for (let i = 0; i < 64; i++) update(world, TICK, NO_INPUT);
431 // The end of a round nobody walked away from: all ten down and the bomb
432 // planted. Every body is one more instance on the player mesh and one trace
433 // for the floor under it, which is the cost this holds.
434 for (const p of world.players) p.alive = false;
435 world.bomb.state = "planted";
436 world.bomb.pos = [500, 600, 20];
437 const camera: Camera = {
438 pos: [world.me.pos[0], world.me.pos[1], world.me.pos[2] + 120],
439 yaw: world.me.yaw, pitch: 40, fov: 90,
440 };
441 const scene = createScene();
442 buildScene(world, camera, scene);
443 world.time += 1;
444 const runs = 1000;
445 const each = measure("buildScene, ten bodies down", SCENE_CEILING, () =>
446 timed(runs, () => {
447 for (let i = 0; i < runs; i++) buildScene(world, camera, scene);
448 }),
449 );
450
451 expect(each).toBeLessThan(SCENE_CEILING);
452 // All ten were laid, inside the pool, and none of them standing.
453 const bodies = scene.instances.slice(0, scene.instanceCount).filter((i) => (i.tip ?? 0) > 0);
454 expect(bodies.length).toBe(10);
455 expect(scene.instanceCount).toBe(12);
456 }, PATIENCE);
457
426458 test("the range's scene is built in a fraction of what a tick costs", () => {
427459 const run = createRun(1);
428460 start(run);
src/client/main.test.ts +5 −2
@@ -891,9 +891,12 @@ test("dying moves the camera behind a teammate, and fire moves it behind the nex
891891 world.me.alive = false;
892892 stage.step(FRAME);
893893 const watched = [...(stage.draws[1]?.scene.camera.pos ?? [])];
894 // Somewhere else entirely, and one body fewer: the one the camera is inside.
894 // Somewhere else entirely. One standing body fewer — the one the camera is
895 // inside — and one lying body more: mine, where I fell (#296).
895896 expect(watched).not.toEqual(mine);
896 expect(stage.draws[1]?.scene.instanceCount).toBe(9);
897 expect(stage.draws[1]?.scene.instanceCount).toBe(10);
898 const lying = stage.draws[1]?.scene.instances.slice(0, 10).filter((i) => i.mesh === "player" && i.pos[0] === world.me.pos[0] && i.pos[1] === world.me.pos[1]);
899 expect(lying?.length).toBe(1);
897900 // And it is a teammate's eye, not a bot from the other side of the map.
898901 const mates = world.players.filter((p) => p.alive && p.team === world.me.team);
899902 expect(mates.some((p) => p.pos[0] === watched[0] && p.pos[1] === watched[1])).toBe(true);
src/client/scene.test.ts +107 −5
@@ -13,12 +13,16 @@
1313 import { expect, test } from "bun:test";
1414 import { existsSync, readFileSync } from "node:fs";
1515 import { join } from "node:path";
16 import { pushEffect, resetEffects, type Camera } from "../engine/index.ts";
16 import { groundUnder, pushEffect, resetEffects, type Camera } from "../engine/index.ts";
1717 import { CS_HULL } from "./movement.ts";
18 import { buildScene, createScene, LINE_CAPACITY, RAISE_DROP, raiseLeft, SKY } from "./scene.ts";
18 import { digest, fold, reader, TICK, TICKS_PER_SECOND } from "./replay.ts";
19 import {
20 BODY_FALL, BODY_FLATTEN, BODY_LIFT, BODY_TIP, buildScene, createScene, LINE_CAPACITY, RAISE_DROP, raiseLeft, SKY,
21 } from "./scene.ts";
1922 import { DE_FORGE } from "./map.ts";
2023 import { SHORT } from "./match.ts";
21 import { createWorld, EFFECT_CAPACITY, type Effect, type Player, type World } from "./world.ts";
24 import { useShipped } from "./shipped.ts";
25 import { createWorld, EFFECT_CAPACITY, update, type Effect, type Player, type World } from "./world.ts";
2226
2327 /** One more thing that happened, stamped with the time it says it happened at. */
2428 const add = (w: World, effect: Effect): void => pushEffect(w.effects, effect, effect.at);
@@ -59,11 +63,109 @@ test("the level is drawn first, every living other player after it, and never me
5963 expect(drawn.some((i) => i.pos[0] === mine[0] && i.pos[1] === mine[1] && i.pos[2] === mine[2] && i.mesh === "player"))
6064 .toBe(false);
6165
62 // A dead player is not an instance.
66 // A dead player is still an instance — a body, lying down — and never a
67 // standing one: the round keeps where they fell (#296).
6368 const other = w.players.find((p) => p !== w.me) as (typeof w.players)[number];
6469 other.alive = false;
6570 buildScene(w, camera(), scene);
66 expect(meshes(scene)).toEqual(["level", ...Array<string>(8).fill("player")]);
71 w.time += BODY_FALL;
72 buildScene(w, camera(), scene);
73 expect(meshes(scene)).toEqual(["level", ...Array<string>(9).fill("player")]);
74 expect(scene.instances.slice(1, scene.instanceCount).filter((i) => (i.tip ?? 0) > 0).length).toBe(1);
75 });
76
77 /** The top of whatever a body is standing on. */
78 const floorUnder = (w: World, p: Player): number =>
79 groundUnder(p.pos, 0, w.level.boxes, 512, CS_HULL) ?? NaN;
80
81 test("a body falls over where the player stood, and lies along the way they faced", () => {
82 const w = world();
83 const scene = buildScene(w, camera(), createScene());
84 const other = w.players.find((p) => p !== w.me) as Player;
85 other.yaw = 37;
86 other.alive = false;
87 const body = () => scene.instances.slice(1, scene.instanceCount).find((i) => (i.tip ?? 0) > 0);
88 // The frame it dies it is still standing where it stood: a topple, not a cut.
89 buildScene(w, camera(), scene);
90 expect(body()).toBeUndefined();
91 const standing = scene.instances.slice(1, scene.instanceCount).find((i) => i.pos[0] === other.pos[0] && i.pos[1] === other.pos[1]);
92 expect(standing?.tip).toBe(0);
93 // Halfway through the fall it is on its way over, and never further than all the way.
94 w.time += BODY_FALL / 2;
95 buildScene(w, camera(), scene);
96 expect(body()?.tip).toBeCloseTo(BODY_TIP / 2);
97 w.time += BODY_FALL;
98 buildScene(w, camera(), scene);
99 const down = body();
100 expect(down?.tip).toBe(BODY_TIP);
101 expect(down?.yaw).toBe(37);
102 expect(down?.scale).toBeCloseTo(BODY_FLATTEN);
103 expect([down?.pos[0], down?.pos[1]]).toEqual([other.pos[0], other.pos[1]]);
104 expect(down?.pos[2]).toBeCloseTo(floorUnder(w, other) + BODY_LIFT);
105 // Its side's tint, dimmed: a body is never mistaken for someone still up.
106 const living = scene.instances.slice(1, scene.instanceCount).find((i) => (i.tip ?? 0) === 0 && i.tint[0] !== 0);
107 expect(down?.tint[2]).toBeLessThan(living?.tint[2] ?? 0);
108 // And the next frame does not start the fall again.
109 w.time += 1;
110 buildScene(w, camera(), scene);
111 expect(body()?.tip).toBe(BODY_TIP);
112 });
113
114 test("a player killed in the air lies on the floor under them", () => {
115 const w = world();
116 const scene = buildScene(w, camera(), createScene());
117 const other = w.players.find((p) => p !== w.me) as Player;
118 const floor = floorUnder(w, other);
119 other.pos[2] += 40;
120 other.alive = false;
121 buildScene(w, camera(), scene);
122 w.time += BODY_FALL;
123 buildScene(w, camera(), scene);
124 const body = scene.instances.slice(1, scene.instanceCount).find((i) => (i.tip ?? 0) > 0);
125 expect(body?.pos[2]).toBeCloseTo(floor + BODY_LIFT);
126 });
127
128 test("a body already down the first frame the world is drawn does not fall again", () => {
129 const w = world();
130 (w.players[3] as Player).alive = false;
131 const scene = buildScene(w, camera(), createScene());
132 expect(scene.instances.slice(1, scene.instanceCount).filter((i) => i.tip === BODY_TIP).length).toBe(1);
133 });
134
135 test("a body goes when the player stands up again, and my own is drawn once I am not its eye", () => {
136 const w = world();
137 const scene = createScene();
138 w.me.alive = false;
139 // Nobody left to watch: the eye is still mine, lifted out of my body by
140 // `spectate.ts`, so the body is drawn like anyone's.
141 for (const p of w.players) p.alive = false;
142 buildScene(w, camera(), scene, w.me);
143 expect(scene.instances.slice(1, scene.instanceCount).filter((i) => (i.tip ?? 0) > 0).length).toBe(10);
144 // The next round's respawn is what takes them away.
145 for (const p of w.players) p.alive = true;
146 w.time += 1;
147 buildScene(w, camera(), scene);
148 expect(scene.instances.slice(1, scene.instanceCount).filter((i) => (i.tip ?? 0) > 0).length).toBe(0);
149 expect(meshes(scene)).toEqual(["level", ...Array<string>(9).fill("player")]);
150 });
151
152 test("drawing the dead moves nothing in the round: the digest is the same with or without a frame", () => {
153 // Forty seconds of the scripted round — the first body falls at 33 — once bare and once with a frame built
154 // after every tick. Bodies are drawn in the second, and the round is the same.
155 useShipped();
156 const ticks = 40 * TICKS_PER_SECOND;
157 const bare = digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), ticks).digest;
158 const w = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7);
159 const scene = createScene();
160 const next = reader();
161 let bodies = 0;
162 const drawn = fold(w, ticks, (tick) => {
163 update(w, TICK, next(tick, w, w.me));
164 buildScene(w, camera(), scene);
165 for (let i = 0; i < scene.instanceCount; i++) if ((scene.instances[i]?.tip ?? 0) > 0) bodies++;
166 }).digest;
167 expect(bodies).toBeGreaterThan(0);
168 expect(drawn).toBe(bare);
67169 });
68170
69171 test("the body left out is the one the camera is inside, which is not always me", () => {
src/client/scene.ts +79 −7
@@ -69,7 +69,18 @@ export type Scene = Frame & {
6969 * walk cycle the view model bobs on, the world time it last saw, and the one
7070 * view model object `viewModel` points at whenever there is a gun to draw.
7171 */
72 readonly keep: { bobPhase: number; time: number; viewModel: ViewModel };
72 readonly keep: {
73 bobPhase: number; time: number; viewModel: ViewModel;
74 /**
75 * When each seat of the roster was first drawn dead, by index into
76 * `world.players`, or -1 while it stands: the clock a body's fall is read
77 * off. It is the frame's memory and not the round's — nothing in the
78 * simulation knows a body toppled, so the digest of a round cannot move.
79 */
80 fell: number[];
81 /** False until the first frame is built: a body already down then does not fall again. */
82 drawn: boolean;
83 };
7384 };
7485
7586 /** Line vertices one frame may draw. Past this, an effect is simply not drawn. */
@@ -110,6 +121,26 @@ export const SHADOW_CAPACITY = 24;
110121 */
111122 export const SHADOW_REACH = 96;
112123
124 /**
125 * A body, from the player mesh: tipped onto its face so it lies along the way
126 * it was facing, pressed a little flatter than a standing player is deep, and
127 * lifted by what is left of that so it rests on the floor instead of in it.
128 * Its team's tint again, dimmed — a corpse reads as the side it was on and
129 * never as somebody still in the round.
130 *
131 * It gets there over `BODY_FALL` seconds of simulated time from exactly where
132 * the living mesh stood, and down onto whatever floor is under it if it died
133 * in the air: a player drawn one frame and a body the next has to topple, or
134 * the kill reads as a disappearance all over again.
135 */
136 export const BODY_TIP = 90;
137 export const BODY_FLATTEN = 0.75;
138 export const BODY_LIFT = 7;
139 export const BODY_FALL = 0.4;
140 /** How far under a player killed in the air the floor is looked for. */
141 export const BODY_REACH = 512;
142 const BODY_SHADE = 0.66;
143
113144 /** A body's shadow on the ground it stands on: a touch wider than the 32-unit hull. */
114145 const BODY_SHADOW = 22, BODY_DARK = 0.55;
115146 /** A thrown thing is eight units across; the bomb's case is twenty-two by fourteen. */
@@ -133,6 +164,12 @@ const TEAM_TINT: Record<string, Tint> = {
133164
134165 const WHITE: Tint = [1, 1, 1];
135166
167 /** The team tints, dimmed for the dead. */
168 const BODY_TINT: Record<string, Tint> = {
169 t: [TEAM_TINT.t![0] * BODY_SHADE, TEAM_TINT.t![1] * BODY_SHADE, TEAM_TINT.t![2] * BODY_SHADE],
170 ct: [TEAM_TINT.ct![0] * BODY_SHADE, TEAM_TINT.ct![1] * BODY_SHADE, TEAM_TINT.ct![2] * BODY_SHADE],
171 };
172
136173 /**
137174 * What a cloud is tinted. The mesh is painted with the sand layer — smooth fbm
138175 * noise, no seam and no straight line — and sand is (196, 174, 132), so these
@@ -178,7 +215,7 @@ export function createScene(): Scene {
178215 shadows: SHADOW_CAPACITY,
179216 }),
180217 sky: [SKY[0], SKY[1], SKY[2]],
181 keep: { bobPhase: 0, time: 0, viewModel: { mesh: "rifle", offset: [0, 0, 0] } },
218 keep: { bobPhase: 0, time: 0, viewModel: { mesh: "rifle", offset: [0, 0, 0] }, fell: [], drawn: false },
182219 };
183220 }
184221
@@ -189,7 +226,7 @@ export function createScene(): Scene {
189226 * whatever radius it has grown to.
190227 */
191228 function place(
192 scene: Scene, mesh: string, pos: Vec3, yaw: number, scale: number, tint: Tint, size = 1,
229 scene: Scene, mesh: string, pos: Vec3, yaw: number, scale: number, tint: Tint, size = 1, tip = 0,
193230 ): void {
194231 const into = nextInstance(scene);
195232 if (into === null) return;
@@ -198,6 +235,7 @@ function place(
198235 into.yaw = yaw;
199236 into.scale = scale;
200237 into.size = size;
238 into.tip = tip;
201239 into.tint[0] = tint[0]; into.tint[1] = tint[1]; into.tint[2] = tint[2];
202240 }
203241
@@ -218,16 +256,38 @@ function shade(
218256 pushShadow(scene, pos, ground, SHADOW_REACH, radius, dark);
219257 }
220258
259 /**
260 * One of the dead, where they fell: the player mesh on the same buffer as the
261 * living, tipped `BODY_TIP` over the way they faced and pressed onto the floor
262 * under them. `fell` is the time it was first drawn dead, so `world.time -
263 * fell` is how far through its fall it is — smoothstep, so it goes over and
264 * settles. No shadow: a body lying on the floor is its own contact with it.
265 */
266 function lay(scene: Scene, world: World, p: Player, fell: number): void {
267 const t = clamp((world.time - fell) / BODY_FALL, 0, 1);
268 const fall = t * t * (3 - 2 * t);
269 const floor = groundUnder(p.pos, 0, world.level.boxes, BODY_REACH, CS_HULL) ?? p.pos[2];
270 at(FROM, p.pos[0], p.pos[1], p.pos[2] + (floor + BODY_LIFT - p.pos[2]) * fall);
271 place(scene, "player", FROM, p.yaw, 1 + (BODY_FLATTEN - 1) * fall, BODY_TINT[p.team] ?? WHITE, 1, BODY_TIP * fall);
272 }
273
221274 /**
222275 * Fill `into` with what this world looks like from this camera, and hand the
223276 * same object back. The order the instances go in is the order they are drawn:
224 * the level, then every other living player, then the bomb.
277 * the level, then every other living player and every body of this round, then
278 * the bomb.
225279 *
226280 * `view` is whose eyes the camera is in — you, or the teammate you are watching
227281 * once you are dead (`src/client/spectate.ts` decides which). Everything that
228282 * is a fact about the eye rather than about the world is read off it: the body
229283 * that is left out because the camera is inside its head, and the gun that is
230 * drawn in view space because it is the one those hands are holding.
284 * drawn in view space because it is the one those hands are holding. A body is
285 * never left out: once `view` is dead, `spectate.ts` has lifted the camera
286 * out of it.
287 *
288 * A body is drawing and nothing else. It is the player's own position read
289 * while `alive` is false, so it goes when the round's respawn stands them up,
290 * and it is in no trace, no sightline and no replay's hash.
231291 */
232292 export function buildScene(
233293 world: World, camera: Camera, into: Scene, view: Player = world.me, motion: Motion = DEFAULT_MOTION,
@@ -242,8 +302,19 @@ export function buildScene(
242302
243303 resetScene(into);
244304 place(into, "level", ORIGIN, 0, 1, WHITE);
245 for (const p of world.players) {
246 if (!p.alive) continue;
305 // A world that has never been drawn, or whose clock went back (a restart, a
306 // replay seeked), has no fall in progress: whoever is down is already down.
307 const settled = !into.keep.drawn || world.time < into.keep.time;
308 const fell = into.keep.fell;
309 for (let seat = 0; seat < world.players.length; seat++) {
310 const p = world.players[seat] as Player;
311 if (!p.alive) {
312 const since = fell[seat] ?? -1;
313 if (since === -1 || settled) fell[seat] = settled ? -Infinity : world.time;
314 lay(into, world, p, fell[seat] as number);
315 continue;
316 }
317 fell[seat] = -1;
247318 // Every living body stands on something, including the one the camera is
248319 // inside: look down while you jump and the floor is where it says you are.
249320 shade(into, world, p.pos, CS_HULL, BODY_SHADOW, BODY_DARK);
@@ -379,5 +450,6 @@ export function buildScene(
379450 into.viewModel = null;
380451 }
381452 into.keep.time = world.time;
453 into.keep.drawn = true;
382454 return into;
383455 }
src/client/spectate.test.ts +35 −2
@@ -11,12 +11,12 @@
1111 */
1212
1313 import { expect, test } from "bun:test";
14 import { eyeHeight, type Camera } from "../engine/index.ts";
14 import { eyeHeight, type Box, type Camera } from "../engine/index.ts";
1515 import { DE_FORGE } from "./map.ts";
1616 import { SHORT } from "./match.ts";
1717 import { CS_HULL } from "./movement.ts";
1818 import {
19 ANGLE_TAU, CUT_DISTANCE, POS_TAU, createSpectator, cycle, follow, watched, type Spectator,
19 ANGLE_TAU, CUT_DISTANCE, DEATH_BACK, POS_TAU, createSpectator, cycle, follow, watched, type Spectator,
2020 } from "./spectate.ts";
2121 import { createWorld, type Player, type World } from "./world.ts";
2222
@@ -140,6 +140,39 @@ test("with your whole side gone the camera watches whoever is left, not your bod
140140 expect(follow(w, spectator, camera(), FRAME)).toBe(w.me);
141141 });
142142
143 test("left with your own body, the camera is lifted out of it and looks down at it", () => {
144 const w = world();
145 onlyAlive(w);
146 w.me.yaw = 30;
147 const cam = camera();
148 expect(follow(w, createSpectator(), cam, FRAME)).toBe(w.me);
149 // The body lies from the feet 72 units along the yaw, and never stands higher
150 // than a crouch. The eye is behind its feet and above the whole standing hull.
151 const r = (w.me.yaw * Math.PI) / 180;
152 const dx = cam.pos[0] - w.me.pos[0], dy = cam.pos[1] - w.me.pos[1];
153 expect(dx * Math.cos(r) + dy * Math.sin(r)).toBeLessThan(-DEATH_BACK / 2);
154 expect(cam.pos[2] - w.me.pos[2]).toBeGreaterThan(CS_HULL.stand);
155 expect(cam.pos).not.toEqual(eyeOf(w.me));
156 // Facing the way the body lies, and looking down on it.
157 expect(Math.abs(cam.yaw - w.me.yaw)).toBeLessThan(1);
158 expect(cam.pitch).toBeGreaterThan(20);
159 });
160
161 test("a wall behind the body stops the camera short of it", () => {
162 const w = world();
163 onlyAlive(w);
164 w.me.yaw = 0;
165 const [x, y, z] = w.me.pos;
166 // A slab 40 units behind the feet, taller than anything the camera would clear.
167 // The level is the map's own and shared by every world, so this one gets a copy.
168 const slab: Box = { min: [x - 50, y - 200, z - 10], max: [x - 40, y + 200, z + 400], mat: 0 };
169 w.level = { ...w.level, boxes: [...w.level.boxes, slab] };
170 const cam = camera();
171 follow(w, createSpectator(), cam, FRAME);
172 expect(cam.pos[0]).toBeGreaterThan(x - 40);
173 expect(cam.pos[2]).toBeGreaterThan(z + CS_HULL.stand / 2);
174 });
175
143176 // --- how it moves ---------------------------------------------------------
144177
145178 test("a change of target is a cut: the camera is placed, not flown", () => {
src/client/spectate.ts +60 −7
@@ -28,7 +28,7 @@
2828 * walked by index rather than filtered into a list.
2929 */
3030
31 import { angleDelta, clamp, eyeHeight, type Camera } from "../engine/index.ts";
31 import { angleDelta, clamp, deg, eyeHeight, traceWorld, type Camera, type Vec3 } from "../engine/index.ts";
3232 import { fovFor } from "./look.ts";
3333 import { CS_HULL } from "./movement.ts";
3434 import type { Player, World } from "./world.ts";
@@ -53,6 +53,46 @@ export const FOV_TAU = 0.05;
5353 */
5454 export const CUT_DISTANCE = 128;
5555
56 /**
57 * Where the camera goes when the body it is left with is a dead one — your
58 * own, once there is nobody at all left standing to watch: back along the way
59 * it faced and up, looking down at where it fell. A body lies along its yaw,
60 * so from behind its feet and above its hull the whole of it is in front of
61 * the lens and none of it is around it. A wall in the way stops the camera
62 * short of it, by `DEATH_MARGIN`, so it never looks out of the brickwork either.
63 */
64 export const DEATH_BACK = 72;
65 export const DEATH_RISE = 120;
66 const DEATH_MARGIN = 8;
67 /** Where a dead eye looks: this far along the body from its feet, about its middle. */
68 const DEATH_LOOK = 36;
69
70 /** Scratch for the one trace a dead eye needs, so a frame allocates only on a hit. */
71 const FROM: Vec3 = [0, 0, 0];
72 const TO: Vec3 = [0, 0, 0];
73
74 /**
75 * The eye over a body: behind its feet and above it, pulled in short of any
76 * wall between, written into `into`. The trace starts in the middle of the hull
77 * the player stood in, which is air by construction.
78 */
79 function overBody(world: World, p: Player, into: Vec3): void {
80 const yaw = (p.yaw * Math.PI) / 180;
81 FROM[0] = p.pos[0]; FROM[1] = p.pos[1]; FROM[2] = p.pos[2] + CS_HULL.stand / 2;
82 TO[0] = p.pos[0] - Math.cos(yaw) * DEATH_BACK;
83 TO[1] = p.pos[1] - Math.sin(yaw) * DEATH_BACK;
84 TO[2] = p.pos[2] + DEATH_RISE;
85 const hit = traceWorld(FROM, TO, world.level.boxes);
86 const length = Math.hypot(TO[0] - FROM[0], TO[1] - FROM[1], TO[2] - FROM[2]);
87 const t = hit === null ? 1 : Math.max(0, hit.t - DEATH_MARGIN / length);
88 into[0] = FROM[0] + (TO[0] - FROM[0]) * t;
89 into[1] = FROM[1] + (TO[1] - FROM[1]) * t;
90 into[2] = FROM[2] + (TO[2] - FROM[2]) * t;
91 }
92
93 /** Scratch for where a dead eye sits, written by `overBody`. */
94 const EYE: Vec3 = [0, 0, 0];
95
5696 /** The frame the camera is willing to catch up over. A slept tab is a cut anyway. */
5797 const MAX_DT = 0.25;
5898
@@ -146,13 +186,25 @@ export function follow(world: World, spectator: Spectator, camera: Camera, dt: n
146186 spectator.target = view.id;
147187 spectator.cut = false;
148188
149 const x = view.pos[0];
150 const y = view.pos[1];
151 const z = view.pos[2] + eyeHeight(view, CS_HULL);
189 let x = view.pos[0];
190 let y = view.pos[1];
191 let z = view.pos[2] + eyeHeight(view, CS_HULL);
152192 // The punch is the recoil the body is wearing, which is part of what you are
153193 // watching; it is eased with everything else rather than shaken in raw.
154 const yaw = view.yaw - view.punch[0] * 0.4;
155 const pitch = view.pitch - view.punch[1] * 0.4;
194 let yaw = view.yaw - view.punch[0] * 0.4;
195 let pitch = view.pitch - view.punch[1] * 0.4;
196 // A dead body is drawn lying where it fell (`scene.ts`), so an eye left with
197 // one — your own, with nobody else standing — is lifted out of it and looks
198 // down at it instead of out of it.
199 if (!view.alive) {
200 overBody(world, view, EYE);
201 x = EYE[0]; y = EYE[1]; z = EYE[2];
202 const r = (view.yaw * Math.PI) / 180;
203 const dx = view.pos[0] + Math.cos(r) * DEATH_LOOK - x;
204 const dy = view.pos[1] + Math.sin(r) * DEATH_LOOK - y;
205 yaw = deg(Math.atan2(dy, dx));
206 pitch = deg(Math.atan2(z - view.pos[2], Math.hypot(dx, dy)));
207 }
156208 // The field of view a scope brings on, and the one the game is played at,
157209 // are `look.ts`'s numbers: the camera eases to whichever this body is under,
158210 // and the same pair is what the mouse is scaled by while you are in it.
@@ -160,7 +212,8 @@ export function follow(world: World, spectator: Spectator, camera: Camera, dt: n
160212
161213 const jumped = Math.hypot(x - camera.pos[0], y - camera.pos[1], z - camera.pos[2]) > CUT_DISTANCE;
162214 // Your own head is never eased: that would be input lag on your own mouse.
163 if (cut || jumped || view === world.me) {
215 // Your own body is — rising out of it is a camera move, not your hand.
216 if (cut || jumped || (view === world.me && view.alive)) {
164217 camera.pos[0] = x;
165218 camera.pos[1] = y;
166219 camera.pos[2] = z;
src/engine/math.test.ts +28 −1
@@ -1,5 +1,5 @@
11 import { expect, test } from "bun:test";
2 import { angleDelta, angleVectors, cross, dot, mat4, multiply, normalize, perspective, rng, viewMatrix } from "./math.ts";
2 import { angleDelta, angleVectors, cross, dot, fallenMatrix, mat4, modelMatrix, multiply, normalize, perspective, rng, viewMatrix } from "./math.ts";
33 import type { Mat4 } from "./math.ts";
44
55 /**
@@ -77,3 +77,30 @@ test("a seed replays exactly", () => {
7777 expect(x).toBeLessThan(1);
7878 }
7979 });
80
81 /** Where a model-space point lands under a column-major matrix. */
82 const apply = (m: Mat4, x: number, y: number, z: number): number[] => [0, 1, 2].map((r) =>
83 cell(m, r) * x + cell(m, 4 + r) * y + cell(m, 8 + r) * z + cell(m, 12 + r));
84
85 test("a fallen model lies along the way it was facing, face down", () => {
86 // Facing north, tipped all the way: the crown of a 72-unit mesh is 72 units
87 // north of the feet and on the floor, and its chest (+X) points down.
88 const m = fallenMatrix([100, 200, 10], 90, 90);
89 const crown = apply(m, 0, 0, 72);
90 expect(crown[0]).toBeCloseTo(100);
91 expect(crown[1]).toBeCloseTo(272);
92 expect(crown[2]).toBeCloseTo(10);
93 expect(apply(m, 8, 0, 0)[2]).toBeCloseTo(2);
94 });
95
96 test("a fallen model at no tip is the standing one, and flatten presses it against Z", () => {
97 const standing = modelMatrix([5, 6, 7], 30);
98 const upright = fallenMatrix([5, 6, 7], 30, 0);
99 for (let i = 0; i < 16; i++) expect(cell(upright, i)).toBeCloseTo(cell(standing, i));
100 const pressed = fallenMatrix([0, 0, 0], 0, 90, 0.5);
101 // What sticks up off the floor after the tip is half as tall; what lies along it is untouched.
102 expect(apply(pressed, -10, 0, 0)[2]).toBeCloseTo(5);
103 expect(apply(pressed, 0, 0, 72)[0]).toBeCloseTo(72);
104 // And `size` scales the whole mesh before any of it.
105 expect(apply(fallenMatrix([0, 0, 0], 0, 90, 1, 0.5), 0, 0, 72)[0]).toBeCloseTo(36);
106 });
src/engine/math.ts +19
@@ -111,6 +111,25 @@ export function modelMatrix(pos: Vec3, yaw: number, sx = 1, sy = 1, sz = 1): Mat
111111 return m;
112112 }
113113
114 /**
115 * A model tipped over onto its face: translate · yaw · tip about the model's
116 * own Y axis, with `flatten` squashing what is left of it against the world's
117 * Z afterwards. A mesh built standing at the origin facing +X lies along the
118 * way it was facing at a tip of 90°, which is how a body ends up on the floor.
119 * `size` scales the whole mesh first, as it does in `modelMatrix`.
120 */
121 export function fallenMatrix(pos: Vec3, yaw: number, tip: number, flatten = 1, size = 1): Mat4 {
122 const c = Math.cos(rad(yaw)), s = Math.sin(rad(yaw));
123 const ct = Math.cos(rad(tip)), st = Math.sin(rad(tip));
124 const m = new Float32Array(16);
125 m[0] = c * ct * size; m[1] = s * ct * size; m[2] = -st * flatten * size;
126 m[4] = -s * size; m[5] = c * size; m[6] = 0;
127 m[8] = c * st * size; m[9] = s * st * size; m[10] = ct * flatten * size;
128 m[12] = pos[0]; m[13] = pos[1]; m[14] = pos[2];
129 m[15] = 1;
130 return m;
131 }
132
114133 /**
115134 * A deterministic random source. Every spray pattern and every bot decision
116135 * draws from one of these, so a seed reproduces a round exactly — which is
src/engine/renderer.test.ts +19
@@ -218,6 +218,25 @@ test("an instance stands at its own size, squashed by its own scale", () => {
218218 expect([models[1]?.[0], models[1]?.[5], models[1]?.[10]]).toEqual([0.5, 0.5, 0.25]);
219219 });
220220
221 test("a tipped instance is drawn lying down, and an untipped one is not", () => {
222 const r = renderer();
223 const two = scene("level");
224 // The second instance faces north and has gone all the way over: what was
225 // its up (+Z) now points north, and what was its front (+X) points down.
226 (two.instances[1] as { yaw: number; tip: number }).yaw = 90;
227 (two.instances[1] as { yaw: number; tip: number }).tip = 90;
228 r.r.draw(two, 1 / 60);
229 const models = frameCalls(r, "uniformMatrix4fv")
230 .filter((c) => (c[1] as Resource).kind === "uModel")
231 .map((c) => c[3] as Float32Array);
232 expect(models[0]?.[10]).toBe(1);
233 expect(models[1]?.[9]).toBeCloseTo(1);
234 expect(models[1]?.[10]).toBeCloseTo(0);
235 expect(models[1]?.[2]).toBeCloseTo(-1);
236 // Still one mesh and one bind: a body is a matrix, not a new draw path.
237 expect(frameCalls(r, "drawElements").length).toBe(2);
238 });
239
221240 test("a scene of two different meshes binds each of them once", () => {
222241 const r = renderer();
223242 const two = scene("level");
src/engine/renderer.ts +5 −2
@@ -23,7 +23,7 @@
2323 * test can drive a whole frame with no window in the room.
2424 */
2525
26 import { multiply, perspective, viewMatrix, modelMatrix, type Vec3 } from "./math.ts";
26 import { fallenMatrix, multiply, perspective, viewMatrix, modelMatrix, type Vec3 } from "./math.ts";
2727 import type { Mesh } from "./mesh.ts";
2828 import { backing } from "./pixels.ts";
2929 import type { Scene, Tint } from "./scene.ts";
@@ -401,10 +401,13 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets
401401 if (instance === undefined) break;
402402 const buffers = buffersFor(instance.mesh);
403403 const size = instance.size ?? 1;
404 const tip = instance.tip ?? 0;
404405 gl.uniformMatrix4fv(
405406 u.model,
406407 false,
407 modelMatrix(instance.pos, instance.yaw, size, size, size * instance.scale),
408 tip === 0
409 ? modelMatrix(instance.pos, instance.yaw, size, size, size * instance.scale)
410 : fallenMatrix(instance.pos, instance.yaw, tip, instance.scale, size),
408411 );
409412 gl.uniform3f(u.tint, instance.tint[0], instance.tint[1], instance.tint[2]);
410413 if (buffers !== bound) {
src/engine/scene.ts +8 −1
@@ -45,6 +45,12 @@ export type Camera = { pos: Vec3; yaw: number; pitch: number; fov: number };
4545 * and `scale` squashes it vertically on top of that, a duck in the shooter.
4646 * Left out, `size` is 1: a game whose meshes are already the right size never
4747 * writes it.
48 *
49 * `tip` is how far the mesh has gone over onto its face, in degrees about its
50 * own Y axis after the yaw — 90 is lying along the way it was facing, which is
51 * how the shooter leaves a body on the floor — and `scale` then presses what is
52 * left of it against Z. Left out, or 0, it is standing, and the draw is the
53 * same matrix it always was.
4854 */
4955 export type Instance = {
5056 mesh: string;
@@ -53,6 +59,7 @@ export type Instance = {
5359 scale: number;
5460 tint: Tint;
5561 size?: number;
62 tip?: number;
5663 };
5764
5865 /**
@@ -131,7 +138,7 @@ export function createScene(capacity: SceneCapacity): Scene {
131138 if (wrong !== null) throw new Error(wrong);
132139 const instances: Instance[] = [];
133140 for (let i = 0; i < capacity.instances; i++)
134 instances.push({ mesh: "", pos: [0, 0, 0], yaw: 0, scale: 1, size: 1, tint: [1, 1, 1] });
141 instances.push({ mesh: "", pos: [0, 0, 0], yaw: 0, scale: 1, size: 1, tip: 0, tint: [1, 1, 1] });
135142 const shadows: Shadow[] = [];
136143 for (let i = 0; i < (capacity.shadows ?? 0); i++)
137144 shadows.push({ pos: [0, 0, 0], radius: 0, strength: 0 });
src/games.ts +5
@@ -118,6 +118,11 @@ export const GAMES: readonly Game[] = [
118118 * 178 gzipped bytes over, and `snipe` is 439 raw and 161 gzipped more.
119119 * 133,665 raw and 51,839 gzipped, measured by `bun run scripts/build.ts`;
120120 * the ceiling is that plus about five per cent.
121 *
122 * #296, which leaves the dead on the floor — the tipped matrix in the
123 * renderer, the fall in `src/client/scene.ts` and the camera lifted out of
124 * your own body in `src/client/spectate.ts` — landed under it without a
125 * raise: 135,163 raw and 52,467 gzipped after the merge, measured the same way.
121126 */
122127 ceiling: { raw: 140_500, gzip: 54_500 },
123128 assets: [

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs