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

Merged opened this

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

Merged

vesper/cs:vesper/208-the-gun-is-up-on-screen-a-second-before → main 1 commit fork

  • Merged. vesper/208-the-gun-is-up-on-screen-a-second-before was merged into main as 6cb6517.

Review

Sign in to review this change.
7 files changed +354 −33
README.md +12 −3
@@ -102,8 +102,14 @@ wants one, but the running server never reads it.
102102 no muzzle and flash for nobody. A **reload** is something the hands do: the
103103 gun drops out of the way and comes back, driven by how far through the
104104 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
107113 still instantaneous, as it always was, so a gun still on its way up fires the
108114 moment the simulation says it may, and no bot, no `observe.ts` input and no
109115 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.
198204 **A gun has to come up before it fires.** Every weapon has a draw — the knife
199205 0.4 s, a pistol 0.5–0.6 s, a rifle 1 s, the AWP 1.25 s — and for that long
200206 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
202211 whole point of it: the knife is the fastest thing to run on (250 u/s against
203212 the AK's 215 and the AWP's 200), so running on it is a real choice and not a
204213 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 @@
88 import { expect, test } from "bun:test";
99 import { DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts";
1010 import { drawHud } from "./hud.ts";
11 import { TICK } from "./loop.ts";
1112 import { SHORT } from "./match.ts";
1213 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";
1416
1517 type Call = {
1618 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", () => {
404406 expect(border).toBeDefined();
405407 });
406408
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
407459 test("the HUD says what is in the pocket, and counts it where the ammo goes", () => {
408460 const world = createWorld(SHORT, "me", "ct", 5, 97);
409461 world.me.grenades = { he: 1 };
src/client/hud.ts +17 −2
@@ -20,7 +20,7 @@ import { drawReport } from "./reportPanel.ts";
2020 import { deathNotice } from "./spectate.ts";
2121 import { inaccuracy, WEAPONS } from "./weapons.ts";
2222 import {
23 canSee, currentWeapon, eyePos, grenadeCount, grenadeKinds, offeredPickup, useProgress,
23 canSee, currentWeapon, deploying, eyePos, grenadeCount, grenadeKinds, offeredPickup, useProgress,
2424 type Player, type World,
2525 } from "./world.ts";
2626
@@ -36,6 +36,16 @@ const HURT_FADE = 1.1;
3636 /** How long a round message stays up when the clock is not holding it there. */
3737 const MESSAGE_FADE = 4;
3838
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
3949 /**
4050 * How long a hit marker lives, and how much longer the one for a kill does:
4151 * long enough to register as its own thing at a glance, still well inside the
@@ -465,8 +475,12 @@ export function drawHud(
465475 ctx.fillText(`${label} · 4`, pad, carried);
466476 }
467477
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.
469482 ctx.textAlign = "right";
483 ctx.globalAlpha = deploying(world, view) ? NOT_READY_ALPHA : 1;
470484 ctx.font = big;
471485 ctx.fillStyle = "#f3f5f8";
472486 const ammo = view.ammo[weapon.id];
@@ -478,6 +492,7 @@ export function drawHud(
478492 ctx.font = small;
479493 ctx.fillStyle = "#c8ccd4";
480494 ctx.fillText(weapon.name + (view.hasBomb ? " · bomb" : ""), width - pad, height - pad - Math.round(30 * scale));
495 ctx.globalAlpha = 1;
481496 if (world.time < view.reloadUntil) {
482497 ctx.fillStyle = "#f0c060";
483498 ctx.fillText("reloading", width - pad, height - pad - Math.round(50 * scale));
src/client/renderer.test.ts +137 −12
@@ -24,10 +24,13 @@ import {
2424 DECAL_CAPACITY, DECAL_FLOATS, DECAL_LIFE, DECAL_LIFT, DECAL_RADIUS, DECAL_VERTICES,
2525 } from "./decals.ts";
2626 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";
2830 import { WEAPONS, type WeaponId } from "./weapons.ts";
2931 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,
3134 } from "./world.ts";
3235
3336 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", ()
287290 expect(heldPlace(calls, "rifle")).toEqual(idle);
288291 });
289292
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", () => {
291326 const { canvas, calls } = recorder();
292327 const world = scene();
293 world.me.weapon = "ak47";
328 armed(world, "awp");
294329 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
296331
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" });
298337 const before = structuredClone(world.me);
299338 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
302341 // Nothing about the player moved: the raise is this file's and no rule of
303342 // the world's, so `observe.ts` and the arena see the same player they did.
304343 expect(structuredClone(world.me)).toEqual(before);
305344
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;
308347 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);
311436 });
312437
313438 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";
2626 import { hinge, neutral, phaseAt, stride, type Stride } from "./stride.ts";
2727 import { MAX_SCALE, MIN_SCALE } from "./quality.ts";
2828 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";
3030 import { hullHeight } from "./physics.ts";
3131 import { WEAPONS } from "./weapons.ts";
3232
@@ -223,12 +223,20 @@ const FLASH_WORLD = 17;
223223 const FLASH_VIEW = 0.42;
224224
225225 /**
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
227227 * down it goes to be reloaded. Both are animation and nothing else — the
228228 * 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.
230238 */
231 const RAISE_TIME = 0.24;
239 export const MIN_RAISE = 0.12;
232240 const RAISE_DROP = 0.7;
233241 const RAISE_ROLL = 14;
234242 const RELOAD_DROP = 0.55;
@@ -458,8 +466,10 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks =
458466
459467 let bobPhase = 0;
460468 // 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.
463473 let heldWeapon = "";
464474 let heldId = -1;
465475 let raisedAt = -99;
@@ -770,18 +780,51 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks =
770780 const weapon = WEAPONS[view.weapon];
771781 const kind = weapon?.kind ?? "rifle";
772782
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.
778814 if (view.weapon !== heldWeapon || view.id !== heldId || world.time < raisedAt) {
779815 heldWeapon = view.weapon;
780816 heldId = view.id;
781 raisedAt = world.time;
817 const charged = view.nextShotAt !== 0 && view.nextShotAt >= world.time - dt;
818 raisedAt = charged ? world.time : -99;
782819 }
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
785828
786829 // A reload the hands perform: down and rolled away, and back up, over the
787830 // 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";
1010 import { snapshot, type Saved } from "./saved.ts";
1111 import {
1212 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,
1414 nextGrenade, NO_INPUT, offeredPickup, radioCall, restoreWorld, shoot, startRound, update,
1515 type Input, type Player, type World,
1616 } from "./world.ts";
@@ -482,6 +482,59 @@ test("the draw stops nobody walking: the speed is the weapon in your hands, up o
482482 expect(WEAPONS.ak47.carrySpeed).toBeGreaterThan(WEAPONS.awp.carrySpeed);
483483 });
484484
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
485538 test("a round starts with everyone deployed: the first live tick can shoot", () => {
486539 const world = createWorld(SHORT, "me", "ct", 5, 407);
487540 armed(world, "awp");
src/client/world.ts +24
@@ -725,6 +725,30 @@ function deploy(world: World, p: Player, id: WeaponId): void {
725725 p.nextShotAt = Math.max(p.nextShotAt, world.time + (WEAPONS[id]?.deployTime ?? 0));
726726 }
727727
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
728752 function reload(world: World, p: Player): void {
729753 const w = currentWeapon(p);
730754 const ammo = p.ammo[w.id];

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs