Split The code is public; the app answers only to the crew.

The flashbang: the grenade that opens a site, and the blindness rule behind it #159

Closed opened this
Closed without merging

vesper/cs:vesper/110-the-flashbang-the-grenade-that-opens-a-s → main 2 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
18 files changed +700 −51
README.md +36 −8
@@ -68,19 +68,35 @@ wants one, but the running server never reads it.
6868 one already, <kbd>E</kbd> swaps, and the gun you were holding lands at your
6969 feet. There is no team rule: a CT who finds an AK-47 keeps it. The floor is
7070 swept at the start of the next round, and whatever you are carrying then comes
71 back with a full magazine. There is one piece of utility: an **HE grenade**,
72 $300 in the buy menu, one in a pocket at a time. <kbd>4</kbd> puts it in your
71 back with a full magazine. There are two pieces of utility: an **HE grenade**,
72 $300 in the buy menu, and a **flashbang**, $200 — one of each in a pocket at
73 a time, and <kbd>4</kbd> is the only key either answers to: it draws the
74 first kind you are carrying, and pressing it again while one is in your hand
75 cycles to the other and back around. The HUD names what is in the pocket and
76 what is in the hand, so the key never has to be guessed at.
77 <kbd>4</kbd> puts a grenade in your
7378 hand and fire throws it — out of your eye, a little above where you are
7479 looking, carrying whatever speed you were already running at. It falls under
7580 the same 800 u/s² the rest of the game does, bounces off walls and crates
76 keeping 45% of the speed it hit them with, and goes off **1.6 seconds** after
77 it left your hand wherever it is by then. The blast is **98 damage at its
81 keeping 45% of the speed it hit them with, and goes off wherever it is by
82 then: **1.6 seconds** for an HE, **1.4** for a flash, which is thrown into a
83 place you are already walking into. The HE's blast is **98 damage at its
7884 centre, falling linearly to nothing at 350 units**, halved by armour (which
7985 takes half of what it stops), and it does nothing at all through a wall — the
8086 same sight line a bot sees you with, its centre to your eye. It cannot hurt
81 your own side, and it can very much kill you. A grenade thrown is gone; one
87 your own side, and it can very much kill you. The flash hurts nobody and
88 takes seconds instead: **3.4 of them at most**, square in the face and inside
89 300 units, falling with distance to nothing at 1400 and with how far off your
90 view it went off — a quarter of it behind you, and nothing whatsoever through
91 a wall, the same trace the HE asks. Your screen whitens to **90% opaque at
92 the very worst** and fades as your eyes come back, never to white you cannot
93 see the round through. **Friendly fire applies**: your own flash blinds your
94 own side and you with them, which is what makes throwing one a skill. It
95 blinds bots as hard as it blinds you — while a bot's eyes are gone its aim
96 wanders up to 30° and it takes up to 0.8 s longer to answer you, which is the
97 entry you paid $200 for. A grenade thrown is gone; one
8298 kept is still yours next round, the same rule the guns follow. The bots do not
83 buy or throw one: their fight was learned in an arena that has no grenades in
99 buy or throw either: their fight was learned in an arena that has no grenades in
84100 it, and they keep buying guns. Hit feedback runs both ways: a hit marker and a beep when you hit
85101 someone, and when you are hit a view punch away from the shot, a wedge around
86102 the crosshair pointing at whoever fired, a vignette that reddens as your health
@@ -175,7 +191,8 @@ wants one, but the running server never reads it.
175191 <kbd>E</kbd> plants, defuses or takes the gun you are standing on (the HUD says
176192 which gun when it is one you have to ask for), <kbd>B</kbd> opens the buy menu in the freeze
177193 time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns and <kbd>4</kbd> takes
178 out the HE grenade if you bought one (fire throws it, and the hand goes back to
194 out a grenade if you bought one — the **HE grenade** or the **flashbang**,
195 pressed again to cycle to the other (fire throws it, and the hand goes back to
179196 the gun once it is gone), <kbd>Z</kbd> opens the radio, <kbd>Tab</kbd> shows
180197 the scoreboard, <kbd>Esc</kbd> pauses the match on a desktop and `menu` on the
181198 pad does it on a phone. Pausing also opens mute, volume and
@@ -415,7 +432,12 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
415432 plays a seeded headless match out on each map in turn, so a layout the bots
416433 cannot path is a red test rather than a match that never ends),
417434 `physics`, `weapons`, `combat`, `grenades` (a thrown thing's flight: gravity,
418 bounce, fuse and how far a blast reaches), `match` (the rules), `world` (the simulation),
435 bounce, fuse and how far a blast reaches — one arc for both kinds, and only
436 the fuse and what the end of it means differ),
437 `flash` (how long a flashbang takes your eyes: a position, an eye, a view
438 direction and one trace in, seconds of blindness out — and what those
439 seconds are worth as white on a screen or as error in a bot's hands),
440 `match` (the rules), `world` (the simulation),
419441 `buy` (the buy panel's rows — what a side can afford, what it already owns
420442 and why the rest are dead — read off `world` and handed to `main` as plain data),
421443 `bots`, `plan` (which site a side commits to, what the radio can say about
@@ -762,6 +784,12 @@ number down. The max swings between 3 ms and 30 ms run to run; that is the
762784 garbage collector and whatever else the box is doing, not the simulation, so
763785 read the mean and the percentiles and ignore it.
764786
787 A flash going off is the dearest single event the simulation has, and it is
788 still nothing: it traces once per living player in range — ten at the very
789 worst, on the one tick the fuse ends — which measured **3 µs on top of a 68 µs
790 tick** on the same box, about 4%, once in a while rather than every tick. A
791 round that never buys a grenade pays one comparison for the whole of it.
792
765793 The arithmetic is `summarize` in the script itself — a pure function over a
766794 list of milliseconds, tested on a known list with no clock in sight. Exactly
767795 one assertion in the suite depends on how fast the machine is, and it is in
src/client/audio.test.ts +21
@@ -334,6 +334,27 @@ test("explode and blast take the same path, and ignore the pitch a beep would us
334334 }
335335 });
336336
337 test("a flash is its own sound: a bright crack that rises, panned where it went off", () => {
338 const { Ctor, instances } = recorder();
339 const audio = createAudio(Ctor);
340 audio.world("flash", [0, -400, 0], LISTENER, 0); // off to the listener's right
341 const ctx = instances[0] as FakeAudioContext;
342
343 // It goes up where an explosion's boom goes down, which is the whole of how
344 // an ear tells the two apart.
345 const ring = last(ctx, "oscillator") as FakeOscillator;
346 expect(ring.frequency.events[0]?.value).toBe(700);
347 expect(ring.frequency.value).toBeGreaterThan(700);
348 // Noise through a high-pass: a crack, with none of a blast's chest.
349 const filter = last(ctx, "filter") as FakeBiquadFilter;
350 expect(filter.type).toBe("highpass");
351 const panner = last(ctx, "panner") as FakeStereoPanner;
352 expect(panner.pan.value).toBeCloseTo(ears([0, -400, 0], LISTENER, 0).pan, 6);
353 // Nothing is left running: both sources start and both are stopped.
354 expect(ctx.started.length).toBe(2);
355 expect(new Set(ctx.stopped)).toEqual(new Set(ctx.started));
356 });
357
337358 test("a name with no tone and no special case creates nothing but the panner", () => {
338359 const { Ctor, instances } = recorder();
339360 const audio = createAudio(Ctor);
src/client/audio.ts +36 −2
@@ -43,8 +43,8 @@ const TONE: Record<string, { cut: number; thump: number; length: number }> = {
4343
4444 /** One tone per named world sound: a square blip for the bomb's own beep,
4545 * two-note chirps for the plant and the defuse, a click for a dry trigger.
46 * `explode`, `blast` and `bounce` are noise, not a tone, and have no entry
47 * here — `world()` below synthesises those directly. */
46 * `explode`, `blast`, `flash` and `bounce` are noise, not a tone, and have no
47 * entry here — `world()` below synthesises those directly. */
4848 const WORLD_TONE: Partial<Record<WorldSoundName, { freq: number; type: OscillatorType; length: number }>> = {
4949 beep: { freq: 900, type: "square", length: 0.08 },
5050 plant: { freq: 420, type: "sine", length: 0.22 },
@@ -277,6 +277,40 @@ export function createAudio(Ctor: typeof AudioContext | undefined = defaultConte
277277 return;
278278 }
279279
280 // A flashbang: a crack rather than a boom. The same burst of noise an
281 // explosion uses, but short and bright — a high-pass keeps the chest out
282 // of it — over a tone that snaps up instead of falling away, so an ear
283 // across the site can tell a flash it should be turning away from from an
284 // HE it should be running out of.
285 if (name === "flash") {
286 const length = 0.35;
287 const crack = context.createBufferSource();
288 crack.buffer = noise;
289 crack.playbackRate.value = 1.6;
290 const filter = context.createBiquadFilter();
291 filter.type = "highpass";
292 filter.frequency.setValueAtTime(900, now);
293 filter.frequency.exponentialRampToValueAtTime(3500, now + length);
294 const crackGain = context.createGain();
295 crackGain.gain.setValueAtTime(0.4 * volume, now);
296 crackGain.gain.exponentialRampToValueAtTime(0.0008, now + length);
297 crack.connect(filter).connect(crackGain).connect(panner);
298 crack.start(now);
299 crack.stop(now + length);
300
301 const ring = context.createOscillator();
302 ring.type = "triangle";
303 ring.frequency.setValueAtTime(700, now);
304 ring.frequency.exponentialRampToValueAtTime(2600, now + length * 0.5);
305 const ringGain = context.createGain();
306 ringGain.gain.setValueAtTime(0.18 * volume, now);
307 ringGain.gain.exponentialRampToValueAtTime(0.0008, now + length);
308 ring.connect(ringGain).connect(panner);
309 ring.start(now);
310 ring.stop(now + length);
311 return;
312 }
313
280314 // A grenade off a wall: a tick of filtered noise, shorter than a footstep.
281315 if (name === "bounce") {
282316 const length = 0.07;
src/client/bots.test.ts +48 −1
@@ -1,7 +1,8 @@
11 import { expect, test } from "bun:test";
22 import {
3 applyIntent, botThink, CARD, DIFFICULTIES, makeBrain, policy, restyleBrain, skillBand, think,
3 applyIntent, botThink, CARD, DIFFICULTIES, hands, makeBrain, policy, restyleBrain, skillBand, think,
44 } from "./bots.ts";
5 import { blind, FLASH } from "./flash.ts";
56 import { DE_FORGE } from "./map.ts";
67 import { dist2d } from "./math.ts";
78 import { COMPETITIVE, SHORT } from "./match.ts";
@@ -88,6 +89,52 @@ test("a bot that has only just seen you does not shoot yet", () => {
8889 expect(immediate.fire).toBe(false);
8990 });
9091
92 // --- being flashed --------------------------------------------------------
93
94 test("blindness is a handicap on the hands, and it wears off on its own", () => {
95 const { world, bot } = duel();
96 const brain = bot.brain!;
97 const clear = hands(brain, bot, world.time);
98 expect(clear.aimError).toBe(brain.aimError);
99 expect(clear.reaction).toBe(brain.reaction);
100
101 blind(bot, world.time, FLASH.maxBlind);
102 const flashed = hands(brain, bot, world.time);
103 expect(flashed.aimError).toBeGreaterThan(clear.aimError * 3);
104 expect(flashed.reaction).toBeGreaterThan(clear.reaction + 0.5);
105 // Halfway back, halfway handicapped; and at the end, its own hands again.
106 const half = hands(brain, bot, world.time + FLASH.maxBlind / 2);
107 expect(half.aimError).toBeLessThan(flashed.aimError);
108 expect(half.aimError).toBeGreaterThan(clear.aimError);
109 expect(hands(brain, bot, world.time + FLASH.maxBlind).aimError).toBe(clear.aimError);
110 });
111
112 test("a flash buys the entry: a blinded bot answers later and shoots worse", () => {
113 /** A second of one bot holding an angle on the person who just stepped in. */
114 const hold = (flashed: boolean): { early: number; wander: number } => {
115 const { world, me, bot } = duel(31);
116 bot.yaw = aimAt(bot, bodyCentre(me)).yaw; // dead on the person before anything happens
117 bot.brain!.seenAt = world.time; // and they have only this instant appeared
118 if (flashed) blind(bot, world.time, FLASH.maxBlind);
119 let early = 0;
120 let wander = 0;
121 for (let t = 0; t < 64; t++) {
122 const input = botThink(world, bot, 1 / 64);
123 world.time += 1 / 64;
124 if (input.fire && t < 32) early += 1; // inside the first half second
125 const wanted = aimAt(bot, bodyCentre(me)).yaw;
126 wander += Math.abs(((wanted - bot.yaw + 540) % 360) - 180);
127 }
128 return { early, wander: wander / 64 };
129 };
130
131 const seeing = hold(false);
132 const blinded = hold(true);
133 expect(seeing.early).toBeGreaterThan(0); // it answers inside half a second
134 expect(blinded.early).toBe(0); // flashed, it does not answer at all in that time
135 expect(blinded.wander).toBeGreaterThan(seeing.wander * 3); // and its aim is nowhere near
136 });
137
91138 // --- hearing --------------------------------------------------------------
92139
93140 /**
src/client/bots.ts +29 −5
@@ -12,6 +12,7 @@
1212 */
1313
1414 import { decode, HEADS, STILL, type Intent } from "./action.ts";
15 import { blindness } from "./flash.ts";
1516 import { fromOther, loudness, reported, type HeardName, type Maker } from "./hearing.ts";
1617 import { angleDelta, clamp, dist, dist2d, rng, type Vec3 } from "./math.ts";
1718 import { findPath, siteAt, walkable, colOf, rowOf } from "./map.ts";
@@ -148,6 +149,28 @@ function brainOf(world: World, bot: Player): BotBrain {
148149 return bot.brain;
149150 }
150151
152 /** Degrees of aim error a bot with its eyes fully gone carries on top of its own. */
153 const BLIND_AIM_ERROR = 30;
154
155 /** Seconds a full flash adds to how long it takes a bot to answer a target. */
156 const BLIND_REACTION = 0.8;
157
158 /**
159 * The hands this bot has right now. Its skill is the floor of them; a flash is
160 * the only thing that takes any of it away, and it takes it in proportion to
161 * how blind the bot still is — `flash.ts` owns that fraction, this file owns
162 * what it costs. Every reader of `aimError` and `reaction` goes through here,
163 * so being flashed is one rule rather than a new branch in the shooting code.
164 */
165 export function hands(brain: BotBrain, bot: Player, now: number): { aimError: number; reaction: number } {
166 const blind = blindness(bot, now);
167 if (blind <= 0) return { aimError: brain.aimError, reaction: brain.reaction };
168 return {
169 aimError: brain.aimError + BLIND_AIM_ERROR * blind,
170 reaction: brain.reaction + BLIND_REACTION * blind,
171 };
172 }
173
151174 /**
152175 * Re-skill a brain for a new difficulty in place: the hands change, but the
153176 * path, the plan and the memory it already built up this match do not. Called
@@ -417,11 +440,12 @@ export function scriptedFight(
417440 world: World, bot: Player, brain: BotBrain, target: Player, input: Input, dt: number,
418441 ): Input {
419442 const spraying = bot.shotIndex > 0 && world.time - bot.lastShotAt < 0.3;
420 const wobble = brain.aimError * (spraying ? 1.6 : 1) * (brain.random() * 2 - 1) * 0.15;
443 const { aimError, reaction } = hands(brain, bot, world.time);
444 const wobble = aimError * (spraying ? 1.6 : 1) * (brain.random() * 2 - 1) * 0.15;
421445 const aim = aimAt(bot, bodyCentre(target));
422446 turnTowards(bot, { yaw: aim.yaw + wobble, pitch: aim.pitch + wobble * 0.4 }, brain.aimSpeed, dt);
423 const ready = world.time - brain.seenAt > brain.reaction;
424 const onTarget = Math.abs(angleDelta(bot.yaw, aim.yaw)) < 3 + brain.aimError;
447 const ready = world.time - brain.seenAt > reaction;
448 const onTarget = Math.abs(angleDelta(bot.yaw, aim.yaw)) < 3 + aimError;
425449 const ammo = bot.ammo[bot.weapon];
426450 if (ammo !== undefined && ammo.mag === 0) input.reload = true;
427451 if (ready && onTarget && world.time > brain.restUntil) {
@@ -474,7 +498,7 @@ export function think(world: World, bot: Player, brain: BotBrain, target: Player
474498 */
475499 export function applyIntent(world: World, bot: Player, brain: BotBrain, intent: Intent, dt: number): void {
476500 if (world.time > brain.jitterUntil) {
477 brain.jitter = (brain.random() * 2 - 1) * brain.aimError;
501 brain.jitter = (brain.random() * 2 - 1) * hands(brain, bot, world.time).aimError;
478502 brain.jitterUntil = world.time + 0.25 + brain.random() * 0.35;
479503 }
480504 const limit = brain.aimSpeed;
@@ -595,7 +619,7 @@ export function botThink(world: World, bot: Player, dt: number): Input {
595619 // that is one shot and then nothing, so the trigger is released between
596620 // decisions and each decision is worth one pull.
597621 const held = WEAPONS[bot.weapon]?.automatic !== false || brain.hold === SKIP - 1;
598 input.fire = intent.fire && held && world.time - brain.seenAt > brain.reaction;
622 input.fire = intent.fire && held && world.time - brain.seenAt > hands(brain, bot, world.time).reaction;
599623 if (pushing(world, bot, brain)) {
600624 // Out of clock: the policy keeps the gun, the plan takes the feet. It
601625 // runs to the site shooting at whatever it is looking at rather than
src/client/flash.test.ts added +114
@@ -0,0 +1,114 @@
1 import { expect, test } from "bun:test";
2 import { blind, blindness, blindSeconds, facingShare, flashAlpha, FLASH, rangeShare } from "./flash.ts";
3 import { angleVectors, type Vec3 } from "./math.ts";
4
5 const EYE: Vec3 = [0, 0, 64];
6 /** The flash, `range` units away along +x at eye height. */
7 const at = (range: number): Vec3 => [range, 0, 64];
8 /** Looking `yaw` degrees round from +x, level. */
9 const look = (yaw: number): Vec3 => angleVectors(yaw, 0).forward;
10
11 test("full in the face is the maximum, and nothing is worse than it", () => {
12 expect(blindSeconds(at(40), EYE, look(0), false)).toBeCloseTo(FLASH.maxBlind, 6);
13 // Anywhere at all, looking anywhere at all, never beats standing in it.
14 for (const range of [0, 1, 120, 300, 700, 1399]) {
15 for (let yaw = 0; yaw < 360; yaw += 15) {
16 expect(blindSeconds(at(range), EYE, look(yaw), false)).toBeLessThanOrEqual(FLASH.maxBlind + 1e-9);
17 }
18 }
19 });
20
21 test("looking away is a fraction of it, not all and not nothing", () => {
22 const away = blindSeconds(at(200), EYE, look(180), false);
23 expect(away).toBeGreaterThan(0);
24 expect(away).toBeCloseTo(FLASH.maxBlind * FLASH.awayShare, 6);
25 expect(away).toBeLessThan(FLASH.maxBlind * 0.25);
26 // A quarter turn off is worse than a full turn away and better than square on.
27 const side = blindSeconds(at(200), EYE, look(90), false);
28 expect(side).toBeGreaterThan(away);
29 expect(side).toBeLessThan(blindSeconds(at(200), EYE, look(0), false));
30 });
31
32 test("a wall is nothing at all, however square and however close", () => {
33 expect(blindSeconds(at(40), EYE, look(0), true)).toBe(0);
34 expect(blindSeconds(at(300), EYE, look(0), true)).toBe(0);
35 });
36
37 test("the boundary where the flash is just outside the view is not a cliff", () => {
38 const inside = blindSeconds(at(200), EYE, look(FLASH.viewAngle - 1), false);
39 const outside = blindSeconds(at(200), EYE, look(FLASH.viewAngle + 1), false);
40 expect(inside).toBeGreaterThan(outside);
41 // Two degrees across the edge of the view cost a tenth of a second, not the
42 // difference between blind and fine.
43 expect(inside - outside).toBeLessThan(0.1);
44 expect(outside).toBeGreaterThan(FLASH.maxBlind * FLASH.awayShare);
45 expect(inside).toBeLessThan(FLASH.maxBlind * 0.5);
46 });
47
48 test("distance takes it away: full up close, gone past the radius", () => {
49 expect(rangeShare(0)).toBe(1);
50 expect(rangeShare(FLASH.near)).toBe(1);
51 expect(rangeShare(FLASH.radius)).toBe(0);
52 expect(blindSeconds(at(FLASH.radius + 1), EYE, look(0), false)).toBe(0);
53 // Square in the face from the far side of the map: a flicker, rounded away.
54 expect(blindSeconds(at(FLASH.radius - 10), EYE, look(0), false)).toBe(0);
55 // And in between, more blindness the closer it went off.
56 let last = Infinity;
57 for (let range = 300; range <= 1200; range += 100) {
58 const seconds = blindSeconds(at(range), EYE, look(0), false);
59 expect(seconds).toBeLessThan(last);
60 last = seconds;
61 }
62 });
63
64 test("anything it does grant is worth having: never a flicker", () => {
65 for (let range = 0; range < FLASH.radius; range += 7) {
66 for (let yaw = 0; yaw < 360; yaw += 11) {
67 const seconds = blindSeconds(at(range), EYE, look(yaw), false);
68 expect(seconds === 0 || seconds >= FLASH.minBlind).toBe(true);
69 }
70 }
71 });
72
73 test("the facing share is continuous across both of its joins", () => {
74 expect(facingShare(0)).toBe(1);
75 expect(facingShare(FLASH.fullAngle)).toBeCloseTo(1, 9);
76 expect(facingShare(FLASH.viewAngle)).toBeCloseTo(FLASH.edgeShare, 9);
77 expect(facingShare(180)).toBeCloseTo(FLASH.awayShare, 9);
78 let last = Infinity;
79 for (let a = 0; a <= 180; a += 0.5) {
80 const share = facingShare(a);
81 expect(share).toBeLessThanOrEqual(last + 1e-9);
82 last = share;
83 }
84 });
85
86 test("blindness decays over the seconds it was granted and then it is over", () => {
87 const p = { blindUntil: 0 };
88 blind(p, 10, FLASH.maxBlind);
89 expect(p.blindUntil).toBeCloseTo(10 + FLASH.maxBlind, 9);
90 expect(blindness(p, 10)).toBeCloseTo(1, 9);
91 expect(blindness(p, 10 + FLASH.maxBlind / 2)).toBeCloseTo(0.5, 9);
92 expect(blindness(p, 10 + FLASH.maxBlind)).toBe(0);
93 expect(blindness(p, 99)).toBe(0);
94 });
95
96 test("the screen never goes fully white, and a weak flash never goes near it", () => {
97 const worst = { blindUntil: 100 };
98 expect(flashAlpha(worst, 100 - FLASH.maxBlind)).toBeCloseTo(FLASH.maxAlpha, 9);
99 expect(FLASH.maxAlpha).toBeLessThan(1);
100 const weak = { blindUntil: 0 };
101 blind(weak, 0, FLASH.minBlind);
102 expect(flashAlpha(weak, 0)).toBeLessThan(0.2);
103 });
104
105 test("a second flash extends the first and never cuts it short", () => {
106 const p = { blindUntil: 0 };
107 blind(p, 0, 2);
108 blind(p, 0.5, 0.4); // a weak one while the first is still running
109 expect(p.blindUntil).toBe(2);
110 blind(p, 1, 2); // a real one on top
111 expect(p.blindUntil).toBe(3);
112 blind(p, 1, 0); // and nothing does nothing
113 expect(p.blindUntil).toBe(3);
114 });
src/client/flash.ts added +120
@@ -0,0 +1,120 @@
1 /**
2 * Blindness: how long a flashbang takes someone's eyes away, and how much of
3 * their sight is gone at a given moment. It is arithmetic over three things —
4 * how far the flash went off, how far off their view it was, and whether
5 * anything stood in between — so the rule can be read, argued with and tested
6 * without a world, a canvas or a bot.
7 *
8 * Nothing here knows what a `Player` is. `world.ts` hands it a position, an
9 * eye, a view direction and the answer of one trace; `hud.ts` asks how white
10 * the screen should be; `bots.ts` asks how much of a bot's hands it took. The
11 * state is one number — the world clock the eyes come back at — so a player
12 * carries a flash in a single field.
13 */
14
15 import { clamp, deg, dot, length, normalize, sub, type Vec3 } from "./math.ts";
16
17 export const FLASH = {
18 /**
19 * Point blank and straight in the face, in seconds. Deliberately short of
20 * CS's ~5 s: this is a screen a person is holding a mouse in front of, and
21 * three and a half seconds of white is already a long time to be told to
22 * wait. Everything else is a fraction of this.
23 */
24 maxBlind: 3.4,
25 /**
26 * Shorter than this is not worth a white screen at all: a flicker that
27 * takes nothing from you reads as a bug, so the far edges round to nothing.
28 */
29 minBlind: 0.3,
30 /** Full brightness inside this; it falls off from here to `radius`. */
31 near: 300,
32 /** Past this it is a noise and a light and nothing more. */
33 radius: 1400,
34 /** Degrees off the view direction that still count as "in the face". */
35 fullAngle: 25,
36 /**
37 * The edge of what a person can see: half of the 90° field of view the
38 * camera in `main.ts` uses, plus a little for the eye's own periphery. This
39 * is the boundary the rule is continuous across — a flash one degree
40 * outside your view is not suddenly harmless.
41 */
42 viewAngle: 55,
43 /** The share left at that boundary. */
44 edgeShare: 0.38,
45 /** The share left with the flash directly behind you. */
46 awayShare: 0.12,
47 /**
48 * The whitest the screen ever goes. Never 1: a screen you cannot see
49 * through at all for three seconds is a bug report, so the brightest moment
50 * of the worst flash still leaves the world faintly readable underneath.
51 */
52 maxAlpha: 0.9,
53 } as const;
54
55 /**
56 * The share of a full flash someone looking `angle` degrees away from it
57 * takes: all of it in the face, `edgeShare` at the edge of the view, down to
58 * `awayShare` with it behind them. Continuous at both joins, so no boundary
59 * in here is a cliff a player could feel as unfairness.
60 */
61 export function facingShare(angle: number): number {
62 const a = clamp(Math.abs(angle), 0, 180);
63 if (a <= FLASH.fullAngle) return 1;
64 if (a <= FLASH.viewAngle) {
65 const t = (a - FLASH.fullAngle) / (FLASH.viewAngle - FLASH.fullAngle);
66 return 1 + (FLASH.edgeShare - 1) * t;
67 }
68 const t = (a - FLASH.viewAngle) / (180 - FLASH.viewAngle);
69 return FLASH.edgeShare + (FLASH.awayShare - FLASH.edgeShare) * t;
70 }
71
72 /** The share of a full flash left at `range` units: all of it close, none at `radius`. */
73 export const rangeShare = (range: number): number =>
74 clamp(1 - (range - FLASH.near) / (FLASH.radius - FLASH.near), 0, 1);
75
76 /**
77 * How many seconds a flash at `at` blinds someone whose eye is at `eye`
78 * looking along `forward`. `blocked` is the caller's one trace — eye to
79 * flash — and a wall is the whole of the cover rule: no light, no blindness,
80 * however close it went off and however squarely you were looking at it.
81 *
82 * Zero means not blinded at all, which is what a fraction of a second rounds
83 * to. The maximum is `FLASH.maxBlind`.
84 */
85 export function blindSeconds(at: Vec3, eye: Vec3, forward: Vec3, blocked: boolean): number {
86 if (blocked) return 0;
87 const to = sub(at, eye);
88 const range = length(to);
89 if (range >= FLASH.radius) return 0;
90 // Gone off inside your own head: there is no direction to it, and it is as
91 // square in the face as a flash gets.
92 const angle = range < 1e-3 ? 0 : deg(Math.acos(clamp(dot(normalize(to), normalize(forward)), -1, 1)));
93 const seconds = FLASH.maxBlind * rangeShare(range) * facingShare(angle);
94 return seconds < FLASH.minBlind ? 0 : seconds;
95 }
96
97 /** Anything a flash can reach: one number, the clock its eyes come back at. */
98 export type Blindable = { blindUntil: number };
99
100 /**
101 * Blind `p` for `seconds` from `now`. A second flash extends the first and
102 * never cuts it short — two in a doorway is worse than one, and the later one
103 * cannot be a mercy.
104 */
105 export function blind(p: Blindable, now: number, seconds: number): void {
106 if (!(seconds > 0)) return;
107 p.blindUntil = Math.max(p.blindUntil, now + seconds);
108 }
109
110 /**
111 * How blind `p` is at `now`, 0..1: 1 the instant the worst possible flash
112 * lands, falling linearly to 0 as their eyes come back. A short flash starts
113 * dimmer as well as ending sooner, which is what makes a flash from across
114 * the map worth throwing but not worth much.
115 */
116 export const blindness = (p: Blindable, now: number): number =>
117 clamp((p.blindUntil - now) / FLASH.maxBlind, 0, 1);
118
119 /** How white to paint the screen for `p` at `now`: `FLASH.maxAlpha` at worst, 0 when it is over. */
120 export const flashAlpha = (p: Blindable, now: number): number => blindness(p, now) * FLASH.maxAlpha;
src/client/grenades.test.ts +7 −7
@@ -25,7 +25,7 @@ test("a flat throw falls like a thrown thing: the range the numbers say", () =>
2525 // up over a floor at zero. It is a projectile from there: it should land
2626 // v·√(2h/g) away, the same answer as the back of an envelope.
2727 const from: Vec3 = [0, 0, 200];
28 const g = thrown(0, "t", from, 0, GRENADE.throwLift, [0, 0, 0]);
28 const g = thrown("he", 0, "t", from, 0, GRENADE.throwLift, [0, 0, 0]);
2929 const drop = (g.pos[2] as number) - GRENADE.radius;
3030 const expected = GRENADE.throwSpeed * Math.sqrt((2 * drop) / GRENADE.gravity);
3131
@@ -44,8 +44,8 @@ test("a flat throw falls like a thrown thing: the range the numbers say", () =>
4444 });
4545
4646 test("the thrower's own speed goes with it: running throws further", () => {
47 const still = thrown(0, "t", [0, 0, 200], 0, GRENADE.throwLift, [0, 0, 0]);
48 const running = thrown(0, "t", [0, 0, 200], 0, GRENADE.throwLift, [250, 0, 0]);
47 const still = thrown("he", 0, "t", [0, 0, 200], 0, GRENADE.throwLift, [0, 0, 0]);
48 const running = thrown("he", 0, "t", [0, 0, 200], 0, GRENADE.throwLift, [250, 0, 0]);
4949 fly(still, 0.5, FLOOR);
5050 fly(running, 0.5, FLOOR);
5151 expect(running.pos[0] as number).toBeGreaterThan((still.pos[0] as number) + 100);
@@ -53,7 +53,7 @@ test("the thrower's own speed goes with it: running throws further", () => {
5353
5454 test("a wall bounces it back, slower, and it never ends up inside one", () => {
5555 const wall: Box[] = [...FLOOR, { min: [300, -500, 0], max: [340, 500, 400], mat: 0 }];
56 const g = thrown(0, "t", [0, 0, 64], 0, 0, [0, 0, 0]);
56 const g = thrown("he", 0, "t", [0, 0, 64], 0, 0, [0, 0, 0]);
5757 const bounces = fly(g, GRENADE.fuse, wall);
5858 expect(bounces).toBeGreaterThan(0);
5959 expect(g.pos[0] as number).toBeLessThan(300);
@@ -68,7 +68,7 @@ test("thrown at a wall of de_forge it stays in the map and comes to rest", () =>
6868 const eye: Vec3 = [at[0], at[1], 64];
6969 // Eight directions from a spawn, hard: none of them may put it outside.
7070 for (let i = 0; i < 8; i++) {
71 const g = thrown(0, "t", eye, i * 45, -20, [0, 0, 0]);
71 const g = thrown("he", 0, "t", eye, i * 45, -20, [0, 0, 0]);
7272 fly(g, GRENADE.fuse, DE_FORGE.boxes);
7373 expect(Number.isFinite(g.pos[0]) && Number.isFinite(g.pos[2])).toBe(true);
7474 expect(walkable(DE_FORGE, colOf(DE_FORGE, g.pos[0] as number), rowOf(DE_FORGE, g.pos[1] as number))).toBe(true);
@@ -85,7 +85,7 @@ test("a grenade dropped on the floor stops instead of trembling on it", () => {
8585 });
8686
8787 test("the fuse runs on the clock: nothing before 1.6s, and then it goes", () => {
88 const g = thrown(0, "t", [0, 0, 64], 0, 0, [0, 0, 0]);
88 const g = thrown("he", 0, "t", [0, 0, 64], 0, 0, [0, 0, 0]);
8989 let fired = -1;
9090 for (let i = 0; i < 200; i++) {
9191 if (step(g, TICK, FLOOR).detonated) { fired = i; break; }
@@ -137,7 +137,7 @@ test("the bomb's blast is the same function as the HE's, with its own radius and
137137 });
138138
139139 test("a grenade remembers where it was, so the renderer can draw it between two ticks", () => {
140 const g = thrown(0, "t", [0, 0, 64], 0, 0, [0, 0, 0]);
140 const g = thrown("he", 0, "t", [0, 0, 64], 0, 0, [0, 0, 0]);
141141 // It has not moved yet: the first frame draws it at the hand, not between
142142 // the hand and the origin.
143143 expect(g.prev).toEqual(g.pos);
src/client/grenades.ts +23 −5
@@ -17,10 +17,27 @@
1717 import type { Box } from "./map.ts";
1818 import { rememberPos } from "./interpolate.ts";
1919 import { angleVectors, clamp, type Vec3 } from "./math.ts";
20 import { armorSplit, WEAPONS } from "./weapons.ts";
20 import { armorSplit, WEAPONS, type WeaponId } from "./weapons.ts";
21
22 /**
23 * What is in the air. The flight is the same for both — one arc, one gravity,
24 * one bounce, one fuse — and the kind decides only how long that fuse is and
25 * what `world.ts` does at the end of it: an HE hurts, a flash blinds.
26 */
27 export type GrenadeKind = "he" | "flash";
28
29 /**
30 * The one flight number that differs by kind. A flash is quicker off the mark
31 * than an HE: it is thrown into a place you are already walking into, and a
32 * fuse you have to wait out is one the defender has time to turn away from.
33 */
34 export const FUSE: Record<GrenadeKind, number> = { he: 1.6, flash: 1.4 };
35
36 /** Which kind a weapon id throws. Only a grenade ever reaches here. */
37 export const kindOf = (id: WeaponId): GrenadeKind => (id === "flash" ? "flash" : "he");
2138
2239 export type Grenade = {
23 kind: "he";
40 kind: GrenadeKind;
2441 owner: number; // the player id who threw it; the blast is credited to them
2542 team: "t" | "ct"; // their side when it left the hand, for friendly fire
2643 pos: Vec3;
@@ -41,7 +58,7 @@ export const GRENADE = {
4158 bounce: 0.45, // the share of the speed into a surface that comes back out
4259 friction: 0.75, // the share of the speed along it that survives the hit
4360 rest: 50, // below this, a grenade against a floor stops dead
44 fuse: 1.6, // seconds from the hand to the bang, whatever it hit on the way
61 fuse: FUSE.he, // the HE's seconds from the hand to the bang, whatever it hit on the way
4562 throwSpeed: 780, // u/s out of the hand, before the thrower's own velocity
4663 throwLift: 12, // degrees above where you are looking
4764 throwFrom: 18, // units in front of the eye it appears
@@ -111,6 +128,7 @@ export function step(g: Grenade, dt: number, boxes: Box[]): Step {
111128 * added — running out of a doorway throws it further than standing does.
112129 */
113130 export function thrown(
131 kind: GrenadeKind,
114132 owner: number,
115133 team: "t" | "ct",
116134 eye: Vec3,
@@ -126,7 +144,7 @@ export function thrown(
126144 eye[2] + dir[2] * GRENADE.throwFrom,
127145 ];
128146 return {
129 kind: "he",
147 kind,
130148 owner,
131149 team,
132150 pos: [from[0], from[1], from[2]],
@@ -138,7 +156,7 @@ export function thrown(
138156 dir[1] * GRENADE.throwSpeed + velocity[1],
139157 dir[2] * GRENADE.throwSpeed + velocity[2],
140158 ],
141 fuse: GRENADE.fuse,
159 fuse: FUSE[kind],
142160 };
143161 }
144162
src/client/hearing.ts +4 −1
@@ -37,7 +37,10 @@ const LOUDNESS: Record<HeardName, number> = {
3737 knife: 0.08, he: 0.1, bomb: 0,
3838 // The world. A plant and a defuse are deliberate and carry past the site; a
3939 // footstep is a room away; a dry trigger is the gun in your own hands.
40 explode: 0.95, blast: 0.9, plant: 0.9, defuse: 0.8, beep: 0.5,
40 // `flash` is both a weapon id and a world sound, and one entry answers for
41 // both: a flashbang is never fired, so the only noise it can make is its own
42 // bang — sharp and nearly as far-carrying as an HE's.
43 explode: 0.95, blast: 0.9, plant: 0.9, flash: 0.85, defuse: 0.8, beep: 0.5,
4144 step: 0.45, bounce: 0.35, empty: 0.25,
4245 };
4346
src/client/hud.test.ts +43
@@ -7,6 +7,7 @@
77
88 import { expect, test } from "bun:test";
99 import { DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts";
10 import { blind, FLASH } from "./flash.ts";
1011 import { drawHud } from "./hud.ts";
1112 import { SHORT } from "./match.ts";
1213 import type { Camera } from "./renderer.ts";
@@ -144,6 +145,48 @@ test("the hurt vignette follows health and its gradient is built once, not per f
144145 expect(again.calls.filter((c) => c.name === "createRadialGradient").length).toBe(0);
145146 });
146147
148 test("a flash whitens the whole screen and fades with the eyes, never fully opaque", () => {
149 const world = createWorld(SHORT, "me", "ct", 5, 79);
150 /** The full-screen white fills of one frame, with the alpha each was drawn at. */
151 const whites = (calls: Call[]): Call[] =>
152 calls.filter((c) =>
153 c.name === "fillRect" && c.fill === "#ffffff" &&
154 c.args[0] === 0 && c.args[1] === 0 && c.args[2] === W && c.args[3] === H);
155
156 const clear = recorder();
157 draw(clear, world);
158 expect(whites(clear.calls).length).toBe(0);
159
160 blind(world.me, world.time, FLASH.maxBlind);
161 const worst = recorder();
162 draw(worst, world);
163 expect(whites(worst.calls).length).toBe(1);
164 const first = whites(worst.calls)[0] as Call;
165 expect(first.alpha).toBeCloseTo(FLASH.maxAlpha, 6);
166 expect(first.alpha).toBeLessThan(1); // the world underneath is never taken away entirely
167
168 // Halfway through it is half as white, and afterwards there is nothing left.
169 world.time += FLASH.maxBlind / 2;
170 const fading = recorder();
171 draw(fading, world);
172 expect((whites(fading.calls)[0] as Call).alpha).toBeCloseTo(FLASH.maxAlpha / 2, 6);
173
174 world.time += FLASH.maxBlind;
175 const over = recorder();
176 draw(over, world);
177 expect(whites(over.calls).length).toBe(0);
178 });
179
180 test("the white is the watched player's, so a spectator sees what they see", () => {
181 const world = createWorld(SHORT, "me", "ct", 5, 81);
182 const mate = world.players.find((p) => p.team === "ct" && p !== world.me) as Player;
183 world.me.alive = false;
184 blind(mate, world.time, FLASH.maxBlind);
185 const frame = recorder();
186 draw(frame, world, mate);
187 expect(frame.calls.some((c) => c.name === "fillRect" && c.fill === "#ffffff" && c.args[2] === W)).toBe(true);
188 });
189
147190 /**
148191 * The crosshair's arms as the context was told to draw them: each `moveTo`
149192 * paired with the `lineTo` that follows it, kept only when it starts at the
src/client/hud.ts +13
@@ -9,6 +9,7 @@
99 */
1010
1111 import { crosshairShape, DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts";
12 import { flashAlpha } from "./flash.ts";
1213 import { lerpPos } from "./interpolate.ts";
1314 import { angleDelta, clamp, rad, type Vec3 } from "./math.ts";
1415 import { walkable, type MapDef } from "./map.ts";
@@ -552,6 +553,18 @@ export function drawHud(
552553 );
553554 }
554555
556 // --- flashed: white over the scene and over the HUD with it, fading as the
557 // eyes come back. It goes on here, under the two things a person explicitly
558 // asked for — the scoreboard and the damage report — because a flash takes
559 // your sight, not the screen you pressed Tab to read.
560 const white = flashAlpha(view, world.time);
561 if (white > 0) {
562 ctx.globalAlpha = white;
563 ctx.fillStyle = "#ffffff";
564 ctx.fillRect(0, 0, width, height);
565 ctx.globalAlpha = 1;
566 }
567
555568 // --- the damage report: only during the round-over delay, and never under
556569 // the scoreboard, which a held Tab can ask for at the same moment.
557570 if (!showScoreboard) drawReport(ctx, world, view, width, height, scale);
src/client/main.ts +10 −5
@@ -406,8 +406,12 @@ function boot(): void {
406406 button.className = "buy-item";
407407 button.id = `buy-item-${row.item}`;
408408 button.setAttribute("aria-describedby", whyId);
409 // The tenth row's key is 0, the way a row of digits on a keyboard runs:
410 // a CT's list is ten long now that there are two grenades, and a row
411 // with no key on it is a row half the people here will never press.
412 const key = i < 10 ? String((i + 1) % 10) : "";
409413 button.innerHTML =
410 `<b class="buy-index">${i + 1}</b><span class="buy-line"></span><small class="buy-why" id="${whyId}"></small>`;
414 `<b class="buy-index">${key}</b><span class="buy-line"></span><small class="buy-why" id="${whyId}"></small>`;
411415 button.addEventListener("click", () => buyRow(row));
412416 buyGrid.appendChild(button);
413417 });
@@ -435,14 +439,15 @@ function boot(): void {
435439 });
436440 }
437441
438 // Digits 1-9 buy the numbered row without also reaching the slot-switch
439 // keys bound on `window` — the panel answers the press before it gets there.
442 // Digits 1-9 and then 0 buy the numbered row without also reaching the
443 // slot-switch keys bound on `window` — the panel answers the press before it
444 // gets there.
440445 buyPanel.addEventListener("keydown", (event) => {
441446 if (world === null || buyPanel.hidden) return;
442 const match = /^Digit([1-9])$/.exec(event.code);
447 const match = /^Digit([0-9])$/.exec(event.code);
443448 if (match === null) return;
444449 event.stopPropagation();
445 const row = buyMenu(world, world.me).rows[Number(match[1]) - 1];
450 const row = buyMenu(world, world.me).rows[(Number(match[1]) + 9) % 10];
446451 if (row !== undefined) buyRow(row);
447452 });
448453
src/client/renderer.ts +4 −1
@@ -568,9 +568,12 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks =
568568 // Grenades in the air, tumbling as they go — a thrown object you cannot
569569 // see land is one nobody can play around.
570570 if (world.grenades.length > 0) {
571 gl.uniform3f(u.tint, 0.8, 0.95, 0.72);
572571 gl.bindVertexArray(grenade.vao);
573572 for (const g of world.grenades) {
573 // Olive for an HE, white for a flashbang: what is coming at you is
574 // the one thing you have to read off a grenade mid-air.
575 if (g.kind === "flash") gl.uniform3f(u.tint, 1, 1, 0.92);
576 else gl.uniform3f(u.tint, 0.8, 0.95, 0.72);
574577 // A grenade crosses twelve units in a tick: it is the thing in this
575578 // world that judders hardest without this line.
576579 gl.uniformMatrix4fv(u.model, false, modelMatrix(lerpPos(g.prev, g.pos, alpha, spot), world.time * 520));
src/client/weapons.ts +6 −1
@@ -11,7 +11,7 @@
1111 import { rng } from "./math.ts";
1212
1313 export type WeaponId =
14 | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp" | "he" | "bomb";
14 | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp" | "he" | "flash" | "bomb";
1515
1616 export type Weapon = {
1717 id: WeaponId;
@@ -49,6 +49,10 @@ const WEAPON_LIST: Weapon[] = [
4949 // last because a weapon's spray pattern is seeded from its place in this
5050 // list, and every gun above it already has the pattern people have learned.
5151 { id: "he", name: "HE Grenade", kind: "grenade", team: "both", price: 300, damage: 98, armorPenetration: 0.5, rangeModifier: 1, rpm: 120, magazine: 0, reserve: 0, reloadTime: 0, killReward: 300, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false },
52 // A flashbang does no damage at all: what it costs the people who see it is
53 // seconds, and `flash.ts` owns that number. $200, as in CS — the cheapest
54 // thing in the menu, because the entry it buys is worth more than it costs.
55 { id: "flash", name: "Flashbang", kind: "grenade", team: "both", price: 200, damage: 0, armorPenetration: 0, rangeModifier: 1, rpm: 120, magazine: 0, reserve: 0, reloadTime: 0, killReward: 0, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false },
5256 // Never bought, never held — this exists so the kill feed and the blast
5357 // that reads `damage`/`armorPenetration` have one row to point at, the same
5458 // way every gun does. `buyable` and `canBuyItem` refuse it by kind.
@@ -88,6 +92,7 @@ const PENETRATION: Record<WeaponId, Penetration> = {
8892 glock: { units: 48, survives: 0.42 },
8993 knife: SOLID,
9094 he: SOLID,
95 flash: SOLID,
9196 bomb: SOLID,
9297 };
9398
src/client/world.test.ts +115
@@ -1,4 +1,5 @@
11 import { expect, test } from "bun:test";
2 import { blindness, FLASH } from "./flash.ts";
23 import { GRENADE } from "./grenades.ts";
34 import { teleported } from "./interpolate.ts";
45 import { dist } from "./math.ts";
@@ -850,6 +851,120 @@ test("a grenade thrown is gone at the next round; one kept is still yours", () =
850851 expect(world.me.weapon).not.toBe("he"); // a round starts with the gun up
851852 });
852853
854 /** Put a flashbang about to go off at `pos`, thrown by `thrower`, and step once. */
855 function flashAt(world: World, thrower: Player, pos: [number, number, number]): void {
856 world.grenades.push({
857 kind: "flash", owner: thrower.id, team: thrower.team,
858 pos: [...pos], prev: [...pos], vel: [0, 0, 0], fuse: TICK / 2,
859 });
860 world.effects.length = 0;
861 update(world, TICK, NO_INPUT);
862 }
863
864 /** Seconds of sight `p` has left to lose, right now. */
865 const blindFor = (world: World, p: Player): number => Math.max(0, p.blindUntil - world.time);
866
867 test("a flashbang costs $200 and shares the pocket with the HE, one key for both", () => {
868 const world = createWorld(SHORT, "me", "ct", 5, 261);
869 world.me.money = 1000;
870 expect(buy(world, world.me, "flash")).toBe(true);
871 expect(world.me.money).toBe(800);
872 expect(grenadeCount(world.me, "flash")).toBe(1);
873 expect(world.me.weapon).not.toBe("flash"); // into the pocket, not into your hands
874 expect(buy(world, world.me, "flash")).toBe(false); // one of each kind, as with the HE
875 expect(buy(world, world.me, "he")).toBe(true);
876
877 // One key: slot 4 draws the first kind and pressing it again cycles to the
878 // other, which is the rule `nextGrenade` already proved with a stand-in.
879 world.me.weapon = world.me.secondary;
880 const first = grenadeSlot(world.me) as WeaponId;
881 expect(first).toBe("he"); // weapons.ts's order
882 world.me.weapon = first;
883 expect(grenadeSlot(world.me)).toBe("flash");
884 world.me.weapon = "flash";
885 expect(grenadeSlot(world.me)).toBe("he");
886 });
887
888 test("a flash goes off once, does no damage, and makes its own sound where it went off", () => {
889 const world = createWorld(SHORT, "me", "ct", 5, 263);
890 armWithGrenade(world);
891 for (const p of world.players) if (p !== world.me) p.alive = false; // nobody else's bullets in it
892 world.me.grenades = { flash: 1 };
893 applyInput(world, world.me, { ...NO_INPUT, switchTo: "flash", fire: true }, TICK);
894 expect(world.grenades[0]?.kind).toBe("flash");
895
896 let flashes = 0;
897 for (let t = 0; t < 4; t += TICK) {
898 update(world, TICK, NO_INPUT);
899 flashes += soundsNow(world, "flash");
900 }
901 expect(flashes).toBe(1);
902 expect(world.grenades.length).toBe(0);
903 expect(blastsIn(world)).toBe(0); // a flash is not a blast
904 expect(world.me.health).toBe(100); // it went off at the thrower's feet and cost nothing
905 expect(world.me.blindUntil).toBeGreaterThan(0); // but it did take their eyes on the way
906 });
907
908 test("a flash blinds by where you were looking, your own side included, and never through a wall", () => {
909 const world = createWorld(SHORT, "me", "ct", 5, 267);
910 armWithGrenade(world);
911 const mate = world.players.find((p) => p.team === "ct" && p !== world.me) as Player;
912 const enemy = world.players.find((p) => p.team === "t") as Player;
913 for (const p of world.players) if (p !== world.me && p !== mate && p !== enemy) p.alive = false;
914 const height = eyePos(world.me)[2];
915 // The person square in it; their own teammate past it with their back to
916 // it; an enemy on the far side of the map.
917 world.me.pos = [0, 0, 0];
918 world.me.yaw = 0;
919 mate.pos = [600, 0, 0];
920 mate.yaw = 0;
921 mate.pitch = 0;
922 enemy.pos = [4000, 0, 0];
923 flashAt(world, world.me, [300, 0, height]);
924
925 expect(blindFor(world, world.me)).toBeCloseTo(FLASH.maxBlind, 2);
926 // Friendly fire: your own flash takes your own side's eyes too, and looking
927 // away is a fraction of it rather than nothing.
928 expect(blindFor(world, mate)).toBeGreaterThan(0);
929 expect(blindFor(world, mate)).toBeLessThan(blindFor(world, world.me) / 2);
930 expect(enemy.blindUntil).toBe(0); // out of range entirely
931
932 // The same flash, the same eyes, a wall in between: nothing at all.
933 const behind = createWorld(SHORT, "me", "ct", 5, 267);
934 armWithGrenade(behind);
935 behind.boxes = [{ min: [100, -400, -100], max: [140, 400, 400], mat: 0 }];
936 behind.me.pos = [0, 0, 0];
937 behind.me.yaw = 0;
938 flashAt(behind, behind.me, [300, 0, eyePos(behind.me)[2]]);
939 expect(behind.me.blindUntil).toBe(0);
940 });
941
942 test("the eyes decay and come back on their own, with no tick spent on them", () => {
943 const world = createWorld(SHORT, "me", "ct", 5, 269);
944 armWithGrenade(world);
945 flashAt(world, world.me, [300, 0, eyePos(world.me)[2]]);
946 expect(blindness(world.me, world.time)).toBeCloseTo(1, 2);
947 run(world, FLASH.maxBlind / 2);
948 expect(blindness(world.me, world.time)).toBeGreaterThan(0.3);
949 expect(blindness(world.me, world.time)).toBeLessThan(0.7);
950 run(world, FLASH.maxBlind);
951 expect(blindness(world.me, world.time)).toBe(0);
952 });
953
954 test("a round restart leaves nobody blind", () => {
955 const world = createWorld(SHORT, "me", "ct", 5, 271);
956 armWithGrenade(world);
957 const mate = world.players.find((p) => p.team === "ct" && p !== world.me) as Player;
958 mate.pos = [200, 0, 0];
959 mate.yaw = 0;
960 flashAt(world, world.me, [300, 0, eyePos(world.me)[2]]);
961 expect(world.players.some((p) => p.blindUntil > world.time)).toBe(true);
962
963 startRound(world);
964 expect(world.players.every((p) => p.blindUntil === 0)).toBe(true);
965 expect(world.players.every((p) => blindness(p, world.time) === 0)).toBe(true);
966 });
967
853968 test("a player killed holding a grenade leaves their gun, never the grenade", () => {
854969 const world = createWorld(SHORT, "me", "ct", 5, 251);
855970 const shooter = world.players.find((p) => p.team === "t") as Player;
src/client/world.ts +60 −15
@@ -10,8 +10,9 @@
1010 import { addBody, type Corpse } from "./bodies.ts";
1111 import { applyHit, traceBullet, type Actor } from "./combat.ts";
1212 import { stepBeep } from "./cues.ts";
13 import { blind, blindSeconds, FLASH } from "./flash.ts";
1314 import {
14 blastDamage, GRENADE, step as stepGrenade, thrown, type Grenade,
15 blastDamage, GRENADE, kindOf, step as stepGrenade, thrown, type Grenade,
1516 } from "./grenades.ts";
1617 import { pose, remember, rememberPos, type Pose } from "./interpolate.ts";
1718 import { DE_FORGE, siteAt, type MapDef } from "./map.ts";
@@ -52,6 +53,12 @@ export type Player = Actor & Body & {
5253 grenades: Partial<Record<WeaponId, number>>;
5354 defuseKit: boolean;
5455 hasBomb: boolean;
56 /**
57 * The world clock this player's eyes come back at, or 0. One number is the
58 * whole of being flashed: how white the screen is and how much of a bot's
59 * hands it took are both `flash.ts`'s arithmetic over it and `world.time`.
60 */
61 blindUntil: number;
5562 nextShotAt: number;
5663 reloadUntil: number;
5764 shotIndex: number;
@@ -98,7 +105,8 @@ export type Pickup = {
98105 };
99106
100107 /** Every name a `{ kind: "sound" }` effect can carry — the vocabulary `audio.ts` plays. */
101 export type SoundName = "step" | "beep" | "plant" | "defuse" | "explode" | "empty" | "bounce" | "blast";
108 export type SoundName =
109 | "step" | "beep" | "plant" | "defuse" | "explode" | "empty" | "bounce" | "blast" | "flash";
102110
103111 export type Effect =
104112 | { kind: "tracer"; from: Vec3; to: Vec3; at: number }
@@ -239,7 +247,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player
239247 weapon: secondary, primary: null, secondary,
240248 ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) },
241249 grenades: {},
242 defuseKit: false, hasBomb: false,
250 defuseKit: false, hasBomb: false, blindUntil: 0,
243251 nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false, firedThisPress: false, emptyThisPress: false,
244252 punch: [0, 0], nextPickupAt: 0,
245253 kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0, stepDistance: 0, prevStep: 0,
@@ -353,6 +361,9 @@ export function startRound(world: World): void {
353361 p.prevStep = 0;
354362 p.spawnAt = world.time;
355363 p.nextPickupAt = 0;
364 // A round starts with everybody's eyes: a flash thrown in the last second
365 // of the last round does not carry over the barrier.
366 p.blindUntil = 0;
356367 p.yaw = p.team === "t" ? 90 : -90;
357368 p.pitch = 0;
358369 // A spawn is a teleport, so where they were last tick is where they are
@@ -847,6 +858,23 @@ const THROW_COOLDOWN = 0.6;
847858 const BURST: Vec3[] = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1]];
848859 const BURST_SPREAD = 70;
849860
861 /**
862 * The star of sparks any grenade going off leaves in the air. Nothing was
863 * struck at any of these five points, so nothing is marked by them — a
864 * `surface: false` impact is light, never a decal.
865 */
866 function sparks(world: World, at: Vec3): void {
867 for (const n of BURST) {
868 world.effects.push({
869 kind: "impact",
870 at: world.time,
871 pos: [at[0] + n[0] * BURST_SPREAD, at[1] + n[1] * BURST_SPREAD, at[2] + n[2] * BURST_SPREAD],
872 normal: n,
873 surface: false,
874 });
875 }
876 }
877
850878 /**
851879 * Throw the grenade in `p`'s hand. It leaves the eye, forward and a little up,
852880 * carrying whatever `p` was doing at the time, and the hand goes back to the
@@ -857,11 +885,34 @@ function throwGrenade(world: World, p: Player): boolean {
857885 if (!takeGrenade(p, id)) return false;
858886 p.nextShotAt = world.time + THROW_COOLDOWN;
859887 p.lastShotAt = world.time;
860 world.grenades.push(thrown(p.id, p.team, eyePos(p), p.yaw, p.pitch, p.vel));
888 world.grenades.push(thrown(kindOf(id), p.id, p.team, eyePos(p), p.yaw, p.pitch, p.vel));
861889 if (grenadeCount(p, id) === 0) p.weapon = p.primary ?? p.secondary;
862890 return true;
863891 }
864892
893 /**
894 * A flashbang: nobody is hurt and nothing is credited — what it costs is
895 * seconds of sight, and `flash.ts` decides how many from where everyone was
896 * looking. Friendly fire is the point rather than an oversight: your own
897 * flash blinds your own side, and one you throw at your own feet blinds you,
898 * which is what makes throwing it a skill instead of a button.
899 *
900 * The cost is one trace per living player inside the radius — ten at the very
901 * worst, once, on the tick a flash goes off. Nothing here runs on a tick that
902 * has no flash detonating on it.
903 */
904 function flashbang(world: World, g: Grenade): void {
905 world.effects.push({ kind: "sound", at: world.time, pos: [...g.pos] as Vec3, name: "flash", by: g.owner });
906 sparks(world, g.pos);
907 for (const p of world.players) {
908 if (!p.alive) continue;
909 const eye = eyePos(p);
910 if (dist(g.pos, eye) >= FLASH.radius) continue; // no trace for someone it could never reach
911 const forward = angleVectors(p.yaw, p.pitch).forward;
912 blind(p, world.time, blindSeconds(g.pos, eye, forward, !visible(g.pos, eye, world.boxes)));
913 }
914 }
915
865916 /**
866917 * A blast: everyone it can see, from full damage at the centre down to nothing
867918 * at the radius, less whatever their armour stops. A wall between the two is
@@ -870,18 +921,12 @@ function throwGrenade(world: World, p: Player): boolean {
870921 * bullet, though the thrower is very much in range of their own grenade.
871922 */
872923 function detonate(world: World, g: Grenade): void {
873 world.effects.push({ kind: "sound", at: world.time, pos: [...g.pos] as Vec3, name: "blast", by: g.owner });
874 for (const n of BURST) {
875 world.effects.push({
876 kind: "impact",
877 at: world.time,
878 pos: [g.pos[0] + n[0] * BURST_SPREAD, g.pos[1] + n[1] * BURST_SPREAD, g.pos[2] + n[2] * BURST_SPREAD],
879 normal: n,
880 // A star of sparks around the blast, in whatever air is there: nothing
881 // was struck at any of these five points, so nothing is marked by them.
882 surface: false,
883 });
924 if (g.kind === "flash") {
925 flashbang(world, g);
926 return;
884927 }
928 world.effects.push({ kind: "sound", at: world.time, pos: [...g.pos] as Vec3, name: "blast", by: g.owner });
929 sparks(world, g.pos);
885930 const thrower = world.players.find((p) => p.id === g.owner) ?? null;
886931 for (const victim of world.players) {
887932 if (!victim.alive) continue;
src/readme.test.ts +11
@@ -9,6 +9,7 @@ import { expect, test } from "bun:test";
99 import { readFileSync } from "node:fs";
1010 import { join } from "node:path";
1111 import { DEFAULT_MAP, MAP_NAMES } from "./client/map.ts";
12 import { WEAPONS } from "./client/weapons.ts";
1213
1314 const root = join(import.meta.dir, "..");
1415 const read = (path: string): string => readFileSync(join(root, path), "utf8");
@@ -90,6 +91,16 @@ test("the readme says where the crosshair setting is, and that dynamic is the de
9091 for (const name of names) expect(playing).toContain(name);
9192 });
9293
94 // A grenade nobody can find the key for is a grenade nobody throws, so the
95 // section that lists the keys has to name every kind the buy menu sells.
96 test("the readme's Playing section names every grenade a person can buy", () => {
97 const playing = section("Playing").toLowerCase();
98 const kinds = Object.values(WEAPONS).filter((w) => w.kind === "grenade");
99 expect(kinds.length).toBeGreaterThan(1);
100 for (const weapon of kinds) expect(playing).toContain(weapon.name.toLowerCase());
101 expect(playing).toContain("<kbd>4</kbd>");
102 });
103
93104 test("the readme names every map in the catalogue, and no map the catalogue does not hold", () => {
94105 const playing = section("Playing");
95106 expect(playing).toContain("**There are two maps**");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs