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
vesper/cs:vesper/110-the-flashbang-the-grenade-that-opens-a-s → main 2 commits fork
18 files
changed +700
−51
README.md +36 −8
| @@ -68,19 +68,35 @@ wants one, but the running server never reads it. | ||
| 68 | 68 | one already, <kbd>E</kbd> swaps, and the gun you were holding lands at your |
| 69 | 69 | feet. There is no team rule: a CT who finds an AK-47 keeps it. The floor is |
| 70 | 70 | 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 | |
| 73 | 78 | hand and fire throws it — out of your eye, a little above where you are |
| 74 | 79 | looking, carrying whatever speed you were already running at. It falls under |
| 75 | 80 | 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 | |
| 78 | 84 | centre, falling linearly to nothing at 350 units**, halved by armour (which |
| 79 | 85 | takes half of what it stops), and it does nothing at all through a wall — the |
| 80 | 86 | 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 | |
| 82 | 98 | 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 | |
| 84 | 100 | it, and they keep buying guns. Hit feedback runs both ways: a hit marker and a beep when you hit |
| 85 | 101 | someone, and when you are hit a view punch away from the shot, a wedge around |
| 86 | 102 | 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. | ||
| 175 | 191 | <kbd>E</kbd> plants, defuses or takes the gun you are standing on (the HUD says |
| 176 | 192 | which gun when it is one you have to ask for), <kbd>B</kbd> opens the buy menu in the freeze |
| 177 | 193 | 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 | |
| 179 | 196 | the gun once it is gone), <kbd>Z</kbd> opens the radio, <kbd>Tab</kbd> shows |
| 180 | 197 | the scoreboard, <kbd>Esc</kbd> pauses the match on a desktop and `menu` on the |
| 181 | 198 | 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. | ||
| 415 | 432 | plays a seeded headless match out on each map in turn, so a layout the bots |
| 416 | 433 | cannot path is a red test rather than a match that never ends), |
| 417 | 434 | `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), | |
| 419 | 441 | `buy` (the buy panel's rows — what a side can afford, what it already owns |
| 420 | 442 | and why the rest are dead — read off `world` and handed to `main` as plain data), |
| 421 | 443 | `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 | ||
| 762 | 784 | garbage collector and whatever else the box is doing, not the simulation, so |
| 763 | 785 | read the mean and the percentiles and ignore it. |
| 764 | 786 | |
| 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 | ||
| 765 | 793 | The arithmetic is `summarize` in the script itself — a pure function over a |
| 766 | 794 | list of milliseconds, tested on a known list with no clock in sight. Exactly |
| 767 | 795 | 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 | ||
| 334 | 334 | } |
| 335 | 335 | }); |
| 336 | 336 | |
| 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 | ||
| 337 | 358 | test("a name with no tone and no special case creates nothing but the panner", () => { |
| 338 | 359 | const { Ctor, instances } = recorder(); |
| 339 | 360 | const audio = createAudio(Ctor); |
src/client/audio.ts +36 −2
| @@ -43,8 +43,8 @@ const TONE: Record<string, { cut: number; thump: number; length: number }> = { | ||
| 43 | 43 | |
| 44 | 44 | /** One tone per named world sound: a square blip for the bomb's own beep, |
| 45 | 45 | * 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. */ | |
| 48 | 48 | const WORLD_TONE: Partial<Record<WorldSoundName, { freq: number; type: OscillatorType; length: number }>> = { |
| 49 | 49 | beep: { freq: 900, type: "square", length: 0.08 }, |
| 50 | 50 | plant: { freq: 420, type: "sine", length: 0.22 }, |
| @@ -277,6 +277,40 @@ export function createAudio(Ctor: typeof AudioContext | undefined = defaultConte | ||
| 277 | 277 | return; |
| 278 | 278 | } |
| 279 | 279 | |
| 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 | ||
| 280 | 314 | // A grenade off a wall: a tick of filtered noise, shorter than a footstep. |
| 281 | 315 | if (name === "bounce") { |
| 282 | 316 | const length = 0.07; |
src/client/bots.test.ts +48 −1
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { |
| 3 | applyIntent, botThink, CARD, DIFFICULTIES, makeBrain, policy, restyleBrain, skillBand, think, | |
| 3 | applyIntent, botThink, CARD, DIFFICULTIES, hands, makeBrain, policy, restyleBrain, skillBand, think, | |
| 4 | 4 | } from "./bots.ts"; |
| 5 | import { blind, FLASH } from "./flash.ts"; | |
| 5 | 6 | import { DE_FORGE } from "./map.ts"; |
| 6 | 7 | import { dist2d } from "./math.ts"; |
| 7 | 8 | import { COMPETITIVE, SHORT } from "./match.ts"; |
| @@ -88,6 +89,52 @@ test("a bot that has only just seen you does not shoot yet", () => { | ||
| 88 | 89 | expect(immediate.fire).toBe(false); |
| 89 | 90 | }); |
| 90 | 91 | |
| 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 | ||
| 91 | 138 | // --- hearing -------------------------------------------------------------- |
| 92 | 139 | |
| 93 | 140 | /** |
src/client/bots.ts +29 −5
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | 14 | import { decode, HEADS, STILL, type Intent } from "./action.ts"; |
| 15 | import { blindness } from "./flash.ts"; | |
| 15 | 16 | import { fromOther, loudness, reported, type HeardName, type Maker } from "./hearing.ts"; |
| 16 | 17 | import { angleDelta, clamp, dist, dist2d, rng, type Vec3 } from "./math.ts"; |
| 17 | 18 | import { findPath, siteAt, walkable, colOf, rowOf } from "./map.ts"; |
| @@ -148,6 +149,28 @@ function brainOf(world: World, bot: Player): BotBrain { | ||
| 148 | 149 | return bot.brain; |
| 149 | 150 | } |
| 150 | 151 | |
| 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 | ||
| 151 | 174 | /** |
| 152 | 175 | * Re-skill a brain for a new difficulty in place: the hands change, but the |
| 153 | 176 | * path, the plan and the memory it already built up this match do not. Called |
| @@ -417,11 +440,12 @@ export function scriptedFight( | ||
| 417 | 440 | world: World, bot: Player, brain: BotBrain, target: Player, input: Input, dt: number, |
| 418 | 441 | ): Input { |
| 419 | 442 | 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; | |
| 421 | 445 | const aim = aimAt(bot, bodyCentre(target)); |
| 422 | 446 | 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; | |
| 425 | 449 | const ammo = bot.ammo[bot.weapon]; |
| 426 | 450 | if (ammo !== undefined && ammo.mag === 0) input.reload = true; |
| 427 | 451 | if (ready && onTarget && world.time > brain.restUntil) { |
| @@ -474,7 +498,7 @@ export function think(world: World, bot: Player, brain: BotBrain, target: Player | ||
| 474 | 498 | */ |
| 475 | 499 | export function applyIntent(world: World, bot: Player, brain: BotBrain, intent: Intent, dt: number): void { |
| 476 | 500 | 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; | |
| 478 | 502 | brain.jitterUntil = world.time + 0.25 + brain.random() * 0.35; |
| 479 | 503 | } |
| 480 | 504 | const limit = brain.aimSpeed; |
| @@ -595,7 +619,7 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 595 | 619 | // that is one shot and then nothing, so the trigger is released between |
| 596 | 620 | // decisions and each decision is worth one pull. |
| 597 | 621 | 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; | |
| 599 | 623 | if (pushing(world, bot, brain)) { |
| 600 | 624 | // Out of clock: the policy keeps the gun, the plan takes the feet. It |
| 601 | 625 | // 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", () => | ||
| 25 | 25 | // up over a floor at zero. It is a projectile from there: it should land |
| 26 | 26 | // v·√(2h/g) away, the same answer as the back of an envelope. |
| 27 | 27 | 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]); | |
| 29 | 29 | const drop = (g.pos[2] as number) - GRENADE.radius; |
| 30 | 30 | const expected = GRENADE.throwSpeed * Math.sqrt((2 * drop) / GRENADE.gravity); |
| 31 | 31 | |
| @@ -44,8 +44,8 @@ test("a flat throw falls like a thrown thing: the range the numbers say", () => | ||
| 44 | 44 | }); |
| 45 | 45 | |
| 46 | 46 | 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]); | |
| 49 | 49 | fly(still, 0.5, FLOOR); |
| 50 | 50 | fly(running, 0.5, FLOOR); |
| 51 | 51 | 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", () => { | ||
| 53 | 53 | |
| 54 | 54 | test("a wall bounces it back, slower, and it never ends up inside one", () => { |
| 55 | 55 | 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]); | |
| 57 | 57 | const bounces = fly(g, GRENADE.fuse, wall); |
| 58 | 58 | expect(bounces).toBeGreaterThan(0); |
| 59 | 59 | 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", () => | ||
| 68 | 68 | const eye: Vec3 = [at[0], at[1], 64]; |
| 69 | 69 | // Eight directions from a spawn, hard: none of them may put it outside. |
| 70 | 70 | 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]); | |
| 72 | 72 | fly(g, GRENADE.fuse, DE_FORGE.boxes); |
| 73 | 73 | expect(Number.isFinite(g.pos[0]) && Number.isFinite(g.pos[2])).toBe(true); |
| 74 | 74 | 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", () => { | ||
| 85 | 85 | }); |
| 86 | 86 | |
| 87 | 87 | 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]); | |
| 89 | 89 | let fired = -1; |
| 90 | 90 | for (let i = 0; i < 200; i++) { |
| 91 | 91 | 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 | ||
| 137 | 137 | }); |
| 138 | 138 | |
| 139 | 139 | 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]); | |
| 141 | 141 | // It has not moved yet: the first frame draws it at the hand, not between |
| 142 | 142 | // the hand and the origin. |
| 143 | 143 | expect(g.prev).toEqual(g.pos); |
src/client/grenades.ts +23 −5
| @@ -17,10 +17,27 @@ | ||
| 17 | 17 | import type { Box } from "./map.ts"; |
| 18 | 18 | import { rememberPos } from "./interpolate.ts"; |
| 19 | 19 | 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"); | |
| 21 | 38 | |
| 22 | 39 | export type Grenade = { |
| 23 | kind: "he"; | |
| 40 | kind: GrenadeKind; | |
| 24 | 41 | owner: number; // the player id who threw it; the blast is credited to them |
| 25 | 42 | team: "t" | "ct"; // their side when it left the hand, for friendly fire |
| 26 | 43 | pos: Vec3; |
| @@ -41,7 +58,7 @@ export const GRENADE = { | ||
| 41 | 58 | bounce: 0.45, // the share of the speed into a surface that comes back out |
| 42 | 59 | friction: 0.75, // the share of the speed along it that survives the hit |
| 43 | 60 | 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 | |
| 45 | 62 | throwSpeed: 780, // u/s out of the hand, before the thrower's own velocity |
| 46 | 63 | throwLift: 12, // degrees above where you are looking |
| 47 | 64 | throwFrom: 18, // units in front of the eye it appears |
| @@ -111,6 +128,7 @@ export function step(g: Grenade, dt: number, boxes: Box[]): Step { | ||
| 111 | 128 | * added — running out of a doorway throws it further than standing does. |
| 112 | 129 | */ |
| 113 | 130 | export function thrown( |
| 131 | kind: GrenadeKind, | |
| 114 | 132 | owner: number, |
| 115 | 133 | team: "t" | "ct", |
| 116 | 134 | eye: Vec3, |
| @@ -126,7 +144,7 @@ export function thrown( | ||
| 126 | 144 | eye[2] + dir[2] * GRENADE.throwFrom, |
| 127 | 145 | ]; |
| 128 | 146 | return { |
| 129 | kind: "he", | |
| 147 | kind, | |
| 130 | 148 | owner, |
| 131 | 149 | team, |
| 132 | 150 | pos: [from[0], from[1], from[2]], |
| @@ -138,7 +156,7 @@ export function thrown( | ||
| 138 | 156 | dir[1] * GRENADE.throwSpeed + velocity[1], |
| 139 | 157 | dir[2] * GRENADE.throwSpeed + velocity[2], |
| 140 | 158 | ], |
| 141 | fuse: GRENADE.fuse, | |
| 159 | fuse: FUSE[kind], | |
| 142 | 160 | }; |
| 143 | 161 | } |
| 144 | 162 |
src/client/hearing.ts +4 −1
| @@ -37,7 +37,10 @@ const LOUDNESS: Record<HeardName, number> = { | ||
| 37 | 37 | knife: 0.08, he: 0.1, bomb: 0, |
| 38 | 38 | // The world. A plant and a defuse are deliberate and carry past the site; a |
| 39 | 39 | // 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, | |
| 41 | 44 | step: 0.45, bounce: 0.35, empty: 0.25, |
| 42 | 45 | }; |
| 43 | 46 |
src/client/hud.test.ts +43
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { expect, test } from "bun:test"; |
| 9 | 9 | import { DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts"; |
| 10 | import { blind, FLASH } from "./flash.ts"; | |
| 10 | 11 | import { drawHud } from "./hud.ts"; |
| 11 | 12 | import { SHORT } from "./match.ts"; |
| 12 | 13 | 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 | ||
| 144 | 145 | expect(again.calls.filter((c) => c.name === "createRadialGradient").length).toBe(0); |
| 145 | 146 | }); |
| 146 | 147 | |
| 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 | ||
| 147 | 190 | /** |
| 148 | 191 | * The crosshair's arms as the context was told to draw them: each `moveTo` |
| 149 | 192 | * paired with the `lineTo` that follows it, kept only when it starts at the |
src/client/hud.ts +13
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { crosshairShape, DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts"; |
| 12 | import { flashAlpha } from "./flash.ts"; | |
| 12 | 13 | import { lerpPos } from "./interpolate.ts"; |
| 13 | 14 | import { angleDelta, clamp, rad, type Vec3 } from "./math.ts"; |
| 14 | 15 | import { walkable, type MapDef } from "./map.ts"; |
| @@ -552,6 +553,18 @@ export function drawHud( | ||
| 552 | 553 | ); |
| 553 | 554 | } |
| 554 | 555 | |
| 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 | ||
| 555 | 568 | // --- the damage report: only during the round-over delay, and never under |
| 556 | 569 | // the scoreboard, which a held Tab can ask for at the same moment. |
| 557 | 570 | if (!showScoreboard) drawReport(ctx, world, view, width, height, scale); |
src/client/main.ts +10 −5
| @@ -406,8 +406,12 @@ function boot(): void { | ||
| 406 | 406 | button.className = "buy-item"; |
| 407 | 407 | button.id = `buy-item-${row.item}`; |
| 408 | 408 | 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) : ""; | |
| 409 | 413 | 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>`; | |
| 411 | 415 | button.addEventListener("click", () => buyRow(row)); |
| 412 | 416 | buyGrid.appendChild(button); |
| 413 | 417 | }); |
| @@ -435,14 +439,15 @@ function boot(): void { | ||
| 435 | 439 | }); |
| 436 | 440 | } |
| 437 | 441 | |
| 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. | |
| 440 | 445 | buyPanel.addEventListener("keydown", (event) => { |
| 441 | 446 | if (world === null || buyPanel.hidden) return; |
| 442 | const match = /^Digit([1-9])$/.exec(event.code); | |
| 447 | const match = /^Digit([0-9])$/.exec(event.code); | |
| 443 | 448 | if (match === null) return; |
| 444 | 449 | 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]; | |
| 446 | 451 | if (row !== undefined) buyRow(row); |
| 447 | 452 | }); |
| 448 | 453 |
src/client/renderer.ts +4 −1
| @@ -568,9 +568,12 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks = | ||
| 568 | 568 | // Grenades in the air, tumbling as they go — a thrown object you cannot |
| 569 | 569 | // see land is one nobody can play around. |
| 570 | 570 | if (world.grenades.length > 0) { |
| 571 | gl.uniform3f(u.tint, 0.8, 0.95, 0.72); | |
| 572 | 571 | gl.bindVertexArray(grenade.vao); |
| 573 | 572 | 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); | |
| 574 | 577 | // A grenade crosses twelve units in a tick: it is the thing in this |
| 575 | 578 | // world that judders hardest without this line. |
| 576 | 579 | 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 @@ | ||
| 11 | 11 | import { rng } from "./math.ts"; |
| 12 | 12 | |
| 13 | 13 | 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"; | |
| 15 | 15 | |
| 16 | 16 | export type Weapon = { |
| 17 | 17 | id: WeaponId; |
| @@ -49,6 +49,10 @@ const WEAPON_LIST: Weapon[] = [ | ||
| 49 | 49 | // last because a weapon's spray pattern is seeded from its place in this |
| 50 | 50 | // list, and every gun above it already has the pattern people have learned. |
| 51 | 51 | { 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 }, | |
| 52 | 56 | // Never bought, never held — this exists so the kill feed and the blast |
| 53 | 57 | // that reads `damage`/`armorPenetration` have one row to point at, the same |
| 54 | 58 | // way every gun does. `buyable` and `canBuyItem` refuse it by kind. |
| @@ -88,6 +92,7 @@ const PENETRATION: Record<WeaponId, Penetration> = { | ||
| 88 | 92 | glock: { units: 48, survives: 0.42 }, |
| 89 | 93 | knife: SOLID, |
| 90 | 94 | he: SOLID, |
| 95 | flash: SOLID, | |
| 91 | 96 | bomb: SOLID, |
| 92 | 97 | }; |
| 93 | 98 |
src/client/world.test.ts +115
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { blindness, FLASH } from "./flash.ts"; | |
| 2 | 3 | import { GRENADE } from "./grenades.ts"; |
| 3 | 4 | import { teleported } from "./interpolate.ts"; |
| 4 | 5 | import { dist } from "./math.ts"; |
| @@ -850,6 +851,120 @@ test("a grenade thrown is gone at the next round; one kept is still yours", () = | ||
| 850 | 851 | expect(world.me.weapon).not.toBe("he"); // a round starts with the gun up |
| 851 | 852 | }); |
| 852 | 853 | |
| 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 | ||
| 853 | 968 | test("a player killed holding a grenade leaves their gun, never the grenade", () => { |
| 854 | 969 | const world = createWorld(SHORT, "me", "ct", 5, 251); |
| 855 | 970 | const shooter = world.players.find((p) => p.team === "t") as Player; |
src/client/world.ts +60 −15
| @@ -10,8 +10,9 @@ | ||
| 10 | 10 | import { addBody, type Corpse } from "./bodies.ts"; |
| 11 | 11 | import { applyHit, traceBullet, type Actor } from "./combat.ts"; |
| 12 | 12 | import { stepBeep } from "./cues.ts"; |
| 13 | import { blind, blindSeconds, FLASH } from "./flash.ts"; | |
| 13 | 14 | import { |
| 14 | blastDamage, GRENADE, step as stepGrenade, thrown, type Grenade, | |
| 15 | blastDamage, GRENADE, kindOf, step as stepGrenade, thrown, type Grenade, | |
| 15 | 16 | } from "./grenades.ts"; |
| 16 | 17 | import { pose, remember, rememberPos, type Pose } from "./interpolate.ts"; |
| 17 | 18 | import { DE_FORGE, siteAt, type MapDef } from "./map.ts"; |
| @@ -52,6 +53,12 @@ export type Player = Actor & Body & { | ||
| 52 | 53 | grenades: Partial<Record<WeaponId, number>>; |
| 53 | 54 | defuseKit: boolean; |
| 54 | 55 | 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; | |
| 55 | 62 | nextShotAt: number; |
| 56 | 63 | reloadUntil: number; |
| 57 | 64 | shotIndex: number; |
| @@ -98,7 +105,8 @@ export type Pickup = { | ||
| 98 | 105 | }; |
| 99 | 106 | |
| 100 | 107 | /** 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"; | |
| 102 | 110 | |
| 103 | 111 | export type Effect = |
| 104 | 112 | | { kind: "tracer"; from: Vec3; to: Vec3; at: number } |
| @@ -239,7 +247,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player | ||
| 239 | 247 | weapon: secondary, primary: null, secondary, |
| 240 | 248 | ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) }, |
| 241 | 249 | grenades: {}, |
| 242 | defuseKit: false, hasBomb: false, | |
| 250 | defuseKit: false, hasBomb: false, blindUntil: 0, | |
| 243 | 251 | nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false, firedThisPress: false, emptyThisPress: false, |
| 244 | 252 | punch: [0, 0], nextPickupAt: 0, |
| 245 | 253 | kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0, stepDistance: 0, prevStep: 0, |
| @@ -353,6 +361,9 @@ export function startRound(world: World): void { | ||
| 353 | 361 | p.prevStep = 0; |
| 354 | 362 | p.spawnAt = world.time; |
| 355 | 363 | 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; | |
| 356 | 367 | p.yaw = p.team === "t" ? 90 : -90; |
| 357 | 368 | p.pitch = 0; |
| 358 | 369 | // A spawn is a teleport, so where they were last tick is where they are |
| @@ -847,6 +858,23 @@ const THROW_COOLDOWN = 0.6; | ||
| 847 | 858 | const BURST: Vec3[] = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1]]; |
| 848 | 859 | const BURST_SPREAD = 70; |
| 849 | 860 | |
| 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 | ||
| 850 | 878 | /** |
| 851 | 879 | * Throw the grenade in `p`'s hand. It leaves the eye, forward and a little up, |
| 852 | 880 | * 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 { | ||
| 857 | 885 | if (!takeGrenade(p, id)) return false; |
| 858 | 886 | p.nextShotAt = world.time + THROW_COOLDOWN; |
| 859 | 887 | 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)); | |
| 861 | 889 | if (grenadeCount(p, id) === 0) p.weapon = p.primary ?? p.secondary; |
| 862 | 890 | return true; |
| 863 | 891 | } |
| 864 | 892 | |
| 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 | ||
| 865 | 916 | /** |
| 866 | 917 | * A blast: everyone it can see, from full damage at the centre down to nothing |
| 867 | 918 | * 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 { | ||
| 870 | 921 | * bullet, though the thrower is very much in range of their own grenade. |
| 871 | 922 | */ |
| 872 | 923 | 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; | |
| 884 | 927 | } |
| 928 | world.effects.push({ kind: "sound", at: world.time, pos: [...g.pos] as Vec3, name: "blast", by: g.owner }); | |
| 929 | sparks(world, g.pos); | |
| 885 | 930 | const thrower = world.players.find((p) => p.id === g.owner) ?? null; |
| 886 | 931 | for (const victim of world.players) { |
| 887 | 932 | if (!victim.alive) continue; |
src/readme.test.ts +11
| @@ -9,6 +9,7 @@ import { expect, test } from "bun:test"; | ||
| 9 | 9 | import { readFileSync } from "node:fs"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { DEFAULT_MAP, MAP_NAMES } from "./client/map.ts"; |
| 12 | import { WEAPONS } from "./client/weapons.ts"; | |
| 12 | 13 | |
| 13 | 14 | const root = join(import.meta.dir, ".."); |
| 14 | 15 | 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 | ||
| 90 | 91 | for (const name of names) expect(playing).toContain(name); |
| 91 | 92 | }); |
| 92 | 93 | |
| 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 | ||
| 93 | 104 | test("the readme names every map in the catalogue, and no map the catalogue does not hold", () => { |
| 94 | 105 | const playing = section("Playing"); |
| 95 | 106 | expect(playing).toContain("**There are two maps**"); |