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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/296-bodies-stay-where-they-fell-a-corpse-unt → main 2 commits fork
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 | ||
| 610 | 610 | with their aim, and it stays on them until they die or you ask for someone else. With your whole side down and |
| 611 | 611 | the bomb still ticking it watches whoever is left alive rather than your own |
| 612 | 612 | 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. | |
| 614 | 625 | |
| 615 | 626 | The guns are in `src/client/weapons.ts` and follow CS's shape: damage falls off |
| 616 | 627 | with range, armour eats a share of it, a helmet is the only thing that helps a |
| @@ -1018,7 +1029,8 @@ never checked itself. | ||
| 1018 | 1029 | And nothing is *slow*: `src/budget.test.ts` is the ceiling on a frame. It |
| 1019 | 1030 | measures a 64 Hz tick of ten bodies with nine bots thinking (**0.078 ms** |
| 1020 | 1031 | 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 | |
| 1022 | 1034 | `buildRangeScene` on a full lane (**0.0003 ms**), against a 15.625 ms frame, |
| 1023 | 1035 | and fails if any of the three passes a ceiling ten to sixty times those |
| 1024 | 1036 | 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 @@ | ||
| 27 | 27 | * this is the number that decides how many rooms one box holds. |
| 28 | 28 | * - `buildScene` on the world that tick left, with a firefight's worth of |
| 29 | 29 | * 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). | |
| 31 | 32 | * - `buildRangeScene` on a live run: the room, the plates standing in it and a |
| 32 | 33 | * tracer still fading. The range's frame is a thirtieth of the shooter's and |
| 33 | 34 | * 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", () => { | ||
| 423 | 424 | expect(scene.lineCount).toBeGreaterThan(100); |
| 424 | 425 | }, PATIENCE); |
| 425 | 426 | |
| 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 | ||
| 426 | 458 | test("the range's scene is built in a fraction of what a tick costs", () => { |
| 427 | 459 | const run = createRun(1); |
| 428 | 460 | 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 | ||
| 891 | 891 | world.me.alive = false; |
| 892 | 892 | stage.step(FRAME); |
| 893 | 893 | 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). | |
| 895 | 896 | 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); | |
| 897 | 900 | // And it is a teammate's eye, not a bot from the other side of the map. |
| 898 | 901 | const mates = world.players.filter((p) => p.alive && p.team === world.me.team); |
| 899 | 902 | 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 @@ | ||
| 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 { pushEffect, resetEffects, type Camera } from "../engine/index.ts"; | |
| 16 | import { groundUnder, pushEffect, resetEffects, type Camera } from "../engine/index.ts"; | |
| 17 | 17 | 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"; | |
| 19 | 22 | import { DE_FORGE } from "./map.ts"; |
| 20 | 23 | 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"; | |
| 22 | 26 | |
| 23 | 27 | /** One more thing that happened, stamped with the time it says it happened at. */ |
| 24 | 28 | 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 | ||
| 59 | 63 | expect(drawn.some((i) => i.pos[0] === mine[0] && i.pos[1] === mine[1] && i.pos[2] === mine[2] && i.mesh === "player")) |
| 60 | 64 | .toBe(false); |
| 61 | 65 | |
| 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). | |
| 63 | 68 | const other = w.players.find((p) => p !== w.me) as (typeof w.players)[number]; |
| 64 | 69 | other.alive = false; |
| 65 | 70 | 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); | |
| 67 | 169 | }); |
| 68 | 170 | |
| 69 | 171 | 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 & { | ||
| 69 | 69 | * walk cycle the view model bobs on, the world time it last saw, and the one |
| 70 | 70 | * view model object `viewModel` points at whenever there is a gun to draw. |
| 71 | 71 | */ |
| 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 | }; | |
| 73 | 84 | }; |
| 74 | 85 | |
| 75 | 86 | /** Line vertices one frame may draw. Past this, an effect is simply not drawn. */ |
| @@ -110,6 +121,26 @@ export const SHADOW_CAPACITY = 24; | ||
| 110 | 121 | */ |
| 111 | 122 | export const SHADOW_REACH = 96; |
| 112 | 123 | |
| 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 | ||
| 113 | 144 | /** A body's shadow on the ground it stands on: a touch wider than the 32-unit hull. */ |
| 114 | 145 | const BODY_SHADOW = 22, BODY_DARK = 0.55; |
| 115 | 146 | /** 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> = { | ||
| 133 | 164 | |
| 134 | 165 | const WHITE: Tint = [1, 1, 1]; |
| 135 | 166 | |
| 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 | ||
| 136 | 173 | /** |
| 137 | 174 | * What a cloud is tinted. The mesh is painted with the sand layer — smooth fbm |
| 138 | 175 | * noise, no seam and no straight line — and sand is (196, 174, 132), so these |
| @@ -178,7 +215,7 @@ export function createScene(): Scene { | ||
| 178 | 215 | shadows: SHADOW_CAPACITY, |
| 179 | 216 | }), |
| 180 | 217 | 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 }, | |
| 182 | 219 | }; |
| 183 | 220 | } |
| 184 | 221 | |
| @@ -189,7 +226,7 @@ export function createScene(): Scene { | ||
| 189 | 226 | * whatever radius it has grown to. |
| 190 | 227 | */ |
| 191 | 228 | 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, | |
| 193 | 230 | ): void { |
| 194 | 231 | const into = nextInstance(scene); |
| 195 | 232 | if (into === null) return; |
| @@ -198,6 +235,7 @@ function place( | ||
| 198 | 235 | into.yaw = yaw; |
| 199 | 236 | into.scale = scale; |
| 200 | 237 | into.size = size; |
| 238 | into.tip = tip; | |
| 201 | 239 | into.tint[0] = tint[0]; into.tint[1] = tint[1]; into.tint[2] = tint[2]; |
| 202 | 240 | } |
| 203 | 241 | |
| @@ -218,16 +256,38 @@ function shade( | ||
| 218 | 256 | pushShadow(scene, pos, ground, SHADOW_REACH, radius, dark); |
| 219 | 257 | } |
| 220 | 258 | |
| 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 | ||
| 221 | 274 | /** |
| 222 | 275 | * Fill `into` with what this world looks like from this camera, and hand the |
| 223 | 276 | * 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. | |
| 225 | 279 | * |
| 226 | 280 | * `view` is whose eyes the camera is in — you, or the teammate you are watching |
| 227 | 281 | * once you are dead (`src/client/spectate.ts` decides which). Everything that |
| 228 | 282 | * is a fact about the eye rather than about the world is read off it: the body |
| 229 | 283 | * 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. | |
| 231 | 291 | */ |
| 232 | 292 | export function buildScene( |
| 233 | 293 | world: World, camera: Camera, into: Scene, view: Player = world.me, motion: Motion = DEFAULT_MOTION, |
| @@ -242,8 +302,19 @@ export function buildScene( | ||
| 242 | 302 | |
| 243 | 303 | resetScene(into); |
| 244 | 304 | 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; | |
| 247 | 318 | // Every living body stands on something, including the one the camera is |
| 248 | 319 | // inside: look down while you jump and the floor is where it says you are. |
| 249 | 320 | shade(into, world, p.pos, CS_HULL, BODY_SHADOW, BODY_DARK); |
| @@ -379,5 +450,6 @@ export function buildScene( | ||
| 379 | 450 | into.viewModel = null; |
| 380 | 451 | } |
| 381 | 452 | into.keep.time = world.time; |
| 453 | into.keep.drawn = true; | |
| 382 | 454 | return into; |
| 383 | 455 | } |
src/client/spectate.test.ts +35 −2
| @@ -11,12 +11,12 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | 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"; | |
| 15 | 15 | import { DE_FORGE } from "./map.ts"; |
| 16 | 16 | import { SHORT } from "./match.ts"; |
| 17 | 17 | import { CS_HULL } from "./movement.ts"; |
| 18 | 18 | 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, | |
| 20 | 20 | } from "./spectate.ts"; |
| 21 | 21 | import { createWorld, type Player, type World } from "./world.ts"; |
| 22 | 22 | |
| @@ -140,6 +140,39 @@ test("with your whole side gone the camera watches whoever is left, not your bod | ||
| 140 | 140 | expect(follow(w, spectator, camera(), FRAME)).toBe(w.me); |
| 141 | 141 | }); |
| 142 | 142 | |
| 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 | ||
| 143 | 176 | // --- how it moves --------------------------------------------------------- |
| 144 | 177 | |
| 145 | 178 | test("a change of target is a cut: the camera is placed, not flown", () => { |
src/client/spectate.ts +60 −7
| @@ -28,7 +28,7 @@ | ||
| 28 | 28 | * walked by index rather than filtered into a list. |
| 29 | 29 | */ |
| 30 | 30 | |
| 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"; | |
| 32 | 32 | import { fovFor } from "./look.ts"; |
| 33 | 33 | import { CS_HULL } from "./movement.ts"; |
| 34 | 34 | import type { Player, World } from "./world.ts"; |
| @@ -53,6 +53,46 @@ export const FOV_TAU = 0.05; | ||
| 53 | 53 | */ |
| 54 | 54 | export const CUT_DISTANCE = 128; |
| 55 | 55 | |
| 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 | ||
| 56 | 96 | /** The frame the camera is willing to catch up over. A slept tab is a cut anyway. */ |
| 57 | 97 | const MAX_DT = 0.25; |
| 58 | 98 | |
| @@ -146,13 +186,25 @@ export function follow(world: World, spectator: Spectator, camera: Camera, dt: n | ||
| 146 | 186 | spectator.target = view.id; |
| 147 | 187 | spectator.cut = false; |
| 148 | 188 | |
| 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); | |
| 152 | 192 | // The punch is the recoil the body is wearing, which is part of what you are |
| 153 | 193 | // 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 | } | |
| 156 | 208 | // The field of view a scope brings on, and the one the game is played at, |
| 157 | 209 | // are `look.ts`'s numbers: the camera eases to whichever this body is under, |
| 158 | 210 | // 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 | ||
| 160 | 212 | |
| 161 | 213 | const jumped = Math.hypot(x - camera.pos[0], y - camera.pos[1], z - camera.pos[2]) > CUT_DISTANCE; |
| 162 | 214 | // 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)) { | |
| 164 | 217 | camera.pos[0] = x; |
| 165 | 218 | camera.pos[1] = y; |
| 166 | 219 | camera.pos[2] = z; |
src/engine/math.test.ts +28 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | import type { Mat4 } from "./math.ts"; |
| 4 | 4 | |
| 5 | 5 | /** |
| @@ -77,3 +77,30 @@ test("a seed replays exactly", () => { | ||
| 77 | 77 | expect(x).toBeLessThan(1); |
| 78 | 78 | } |
| 79 | 79 | }); |
| 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 | ||
| 111 | 111 | return m; |
| 112 | 112 | } |
| 113 | 113 | |
| 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 | ||
| 114 | 133 | /** |
| 115 | 134 | * A deterministic random source. Every spray pattern and every bot decision |
| 116 | 135 | * 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", () => { | ||
| 218 | 218 | expect([models[1]?.[0], models[1]?.[5], models[1]?.[10]]).toEqual([0.5, 0.5, 0.25]); |
| 219 | 219 | }); |
| 220 | 220 | |
| 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 | ||
| 221 | 240 | test("a scene of two different meshes binds each of them once", () => { |
| 222 | 241 | const r = renderer(); |
| 223 | 242 | const two = scene("level"); |
src/engine/renderer.ts +5 −2
| @@ -23,7 +23,7 @@ | ||
| 23 | 23 | * test can drive a whole frame with no window in the room. |
| 24 | 24 | */ |
| 25 | 25 | |
| 26 | import { multiply, perspective, viewMatrix, modelMatrix, type Vec3 } from "./math.ts"; | |
| 26 | import { fallenMatrix, multiply, perspective, viewMatrix, modelMatrix, type Vec3 } from "./math.ts"; | |
| 27 | 27 | import type { Mesh } from "./mesh.ts"; |
| 28 | 28 | import { backing } from "./pixels.ts"; |
| 29 | 29 | import type { Scene, Tint } from "./scene.ts"; |
| @@ -401,10 +401,13 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets | ||
| 401 | 401 | if (instance === undefined) break; |
| 402 | 402 | const buffers = buffersFor(instance.mesh); |
| 403 | 403 | const size = instance.size ?? 1; |
| 404 | const tip = instance.tip ?? 0; | |
| 404 | 405 | gl.uniformMatrix4fv( |
| 405 | 406 | u.model, |
| 406 | 407 | 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), | |
| 408 | 411 | ); |
| 409 | 412 | gl.uniform3f(u.tint, instance.tint[0], instance.tint[1], instance.tint[2]); |
| 410 | 413 | if (buffers !== bound) { |
src/engine/scene.ts +8 −1
| @@ -45,6 +45,12 @@ export type Camera = { pos: Vec3; yaw: number; pitch: number; fov: number }; | ||
| 45 | 45 | * and `scale` squashes it vertically on top of that, a duck in the shooter. |
| 46 | 46 | * Left out, `size` is 1: a game whose meshes are already the right size never |
| 47 | 47 | * 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. | |
| 48 | 54 | */ |
| 49 | 55 | export type Instance = { |
| 50 | 56 | mesh: string; |
| @@ -53,6 +59,7 @@ export type Instance = { | ||
| 53 | 59 | scale: number; |
| 54 | 60 | tint: Tint; |
| 55 | 61 | size?: number; |
| 62 | tip?: number; | |
| 56 | 63 | }; |
| 57 | 64 | |
| 58 | 65 | /** |
| @@ -131,7 +138,7 @@ export function createScene(capacity: SceneCapacity): Scene { | ||
| 131 | 138 | if (wrong !== null) throw new Error(wrong); |
| 132 | 139 | const instances: Instance[] = []; |
| 133 | 140 | 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] }); | |
| 135 | 142 | const shadows: Shadow[] = []; |
| 136 | 143 | for (let i = 0; i < (capacity.shadows ?? 0); i++) |
| 137 | 144 | shadows.push({ pos: [0, 0, 0], radius: 0, strength: 0 }); |
src/games.ts +5
| @@ -118,6 +118,11 @@ export const GAMES: readonly Game[] = [ | ||
| 118 | 118 | * 178 gzipped bytes over, and `snipe` is 439 raw and 161 gzipped more. |
| 119 | 119 | * 133,665 raw and 51,839 gzipped, measured by `bun run scripts/build.ts`; |
| 120 | 120 | * 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. | |
| 121 | 126 | */ |
| 122 | 127 | ceiling: { raw: 140_500, gzip: 54_500 }, |
| 123 | 128 | assets: [ |