Split The code is public; the app answers only to the crew.
The gun is up on screen a second before it can fire: the raise is a fixed 0.24 s, the AWP's deploy is 1.25 s #214
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/208-the-gun-is-up-on-screen-a-second-before → main 1 commit fork
7 files
changed +354
−33
README.md +12 −3
| @@ -102,8 +102,14 @@ wants one, but the running server never reads it. | ||
| 102 | 102 | no muzzle and flash for nobody. A **reload** is something the hands do: the |
| 103 | 103 | gun drops out of the way and comes back, driven by how far through the |
| 104 | 104 | weapon's own reload time you are, and it is level again exactly when the |
| 105 | magazine is full. A weapon you **switch to** is raised over a quarter of a | |
| 106 | second. Both of those are animation and nothing else — the switch itself is | |
| 105 | magazine is full. A weapon you **switch to** is raised over its own draw — | |
| 106 | the knife's 0.4 s, the AWP's 1.25 s — so it is level on the frame the trigger | |
| 107 | starts answering, and *the gun is up* and *the gun can shoot* are one moment | |
| 108 | rather than two; while it is on its way the ammo counter and the weapon's | |
| 109 | name dim, so a click that does nothing says why. A grenade, whose draw is | |
| 110 | nothing at all, still gets a tenth of a second of raise rather than popping | |
| 111 | into your hands, and a round starts with the gun already up and so raises | |
| 112 | nothing. Both of those are animation and nothing else — the switch itself is | |
| 107 | 113 | still instantaneous, as it always was, so a gun still on its way up fires the |
| 108 | 114 | moment the simulation says it may, and no bot, no `observe.ts` input and no |
| 109 | 115 | rule of the round can tell the difference. Every world sound is panned around the listener's |
| @@ -198,7 +204,10 @@ are remembered for next time. | ||
| 198 | 204 | **A gun has to come up before it fires.** Every weapon has a draw — the knife |
| 199 | 205 | 0.4 s, a pistol 0.5–0.6 s, a rifle 1 s, the AWP 1.25 s — and for that long |
| 200 | 206 | after you press <kbd>1</kbd>, <kbd>2</kbd> or <kbd>3</kbd> the trigger does |
| 201 | nothing and the scope will not come up. Walking is untouched, and that is the | |
| 207 | nothing and the scope will not come up. You can see all of it: the gun spends | |
| 208 | exactly those seconds coming up in your hands and the ammo counter is dim until | |
| 209 | it is level, so the wait is something you watch rather than something you | |
| 210 | discover by clicking. Walking is untouched, and that is the | |
| 202 | 211 | whole point of it: the knife is the fastest thing to run on (250 u/s against |
| 203 | 212 | the AK's 215 and the AWP's 200), so running on it is a real choice and not a |
| 204 | 213 | free one — you pay the rifle's second at the other end, which is where you |
src/client/hud.test.ts +53 −1
| @@ -8,9 +8,11 @@ | ||
| 8 | 8 | import { expect, test } from "bun:test"; |
| 9 | 9 | import { DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts"; |
| 10 | 10 | import { drawHud } from "./hud.ts"; |
| 11 | import { TICK } from "./loop.ts"; | |
| 11 | 12 | import { SHORT } from "./match.ts"; |
| 12 | 13 | import type { Camera } from "./renderer.ts"; |
| 13 | import { createWorld, eyePos, type Player, type World } from "./world.ts"; | |
| 14 | import { WEAPONS } from "./weapons.ts"; | |
| 15 | import { applyInput, createWorld, eyePos, NO_INPUT, type Player, type World } from "./world.ts"; | |
| 14 | 16 | |
| 15 | 17 | type Call = { |
| 16 | 18 | name: string; args: number[]; text: string; alpha: number; |
| @@ -404,6 +406,56 @@ test("the radar sits in the bottom-left corner and labels both sites", () => { | ||
| 404 | 406 | expect(border).toBeDefined(); |
| 405 | 407 | }); |
| 406 | 408 | |
| 409 | test("the ammo counter dims while the gun is coming up, and lifts as it comes level", () => { | |
| 410 | const world = createWorld(SHORT, "me", "ct", 5, 208); | |
| 411 | world.me.primary = "awp"; | |
| 412 | world.me.weapon = "awp"; | |
| 413 | world.me.ammo.awp = { mag: WEAPONS.awp.magazine, reserve: WEAPONS.awp.reserve }; | |
| 414 | world.time = 30; | |
| 415 | // A round start hands the gun over already up: nothing to say about it. | |
| 416 | const ammoOf = (r: { calls: Call[] }): Call => | |
| 417 | r.calls.find((c) => c.name === "fillText" && c.text.startsWith(`${WEAPONS.awp.magazine} /`)) as Call; | |
| 418 | const up = recorder(); | |
| 419 | draw(up, world); | |
| 420 | expect(ammoOf(up).alpha).toBe(1); | |
| 421 | ||
| 422 | // Drawn off the knife: 1.25 s in which the trigger does nothing, said in the | |
| 423 | // one place on the HUD that claims the gun is ready to use. | |
| 424 | applyInput(world, world.me, { ...NO_INPUT, switchTo: "knife" }, TICK); | |
| 425 | world.time += TICK; | |
| 426 | applyInput(world, world.me, { ...NO_INPUT, switchTo: "awp" }, TICK); | |
| 427 | const coming = recorder(); | |
| 428 | draw(coming, world); | |
| 429 | expect(ammoOf(coming).alpha).toBeLessThan(1); | |
| 430 | // The weapon's name goes with it, and nothing above them does. | |
| 431 | expect((coming.calls.find((c) => c.text === "AWP") as Call).alpha).toBeLessThan(1); | |
| 432 | expect((coming.calls.find((c) => c.text === "$800") as Call).alpha).toBe(1); | |
| 433 | ||
| 434 | // Still dim a whole second in, and full strength the moment the shot clock | |
| 435 | // frees the trigger — the frame the view model is level. | |
| 436 | world.time += 1; | |
| 437 | const nearly = recorder(); | |
| 438 | draw(nearly, world); | |
| 439 | expect(ammoOf(nearly).alpha).toBeLessThan(1); | |
| 440 | world.time = world.me.nextShotAt; | |
| 441 | const ready = recorder(); | |
| 442 | draw(ready, world); | |
| 443 | expect(ammoOf(ready).alpha).toBe(1); | |
| 444 | }); | |
| 445 | ||
| 446 | test("the cooldown between two shots is not a deploy, so the ammo counter holds", () => { | |
| 447 | const world = createWorld(SHORT, "me", "ct", 5, 209); | |
| 448 | world.time = 30; | |
| 449 | const ammoOf = (r: { calls: Call[] }): Call => | |
| 450 | r.calls.find((c) => c.name === "fillText" && c.text.includes(" / ")) as Call; | |
| 451 | // The USP mid-cooldown: the gun is up and working, not arriving. | |
| 452 | world.me.lastShotAt = world.time; | |
| 453 | world.me.nextShotAt = world.time + 60 / WEAPONS.usp.rpm; | |
| 454 | const firing = recorder(); | |
| 455 | draw(firing, world); | |
| 456 | expect(ammoOf(firing).alpha).toBe(1); | |
| 457 | }); | |
| 458 | ||
| 407 | 459 | test("the HUD says what is in the pocket, and counts it where the ammo goes", () => { |
| 408 | 460 | const world = createWorld(SHORT, "me", "ct", 5, 97); |
| 409 | 461 | world.me.grenades = { he: 1 }; |
src/client/hud.ts +17 −2
| @@ -20,7 +20,7 @@ import { drawReport } from "./reportPanel.ts"; | ||
| 20 | 20 | import { deathNotice } from "./spectate.ts"; |
| 21 | 21 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 22 | 22 | import { |
| 23 | canSee, currentWeapon, eyePos, grenadeCount, grenadeKinds, offeredPickup, useProgress, | |
| 23 | canSee, currentWeapon, deploying, eyePos, grenadeCount, grenadeKinds, offeredPickup, useProgress, | |
| 24 | 24 | type Player, type World, |
| 25 | 25 | } from "./world.ts"; |
| 26 | 26 | |
| @@ -36,6 +36,16 @@ const HURT_FADE = 1.1; | ||
| 36 | 36 | /** How long a round message stays up when the clock is not holding it there. */ |
| 37 | 37 | const MESSAGE_FADE = 4; |
| 38 | 38 | |
| 39 | /** | |
| 40 | * What is left of the ammo counter and the weapon's name while the gun is | |
| 41 | * still coming up. The gun rising in the middle of the screen is the cue; this | |
| 42 | * is the one that says what it means, because a person who clicks and gets | |
| 43 | * nothing for the 1.25 s an AWP takes should not have to guess why. It lifts | |
| 44 | * on the frame `nextShotAt` passes — the frame the gun is level and the | |
| 45 | * trigger answers — so the two cues end together. | |
| 46 | */ | |
| 47 | const NOT_READY_ALPHA = 0.35; | |
| 48 | ||
| 39 | 49 | /** |
| 40 | 50 | * How long a hit marker lives, and how much longer the one for a kill does: |
| 41 | 51 | * long enough to register as its own thing at a glance, still well inside the |
| @@ -465,8 +475,12 @@ export function drawHud( | ||
| 465 | 475 | ctx.fillText(`${label} · 4`, pad, carried); |
| 466 | 476 | } |
| 467 | 477 | |
| 468 | // --- ammo and weapon | |
| 478 | // --- ammo and weapon, dimmed while the gun is still on its way up: the | |
| 479 | // simulation refuses the trigger until `nextShotAt` passes, and an AWP owes | |
| 480 | // 1.25 s of that. The reload line below keeps its own full strength — a | |
| 481 | // reload says what it is in words, and the two never overlap anyway. | |
| 469 | 482 | ctx.textAlign = "right"; |
| 483 | ctx.globalAlpha = deploying(world, view) ? NOT_READY_ALPHA : 1; | |
| 470 | 484 | ctx.font = big; |
| 471 | 485 | ctx.fillStyle = "#f3f5f8"; |
| 472 | 486 | const ammo = view.ammo[weapon.id]; |
| @@ -478,6 +492,7 @@ export function drawHud( | ||
| 478 | 492 | ctx.font = small; |
| 479 | 493 | ctx.fillStyle = "#c8ccd4"; |
| 480 | 494 | ctx.fillText(weapon.name + (view.hasBomb ? " · bomb" : ""), width - pad, height - pad - Math.round(30 * scale)); |
| 495 | ctx.globalAlpha = 1; | |
| 481 | 496 | if (world.time < view.reloadUntil) { |
| 482 | 497 | ctx.fillStyle = "#f0c060"; |
| 483 | 498 | ctx.fillText("reloading", width - pad, height - pad - Math.round(50 * scale)); |
src/client/renderer.test.ts +137 −12
| @@ -24,10 +24,13 @@ import { | ||
| 24 | 24 | DECAL_CAPACITY, DECAL_FLOATS, DECAL_LIFE, DECAL_LIFT, DECAL_RADIUS, DECAL_VERTICES, |
| 25 | 25 | } from "./decals.ts"; |
| 26 | 26 | import type { Vec3 } from "./math.ts"; |
| 27 | import { createRenderer, LINE_CAPACITY, type Camera, type Renderer, type RendererHooks } from "./renderer.ts"; | |
| 27 | import { | |
| 28 | createRenderer, LINE_CAPACITY, MIN_RAISE, type Camera, type Renderer, type RendererHooks, | |
| 29 | } from "./renderer.ts"; | |
| 28 | 30 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 29 | 31 | import { |
| 30 | createWorld, eyePos, NO_INPUT, startRound, update, type Effect, type Player, type World, | |
| 32 | applyInput, createWorld, eyePos, NO_INPUT, startRound, update, | |
| 33 | type Effect, type Input, type Player, type World, | |
| 31 | 34 | } from "./world.ts"; |
| 32 | 35 | |
| 33 | 36 | type Call = { name: string; args: unknown[] }; |
| @@ -287,27 +290,149 @@ test("a reload is something the hands do, and it is over when the reload is", () | ||
| 287 | 290 | expect(heldPlace(calls, "rifle")).toEqual(idle); |
| 288 | 291 | }); |
| 289 | 292 | |
| 290 | test("a switch raises the gun over a fixed time, and the simulation never hears about it", () => { | |
| 293 | /** | |
| 294 | * The person alone on a flat floor with `primary` bought and the shot clock | |
| 295 | * clear — a round start's own state, which is `world.ts`'s assignment and not | |
| 296 | * a deploy, so the gun begins already up. | |
| 297 | */ | |
| 298 | function armed(world: World, primary: WeaponId): void { | |
| 299 | world.boxes = [{ min: [-4000, -4000, -64], max: [4000, 4000, 0], mat: 0 }]; | |
| 300 | world.match.phase = "live"; | |
| 301 | for (const p of world.players) if (p !== world.me) p.alive = false; | |
| 302 | world.me.pos = [0, 0, 0]; | |
| 303 | world.me.vel = [0, 0, 0]; | |
| 304 | world.me.onGround = true; | |
| 305 | world.me.primary = primary; | |
| 306 | world.me.ammo[primary] = { mag: WEAPONS[primary].magazine, reserve: WEAPONS[primary].reserve }; | |
| 307 | world.me.weapon = primary; | |
| 308 | world.me.nextShotAt = 0; | |
| 309 | world.me.reloadUntil = 0; | |
| 310 | world.effects.length = 0; | |
| 311 | } | |
| 312 | ||
| 313 | /** One tick of the person's own input, the clock moving the way `update` moves it. */ | |
| 314 | function step(world: World, input: Input, ticks = 1): void { | |
| 315 | for (let i = 0; i < ticks; i++) { | |
| 316 | world.time += TICK; | |
| 317 | applyInput(world, world.me, input, TICK); | |
| 318 | } | |
| 319 | } | |
| 320 | ||
| 321 | /** How high the gun is being drawn: its own Y, which the raise and the dip pull down. */ | |
| 322 | const heldHeight = (calls: Call[], kind: string): number => | |
| 323 | (heldPlace(calls, kind) as Float32Array)[13] as number; | |
| 324 | ||
| 325 | test("the raise lasts exactly the weapon's own deploy, and the simulation never hears about it", () => { | |
| 291 | 326 | const { canvas, calls } = recorder(); |
| 292 | 327 | const world = scene(); |
| 293 | world.me.weapon = "ak47"; | |
| 328 | armed(world, "awp"); | |
| 294 | 329 | frame(canvas, calls, world, world.me); |
| 295 | const raising = heldPlace(calls, "rifle") as Float32Array; | |
| 330 | const level = heldPlace(calls, "sniper") as Float32Array; // handed over already up | |
| 296 | 331 | |
| 297 | world.time += 1; // long past the raise | |
| 332 | // Off the knife and back onto the AWP, through the simulation: the deploy is | |
| 333 | // the world's, and the raise is this file reading the shot clock it set. | |
| 334 | step(world, { ...NO_INPUT, switchTo: "knife" }); | |
| 335 | step(world, NO_INPUT, Math.ceil(WEAPONS.knife.deployTime / TICK) + 1); | |
| 336 | step(world, { ...NO_INPUT, switchTo: "awp" }); | |
| 298 | 337 | const before = structuredClone(world.me); |
| 299 | 338 | frame(canvas, calls, world, world.me); |
| 300 | const up = heldPlace(calls, "rifle") as Float32Array; | |
| 301 | expect(raising[13]).toBeLessThan((up[13] as number) - 0.5); | |
| 339 | const start = heldHeight(calls, "sniper"); | |
| 340 | expect(start).toBeLessThan((level[13] as number) - 0.5); // down, out of the way | |
| 302 | 341 | // Nothing about the player moved: the raise is this file's and no rule of |
| 303 | 342 | // the world's, so `observe.ts` and the arena see the same player they did. |
| 304 | 343 | expect(structuredClone(world.me)).toEqual(before); |
| 305 | 344 | |
| 306 | // Taking out the pistol starts the same raise again, from the same place. | |
| 307 | world.me.weapon = "usp"; | |
| 345 | // Half a second in — a pistol's whole draw — the AWP is still on its way up. | |
| 346 | world.time += 0.5; | |
| 308 | 347 | frame(canvas, calls, world, world.me); |
| 309 | const pistol = heldPlace(calls, "pistol") as Float32Array; | |
| 310 | expect(pistol[13]).toBeCloseTo(raising[13] as number, 6); | |
| 348 | const half = heldHeight(calls, "sniper"); | |
| 349 | expect(half).toBeGreaterThan(start); | |
| 350 | expect(half).toBeLessThan((level[13] as number) - 0.05); | |
| 351 | ||
| 352 | // And level on the frame the trigger frees, not a beat before or after it. | |
| 353 | world.time = world.me.nextShotAt - 0.01; | |
| 354 | frame(canvas, calls, world, world.me); | |
| 355 | expect(heldHeight(calls, "sniper")).toBeLessThan(level[13] as number); | |
| 356 | world.time = world.me.nextShotAt; | |
| 357 | frame(canvas, calls, world, world.me); | |
| 358 | expect(heldPlace(calls, "sniper")).toEqual(level); | |
| 359 | }); | |
| 360 | ||
| 361 | test("a pistol is up in half a second, where the AWP is not", () => { | |
| 362 | const { canvas, calls } = recorder(); | |
| 363 | const world = scene(); | |
| 364 | armed(world, "ak47"); | |
| 365 | frame(canvas, calls, world, world.me); | |
| 366 | const level = heldPlace(calls, "rifle") as Float32Array; | |
| 367 | ||
| 368 | // The USP is the CT's secondary: 0.5 s of draw against the AWP's 1.25 s. | |
| 369 | step(world, { ...NO_INPUT, switchTo: "usp" }); | |
| 370 | frame(canvas, calls, world, world.me); | |
| 371 | expect(heldHeight(calls, "pistol")).toBeLessThan((level[13] as number) - 0.5); | |
| 372 | world.time += 0.5; | |
| 373 | frame(canvas, calls, world, world.me); | |
| 374 | expect(heldHeight(calls, "pistol")).toBeCloseTo(level[13] as number, 6); | |
| 375 | }); | |
| 376 | ||
| 377 | test("a grenade owes no draw at all and still gets the shortest raise there is", () => { | |
| 378 | const { canvas, calls } = recorder(); | |
| 379 | const world = scene(); | |
| 380 | armed(world, "ak47"); | |
| 381 | world.me.grenades = { he: 1 }; | |
| 382 | frame(canvas, calls, world, world.me); | |
| 383 | const level = heldPlace(calls, "rifle") as Float32Array; | |
| 384 | ||
| 385 | expect(WEAPONS.he.deployTime).toBe(0); // the pocket is not a holster | |
| 386 | step(world, { ...NO_INPUT, switchTo: "he" }); | |
| 387 | frame(canvas, calls, world, world.me); | |
| 388 | const start = heldHeight(calls, "grenade"); | |
| 389 | expect(start).toBeLessThan((level[13] as number) - 0.5); | |
| 390 | ||
| 391 | // A floor, not a pop: still rising halfway through it, level at the end. | |
| 392 | world.time += MIN_RAISE / 2; | |
| 393 | frame(canvas, calls, world, world.me); | |
| 394 | const half = heldHeight(calls, "grenade"); | |
| 395 | expect(half).toBeGreaterThan(start); | |
| 396 | expect(half).toBeLessThan((level[13] as number) - 0.05); | |
| 397 | world.time += MIN_RAISE / 2; | |
| 398 | frame(canvas, calls, world, world.me); | |
| 399 | expect(heldHeight(calls, "grenade")).toBeCloseTo(level[13] as number, 6); | |
| 400 | }); | |
| 401 | ||
| 402 | test("the grenade's floor survives a frame that ran two ticks", () => { | |
| 403 | const { canvas, calls } = recorder(); | |
| 404 | const world = scene(); | |
| 405 | armed(world, "ak47"); | |
| 406 | world.me.grenades = { he: 1 }; | |
| 407 | frame(canvas, calls, world, world.me); | |
| 408 | const level = heldPlace(calls, "rifle") as Float32Array; | |
| 409 | ||
| 410 | // The switch and the frame that draws it are not the same tick: a 60 Hz | |
| 411 | // frame runs two of a 64 Hz world's often enough, and the hand still moves. | |
| 412 | step(world, { ...NO_INPUT, switchTo: "he" }); | |
| 413 | step(world, NO_INPUT); | |
| 414 | frame(canvas, calls, world, world.me); | |
| 415 | expect(heldHeight(calls, "grenade")).toBeLessThan((level[13] as number) - 0.3); | |
| 416 | }); | |
| 417 | ||
| 418 | test("a round that starts with the gun already up does not begin with a raise", () => { | |
| 419 | const { canvas, calls } = recorder(); | |
| 420 | const world = scene(); | |
| 421 | armed(world, "awp"); | |
| 422 | frame(canvas, calls, world, world.me); | |
| 423 | const level = heldPlace(calls, "sniper") as Float32Array; | |
| 424 | ||
| 425 | // End the round mid-draw: the knife in the hands, and rising. | |
| 426 | step(world, { ...NO_INPUT, switchTo: "knife" }); | |
| 427 | frame(canvas, calls, world, world.me); | |
| 428 | expect(heldHeight(calls, "knife")).toBeLessThan((level[13] as number) - 0.5); | |
| 429 | ||
| 430 | // `startRound` hands everyone their gun by assignment and zeroes the shot | |
| 431 | // clock, so the freeze opens on a gun that is already up. | |
| 432 | startRound(world); | |
| 433 | world.match.phase = "freeze"; | |
| 434 | frame(canvas, calls, world, world.me); | |
| 435 | expect(heldPlace(calls, "sniper")).toEqual(level); | |
| 311 | 436 | }); |
| 312 | 437 | |
| 313 | 438 | test("ten guns firing at once stay well inside the line budget", () => { |
src/client/renderer.ts +57 −14
| @@ -26,7 +26,7 @@ import type { MapDef } from "./map.ts"; | ||
| 26 | 26 | import { hinge, neutral, phaseAt, stride, type Stride } from "./stride.ts"; |
| 27 | 27 | import { MAX_SCALE, MIN_SCALE } from "./quality.ts"; |
| 28 | 28 | import { LAYERS, TEX_SIZE, materialTextures } from "./textures.ts"; |
| 29 | import type { Player, World } from "./world.ts"; | |
| 29 | import { deploying, type Player, type World } from "./world.ts"; | |
| 30 | 30 | import { hullHeight } from "./physics.ts"; |
| 31 | 31 | import { WEAPONS } from "./weapons.ts"; |
| 32 | 32 | |
| @@ -223,12 +223,20 @@ const FLASH_WORLD = 17; | ||
| 223 | 223 | const FLASH_VIEW = 0.42; |
| 224 | 224 | |
| 225 | 225 | /** |
| 226 | * The hands: how long the gun takes to come up after a switch, and how far | |
| 226 | * The hands: how far down the gun goes to come up after a switch, and how far | |
| 227 | 227 | * down it goes to be reloaded. Both are animation and nothing else — the |
| 228 | 228 | * simulation lets you fire the instant it says you may, and `observe.ts` and |
| 229 | * the arena cannot see either number. | |
| 229 | * the arena cannot see any of these numbers. | |
| 230 | * | |
| 231 | * How *long* a raise takes is not a number here at all: it is the weapon's own | |
| 232 | * `deployTime`, which `world.ts` has already charged onto `nextShotAt`, so the | |
| 233 | * raise is read off the shot clock and cannot disagree with it. `MIN_RAISE` is | |
| 234 | * the floor under that, for the grenade, whose deploy is zero seconds because | |
| 235 | * the pocket is not a holster — a hand that pops into place inside one frame | |
| 236 | * reads as a glitch, and 0.12 s is about seven frames at 60 Hz, the shortest | |
| 237 | * arc that still reads as a hand moving. | |
| 230 | 238 | */ |
| 231 | const RAISE_TIME = 0.24; | |
| 239 | export const MIN_RAISE = 0.12; | |
| 232 | 240 | const RAISE_DROP = 0.7; |
| 233 | 241 | const RAISE_ROLL = 14; |
| 234 | 242 | const RELOAD_DROP = 0.55; |
| @@ -458,8 +466,10 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks = | ||
| 458 | 466 | |
| 459 | 467 | let bobPhase = 0; |
| 460 | 468 | // The gun the hands last had, and when they were given it: a change of |
| 461 | // weapon — or of the player the camera is inside — starts a raise. It is | |
| 462 | // kept here and not on the player because nothing but this file may know it. | |
| 469 | // weapon — or of the player the camera is inside — is the only thing about a | |
| 470 | // raise the shot clock does not already say, and it is what floors the raise | |
| 471 | // of a weapon that owes no draw at all. It is kept here and not on the | |
| 472 | // player because nothing but this file may know it. | |
| 463 | 473 | let heldWeapon = ""; |
| 464 | 474 | let heldId = -1; |
| 465 | 475 | let raisedAt = -99; |
| @@ -770,18 +780,51 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks = | ||
| 770 | 780 | const weapon = WEAPONS[view.weapon]; |
| 771 | 781 | const kind = weapon?.kind ?? "rifle"; |
| 772 | 782 | |
| 773 | // A raise: the gun the hands are holding changed, so it comes up from | |
| 774 | // below over a fixed `RAISE_TIME`. The switch itself was instantaneous | |
| 775 | // and stays that way — this is the hands catching up with a decision the | |
| 776 | // simulation has already made, and it is measured off `world.time` so a | |
| 777 | // paused match does not raise the gun behind the overlay. | |
| 783 | // A raise: the gun comes up from below over exactly as long as the | |
| 784 | // simulation will refuse the trigger for, so *the gun is up* and *the | |
| 785 | // gun can shoot* are one moment and not two. Two things can say a gun | |
| 786 | // has only just arrived, and the raise is whichever of them is further | |
| 787 | // from level. | |
| 788 | // | |
| 789 | // The shot clock first. `deploy` pushes `nextShotAt` out by the weapon's | |
| 790 | // own `deployTime` as it hands the gun over, so the seconds still owed | |
| 791 | // are the seconds of raise left and the span is that `deployTime`: an | |
| 792 | // AWP takes its 1.25 s, a pistol is up in half a second, and a gun drawn | |
| 793 | // mid-cooldown waits down there and then rises to meet the trigger. | |
| 794 | // Read off the clock rather than off a clock of this file's own, it | |
| 795 | // cannot disagree with the simulation and it survives a switch no frame | |
| 796 | // was ever drawn of. `deploying` is what tells those seconds from the | |
| 797 | // ones a rate of fire owes — a gun firing is not a gun arriving. | |
| 798 | const span = Math.max(MIN_RAISE, weapon?.deployTime ?? 0); | |
| 799 | const owed = deploying(world, view) ? view.nextShotAt - world.time : 0; | |
| 800 | ||
| 801 | // Then the hands. A grenade's deploy is zero seconds — the pocket is not | |
| 802 | // a holster — and a hand that pops into place inside one frame reads as | |
| 803 | // a glitch, so a weapon the hands were only just given gets `MIN_RAISE` | |
| 804 | // whatever the clock says. What "only just" asks is whether a deploy | |
| 805 | // charged the clock this frame: `deploy` pushes `nextShotAt` to at least | |
| 806 | // the tick's own time even when it charges nothing, and `- dt` is the | |
| 807 | // frame that ran the tick, which may have run two of them. | |
| 808 | // | |
| 809 | // A `nextShotAt` of exactly zero is that clock at its reset value, which | |
| 810 | // is how a round start hands out the guns: `world.ts` assigns the weapon | |
| 811 | // and zeroes the clock rather than deploying, so the gun is already up | |
| 812 | // and the freeze begins level. A gun bought in the freeze arrives the | |
| 813 | // same way and the same way round — nothing is owed for it. | |
| 778 | 814 | if (view.weapon !== heldWeapon || view.id !== heldId || world.time < raisedAt) { |
| 779 | 815 | heldWeapon = view.weapon; |
| 780 | 816 | heldId = view.id; |
| 781 | raisedAt = world.time; | |
| 817 | const charged = view.nextShotAt !== 0 && view.nextShotAt >= world.time - dt; | |
| 818 | raisedAt = charged ? world.time : -99; | |
| 782 | 819 | } |
| 783 | const raised = Math.min(1, Math.max(0, (world.time - raisedAt) / RAISE_TIME)); | |
| 784 | const rise = (1 - raised) * (1 - raised); // 1 the moment it is drawn, 0 when it is up | |
| 820 | // How far from level the gun is, 1 down and 0 up. Measured off | |
| 821 | // `world.time` both ways, so a paused match does not raise the gun | |
| 822 | // behind the overlay. | |
| 823 | const left = Math.max( | |
| 824 | Math.min(1, Math.max(0, owed / span)), | |
| 825 | Math.min(1, Math.max(0, 1 - (world.time - raisedAt) / MIN_RAISE)), | |
| 826 | ); | |
| 827 | const rise = left * left; // 1 the moment it is drawn, 0 when it is up | |
| 785 | 828 | |
| 786 | 829 | // A reload the hands perform: down and rolled away, and back up, over the |
| 787 | 830 | // weapon's own `reloadTime`. `sin(pi * progress)` is zero at both ends, |
src/client/world.test.ts +54 −1
| @@ -10,7 +10,7 @@ import { WEAPONS, type WeaponId } from "./weapons.ts"; | ||
| 10 | 10 | import { snapshot, type Saved } from "./saved.ts"; |
| 11 | 11 | import { |
| 12 | 12 | aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, callout, createWorld, currentWeapon, damageDealt, |
| 13 | damageTaken, ENVIRONMENT, eyePos, finishRound, flinch, grenadeCount, grenadeSlot, MAX_GRENADES, newBomb, | |
| 13 | damageTaken, deploying, ENVIRONMENT, eyePos, finishRound, flinch, grenadeCount, grenadeSlot, MAX_GRENADES, newBomb, | |
| 14 | 14 | nextGrenade, NO_INPUT, offeredPickup, radioCall, restoreWorld, shoot, startRound, update, |
| 15 | 15 | type Input, type Player, type World, |
| 16 | 16 | } from "./world.ts"; |
| @@ -482,6 +482,59 @@ test("the draw stops nobody walking: the speed is the weapon in your hands, up o | ||
| 482 | 482 | expect(WEAPONS.ak47.carrySpeed).toBeGreaterThan(WEAPONS.awp.carrySpeed); |
| 483 | 483 | }); |
| 484 | 484 | |
| 485 | test("a gun on its way up is one the HUD can name, and a rate of fire is not one", () => { | |
| 486 | const world = createWorld(SHORT, "me", "ct", 5, 408); | |
| 487 | armed(world, "awp"); | |
| 488 | expect(deploying(world, world.me)).toBe(false); // handed over already up | |
| 489 | ||
| 490 | step(world, { ...NO_INPUT, switchTo: "knife" }); | |
| 491 | expect(deploying(world, world.me)).toBe(true); | |
| 492 | step(world, NO_INPUT, ticksFor(WEAPONS.knife.deployTime)); | |
| 493 | expect(deploying(world, world.me)).toBe(false); | |
| 494 | ||
| 495 | // The AWP drawn: owed the whole 1.25 s, and free on the tick it can fire. | |
| 496 | step(world, { ...NO_INPUT, switchTo: "awp" }); | |
| 497 | expect(deploying(world, world.me)).toBe(true); | |
| 498 | step(world, NO_INPUT, ticksFor(WEAPONS.awp.deployTime)); | |
| 499 | expect(deploying(world, world.me)).toBe(false); | |
| 500 | ||
| 501 | // A shot pushes the same clock and this is not that: the AWP's 1.46 s | |
| 502 | // between rounds is a gun working, not a gun arriving. | |
| 503 | expect(shoot(world, world.me)).toBe(true); | |
| 504 | expect(world.me.nextShotAt).toBeGreaterThan(world.time); | |
| 505 | expect(deploying(world, world.me)).toBe(false); | |
| 506 | ||
| 507 | // And a round start says nothing either, however it was interrupted. | |
| 508 | step(world, { ...NO_INPUT, switchTo: "knife" }); | |
| 509 | startRound(world); | |
| 510 | expect(deploying(world, world.me)).toBe(false); | |
| 511 | }); | |
| 512 | ||
| 513 | test("the hand's own cooldown after a throw is not a draw either", () => { | |
| 514 | const world = createWorld(SHORT, "me", "ct", 5, 409); | |
| 515 | armed(world, "ak47"); | |
| 516 | world.me.grenades = { he: 2 }; | |
| 517 | step(world, { ...NO_INPUT, switchTo: "he" }); | |
| 518 | step(world, NO_INPUT, ticksFor(1)); | |
| 519 | expect(deploying(world, world.me)).toBe(false); | |
| 520 | ||
| 521 | // Thrown with another in the pocket: the same grenade is still in hand and | |
| 522 | // it is the hand that is busy, so nothing is arriving. | |
| 523 | step(world, { ...NO_INPUT, switchTo: "he", fire: true }); | |
| 524 | expect(world.me.nextShotAt).toBeGreaterThan(world.time); | |
| 525 | expect(grenadeCount(world.me, "he")).toBe(1); | |
| 526 | expect(deploying(world, world.me)).toBe(false); | |
| 527 | ||
| 528 | // Throw the last one and the rifle comes back to the hands instead: that | |
| 529 | // one is a gun arriving, and it cannot fire until the same moment. | |
| 530 | step(world, NO_INPUT, ticksFor(1)); | |
| 531 | step(world, { ...NO_INPUT, fire: true }); | |
| 532 | expect(world.me.weapon).toBe("ak47"); | |
| 533 | expect(deploying(world, world.me)).toBe(true); | |
| 534 | step(world, NO_INPUT, ticksFor(0.6)); | |
| 535 | expect(deploying(world, world.me)).toBe(false); | |
| 536 | }); | |
| 537 | ||
| 485 | 538 | test("a round starts with everyone deployed: the first live tick can shoot", () => { |
| 486 | 539 | const world = createWorld(SHORT, "me", "ct", 5, 407); |
| 487 | 540 | armed(world, "awp"); |
src/client/world.ts +24
| @@ -725,6 +725,30 @@ function deploy(world: World, p: Player, id: WeaponId): void { | ||
| 725 | 725 | p.nextShotAt = Math.max(p.nextShotAt, world.time + (WEAPONS[id]?.deployTime ?? 0)); |
| 726 | 726 | } |
| 727 | 727 | |
| 728 | /** | |
| 729 | * Is `p`'s gun still on its way up? The shot clock owes time, and not because | |
| 730 | * of the last shot: `shoot` and `throwGrenade` stamp `lastShotAt` at the very | |
| 731 | * moment they push `nextShotAt`, and `deploy` never touches it, so a clock | |
| 732 | * standing further out than the weapon's own rate of fire is a gun arriving | |
| 733 | * rather than a gun working. A deploy that lands inside a slower weapon's | |
| 734 | * cooldown reads as the cooldown, which is the honest half of it — the trigger | |
| 735 | * is owed either way, and the round start's zeroed clock owes nothing. | |
| 736 | * | |
| 737 | * The HUD asks so it can say so: a click that does nothing for the 1.25 s an | |
| 738 | * AWP takes to come up needs something on screen beside the rising gun. | |
| 739 | * Nothing in the simulation asks this, and nothing in it may — it is a | |
| 740 | * question about state the rules already keep, not a rule. | |
| 741 | */ | |
| 742 | export function deploying(world: World, p: Player): boolean { | |
| 743 | if (world.time >= p.nextShotAt) return false; | |
| 744 | const w = currentWeapon(p); | |
| 745 | // What the last use of this weapon could itself have charged: its rate of | |
| 746 | // fire, or — for the one thing here that is thrown rather than fired — the | |
| 747 | // hand's own cooldown after the throw. | |
| 748 | const used = w.kind === "grenade" ? THROW_COOLDOWN : w.rpm > 0 ? 60 / w.rpm : 0; | |
| 749 | return p.nextShotAt > p.lastShotAt + used + 1e-6; | |
| 750 | } | |
| 751 | ||
| 728 | 752 | function reload(world: World, p: Player): void { |
| 729 | 753 | const w = currentWeapon(p); |
| 730 | 754 | const ammo = p.ammo[w.id]; |