Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
spectating is super buggy. i want it buttery smooth #146
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/133-spectating-is-super-buggy-i-want-it-butt → main 2 commits fork
13 files
changed +749
−121
README.md +10 −1
| @@ -112,6 +112,14 @@ phone, where there is no keyboard and no pointer to lock. A browser that will no | ||
| 112 | 112 | put an element full screen — an iPhone will not — never shows that button rather |
| 113 | 113 | than showing one that does nothing. |
| 114 | 114 | |
| 115 | Killed, you watch the rest of the round out of a teammate's eyes: fire moves | |
| 116 | to the next player on your side and aim moves back one, the camera eases onto | |
| 117 | whoever you picked instead of snapping around with their aim, and it stays on | |
| 118 | them until they die or you ask for someone else. With your whole side down and | |
| 119 | the bomb still ticking it watches whoever is left alive rather than your own | |
| 120 | body. `src/client/spectate.ts` is that camera, and nothing else in the game | |
| 121 | reads it. | |
| 122 | ||
| 115 | 123 | At `/range` the same controls drive the second game, on its own bindings: |
| 116 | 124 | <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch, |
| 117 | 125 | <kbd>Shift</kbd> walk, <kbd>F</kbd> or the mouse fires, <kbd>R</kbd> reload, |
| @@ -292,7 +300,8 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 292 | 300 | (the rules), |
| 293 | 301 | `world` (the simulation), |
| 294 | 302 | `bots`, `observe`, `action`, `weights`, `models`, `materials`, |
| 295 | `binding`, `scene`, `hud`, `audio`, `main`. Everything in here is headless and | |
| 303 | `binding`, `scene`, `hud`, `spectate` (whose eyes a frame is drawn from once | |
| 304 | you are dead, and the camera that eases onto them), `audio`, `main`. Everything in here is headless and | |
| 296 | 305 | tested: the WebGL2 is the engine's now, and this game has no renderer of its |
| 297 | 306 | own. The level is handed in rather than imported: `createWorld(level, …)` |
| 298 | 307 | keeps it on the world and the bots, the bullets, the bomb and the meshes read |
src/client/hud.test.ts +65 −8
| @@ -97,13 +97,20 @@ function scene(change: (world: World) => void = () => {}): World { | ||
| 97 | 97 | const WIDTH = 1280; |
| 98 | 98 | const HEIGHT = 720; |
| 99 | 99 | |
| 100 | /** Draw one frame of the HUD onto a fresh recorder. */ | |
| 100 | /** | |
| 101 | * Draw one frame of the HUD onto a fresh recorder. `view` is whose eyes it is | |
| 102 | * drawn from — left out, yours, which is every frame of a round you are alive | |
| 103 | * in; a player once you are dead and watching them. | |
| 104 | */ | |
| 101 | 105 | function draw( |
| 102 | 106 | world: World, |
| 103 | options: { width?: number; height?: number; scoreboard?: boolean } = {}, | |
| 107 | options: { width?: number; height?: number; scoreboard?: boolean; view?: Player } = {}, | |
| 104 | 108 | ): Recorder { |
| 105 | 109 | const paint = recorder(); |
| 106 | drawHud(paint, world, options.width ?? WIDTH, options.height ?? HEIGHT, options.scoreboard ?? false); | |
| 110 | drawHud( | |
| 111 | paint, world, options.width ?? WIDTH, options.height ?? HEIGHT, | |
| 112 | options.scoreboard ?? false, options.view ?? world.me, | |
| 113 | ); | |
| 107 | 114 | return paint; |
| 108 | 115 | } |
| 109 | 116 | |
| @@ -381,15 +388,65 @@ test("the use bar fills as far as the plant or the defuse has got", () => { | ||
| 381 | 388 | |
| 382 | 389 | // --- dead, and the scoreboard ---------------------------------------------- |
| 383 | 390 | |
| 384 | const WATCHING = "You are dead — watching the round out"; | |
| 391 | const ALONE = "You are dead — watching the round out"; | |
| 392 | const SWITCH = "fire — next player · aim — the one before"; | |
| 385 | 393 | |
| 386 | test("a dead player is told they are watching, until the match itself is over", () => { | |
| 387 | expect(says(draw(scene()), WATCHING)).toBe(false); | |
| 388 | expect(says(draw(scene((w) => { w.me.alive = false; })), WATCHING)).toBe(true); | |
| 394 | /** A teammate to watch, and the world with you dead in it. */ | |
| 395 | function spectating(change: (world: World) => void = () => {}): { world: World; mate: Player } { | |
| 396 | const world = scene((w) => { | |
| 397 | w.me.alive = false; | |
| 398 | change(w); | |
| 399 | }); | |
| 400 | const mate = world.players.find((p) => p !== world.me && p.team === world.me.team) as Player; | |
| 401 | return { world, mate }; | |
| 402 | } | |
| 403 | ||
| 404 | test("a dead player watching nobody is told so, until the match itself is over", () => { | |
| 405 | // The whole side down and the round still running — a planted bomb — is the | |
| 406 | // only frame left where the camera is your own body and there is no name. | |
| 407 | expect(says(draw(scene()), ALONE)).toBe(false); | |
| 408 | expect(says(draw(scene((w) => { w.me.alive = false; })), ALONE)).toBe(true); | |
| 389 | 409 | expect(says(draw(scene((w) => { |
| 390 | 410 | w.me.alive = false; |
| 391 | 411 | w.match.phase = "match_over"; |
| 392 | })), WATCHING)).toBe(false); | |
| 412 | })), ALONE)).toBe(false); | |
| 413 | }); | |
| 414 | ||
| 415 | test("watching someone says whose eyes these are, in their team's colour, and how to change them", () => { | |
| 416 | const { world, mate } = spectating(); | |
| 417 | const paint = draw(world, { view: mate }); | |
| 418 | expect(says(paint, `Watching ${mate.name}`)).toBe(true); | |
| 419 | expect(says(paint, SWITCH)).toBe(true); | |
| 420 | expect(says(paint, ALONE)).toBe(false); | |
| 421 | expect(line(paint, `Watching ${mate.name}`).fill).toBe("#6fa8e8"); // this side is CT | |
| 422 | ||
| 423 | // Alive, there is nothing to say: no banner, no keys. | |
| 424 | const playing = draw(scene()); | |
| 425 | expect(words(playing).some((w) => w.startsWith("Watching"))).toBe(false); | |
| 426 | expect(says(playing, SWITCH)).toBe(false); | |
| 427 | }); | |
| 428 | ||
| 429 | test("the numbers under a spectated round are the watched player's, not your corpse's", () => { | |
| 430 | const { world, mate } = spectating((w) => { | |
| 431 | w.me.health = 89; // a corpse's last number, which is nobody's business now | |
| 432 | w.me.money = 1234; | |
| 433 | }); | |
| 434 | mate.health = 57; | |
| 435 | mate.money = 4300; | |
| 436 | mate.weapon = "ak47"; | |
| 437 | mate.ammo["ak47"] = { mag: 21, reserve: 60 }; | |
| 438 | const paint = draw(world, { view: mate }); | |
| 439 | expect(says(paint, "57")).toBe(true); | |
| 440 | expect(says(paint, "$4300")).toBe(true); | |
| 441 | expect(says(paint, "21 / 60")).toBe(true); | |
| 442 | expect(says(paint, WEAPONS.ak47.name)).toBe(true); | |
| 443 | // Nothing of yours is on the screen: not your health, not your wallet. | |
| 444 | expect(says(paint, "89")).toBe(false); | |
| 445 | expect(says(paint, "$1234")).toBe(false); | |
| 446 | ||
| 447 | // And their crosshair is drawn, because *they* are aiming: a dead player's | |
| 448 | // own view had none, which is what made the old spectate look broken. | |
| 449 | expect(paint.calls.some((c) => c.op === "moveTo")).toBe(true); | |
| 393 | 450 | }); |
| 394 | 451 | |
| 395 | 452 | test("the scoreboard ranks by kills, then by damage, marks you, and dims the dead", () => { |
src/client/hud.ts +52 −22
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | import { clamp } from "../engine/index.ts"; |
| 14 | 14 | import { CS_MOVE } from "./movement.ts"; |
| 15 | 15 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 16 | import { currentWeapon, useProgress, type World } from "./world.ts"; | |
| 16 | import { currentWeapon, useProgress, type Player, type World } from "./world.ts"; | |
| 17 | 17 | |
| 18 | 18 | /** Everything this module asks of the thing it draws on. Nothing else. */ |
| 19 | 19 | export type Paint = { |
| @@ -39,32 +39,46 @@ export type Paint = { | ||
| 39 | 39 | const T_COLOR = "#e2b04a"; |
| 40 | 40 | const CT_COLOR = "#6fa8e8"; |
| 41 | 41 | |
| 42 | /** What a dead player is told, in the two cases there are. */ | |
| 43 | const DEAD = "You are dead — watching the round out"; | |
| 44 | const WATCHING = "Watching"; | |
| 45 | const SWITCH = "fire — next player · aim — the one before"; | |
| 46 | ||
| 42 | 47 | const clock = (seconds: number): string => { |
| 43 | 48 | const s = Math.max(0, Math.ceil(seconds)); |
| 44 | 49 | return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; |
| 45 | 50 | }; |
| 46 | 51 | |
| 52 | /** | |
| 53 | * Paint the HUD for this frame. `view` is whose eyes the camera is in — you, or | |
| 54 | * the player you are watching once you are dead — and it is what every number | |
| 55 | * about a body is read off: the crosshair that opens as far as *their* gun is | |
| 56 | * inaccurate, their health, their armour, their magazine, their defuse bar. The | |
| 57 | * round, the feed and the scoreboard belong to the match and are the same from | |
| 58 | * anybody's eyes. Left out, the view is you, which is every frame of a round | |
| 59 | * you are alive in. | |
| 60 | */ | |
| 47 | 61 | export function drawHud( |
| 48 | 62 | ctx: Paint, |
| 49 | 63 | world: World, |
| 50 | 64 | width: number, |
| 51 | 65 | height: number, |
| 52 | 66 | showScoreboard: boolean, |
| 67 | view: Player = world.me, | |
| 53 | 68 | ): void { |
| 54 | 69 | ctx.clearRect(0, 0, width, height); |
| 55 | 70 | ctx.textBaseline = "alphabetic"; |
| 56 | const me = world.me; | |
| 57 | const weapon = currentWeapon(me); | |
| 71 | const weapon = currentWeapon(view); | |
| 58 | 72 | const pad = Math.max(12, Math.round(width * 0.02)); |
| 59 | 73 | |
| 60 | 74 | // --- crosshair, opening up exactly as much as the gun is inaccurate |
| 61 | if (me.alive) { | |
| 62 | const speed = Math.hypot(me.vel[0], me.vel[1]); | |
| 75 | if (view.alive) { | |
| 76 | const speed = Math.hypot(view.vel[0], view.vel[1]); | |
| 63 | 77 | const spread = inaccuracy(weapon, { |
| 64 | 78 | moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1), |
| 65 | inAir: !me.onGround, | |
| 66 | ducking: me.duckAmount > 0.5, | |
| 67 | scoped: me.scoped, | |
| 79 | inAir: !view.onGround, | |
| 80 | ducking: view.duckAmount > 0.5, | |
| 81 | scoped: view.scoped, | |
| 68 | 82 | }); |
| 69 | 83 | const gap = 4 + spread * 14; |
| 70 | 84 | const arm = 7; |
| @@ -76,7 +90,7 @@ export function drawHud( | ||
| 76 | 90 | ctx.lineTo(width / 2 + dx * (gap + arm), height / 2 + dy * (gap + arm)); |
| 77 | 91 | } |
| 78 | 92 | ctx.stroke(); |
| 79 | if (me.scoped) { | |
| 93 | if (view.scoped) { | |
| 80 | 94 | ctx.fillStyle = "rgba(0,0,0,0.55)"; |
| 81 | 95 | ctx.beginPath(); |
| 82 | 96 | ctx.rect(0, 0, width, height); |
| @@ -131,18 +145,18 @@ export function drawHud( | ||
| 131 | 145 | // --- health, armour, money |
| 132 | 146 | ctx.textAlign = "left"; |
| 133 | 147 | ctx.font = big; |
| 134 | ctx.fillStyle = me.health > 30 ? "#f3f5f8" : "#ff6b5b"; | |
| 135 | ctx.fillText(String(Math.max(0, Math.round(me.health))), pad, height - pad); | |
| 148 | ctx.fillStyle = view.health > 30 ? "#f3f5f8" : "#ff6b5b"; | |
| 149 | ctx.fillText(String(Math.max(0, Math.round(view.health))), pad, height - pad); | |
| 136 | 150 | ctx.font = small; |
| 137 | 151 | ctx.fillStyle = "#9fd0ff"; |
| 138 | 152 | ctx.fillText( |
| 139 | `${me.armor > 0 ? `armour ${me.armor}${me.helmet ? " + helmet" : ""}` : "no armour"}`, | |
| 153 | `${view.armor > 0 ? `armour ${view.armor}${view.helmet ? " + helmet" : ""}` : "no armour"}`, | |
| 140 | 154 | pad, height - pad - Math.round(30 * scale), |
| 141 | 155 | ); |
| 142 | 156 | ctx.fillStyle = "#7ddc8a"; |
| 143 | 157 | ctx.font = mid; |
| 144 | ctx.fillText(`$${me.money}`, pad, pad + Math.round(18 * scale)); | |
| 145 | if (me.team === "ct" && me.defuseKit) { | |
| 158 | ctx.fillText(`$${view.money}`, pad, pad + Math.round(18 * scale)); | |
| 159 | if (view.team === "ct" && view.defuseKit) { | |
| 146 | 160 | ctx.fillStyle = "#9fd0ff"; |
| 147 | 161 | ctx.font = small; |
| 148 | 162 | ctx.fillText("defuse kit", pad, pad + Math.round(38 * scale)); |
| @@ -152,13 +166,13 @@ export function drawHud( | ||
| 152 | 166 | ctx.textAlign = "right"; |
| 153 | 167 | ctx.font = big; |
| 154 | 168 | ctx.fillStyle = "#f3f5f8"; |
| 155 | const ammo = me.ammo[weapon.id]; | |
| 169 | const ammo = view.ammo[weapon.id]; | |
| 156 | 170 | const ammoText = weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`; |
| 157 | 171 | ctx.fillText(ammoText, width - pad, height - pad); |
| 158 | 172 | ctx.font = small; |
| 159 | 173 | ctx.fillStyle = "#c8ccd4"; |
| 160 | ctx.fillText(weapon.name + (me.hasBomb ? " · bomb" : ""), width - pad, height - pad - Math.round(30 * scale)); | |
| 161 | if (world.time < me.reloadUntil) { | |
| 174 | ctx.fillText(weapon.name + (view.hasBomb ? " · bomb" : ""), width - pad, height - pad - Math.round(30 * scale)); | |
| 175 | if (world.time < view.reloadUntil) { | |
| 162 | 176 | ctx.fillStyle = "#f0c060"; |
| 163 | 177 | ctx.fillText("reloading", width - pad, height - pad - Math.round(50 * scale)); |
| 164 | 178 | } |
| @@ -197,7 +211,7 @@ export function drawHud( | ||
| 197 | 211 | } |
| 198 | 212 | |
| 199 | 213 | // --- planting or defusing |
| 200 | const progress = useProgress(world, me); | |
| 214 | const progress = useProgress(world, view); | |
| 201 | 215 | if (progress !== null) { |
| 202 | 216 | const w = Math.round(260 * scale), h = Math.round(14 * scale); |
| 203 | 217 | const x = width / 2 - w / 2, y = height * 0.62; |
| @@ -210,11 +224,27 @@ export function drawHud( | ||
| 210 | 224 | ctx.fillText(progress.label, width / 2, y - 8 * scale); |
| 211 | 225 | } |
| 212 | 226 | |
| 213 | if (!me.alive && world.match.phase !== "match_over") { | |
| 227 | // --- dead: whose eyes this is, and how to move to the next pair | |
| 228 | if (!world.me.alive && world.match.phase !== "match_over") { | |
| 214 | 229 | ctx.textAlign = "center"; |
| 215 | ctx.font = big; | |
| 216 | ctx.fillStyle = "rgba(255,255,255,0.85)"; | |
| 217 | ctx.fillText("You are dead — watching the round out", width / 2, height * 0.4); | |
| 230 | if (view === world.me) { | |
| 231 | // Nobody left to watch: your own body is the only camera in the round. | |
| 232 | ctx.font = big; | |
| 233 | ctx.fillStyle = "rgba(255,255,255,0.85)"; | |
| 234 | ctx.fillText(DEAD, width / 2, height * 0.4); | |
| 235 | } else { | |
| 236 | // Above the bottom row, so it never lands on the health or the ammo — | |
| 237 | // at 360 pixels wide those are a thumb's width from the middle. | |
| 238 | const w = Math.round(420 * scale), h = Math.round(34 * scale); | |
| 239 | const y = height - pad - Math.round(64 * scale); | |
| 240 | panel(width / 2 - w / 2, y - h + Math.round(8 * scale), w, h); | |
| 241 | ctx.font = mid; | |
| 242 | ctx.fillStyle = view.team === "t" ? T_COLOR : CT_COLOR; | |
| 243 | ctx.fillText(`${WATCHING} ${view.name}`, width / 2, y - Math.round(14 * scale)); | |
| 244 | ctx.font = small; | |
| 245 | ctx.fillStyle = "#c8ccd4"; | |
| 246 | ctx.fillText(SWITCH, width / 2, y); | |
| 247 | } | |
| 218 | 248 | } |
| 219 | 249 | |
| 220 | 250 | if (showScoreboard) drawScoreboard(ctx, world, width, height, scale); |
src/client/main.test.ts +31
| @@ -288,6 +288,37 @@ test("match_over stops the run, shows the menu and focuses play", () => { | ||
| 288 | 288 | expect(stage.draws.length).toBe(drawn); |
| 289 | 289 | }); |
| 290 | 290 | |
| 291 | test("dying moves the camera behind a teammate, and fire moves it behind the next", () => { | |
| 292 | const stage = playing(); | |
| 293 | stage.lock(true); | |
| 294 | stage.step(FRAME); | |
| 295 | const world = worldOf(stage); | |
| 296 | if (world === null) throw new Error("the first frame drew nothing"); | |
| 297 | ||
| 298 | // Alive, the camera is your own eye and every other living body is drawn. | |
| 299 | const mine = [...(stage.draws[0]?.scene.camera.pos ?? [])]; | |
| 300 | expect(stage.draws[0]?.scene.instanceCount).toBe(10); | |
| 301 | ||
| 302 | world.me.alive = false; | |
| 303 | stage.step(FRAME); | |
| 304 | const watched = [...(stage.draws[1]?.scene.camera.pos ?? [])]; | |
| 305 | // Somewhere else entirely, and one body fewer: the one the camera is inside. | |
| 306 | expect(watched).not.toEqual(mine); | |
| 307 | expect(stage.draws[1]?.scene.instanceCount).toBe(9); | |
| 308 | // And it is a teammate's eye, not a bot from the other side of the map. | |
| 309 | const mates = world.players.filter((p) => p.alive && p.team === world.me.team); | |
| 310 | expect(mates.some((p) => p.pos[0] === watched[0] && p.pos[1] === watched[1])).toBe(true); | |
| 311 | ||
| 312 | // The trigger is the only control a dead player has left: it walks along. | |
| 313 | button(stage, "fire").fire("pointerdown"); | |
| 314 | stage.step(FRAME); | |
| 315 | const next = [...(stage.draws[2]?.scene.camera.pos ?? [])]; | |
| 316 | expect(next).not.toEqual(watched); | |
| 317 | expect(mates.some((p) => p.pos[0] === next[0] && p.pos[1] === next[1])).toBe(true); | |
| 318 | // The HUD says whose eyes they are. | |
| 319 | expect(stage.hud.paint.texts.some((t) => t.startsWith("Watching "))).toBe(true); | |
| 320 | }); | |
| 321 | ||
| 291 | 322 | test("a phone gets the touch pads, a mouse does not", () => { |
| 292 | 323 | expect(playing({ coarse: true }).by("touch").hidden).toBe(false); |
| 293 | 324 | expect(playing().by("touch").hidden).toBe(true); |
src/client/main.ts +27 −21
| @@ -35,6 +35,7 @@ import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; | ||
| 35 | 35 | import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts"; |
| 36 | 36 | import { meshFor } from "./models.ts"; |
| 37 | 37 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; |
| 38 | import { createSpectator, cycle, follow } from "./spectate.ts"; | |
| 38 | 39 | import { buyable, WEAPONS } from "./weapons.ts"; |
| 39 | 40 | import { |
| 40 | 41 | buy, canBuy, createWorld, eyePos, priceOf, update, type BuyItem, type Player, type World, |
| @@ -227,6 +228,9 @@ export function boot(host: Host): Frame { | ||
| 227 | 228 | // them. `scene.ts` keeps the line buffer inside the scene for the same reason. |
| 228 | 229 | const frameScene = createScene(); |
| 229 | 230 | const camera: Camera = { pos: [0, 0, 0], yaw: 0, pitch: 0, fov: 90 }; |
| 231 | // Who the camera is behind once you are dead, kept across the whole run: | |
| 232 | // `spectate.ts` moves it, and a fresh match cuts it back to you. | |
| 233 | const spectator = createSpectator(); | |
| 230 | 234 | let matchId = ""; |
| 231 | 235 | // The map the renderer is holding the geometry of. It starts as the one the |
| 232 | 236 | // meshes were built from, and a match on another map swaps that one mesh. |
| @@ -290,8 +294,14 @@ export function boot(host: Host): Frame { | ||
| 290 | 294 | |
| 291 | 295 | controls.onAction = (action, down) => { |
| 292 | 296 | if (!down) return; |
| 293 | if (action === "buy") openBuy(buyPanel.hidden); | |
| 294 | else if (action === "fullscreen") full.toggle(); | |
| 297 | if (action === "buy") return openBuy(buyPanel.hidden); | |
| 298 | if (action === "fullscreen") return full.toggle(); | |
| 299 | // Dead, the trigger is the only thing left to press: it walks the camera | |
| 300 | // along your side, and the aim button walks it back. The touch pad's fire | |
| 301 | // is the same action, so a phone gets it without a button of its own. | |
| 302 | if (world === null || !running || world.me.alive || !buyPanel.hidden) return; | |
| 303 | if (action === "fire") cycle(world, spectator, 1); | |
| 304 | else if (action === "scope") cycle(world, spectator, -1); | |
| 295 | 305 | }; |
| 296 | 306 | controls.onUnlock = () => { |
| 297 | 307 | if (running && buyPanel.hidden) show(pause, true); |
| @@ -377,21 +387,20 @@ export function boot(host: Host): Frame { | ||
| 377 | 387 | const saved = host.storage.getItem("cs.name"); |
| 378 | 388 | if (saved !== null && nameField.value === "") nameField.value = saved; |
| 379 | 389 | |
| 380 | // --- sound for whatever happened in the ticks we just ran | |
| 381 | function playSounds(current: World): void { | |
| 390 | /** | |
| 391 | * Sound for whatever happened in the ticks we just ran, heard from `ears` — | |
| 392 | * the player the camera is behind. Dead, that is whoever you are watching: | |
| 393 | * a round heard from your own corpse is the other half of a broken spectate. | |
| 394 | */ | |
| 395 | function playSounds(current: World, ears: Player): void { | |
| 382 | 396 | for (const effect of current.effects) { |
| 383 | 397 | if (effect.at <= lastSound) continue; |
| 384 | if (effect.kind === "shot") audio.shot(effect.weapon, effect.pos, eyePos(current.me)); | |
| 398 | if (effect.kind === "shot") audio.shot(effect.weapon, effect.pos, eyePos(ears)); | |
| 385 | 399 | else if (effect.kind === "hitmark") audio.hit(effect.headshot); |
| 386 | 400 | } |
| 387 | 401 | lastSound = current.time; |
| 388 | 402 | } |
| 389 | 403 | |
| 390 | const spectated = (current: World): Player => { | |
| 391 | if (current.me.alive) return current.me; | |
| 392 | return current.players.find((p) => p.alive && p.team === current.me.team) ?? current.me; | |
| 393 | }; | |
| 394 | ||
| 395 | 404 | function frame(elapsed: number): void { |
| 396 | 405 | frameAverage += (elapsed * 1000 - frameAverage) * 0.05; |
| 397 | 406 | // One layer of the atlas per frame, and the browser has the rest of it: the |
| @@ -421,7 +430,12 @@ export function boot(host: Host): Frame { | ||
| 421 | 430 | const steps = loop.advance(elapsed); |
| 422 | 431 | for (let step = 0; step < steps; step++) |
| 423 | 432 | update(world, TICK, buyPanel.hidden ? input : { ...input, fire: false }); |
| 424 | playSounds(world); | |
| 433 | ||
| 434 | // The camera, and with it whose eyes this frame is drawn and heard from. | |
| 435 | // It is moved after the steps and before anything reads it, and it is the | |
| 436 | // one thing here that moves at the frame rate rather than at 64 Hz. | |
| 437 | const view = follow(world, spectator, camera, elapsed); | |
| 438 | playSounds(world, view); | |
| 425 | 439 | |
| 426 | 440 | // The buy window closes itself when the round goes live. |
| 427 | 441 | if (!buyPanel.hidden && !canBuy(world, me)) openBuy(false); |
| @@ -437,15 +451,7 @@ export function boot(host: Host): Frame { | ||
| 437 | 451 | play.focus(); |
| 438 | 452 | } |
| 439 | 453 | |
| 440 | const view = spectated(world); | |
| 441 | const eye = eyePos(view); | |
| 442 | camera.pos[0] = eye[0]; | |
| 443 | camera.pos[1] = eye[1]; | |
| 444 | camera.pos[2] = eye[2]; | |
| 445 | camera.yaw = view.yaw - view.punch[0] * 0.4; | |
| 446 | camera.pitch = view.pitch - view.punch[1] * 0.4; | |
| 447 | camera.fov = view.scoped ? 40 : 90; | |
| 448 | renderer.draw(buildScene(world, camera, frameScene), elapsed); | |
| 454 | renderer.draw(buildScene(world, camera, frameScene, view), elapsed); | |
| 449 | 455 | |
| 450 | 456 | const dpr = Math.min(win.devicePixelRatio || 1, 2); |
| 451 | 457 | const w = Math.round(hudCanvas.clientWidth * dpr); |
| @@ -454,7 +460,7 @@ export function boot(host: Host): Frame { | ||
| 454 | 460 | hudCanvas.width = w; |
| 455 | 461 | hudCanvas.height = h; |
| 456 | 462 | } |
| 457 | drawHud(hudCtx, world, w, h, scoreboard); | |
| 463 | drawHud(hudCtx, world, w, h, scoreboard, view); | |
| 458 | 464 | by("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`; |
| 459 | 465 | } |
| 460 | 466 | loop.start(frame); |
src/client/scene.test.ts +28 −1
| @@ -18,7 +18,7 @@ import { CS_HULL } from "./movement.ts"; | ||
| 18 | 18 | import { buildScene, createScene, LINE_CAPACITY, SKY } from "./scene.ts"; |
| 19 | 19 | import { DE_FORGE } from "./map.ts"; |
| 20 | 20 | import { SHORT } from "./match.ts"; |
| 21 | import { createWorld, type Effect, type World } from "./world.ts"; | |
| 21 | import { createWorld, type Effect, type Player, type World } from "./world.ts"; | |
| 22 | 22 | |
| 23 | 23 | const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90 }); |
| 24 | 24 | |
| @@ -57,6 +57,33 @@ test("the level is drawn first, every living other player after it, and never me | ||
| 57 | 57 | expect(meshes(scene)).toEqual(["level", ...Array<string>(8).fill("player")]); |
| 58 | 58 | }); |
| 59 | 59 | |
| 60 | test("the body left out is the one the camera is inside, which is not always me", () => { | |
| 61 | // Dead, the camera sits behind a teammate — and that teammate's model used to | |
| 62 | // be drawn anyway, one hull thick, right in front of the lens. What is left | |
| 63 | // out is whoever the eye belongs to, and my own corpse is drawn like anyone's. | |
| 64 | const w = world(); | |
| 65 | const watched = w.players.find((p) => p !== w.me && p.team === w.me.team) as Player; | |
| 66 | const scene = buildScene(w, camera(), createScene(), watched); | |
| 67 | const drawn = scene.instances.slice(1, scene.instanceCount); | |
| 68 | expect(drawn.length).toBe(9); | |
| 69 | expect(drawn.some((i) => i.pos[0] === watched.pos[0] && i.pos[1] === watched.pos[1])).toBe(false); | |
| 70 | expect(drawn.some((i) => i.pos[0] === w.me.pos[0] && i.pos[1] === w.me.pos[1])).toBe(true); | |
| 71 | }); | |
| 72 | ||
| 73 | test("the gun in view is the watched player's, and goes with them when they die", () => { | |
| 74 | const w = world(); | |
| 75 | const watched = w.players.find((p) => p !== w.me && p.team === w.me.team) as Player; | |
| 76 | watched.primary = "awp"; | |
| 77 | watched.weapon = "awp"; | |
| 78 | w.me.alive = false; | |
| 79 | const scene = buildScene(w, camera(), createScene(), watched); | |
| 80 | expect(scene.viewModel?.mesh).toBe("sniper"); | |
| 81 | ||
| 82 | watched.alive = false; | |
| 83 | buildScene(w, camera(), scene, watched); | |
| 84 | expect(scene.viewModel).toBe(null); | |
| 85 | }); | |
| 86 | ||
| 60 | 87 | test("a duck squashes a player by the hull it has, a standing one by nothing", () => { |
| 61 | 88 | const w = world(); |
| 62 | 89 | const other = w.players.find((p) => p !== w.me) as (typeof w.players)[number]; |
src/client/scene.ts +16 −10
| @@ -23,7 +23,7 @@ import { | ||
| 23 | 23 | } from "../engine/index.ts"; |
| 24 | 24 | import { CS_HULL } from "./movement.ts"; |
| 25 | 25 | import { WEAPONS } from "./weapons.ts"; |
| 26 | import type { World } from "./world.ts"; | |
| 26 | import type { Player, World } from "./world.ts"; | |
| 27 | 27 | |
| 28 | 28 | export type Tint = [number, number, number]; |
| 29 | 29 | |
| @@ -116,8 +116,14 @@ function place(scene: Scene, mesh: string, pos: Vec3, yaw: number, scale: number | ||
| 116 | 116 | * Fill `into` with what this world looks like from this camera, and hand the |
| 117 | 117 | * same object back. The order the instances go in is the order they are drawn: |
| 118 | 118 | * the level, then every other living player, then the bomb. |
| 119 | * | |
| 120 | * `view` is whose eyes the camera is in — you, or the teammate you are watching | |
| 121 | * once you are dead (`src/client/spectate.ts` decides which). Everything that | |
| 122 | * is a fact about the eye rather than about the world is read off it: the body | |
| 123 | * that is left out because the camera is inside its head, and the gun that is | |
| 124 | * drawn in view space because it is the one those hands are holding. | |
| 119 | 125 | */ |
| 120 | export function buildScene(world: World, camera: Camera, into: Scene): Scene { | |
| 126 | export function buildScene(world: World, camera: Camera, into: Scene, view: Player = world.me): Scene { | |
| 121 | 127 | into.camera.pos[0] = camera.pos[0]; |
| 122 | 128 | into.camera.pos[1] = camera.pos[1]; |
| 123 | 129 | into.camera.pos[2] = camera.pos[2]; |
| @@ -129,8 +135,9 @@ export function buildScene(world: World, camera: Camera, into: Scene): Scene { | ||
| 129 | 135 | resetScene(into); |
| 130 | 136 | place(into, "level", ORIGIN, 0, 1, WHITE); |
| 131 | 137 | for (const p of world.players) { |
| 132 | // The eye is inside your own head: drawing it would fill the screen. | |
| 133 | if (!p.alive || p === world.me) continue; | |
| 138 | // The eye is inside this head: drawing it would fill the screen. Whose head | |
| 139 | // that is moves when you die, which is why it is `view` and not `world.me`. | |
| 140 | if (!p.alive || p === view) continue; | |
| 134 | 141 | place(into, "player", p.pos, p.yaw, hullHeight(p, CS_HULL) / CS_HULL.stand, TEAM_TINT[p.team] ?? WHITE); |
| 135 | 142 | } |
| 136 | 143 | if (world.bomb.state === "planted" || world.bomb.state === "dropped") |
| @@ -178,19 +185,18 @@ export function buildScene(world: World, camera: Camera, into: Scene): Scene { | ||
| 178 | 185 | ); |
| 179 | 186 | } |
| 180 | 187 | |
| 181 | // --- the gun in your hands: a walk cycle it sways on, a kick that decays | |
| 182 | const me = world.me; | |
| 183 | if (me.alive && !me.scoped) { | |
| 188 | // --- the gun in these hands: a walk cycle it sways on, a kick that decays | |
| 189 | if (view.alive && !view.scoped) { | |
| 184 | 190 | // Simulated seconds, not frame seconds: a match that restarts goes back to |
| 185 | 191 | // zero and a paused frame moves nothing, so the clamp holds both ends. |
| 186 | 192 | const dt = clamp(world.time - into.keep.time, 0, 0.1); |
| 187 | const speed = Math.hypot(me.vel[0], me.vel[1]); | |
| 193 | const speed = Math.hypot(view.vel[0], view.vel[1]); | |
| 188 | 194 | into.keep.bobPhase += dt * (4 + speed * 0.02); |
| 189 | 195 | const bobX = Math.sin(into.keep.bobPhase) * Math.min(speed / 250, 1) * 0.05; |
| 190 | 196 | const bobY = Math.abs(Math.cos(into.keep.bobPhase)) * Math.min(speed / 250, 1) * 0.04; |
| 191 | const kick = Math.max(0, 0.12 - (world.time - me.lastShotAt)) * 2.2; | |
| 197 | const kick = Math.max(0, 0.12 - (world.time - view.lastShotAt)) * 2.2; | |
| 192 | 198 | const held = into.keep.viewModel; |
| 193 | held.mesh = WEAPONS[me.weapon]?.kind ?? "rifle"; | |
| 199 | held.mesh = WEAPONS[view.weapon]?.kind ?? "rifle"; | |
| 194 | 200 | held.offset[0] = bobX; |
| 195 | 201 | held.offset[1] = bobY - kick * 0.12; |
| 196 | 202 | held.offset[2] = kick * 0.35; |
src/client/spectate.test.ts added +272
| @@ -0,0 +1,272 @@ | ||
| 1 | /** | |
| 2 | * Spectating, with no browser in the room: a world, a camera object and a `dt` | |
| 3 | * the test chooses. Every claim here is one of the four things that were wrong | |
| 4 | * with watching a round out — who you watch, that it stays who you chose, that | |
| 5 | * you can change it, and that the camera moves like a camera rather than like a | |
| 6 | * simulation that ticks 64 times a second. | |
| 7 | * | |
| 8 | * The roster `createWorld` lays out is you first, then the five terrorists, | |
| 9 | * then the four counter-terrorists left: seats 0 and 6..9 are your side when | |
| 10 | * you play CT, which is what the seat numbers below mean. | |
| 11 | */ | |
| 12 | ||
| 13 | import { expect, test } from "bun:test"; | |
| 14 | import { eyeHeight, type Camera } from "../engine/index.ts"; | |
| 15 | import { DE_FORGE } from "./map.ts"; | |
| 16 | import { SHORT } from "./match.ts"; | |
| 17 | import { CS_HULL } from "./movement.ts"; | |
| 18 | import { | |
| 19 | ANGLE_TAU, CUT_DISTANCE, POS_TAU, createSpectator, cycle, follow, watched, type Spectator, | |
| 20 | } from "./spectate.ts"; | |
| 21 | import { createWorld, type Player, type World } from "./world.ts"; | |
| 22 | ||
| 23 | const FRAME = 1 / 60; | |
| 24 | ||
| 25 | const camera = (): Camera => ({ pos: [0, 0, 0], yaw: 0, pitch: 0, fov: 90 }); | |
| 26 | ||
| 27 | /** A match nobody is playing, with everyone standing where they spawned. */ | |
| 28 | function world(): World { | |
| 29 | const w = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 11); | |
| 30 | w.time = 100; | |
| 31 | return w; | |
| 32 | } | |
| 33 | ||
| 34 | const seat = (w: World, index: number): Player => w.players[index] as Player; | |
| 35 | ||
| 36 | /** Where the camera should end up if it were placed on `p` this instant. */ | |
| 37 | const eyeOf = (p: Player): [number, number, number] => | |
| 38 | [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p, CS_HULL)]; | |
| 39 | ||
| 40 | /** Kill everyone but the seats named, so what is watchable is exactly them. */ | |
| 41 | function onlyAlive(w: World, ...seats: number[]): void { | |
| 42 | for (const [index, p] of w.players.entries()) p.alive = seats.includes(index); | |
| 43 | } | |
| 44 | ||
| 45 | /** A dead player, a spectator on seat `at`, and the camera already cut to them. */ | |
| 46 | function watching(w: World, at: number): { spectator: Spectator; cam: Camera } { | |
| 47 | const spectator = createSpectator(); | |
| 48 | spectator.target = seat(w, at).id; | |
| 49 | const cam = camera(); | |
| 50 | follow(w, spectator, cam, FRAME); | |
| 51 | return { spectator, cam }; | |
| 52 | } | |
| 53 | ||
| 54 | // --- who ------------------------------------------------------------------ | |
| 55 | ||
| 56 | test("while you are alive there is nobody to watch: the camera is your own head", () => { | |
| 57 | const w = world(); | |
| 58 | const spectator = createSpectator(); | |
| 59 | const cam = camera(); | |
| 60 | expect(watched(w, spectator)).toBe(w.me); | |
| 61 | expect(follow(w, spectator, cam, FRAME)).toBe(w.me); | |
| 62 | expect(cam.pos).toEqual(eyeOf(w.me)); | |
| 63 | ||
| 64 | // And it is *placed*, never eased: a smoothed camera on your own mouse is | |
| 65 | // input lag. A step of movement is on screen whole on the next frame. | |
| 66 | w.me.pos[0] += 40; | |
| 67 | w.me.yaw = 33; | |
| 68 | follow(w, spectator, cam, FRAME); | |
| 69 | expect(cam.pos).toEqual(eyeOf(w.me)); | |
| 70 | expect(cam.yaw).toBe(33); | |
| 71 | ||
| 72 | // Cycling a round you are playing is not a thing: it stays you. | |
| 73 | expect(cycle(w, spectator, 1)).toBe(w.me); | |
| 74 | }); | |
| 75 | ||
| 76 | test("dying watches a living teammate, and never the enemy while one of yours stands", () => { | |
| 77 | const w = world(); | |
| 78 | w.me.alive = false; | |
| 79 | const view = watched(w, createSpectator()); | |
| 80 | expect(view).not.toBe(w.me); | |
| 81 | expect(view.team).toBe("ct"); | |
| 82 | expect(view.alive).toBe(true); | |
| 83 | // The seat after yours, which is the first of your side's bots. | |
| 84 | expect(view).toBe(seat(w, 6)); | |
| 85 | }); | |
| 86 | ||
| 87 | test("the camera stays on the player you chose, even when an earlier seat lives", () => { | |
| 88 | const w = world(); | |
| 89 | w.me.alive = false; | |
| 90 | const { spectator, cam } = watching(w, 7); | |
| 91 | expect(watched(w, spectator)).toBe(seat(w, 7)); | |
| 92 | ||
| 93 | // Seat 6 is alive and comes first in the roster: the old camera took whoever | |
| 94 | // `find` reached first and this is the line that says it does not any more. | |
| 95 | expect(seat(w, 6).alive).toBe(true); | |
| 96 | follow(w, spectator, cam, FRAME); | |
| 97 | expect(watched(w, spectator)).toBe(seat(w, 7)); | |
| 98 | ||
| 99 | // Seat 6 dying is not your camera's business either. | |
| 100 | seat(w, 6).alive = false; | |
| 101 | expect(follow(w, spectator, cam, FRAME)).toBe(seat(w, 7)); | |
| 102 | }); | |
| 103 | ||
| 104 | test("when the one you are watching dies the camera walks on, and does not jump back", () => { | |
| 105 | const w = world(); | |
| 106 | w.me.alive = false; | |
| 107 | const { spectator, cam } = watching(w, 7); | |
| 108 | seat(w, 7).alive = false; | |
| 109 | // The next seat along, not the top of the roster. | |
| 110 | expect(follow(w, spectator, cam, FRAME)).toBe(seat(w, 8)); | |
| 111 | expect(spectator.target).toBe(seat(w, 8).id); | |
| 112 | }); | |
| 113 | ||
| 114 | test("fire walks your side, wraps around it, and skips the dead", () => { | |
| 115 | const w = world(); | |
| 116 | onlyAlive(w, 6, 8, 9); | |
| 117 | const spectator = createSpectator(); | |
| 118 | expect(watched(w, spectator)).toBe(seat(w, 6)); | |
| 119 | expect(cycle(w, spectator, 1)).toBe(seat(w, 8)); // seat 7 is dead: skipped | |
| 120 | expect(cycle(w, spectator, 1)).toBe(seat(w, 9)); | |
| 121 | expect(cycle(w, spectator, 1)).toBe(seat(w, 6)); // and round it goes | |
| 122 | // Aim walks the other way, through the same three. | |
| 123 | expect(cycle(w, spectator, -1)).toBe(seat(w, 9)); | |
| 124 | expect(cycle(w, spectator, -1)).toBe(seat(w, 8)); | |
| 125 | }); | |
| 126 | ||
| 127 | test("with your whole side gone the camera watches whoever is left, not your body", () => { | |
| 128 | // A T team wiped with the bomb down is a round that keeps running, and it | |
| 129 | // used to be spent looking out of your own corpse. | |
| 130 | const w = world(); | |
| 131 | onlyAlive(w, 2, 3); | |
| 132 | const spectator = createSpectator(); | |
| 133 | expect(watched(w, spectator)).toBe(seat(w, 2)); | |
| 134 | expect(cycle(w, spectator, 1)).toBe(seat(w, 3)); | |
| 135 | ||
| 136 | // Nobody at all alive — the tick a round ends on — is your own body, and no | |
| 137 | // exception. | |
| 138 | onlyAlive(w); | |
| 139 | expect(watched(w, spectator)).toBe(w.me); | |
| 140 | expect(follow(w, spectator, camera(), FRAME)).toBe(w.me); | |
| 141 | }); | |
| 142 | ||
| 143 | // --- how it moves --------------------------------------------------------- | |
| 144 | ||
| 145 | test("a change of target is a cut: the camera is placed, not flown", () => { | |
| 146 | const w = world(); | |
| 147 | onlyAlive(w, 6, 7); | |
| 148 | const spectator = createSpectator(); | |
| 149 | const cam = camera(); | |
| 150 | follow(w, spectator, cam, FRAME); | |
| 151 | expect(cam.pos).toEqual(eyeOf(seat(w, 6))); | |
| 152 | ||
| 153 | cycle(w, spectator, 1); | |
| 154 | follow(w, spectator, cam, FRAME); | |
| 155 | expect(cam.pos).toEqual(eyeOf(seat(w, 7))); | |
| 156 | expect(cam.yaw).toBe(seat(w, 7).yaw); | |
| 157 | }); | |
| 158 | ||
| 159 | test("between cuts the camera eases towards the body, and gets there", () => { | |
| 160 | const w = world(); | |
| 161 | onlyAlive(w, 6); | |
| 162 | const { spectator, cam } = watching(w, 6); | |
| 163 | const target = seat(w, 6); | |
| 164 | const from = target.pos[0]; | |
| 165 | ||
| 166 | // Forty units in one frame is a run, not a teleport: the camera follows it | |
| 167 | // part of the way, in the direction of travel. | |
| 168 | target.pos[0] = from + 40; | |
| 169 | follow(w, spectator, cam, FRAME); | |
| 170 | expect(cam.pos[0]).toBeGreaterThan(from); | |
| 171 | expect(cam.pos[0]).toBeLessThan(from + 40); | |
| 172 | expect(cam.pos[0]).toBeCloseTo(from + 40 * (1 - Math.exp(-FRAME / POS_TAU)), 8); | |
| 173 | ||
| 174 | // And it closes the gap rather than trailing forever: half a second of | |
| 175 | // frames and the camera is on the body to within a hundredth of a unit. | |
| 176 | for (let frame = 0; frame < 30; frame++) follow(w, spectator, cam, FRAME); | |
| 177 | expect(Math.abs(cam.pos[0] - (from + 40))).toBeLessThan(0.01); | |
| 178 | }); | |
| 179 | ||
| 180 | test("the ease is the same motion at 30 frames a second and at 60", () => { | |
| 181 | const w = world(); | |
| 182 | onlyAlive(w, 6); | |
| 183 | const target = seat(w, 6); | |
| 184 | ||
| 185 | const slow = watching(w, 6); | |
| 186 | const fast = watching(w, 6); | |
| 187 | target.pos[0] += 50; | |
| 188 | target.yaw += 30; | |
| 189 | follow(w, slow.spectator, slow.cam, 1 / 30); | |
| 190 | follow(w, fast.spectator, fast.cam, 1 / 60); | |
| 191 | follow(w, fast.spectator, fast.cam, 1 / 60); | |
| 192 | ||
| 193 | // Two half-frames leave the camera exactly where one whole one does: the ease | |
| 194 | // is framed in seconds, so a fast machine is smoother and not further along. | |
| 195 | expect(fast.cam.pos[0]).toBeCloseTo(slow.cam.pos[0], 8); | |
| 196 | expect(fast.cam.yaw).toBeCloseTo(slow.cam.yaw, 8); | |
| 197 | }); | |
| 198 | ||
| 199 | test("a body that teleports is a cut, and so is a round that restarts under it", () => { | |
| 200 | const w = world(); | |
| 201 | onlyAlive(w, 6); | |
| 202 | const { spectator, cam } = watching(w, 6); | |
| 203 | const target = seat(w, 6); | |
| 204 | target.pos[0] += CUT_DISTANCE + 1; | |
| 205 | follow(w, spectator, cam, FRAME); | |
| 206 | expect(cam.pos).toEqual(eyeOf(target)); | |
| 207 | }); | |
| 208 | ||
| 209 | test("the camera turns the short way round the wrap, never the long way", () => { | |
| 210 | const w = world(); | |
| 211 | onlyAlive(w, 6); | |
| 212 | const { spectator, cam } = watching(w, 6); | |
| 213 | const target = seat(w, 6); | |
| 214 | target.yaw = 170; | |
| 215 | spectator.cut = true; // place it on 170 exactly, so the turn is the whole sum | |
| 216 | follow(w, spectator, cam, FRAME); | |
| 217 | expect(cam.yaw).toBe(170); | |
| 218 | target.yaw = -170; | |
| 219 | follow(w, spectator, cam, FRAME); | |
| 220 | // Twenty degrees up through 180, not three hundred and forty back through 0. | |
| 221 | expect(cam.yaw).toBeGreaterThan(170); | |
| 222 | expect(cam.yaw).toBeCloseTo(170 + 20 * (1 - Math.exp(-FRAME / ANGLE_TAU)), 8); | |
| 223 | }); | |
| 224 | ||
| 225 | test("a watched scope zooms the view in, and your own snaps", () => { | |
| 226 | const w = world(); | |
| 227 | onlyAlive(w, 6); | |
| 228 | const { spectator, cam } = watching(w, 6); | |
| 229 | seat(w, 6).scoped = true; | |
| 230 | follow(w, spectator, cam, FRAME); | |
| 231 | expect(cam.fov).toBeLessThan(90); | |
| 232 | expect(cam.fov).toBeGreaterThan(40); | |
| 233 | for (let frame = 0; frame < 30; frame++) follow(w, spectator, cam, FRAME); | |
| 234 | expect(cam.fov).toBeCloseTo(40, 2); | |
| 235 | ||
| 236 | // Yours is not eased at all: a scope you pressed is on screen on the frame | |
| 237 | // you pressed it. | |
| 238 | const mine = world(); | |
| 239 | const own = createSpectator(); | |
| 240 | const cam2 = camera(); | |
| 241 | mine.me.scoped = true; | |
| 242 | follow(mine, own, cam2, FRAME); | |
| 243 | expect(cam2.fov).toBe(40); | |
| 244 | }); | |
| 245 | ||
| 246 | test("following allocates nothing: the camera's position is the array it was handed", () => { | |
| 247 | const w = world(); | |
| 248 | onlyAlive(w, 6, 7); | |
| 249 | const { spectator, cam } = watching(w, 6); | |
| 250 | const pos = cam.pos; | |
| 251 | seat(w, 6).pos[1] += 10; | |
| 252 | follow(w, spectator, cam, FRAME); | |
| 253 | cycle(w, spectator, 1); | |
| 254 | follow(w, spectator, cam, FRAME); | |
| 255 | expect(cam.pos).toBe(pos); | |
| 256 | }); | |
| 257 | ||
| 258 | test("a dead frame with no time in it moves nothing and breaks nothing", () => { | |
| 259 | // A tab that comes back, a frame with dt 0, a frame the clock slept through: | |
| 260 | // none of them are a reason for the camera to be somewhere impossible. | |
| 261 | const w = world(); | |
| 262 | onlyAlive(w, 6); | |
| 263 | const { spectator, cam } = watching(w, 6); | |
| 264 | const target = seat(w, 6); | |
| 265 | target.pos[1] += 20; | |
| 266 | follow(w, spectator, cam, 0); | |
| 267 | expect(cam.pos[1]).toBe(eyeOf(target)[1] - 20); | |
| 268 | // A frame that claims to have taken three hours is one long frame, clamped: | |
| 269 | // the camera catches up in it rather than overshooting past the body. | |
| 270 | follow(w, spectator, cam, 9999); | |
| 271 | expect(Math.abs(cam.pos[1] - eyeOf(target)[1])).toBeLessThan(0.1); | |
| 272 | }); |
src/client/spectate.ts added +180
| @@ -0,0 +1,180 @@ | ||
| 1 | /** | |
| 2 | * Who you are watching once you are dead, and where the camera sits while you | |
| 3 | * watch them. | |
| 4 | * | |
| 5 | * Death used to hand the camera to `players.find(alive && my team)` on every | |
| 6 | * frame, which is four bugs in one line. The camera was placed *inside* the | |
| 7 | * watched player, whose model was still drawn, so a teammate's chest filled the | |
| 8 | * screen. The choice was whatever the array order said, so it could not be | |
| 9 | * changed and it jumped to a stranger the instant the one you were watching | |
| 10 | * died. It was written straight from the simulation, so it moved in 64 Hz | |
| 11 | * staircases under a 144 Hz frame and snapped wherever a bot's aim snapped. | |
| 12 | * And when the last of your team fell with the bomb still ticking, the fallback | |
| 13 | * was `me` — you spent the rest of the round staring out of your own corpse. | |
| 14 | * | |
| 15 | * So this module holds three things and nothing else. **The choice**: one | |
| 16 | * player, kept until they die or you ask for another. **The order it moves | |
| 17 | * in**: the world's own player order, your side while any of it stands and | |
| 18 | * everyone alive once none of it does. **The camera that follows them**: an | |
| 19 | * exponential ease towards their eye, framed in seconds so it is the same | |
| 20 | * motion at 30 frames a second and at 144, with a hard cut whenever the choice | |
| 21 | * changes or the body teleports — a respawn is not a fly-through. | |
| 22 | * | |
| 23 | * Your own view is never eased. While you are alive the camera is placed | |
| 24 | * exactly where your head is, because a smoothed camera is input lag and the | |
| 25 | * mouse is your hand. The easing is only ever applied to somebody else's. | |
| 26 | * | |
| 27 | * Nothing in here allocates: the camera is written in place, and the order is | |
| 28 | * walked by index rather than filtered into a list. | |
| 29 | */ | |
| 30 | ||
| 31 | import { angleDelta, clamp, eyeHeight, type Camera } from "../engine/index.ts"; | |
| 32 | import { CS_HULL } from "./movement.ts"; | |
| 33 | import type { Player, World } from "./world.ts"; | |
| 34 | ||
| 35 | /** | |
| 36 | * How long the camera takes to close 1 - 1/e of the gap to the body it is | |
| 37 | * following, in seconds. Position is the tighter of the two because a camera | |
| 38 | * that trails a running player reads as drift; the angles are looser because | |
| 39 | * what they are smoothing is a policy that re-aims every tick. | |
| 40 | */ | |
| 41 | export const POS_TAU = 0.045; | |
| 42 | export const ANGLE_TAU = 0.07; | |
| 43 | ||
| 44 | /** The same, for the zoom a watched sniper's scope brings on. */ | |
| 45 | export const FOV_TAU = 0.05; | |
| 46 | ||
| 47 | /** | |
| 48 | * A body that moved further than this between two frames did not run there: it | |
| 49 | * respawned, or the round restarted under it. Easing across that is a second of | |
| 50 | * flying through walls, so it is a cut. A player at the 250-unit run cap covers | |
| 51 | * eight units in a 30 Hz frame, so there is a factor of sixteen of headroom. | |
| 52 | */ | |
| 53 | export const CUT_DISTANCE = 128; | |
| 54 | ||
| 55 | /** The field of view a scope brings on, and the one the game is played at. */ | |
| 56 | const SCOPED_FOV = 40; | |
| 57 | const FOV = 90; | |
| 58 | ||
| 59 | /** The frame the camera is willing to catch up over. A slept tab is a cut anyway. */ | |
| 60 | const MAX_DT = 0.25; | |
| 61 | ||
| 62 | export type Spectator = { | |
| 63 | /** The id of the player being watched, or -1 before the first frame. */ | |
| 64 | target: number; | |
| 65 | /** True when the next `follow` places the camera instead of easing it. */ | |
| 66 | cut: boolean; | |
| 67 | }; | |
| 68 | ||
| 69 | export const createSpectator = (): Spectator => ({ target: -1, cut: true }); | |
| 70 | ||
| 71 | /** Does anybody on your side still stand? While one does, you watch your own. */ | |
| 72 | function sideStands(world: World): boolean { | |
| 73 | return world.players.some((p) => p.alive && p.team === world.me.team); | |
| 74 | } | |
| 75 | ||
| 76 | /** May this camera sit behind `p`'s eyes right now? */ | |
| 77 | const canWatch = (world: World, p: Player, anyone: boolean): boolean => | |
| 78 | p.alive && (anyone || p.team === world.me.team); | |
| 79 | ||
| 80 | /** | |
| 81 | * The first player this camera may watch, starting at `from` and walking | |
| 82 | * `step` at a time around the roster. Null when there is nobody at all. | |
| 83 | */ | |
| 84 | function around(world: World, from: number, step: number, anyone: boolean): Player | null { | |
| 85 | const n = world.players.length; | |
| 86 | for (let k = 0; k < n; k++) { | |
| 87 | const p = world.players[(((from + step * k) % n) + n) % n] as Player; | |
| 88 | if (canWatch(world, p, anyone)) return p; | |
| 89 | } | |
| 90 | return null; | |
| 91 | } | |
| 92 | ||
| 93 | /** An angle folded back into [-180, 180), which is the range a heading lives in. */ | |
| 94 | const wrap = (a: number): number => a - Math.floor((a + 180) / 360) * 360; | |
| 95 | ||
| 96 | /** Where `p` stands in the roster, or -1. */ | |
| 97 | const seatOf = (world: World, id: number): number => world.players.findIndex((p) => p.id === id); | |
| 98 | ||
| 99 | /** | |
| 100 | * Whose eyes the camera is in. You, while you are alive — there is no | |
| 101 | * spectating a round you are playing. Otherwise the player you chose while they | |
| 102 | * live, then the next one along from where they stood, and your own body only | |
| 103 | * when there is nothing else left to watch. It decides and changes nothing; | |
| 104 | * `follow` and `cycle` are what commit a choice. | |
| 105 | */ | |
| 106 | export function watched(world: World, spectator: Spectator): Player { | |
| 107 | if (world.me.alive) return world.me; | |
| 108 | const anyone = !sideStands(world); | |
| 109 | const seat = seatOf(world, spectator.target); | |
| 110 | const chosen = seat < 0 ? null : (world.players[seat] as Player); | |
| 111 | if (chosen !== null && canWatch(world, chosen, anyone)) return chosen; | |
| 112 | // The one you were watching is gone: take the next along from where they | |
| 113 | // stood, so the camera walks the roster rather than snapping back to its top. | |
| 114 | return around(world, seat < 0 ? 0 : seat + 1, 1, anyone) ?? world.me; | |
| 115 | } | |
| 116 | ||
| 117 | /** | |
| 118 | * Watch the next player along, or the one before at `step` of -1, and hand them | |
| 119 | * back. A round you are playing has nobody to cycle to, so it does nothing. | |
| 120 | */ | |
| 121 | export function cycle(world: World, spectator: Spectator, step: number): Player { | |
| 122 | if (world.me.alive) return world.me; | |
| 123 | const anyone = !sideStands(world); | |
| 124 | const now = watched(world, spectator); | |
| 125 | const next = around(world, seatOf(world, now.id) + step, step, anyone) ?? now; | |
| 126 | spectator.target = next.id; | |
| 127 | return next; | |
| 128 | } | |
| 129 | ||
| 130 | /** | |
| 131 | * Move `camera` one frame towards whoever is being watched, and hand that | |
| 132 | * player back so the frame can draw the world from them: the scene leaves their | |
| 133 | * body out and puts their gun in your hands, and the HUD reads their health. | |
| 134 | * | |
| 135 | * `dt` is the frame's own seconds, not the simulation's — this is the one place | |
| 136 | * in the game that is allowed to move at the frame rate, because it is where | |
| 137 | * the 64 Hz staircase is smoothed into something a screen can show. | |
| 138 | */ | |
| 139 | export function follow(world: World, spectator: Spectator, camera: Camera, dt: number): Player { | |
| 140 | const view = watched(world, spectator); | |
| 141 | const cut = spectator.cut || view.id !== spectator.target; | |
| 142 | spectator.target = view.id; | |
| 143 | spectator.cut = false; | |
| 144 | ||
| 145 | const x = view.pos[0]; | |
| 146 | const y = view.pos[1]; | |
| 147 | const z = view.pos[2] + eyeHeight(view, CS_HULL); | |
| 148 | // The punch is the recoil the body is wearing, which is part of what you are | |
| 149 | // watching; it is eased with everything else rather than shaken in raw. | |
| 150 | const yaw = view.yaw - view.punch[0] * 0.4; | |
| 151 | const pitch = view.pitch - view.punch[1] * 0.4; | |
| 152 | const fov = view.scoped ? SCOPED_FOV : FOV; | |
| 153 | ||
| 154 | const jumped = Math.hypot(x - camera.pos[0], y - camera.pos[1], z - camera.pos[2]) > CUT_DISTANCE; | |
| 155 | // Your own head is never eased: that would be input lag on your own mouse. | |
| 156 | if (cut || jumped || view === world.me) { | |
| 157 | camera.pos[0] = x; | |
| 158 | camera.pos[1] = y; | |
| 159 | camera.pos[2] = z; | |
| 160 | camera.yaw = yaw; | |
| 161 | camera.pitch = pitch; | |
| 162 | camera.fov = fov; | |
| 163 | return view; | |
| 164 | } | |
| 165 | ||
| 166 | const step = clamp(dt, 0, MAX_DT); | |
| 167 | const near = 1 - Math.exp(-step / POS_TAU); | |
| 168 | const turn = 1 - Math.exp(-step / ANGLE_TAU); | |
| 169 | const zoom = 1 - Math.exp(-step / FOV_TAU); | |
| 170 | camera.pos[0] += (x - camera.pos[0]) * near; | |
| 171 | camera.pos[1] += (y - camera.pos[1]) * near; | |
| 172 | camera.pos[2] += (z - camera.pos[2]) * near; | |
| 173 | // The turn is the short way round — `angleDelta` is signed and wrapped — and | |
| 174 | // the result is wrapped back, so a camera that spun with a bot all round is | |
| 175 | // still a heading and not a running total of every degree it ever turned. | |
| 176 | camera.yaw = wrap(camera.yaw + angleDelta(camera.yaw, yaw) * turn); | |
| 177 | camera.pitch += (pitch - camera.pitch) * turn; | |
| 178 | camera.fov += (fov - camera.fov) * zoom; | |
| 179 | return view; | |
| 180 | } |
src/games.ts +8
| @@ -175,6 +175,14 @@ export type Game = { | ||
| 175 | 175 | * for the engine's side of that swap (`Renderer.replace`, which it never calls) |
| 176 | 176 | * and the example is unchanged to the byte: it ships no renderer at all. |
| 177 | 177 | * Measured 2026-09-20 after #138, on the bundles this branch commits. |
| 178 | * #133 gave the shooter a spectator — `src/client/spectate.ts`, the choice of | |
| 179 | * who you watch once you are dead and the eased camera that follows them — and | |
| 180 | * the app grew 1,488 raw / 579 gzipped bytes for it: the module itself, the | |
| 181 | * `view` the scene and the HUD are now drawn from, and the banner that names | |
| 182 | * whose eyes they are. Measured 2026-09-20 after #133 on top of #132 and #138: | |
| 183 | * app 79,454 raw / 36,638 gzipped, which fits the 82,000 / 38,000 #138 raised | |
| 184 | * with 2,546 raw and 1,362 gzipped to spare, so the ceiling did not move. | |
| 185 | * Neither other bundle changed a byte: nothing outside `src/client/` did. | |
| 178 | 186 | * The range's numbers here are #121's moving plates and #125's painter at once: |
| 179 | 187 | * both landed a range bundle and neither swept the other's, so static/ held two |
| 180 | 188 | * and every reader of it failed until a plain `bun run build` resolved them. |
src/pages/game.ts +2
| @@ -93,6 +93,8 @@ const menu = (visitor: Who): string => ` | ||
| 93 | 93 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy · |
| 94 | 94 | <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen · |
| 95 | 95 | <kbd>Esc</kbd> pause</p> |
| 96 | <p class="hint">Dead, you watch a teammate until the round ends: fire for the next player, | |
| 97 | aim for the one before.</p> | |
| 96 | 98 | `; |
| 97 | 99 | |
| 98 | 100 | const PAUSE = ` |
static/app.c59820edf9.js added +58
Diff not shown — a line 63,335 characters wide (limit 2,000). +58
View filestatic/app.ec73e969a1.js deleted −58
Diff not shown — a line 61,852 characters wide (limit 2,000). −58
View file
new above
offline