Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

The second grenade is a flash: it blinds what can see it, and it proves one utility was not a special case #172

Merged opened this

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

Merged

torque/cs-engine:torque/164-the-second-grenade-is-a-flash-it-blinds → main 2 commits fork

  • Merged. torque/164-the-second-grenade-is-a-flash-it-blinds was merged into main as bfa2c38.

Review

Sign in to review this change.
26 files changed +975 −228
README.md +4 −1
@@ -31,6 +31,8 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
3131 - **Real.** The game: WebGL2 written by hand, a fixed 64 Hz simulation, Source-style
3232 movement (ground friction, air acceleration, an 18-unit step, strafe jumping),
3333 CS damage with range falloff, armour and hitgroups, deterministic spray patterns,
34 two thrown grenades — an HE, and a flashbang that blinds by how much of it you
35 could see (the line, the angle and the distance, for at most 1.6 seconds) —
3436 a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and
3537 loss bonuses. The leaderboard is a real SQLite table under `FORGE_APP_DATA`.
3638 - **Two kinds of map.** `de_forge` is drawn by hand, cell by cell; the other
@@ -103,7 +105,8 @@ whatever the name field said, and a result posted without one is a **guest**.
103105
104106 <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch,
105107 <kbd>Shift</kbd> walk, mouse aims and fires, <kbd>R</kbd> reload,
106 <kbd>G</kbd> throws the HE grenade in your pocket, if you bought one,
108 <kbd>G</kbd> throws the HE grenade in your pocket and <kbd>T</kbd> the
109 flashbang, if you bought them,
107110 <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze
108111 time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns, <kbd>Tab</kbd> shows
109112 the scoreboard, <kbd>F</kbd> puts the stage on the whole screen and
scripts/build.test.ts +1 −1
@@ -37,7 +37,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri
3737
3838 test("the entry table is what the app ships today, each with the size it may not pass", () => {
3939 expect(ENTRIES).toEqual([
40 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 88_000, gzip: 40_500 } },
40 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 92_000, gzip: 42_000 } },
4141 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } },
4242 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 10_800, gzip: 5_100 } },
4343 ]);
src/client/action.ts +3 −2
@@ -65,8 +65,9 @@ export function toInput(intent: Intent, reload: boolean, switchTo: WeaponId | nu
6565 reload,
6666 use: false,
6767 scope: false,
68 // A bot does not throw grenades yet — the issue after this one teaches it.
69 throwGrenade: false,
68 // A bot throws nothing: it buys neither grenade, and `null` here is the
69 // field's own "neither", which `applyInput` reads as no press at all.
70 throwing: null,
7071 switchTo,
7172 };
7273 }
src/client/audio.ts +17 −1
@@ -11,7 +11,12 @@ import { WEAPONS, type WeaponId } from "./weapons.ts";
1111
1212 export type Audio = {
1313 shot(weapon: WeaponId, at: Vec3, listener: Vec3): void;
14 /** The three noises one grenade makes: leaving the hand, landing, going off. */
14 /**
15 * The noises a grenade makes: leaving the hand, landing, and going off as
16 * whichever kind it was — `blast` for the HE, `flash` for the flashbang. One
17 * path widened by a name rather than a second voice, because the first two
18 * are the same noise whatever is in the shell.
19 */
1520 grenade(sound: GrenadeSound, at: Vec3, listener: Vec3): void;
1621 hit(headshot: boolean): void;
1722 beep(pitch: number, gain?: number): void;
@@ -89,6 +94,17 @@ export function createAudio(device: SoundDevice = createSoundDevice()): Audio {
8994 device.tone({ frequency: 260, slideTo: 150, gain: 0.16 * volume, length: 0.08 });
9095 return;
9196 }
97 if (sound === "flash") {
98 // The flash: the opposite noise to the blast in every way. A bright,
99 // tight crack with no body under it and a ring above it that climbs
100 // rather than falls — what a very loud, very short bang leaves in your
101 // ears. It carries as far as the blast does, and it matters over a
102 // wider circle than one, because so does the light.
103 const bright = attenuate(far, BLAST_EARSHOT, BLAST_CLOSE);
104 device.noiseBurst({ frequency: 5200, gain: 0.55 * bright, length: 0.28, q: 1.2, rate: 1.5 });
105 device.tone({ frequency: 2600, slideTo: 4200, gain: 0.3 * bright, length: 0.5 });
106 return;
107 }
92108 // The blast: a wide, low body of noise with a thump under it that falls
93109 // an octave and a half as it goes.
94110 const volume = attenuate(far, BLAST_EARSHOT, BLAST_CLOSE);
src/client/binding.test.ts +2 −1
@@ -18,12 +18,13 @@ test("the bindings are the ones the keyboard has always had", () => {
1818 expect(CS_KEYS.KeyE).toBe("use");
1919 expect(CS_KEYS.KeyB).toBe("buy");
2020 expect(CS_KEYS.KeyG).toBe("grenade");
21 expect(CS_KEYS.KeyT).toBe("flash");
2122 expect(CS_KEYS.Tab).toBe("scoreboard");
2223 expect(CS_KEYS.Digit1).toBe("slot1");
2324 expect(CS_KEYS.Digit3).toBe("slot3");
2425 expect(CS_MOUSE[0]).toBe("fire");
2526 expect(CS_MOUSE[2]).toBe("scope");
26 expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade"]);
27 expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade", "flash"]);
2728 // F is the page's, not the world's: `main.ts` hears it on `onAction` and asks
2829 // the browser for the screen, and nothing about an `Input` changes for it.
2930 expect(CS_KEYS.KeyF).toBe("fullscreen");
src/client/binding.ts +5 −3
@@ -13,7 +13,7 @@ export const CS_KEYS: Record<string, string> = {
1313 KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right",
1414 ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right",
1515 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
16 KeyR: "reload", KeyE: "use", KeyG: "grenade", KeyB: "buy", Tab: "scoreboard",
16 KeyR: "reload", KeyE: "use", KeyG: "grenade", KeyT: "flash", KeyB: "buy", Tab: "scoreboard",
1717 Digit1: "slot1", Digit2: "slot2", Digit3: "slot3",
1818 // Not a thing the world knows about: `main.ts` hears it on `onAction` and
1919 // asks the browser for the screen, which `readIntent` below never sees.
@@ -24,7 +24,7 @@ export const CS_KEYS: Record<string, string> = {
2424 export const CS_MOUSE: Record<number, string> = { 0: "fire", 2: "scope" };
2525
2626 /** On-screen buttons that are a tap, not a hold. */
27 export const CS_TAP = ["jump", "reload", "buy", "grenade"] as const;
27 export const CS_TAP = ["jump", "reload", "buy", "grenade", "flash"] as const;
2828
2929 export type Intent = {
3030 input: Input;
@@ -51,7 +51,9 @@ export function readIntent(held: ReadonlySet<string>, stick: { x: number; y: num
5151 use: down("use"),
5252 fire: down("fire"),
5353 scope: down("scope"),
54 throwGrenade: down("grenade"),
54 // Two pockets, two keys. Both held is the flash, because the one you just
55 // reached for is the one you meant; releasing it throws the HE still held.
56 throwing: down("flash") ? "flash" : down("grenade") ? "he" : null,
5557 switchTo: null,
5658 };
5759 return {
src/client/bots.test.ts +63 −1
@@ -1,5 +1,7 @@
11 import { expect, test } from "bun:test";
2 import { applyIntent, botThink, CARD, makeBrain, policy, think } from "./bots.ts";
2 import { applyIntent, blindness, botThink, CARD, flashed, makeBrain, policy, think } from "./bots.ts";
3 import { OBS_SIZE } from "./observe.ts";
4 import { FLASH } from "./weapons.ts";
35 import { DE_FORGE } from "./map.ts";
46 import { COMPETITIVE } from "./match.ts";
57 import { aimAt, bodyCentre, createWorld, type Player } from "./world.ts";
@@ -89,3 +91,63 @@ test("the card describes a real training run", () => {
8991 expect(CARD.winRate).toBeLessThanOrEqual(100);
9092 expect(CARD.trainedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
9193 });
94
95 test("a flashed bot keeps its policy and loses its hands: slower, wilder, later, blinder", () => {
96 const { world, me, bot } = duel();
97 const brain = bot.brain!;
98 const clear = flashed(bot, brain);
99 // Unflashed it is exactly the skill it always was — the same numbers, to the
100 // bit, which is why every pinned run in this repository is untouched by this.
101 expect(blindness(bot)).toBe(0);
102 expect(clear.aimSpeed).toBe(brain.aimSpeed);
103 expect(clear.aimError).toBe(brain.aimError);
104 expect(clear.reaction).toBe(brain.reaction);
105
106 bot.blind = FLASH.flash.maxBlind;
107 const hit = flashed(bot, brain);
108 expect(blindness(bot)).toBe(1);
109 expect(hit.aimSpeed).toBeCloseTo(brain.aimSpeed * 0.2, 6);
110 expect(hit.aimError).toBeCloseTo(brain.aimError * 7, 6);
111 expect(hit.reaction).toBeCloseTo(brain.reaction * 4, 6);
112 expect(hit.sight).toBeLessThan(clear.sight);
113 // Half way through the flash it is half of each: the number in the world is
114 // the only thing that decides, and it is the same one the HUD paints.
115 bot.blind = FLASH.flash.maxBlind / 2;
116 expect(flashed(bot, brain).aimSpeed).toBeCloseTo(brain.aimSpeed * 0.6, 6);
117
118 // Its head is untouched: the observation, the heads and the weights know
119 // nothing about any of this.
120 expect(OBS_SIZE).toBe(32);
121 expect(brain.obs.length).toBe(OBS_SIZE);
122 });
123
124 test("a flashed bot turns slower and shoots later than the same bot unflashed", () => {
125 const turn = (blind: number): number => {
126 const { world, me, bot } = duel();
127 bot.blind = blind;
128 bot.yaw = 90; // ninety degrees off the target
129 let fired = 0;
130 for (let t = 0; t < 64; t++) {
131 const input = botThink(world, bot, 1 / 64);
132 world.time += 1 / 64;
133 if (input.fire) fired += 1;
134 }
135 return Math.abs(bot.yaw - 90) - fired * 1e-6;
136 };
137 // A second of the same duel, from the same seed, with and without a flash on
138 // it: the blinded one has come nowhere near as far round towards the shot.
139 expect(turn(FLASH.flash.maxBlind)).toBeLessThan(turn(0));
140
141 // And a bot blinded at the far end of the map does not pick a fight it cannot
142 // see: its sight shrinks with the blindness.
143 const blinded = duel();
144 blinded.bot.blind = FLASH.flash.maxBlind;
145 botThink(blinded.world, blinded.bot, 1 / 64);
146 expect(flashed(blinded.bot, blinded.bot.brain!).sight).toBeLessThan(500);
147 expect(blinded.bot.brain!.targetId).toBe(null);
148 // The same bot, the same room, the same enemy five hundred units away, with
149 // its eyes working: it picks the fight it just could not see.
150 const seeing = duel();
151 botThink(seeing.world, seeing.bot, 1 / 64);
152 expect(seeing.bot.brain!.targetId).toBe(seeing.me.id);
153 });
src/client/bots.ts +49 −9
@@ -19,7 +19,7 @@ import {
1919 import { colOf, findPath, rowOf, siteAt, walkable, type Level } from "./level.ts";
2020 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
2121 import { CARD, PACKED } from "./weights.ts";
22 import { WEAPONS, type WeaponId } from "./weapons.ts";
22 import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts";
2323 import type { Input, Player, World } from "./world.ts";
2424 import { aimAt, bodyCentre, buy, canSee, NO_INPUT } from "./world.ts";
2525
@@ -93,6 +93,41 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
9393 };
9494 }
9595
96 // --- blinded --------------------------------------------------------------
97
98 /**
99 * How badly this bot is flashed, 0 to 1: the seconds of blindness it has left
100 * over the ceiling a flash can hand out. A bot does not see a white screen —
101 * there is no screen — so what a flash costs it is written here, in the same
102 * skill layer its reaction and its shaky hands already live in.
103 *
104 * Not flashed, this is exactly 0 and every number below is multiplied by
105 * exactly 1, which is why an unflashed bot plays the same game to the bit and
106 * the training arena's digest does not move for any of this.
107 */
108 export const blindness = (bot: Player): number => clamp(bot.blind / FLASH.flash.maxBlind, 0, 1);
109
110 /**
111 * What a flash leaves a bot with. Fully blind it turns at a fifth of its usual
112 * speed, its hands wander seven times as far off centre, it takes four times as
113 * long to decide to shoot at all, and it only notices an enemy inside a
114 * twelfth of its usual sight — which is roughly what a person flashed in the
115 * face does, and is the whole of the disadvantage. Its policy is untouched:
116 * `OBS_SIZE`, the heads and the shipped weights know nothing about this.
117 */
118 export function flashed(bot: Player, brain: BotBrain): { aimSpeed: number; aimError: number; reaction: number; sight: number } {
119 const b = blindness(bot);
120 return {
121 aimSpeed: brain.aimSpeed * (1 - 0.8 * b),
122 aimError: brain.aimError * (1 + 6 * b),
123 reaction: brain.reaction * (1 + 3 * b),
124 sight: SIGHT * (1 - 0.92 * b),
125 };
126 }
127
128 /** How far a bot notices an enemy at all, in units, with its eyes working. */
129 const SIGHT = 4000;
130
96131 /** What a bot spends its money on, roughly in the order a person would. */
97132 export function botBuy(world: World, bot: Player): void {
98133 const brain = brainOf(world, bot);
@@ -145,10 +180,13 @@ const nearby = (level: Level, brain: BotBrain, at: Vec3, spread: number): Vec3 =
145180 function pickTarget(world: World, bot: Player, brain: BotBrain): Player | null {
146181 let best: Player | null = null;
147182 let bestScore = Infinity;
183 // Flashed, a bot picks its fights out of a much smaller room: this is the
184 // "choice" half of what a flash costs it, and the aim below is the other.
185 const sight = flashed(bot, brain).sight;
148186 for (const other of world.players) {
149187 if (!other.alive || other.team === bot.team) continue;
150188 const d = dist(bot.pos, other.pos);
151 if (d > 4000) continue;
189 if (d > sight) continue;
152190 if (!canSee(world, bot, other)) continue;
153191 const aim = aimAt(bot, bodyCentre(other));
154192 const off = Math.abs(angleDelta(bot.yaw, aim.yaw));
@@ -179,12 +217,13 @@ function listen(world: World, bot: Player, brain: BotBrain): void {
179217 export function scriptedFight(
180218 world: World, bot: Player, brain: BotBrain, target: Player, input: Input, dt: number,
181219 ): Input {
220 const hands = flashed(bot, brain);
182221 const spraying = bot.shotIndex > 0 && world.time - bot.lastShotAt < 0.3;
183 const wobble = brain.aimError * (spraying ? 1.6 : 1) * (brain.random() * 2 - 1) * 0.15;
222 const wobble = hands.aimError * (spraying ? 1.6 : 1) * (brain.random() * 2 - 1) * 0.15;
184223 const aim = aimAt(bot, bodyCentre(target));
185 turnTowards(bot, { yaw: aim.yaw + wobble, pitch: aim.pitch + wobble * 0.4 }, brain.aimSpeed, dt);
186 const ready = world.time - brain.seenAt > brain.reaction;
187 const onTarget = Math.abs(angleDelta(bot.yaw, aim.yaw)) < 3 + brain.aimError;
224 turnTowards(bot, { yaw: aim.yaw + wobble, pitch: aim.pitch + wobble * 0.4 }, hands.aimSpeed, dt);
225 const ready = world.time - brain.seenAt > hands.reaction;
226 const onTarget = Math.abs(angleDelta(bot.yaw, aim.yaw)) < 3 + hands.aimError;
188227 const ammo = bot.ammo[bot.weapon];
189228 if (ammo !== undefined && ammo.mag === 0) input.reload = true;
190229 if (ready && onTarget && world.time > brain.restUntil) {
@@ -236,11 +275,12 @@ export function think(world: World, bot: Player, brain: BotBrain, target: Player
236275 * is a bot you can beat; the same policy with steady hands is not.
237276 */
238277 export function applyIntent(world: World, bot: Player, brain: BotBrain, intent: Intent, dt: number): void {
278 const hands = flashed(bot, brain);
239279 if (world.time > brain.jitterUntil) {
240 brain.jitter = (brain.random() * 2 - 1) * brain.aimError;
280 brain.jitter = (brain.random() * 2 - 1) * hands.aimError;
241281 brain.jitterUntil = world.time + 0.25 + brain.random() * 0.35;
242282 }
243 const limit = brain.aimSpeed;
283 const limit = hands.aimSpeed;
244284 bot.yaw += clamp(intent.yawRate, -limit, limit) * dt + (brain.jitter - brain.jitterApplied);
245285 bot.pitch = clamp(
246286 bot.pitch + clamp(intent.pitchRate, -limit, limit) * dt + (brain.jitter - brain.jitterApplied) * 0.4,
@@ -303,7 +343,7 @@ export function botThink(world: World, bot: Player, dt: number): Input {
303343 // that is one shot and then nothing, so the trigger is released between
304344 // decisions and each decision is worth one pull.
305345 const held = WEAPONS[bot.weapon]?.automatic !== false || brain.hold === SKIP - 1;
306 input.fire = intent.fire && held && world.time - brain.seenAt > brain.reaction;
346 input.fire = intent.fire && held && world.time - brain.seenAt > flashed(bot, brain).reaction;
307347 input.forward = intent.forward;
308348 input.side = intent.side;
309349 input.walk = intent.walk;
src/client/generate.test.ts +8 −5
@@ -215,12 +215,15 @@ test("a bot walks a map nobody wrote, on that map's own cells", () => {
215215 * paste the new digest in the pull that tuned it and say which seed's map
216216 * changed shape. A digest that moves for any other reason is a bug.
217217 *
218 * Re-pinned for #142 with the other two: the snapshot in `replay.ts` now
219 * carries what is in each pocket and every live grenade, so every digest in
220 * this repository moved with it. The map and the play are untouched — the
221 * assertions under this one still find the same spawns on the same cells.
218 * Re-pinned for #164 with the other two: the snapshot in `replay.ts` now
219 * carries both pockets, how long each body is blinded for and which kind each
220 * live grenade is, so every digest in this repository moved with it. The map
221 * and the play are untouched — the assertions under this one still find the
222 * same spawns on the same cells, and the same ten seconds folded with the old
223 * field list still comes to the digest this line held before,
224 * 48a1d4466065e045b8933b3fbf5ece767a825a60165e6455b4e310a8d7dd5cae.
222225 */
223 const GENERATED_ROUND = "48a1d4466065e045b8933b3fbf5ece767a825a60165e6455b4e310a8d7dd5cae";
226 const GENERATED_ROUND = "d7bd4969d00345feff718f37a6ed13ff6967f8884ca716bc639bff63f085d82e";
224227
225228 test("a seeded round on a generated map plays exactly as it played", () => {
226229 const level = generateLevel(5);
src/client/grenade.test.ts +229 −19
@@ -17,17 +17,20 @@
1717 import { expect, test } from "bun:test";
1818 import { DE_FORGE } from "./map.ts";
1919 import { SHORT } from "./match.ts";
20 import { blastCentre, blastHits, stepGrenade, throwGrenade, THROW_LIFT, THROW_SPEED } from "./grenade.ts";
21 import { NADE_HULL, NADE_MOVE } from "./movement.ts";
22 import { blastDamage, HE, WEAPONS } from "./weapons.ts";
20 import {
21 blastCentre, blastHits, flashHits, MIN_BLIND, stepGrenade, throwGrenade, THROW_LIFT, THROW_SPEED,
22 viewAngle, type Looker,
23 } from "./grenade.ts";
24 import { CS_HULL, NADE_HULL, NADE_MOVE } from "./movement.ts";
25 import { blastDamage, blindSeconds, FLASH, HE, WEAPONS } from "./weapons.ts";
2326 import type { Actor } from "./combat.ts";
2427 import type { Box, Vec3 } from "../engine/index.ts";
2528 import { digest, TICK, withoutEntropy } from "./replay.ts";
2629 import {
27 buy, createWorld, eyePos, MAX_GRENADES, NO_INPUT, throwOne, update,
30 buy, CARRY, createWorld, eyePos, NO_INPUT, throwOne, update,
2831 type Effect, type Input, type Player, type World,
2932 } from "./world.ts";
30 import { effectAt } from "../engine/index.ts";
33 import { effectAt, eyeHeight } from "../engine/index.ts";
3134
3235 /** Did anything the world remembers look like this? The buffer is read by index. */
3336 function heard(world: World, is: (e: Effect) => boolean): boolean {
@@ -62,7 +65,7 @@ test("the grenade is in the buy menu with CS's numbers, and carries a blast", ()
6265 });
6366
6467 test("a grenade thrown level flies where the arithmetic says", () => {
65 const g = throwGrenade(0, "ct", [0, 0, 64], EAST, [FLOOR]);
68 const g = throwGrenade(0, "ct", "he", [0, 0, 64], EAST, [FLOOR]);
6669 // It leaves the hand eighteen units ahead of the eye, at 750 u/s.
6770 expect(g.pos).toEqual([18, 0, 64]);
6871 expect(g.vel).toEqual([THROW_SPEED, 0, 0]);
@@ -80,7 +83,7 @@ test("a grenade thrown level flies where the arithmetic says", () => {
8083 });
8184
8285 test("a grenade thrown at a wall comes back with 0.45 of what it arrived with", () => {
83 const g = throwGrenade(0, "ct", [0, 0, 64], EAST, [FLOOR, EAST_WALL]);
86 const g = throwGrenade(0, "ct", "he", [0, 0, 64], EAST, [FLOOR, EAST_WALL]);
8487 let bounces = 0;
8588 let after = 0;
8689 for (let n = 0; n < 64 && bounces === 0; n++) {
@@ -97,7 +100,7 @@ test("a grenade thrown at a wall comes back with 0.45 of what it arrived with",
97100 });
98101
99102 test("a grenade dropped on the floor bounces, then comes to rest on it", () => {
100 const g = throwGrenade(0, "ct", [0, 0, 200], [0, 0, -1], [FLOOR]);
103 const g = throwGrenade(0, "ct", "he", [0, 0, 200], [0, 0, -1], [FLOOR]);
101104 let bounces = 0;
102105 for (let n = 0; n < 64 * 6; n++) if (stepGrenade(g, TICK, [FLOOR])) bounces += 1;
103106 expect(bounces).toBeGreaterThan(0);
@@ -119,7 +122,7 @@ test("the blast is 98 at the centre, 49 at half the radius, and armour halves it
119122 // The grenade at the feet of a body standing at the origin: the centre of
120123 // the blast is 2 units up and the middle of the chest is 54, so 52 units is
121124 // as close as a standing body gets — 98 · (1 − 52/350) = 83.4.
122 const g = throwGrenade(0, "ct", [0, 0, 0], [0, 0, 0], [FLOOR]);
125 const g = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]);
123126 const at = (x: number, over: Partial<Actor> = {}): { health: number; armor: number } => {
124127 const hits = blastHits(g, [actor([x, 0, 0], over)], []);
125128 return { health: hits[0]?.health ?? 0, armor: hits[0]?.armor ?? 0 };
@@ -145,7 +148,7 @@ test("the blast is 98 at the centre, 49 at half the radius, and armour halves it
145148 });
146149
147150 test("a grenade behind a crate hurts nobody on the far side", () => {
148 const g = throwGrenade(0, "ct", [0, 0, 0], [0, 0, 0], [FLOOR]);
151 const g = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]);
149152 const behind = actor([200, 0, 0]);
150153 // In the open, 200 units is well inside the radius and it hurts.
151154 expect(blastHits(g, [behind], [FLOOR])[0]?.health).toBeGreaterThan(0);
@@ -157,7 +160,7 @@ test("a grenade behind a crate hurts nobody on the far side", () => {
157160 });
158161
159162 test("the blast does not reach the thrower's own side", () => {
160 const g = throwGrenade(0, "ct", [0, 0, 0], [0, 0, 0], [FLOOR]);
163 const g = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]);
161164 expect(blastHits(g, [actor([0, 0, 0], { team: "ct" })], [FLOOR])).toEqual([]);
162165 expect(blastHits(g, [actor([0, 0, 0], { team: "t" })], [FLOOR]).length).toBe(1);
163166 });
@@ -181,7 +184,7 @@ test("a grenade is bought like anything else, one at a time, and goes in a pocke
181184 world.me.money = 5000;
182185 const before = world.me.weapon;
183186 expect(buy(world, world.me, "he")).toBe(true);
184 expect(world.me.grenades).toBe(MAX_GRENADES);
187 expect(world.me.pocket.he).toBe(CARRY.he);
185188 expect(world.me.money).toBe(5000 - HE.price);
186189 // It did not become the gun in your hands and it did not displace one.
187190 expect(world.me.weapon).toBe(before);
@@ -195,17 +198,17 @@ test("the throw key throws one, once a press, and never during the freeze", () =
195198 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 12);
196199 world.me.money = 5000;
197200 buy(world, world.me, "he");
198 const held: Input = { ...NO_INPUT, throwGrenade: true };
201 const held: Input = { ...NO_INPUT, throwing: "he" };
199202 // Frozen: the key does nothing and the grenade stays in the pocket.
200203 update(world, TICK, held);
201204 expect(world.grenades.length).toBe(0);
202 expect(world.me.grenades).toBe(1);
205 expect(world.me.pocket.he).toBe(1);
203206
204207 for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
205208 // Live, and held down for half a second: exactly one leaves the hand.
206209 for (let t = 0; t < 32; t++) update(world, TICK, held);
207210 expect(world.grenades.length).toBe(1);
208 expect(world.me.grenades).toBe(0);
211 expect(world.me.pocket.he).toBe(0);
209212 expect(world.grenades[0]?.owner).toBe(world.me.id);
210213 // And the throw was heard where the hand was.
211214 expect(heard(world, (e) => e.kind === "sound" && e.name === "throw")).toBe(true);
@@ -311,7 +314,7 @@ test("a round with a grenade in it is still bit-for-bit reproducible", () => {
311314 // A script that buys one in the freeze and throws it the moment it is live.
312315 const script = [
313316 { atTick: 0, input: {}, buy: "he" as const },
314 { atTick: 5 * TICKS_PER_SECOND, input: { throwGrenade: true } },
317 { atTick: 5 * TICKS_PER_SECOND, input: { throwing: "he" as const } },
315318 { atTick: 5 * TICKS_PER_SECOND + 8, input: {} },
316319 ];
317320 const run = (): ReturnType<typeof digest> =>
@@ -332,12 +335,219 @@ test("a round with a grenade in it is still bit-for-bit reproducible", () => {
332335
333336 test("an input that never sets the throw plays exactly as it did before", () => {
334337 // The whole of what a bot had to learn for this feature: nothing.
335 expect(NO_INPUT.throwGrenade).toBe(false);
338 expect(NO_INPUT.throwing).toBe(null);
336339 const world = ready(51);
337 world.me.grenades = 1;
340 world.me.pocket.he = 1;
338341 const before = eyePos(world.me);
339342 for (let t = 0; t < 64; t++) update(world, TICK, NO_INPUT);
340343 expect(world.grenades.length).toBe(0);
341 expect(world.me.grenades).toBe(1);
344 expect(world.me.pocket.he).toBe(1);
342345 expect(before.length).toBe(3);
343346 });
347
348 // --- the flash ------------------------------------------------------------
349
350 /** A body at `pos` looking along `yaw`/`pitch`: what a flash needs to see. */
351 const looker = (pos: Vec3, over: Partial<Looker> = {}): Looker => ({
352 ...actor(pos), pitch: 0, ...over,
353 });
354
355 /** The height a standing body's eye is at: what a flash is measured to. */
356 const EYE = eyeHeight({ duckAmount: 0 }, CS_HULL);
357
358 /**
359 * A flashbang lying still with its centre at `where`, out of nobody's hand.
360 * The tests below put it at eye height so the distances are the round numbers
361 * they are written as rather than a hypotenuse nobody can check by hand.
362 */
363 const lit = (where: Vec3): ReturnType<typeof throwGrenade> =>
364 throwGrenade(0, "ct", "flash", [where[0], where[1], where[2] - NADE_HULL.stand / 2], [0, 0, 0], [FLOOR]);
365
366 test("a flashbang is a grenade like the other one: same flight, its own fuse", () => {
367 const he = throwGrenade(0, "ct", "he", [0, 0, 64], EAST, [FLOOR]);
368 const flash = throwGrenade(0, "ct", "flash", [0, 0, 64], EAST, [FLOOR]);
369 expect(flash.weapon).toBe("flash");
370 expect(flash.fuse).toBe(FLASH.flash.fuse);
371 expect(he.fuse).toBe(HE.blast.fuse);
372 // The same body through the same solver: sixteen ticks apart they are still
373 // in exactly the same place, because nothing about the flight is the kind's.
374 for (let n = 0; n < 16; n++) {
375 stepGrenade(he, TICK, [FLOOR]);
376 stepGrenade(flash, TICK, [FLOOR]);
377 }
378 expect(flash.pos).toEqual(he.pos);
379 expect(flash.vel).toEqual(he.vel);
380 // And it does no damage to anybody, however close they stand.
381 expect(blastHits(flash, [actor([0, 0, 0])], [FLOOR])).toEqual([]);
382 // While the HE blinds nobody: the two ends are the only thing that differs.
383 expect(flashHits(he, [looker([0, 0, 0])], [FLOOR])).toEqual([]);
384 });
385
386 test("a flash blinds by how much of it you could see, and not at all with no line", () => {
387 const g = lit([0, 0, EYE]);
388 const seconds = (l: Looker, boxes: Box[] = [FLOOR]): number =>
389 flashHits(g, [l], boxes)[0]?.seconds ?? 0;
390 const max = FLASH.flash.maxBlind;
391
392 // Standing 300 units east, looking west straight at it: 1.6 · 1 · (1 − 300/1200).
393 const staring = looker([300, 0, 0], { yaw: 180 });
394 expect(seconds(staring)).toBeCloseTo(max * 0.75, 6);
395
396 // Looking away works: the same body turned round sees none of it.
397 expect(seconds(looker([300, 0, 0], { yaw: 0 }))).toBe(0);
398 // And half turned away — 90° off, which is 45/105 of the way from 30 to 135.
399 expect(seconds(looker([300, 0, 0], { yaw: 90 }))).toBeCloseTo(max * (45 / 105) * 0.75, 6);
400
401 // A wall works: `traceWorld` decides whether there was a line at all, and a
402 // crate between the two takes every bit of it however squarely they looked.
403 const crate: Box = { min: [100, -80, 0], max: [140, 80, 120], mat: 0 };
404 expect(seconds(staring, [FLOOR, crate])).toBe(0);
405 // The man beside the crate, with nothing in the way, is blinded as before.
406 expect(seconds(looker([300, 300, 0], { yaw: 225 }), [FLOOR, crate])).toBeGreaterThan(0);
407
408 // Being behind it works: a flash at your back, with your view down the far
409 // way, is over your shoulder and costs nothing however close it went off.
410 expect(seconds(looker([-300, 0, 0], { yaw: 180 }))).toBe(0);
411 expect(seconds(looker([-20, 0, 0], { yaw: 180 }))).toBe(0);
412
413 // Being behind *someone* is not cover: a body is not a wall, and the trace
414 // is against the world's boxes exactly like the blast's is.
415 const shield = looker([150, 0, 0], { yaw: 180 });
416 expect(flashHits(g, [shield, staring], [FLOOR]).length).toBe(2);
417
418 // Past the radius it is a light in another room.
419 expect(seconds(looker([1300, 0, 0], { yaw: 180 }))).toBe(0);
420 // And a flicker is not a blinding: anything under MIN_BLIND is no entry.
421 expect(blindSeconds(FLASH.flash, 0, 1150)).toBeLessThan(MIN_BLIND);
422 expect(flashHits(g, [looker([1150, 0, 0], { yaw: 180 })], [FLOOR])).toEqual([]);
423 });
424
425 test("a flash does not care whose side you are on, and the dead keep their sight", () => {
426 const g = lit([0, 0, EYE]);
427 const mine = looker([200, 0, 0], { yaw: 180, team: "ct" }); // the thrower's own side
428 const theirs = looker([200, 0, 0], { yaw: 180, team: "t" });
429 const dead = looker([200, 0, 0], { yaw: 180, team: "t", alive: false });
430 expect(flashHits(g, [mine], [FLOOR]).length).toBe(1);
431 expect(flashHits(g, [theirs], [FLOOR]).length).toBe(1);
432 expect(flashHits(g, [dead], [FLOOR])).toEqual([]);
433 // The same two under the HE: it spares the thrower's team and hurts the other.
434 const he = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]);
435 expect(blastHits(he, [mine], [FLOOR])).toEqual([]);
436 expect(blastHits(he, [theirs], [FLOOR]).length).toBe(1);
437 });
438
439 test("the angle a flash is judged at is the whole view, not the yaw", () => {
440 const overhead: Vec3 = [0, 0, EYE + 200];
441 const g = lit(overhead);
442 const below = looker([0, 0, 0]);
443 // It went off straight overhead: looking up is looking at it, and looking
444 // level is ninety degrees off it whichever way the body is turned.
445 expect(viewAngle({ ...below, pitch: -90 }, overhead)).toBeCloseTo(0, 6);
446 expect(viewAngle({ ...below, pitch: 0 }, overhead)).toBeCloseTo(90, 6);
447 expect(viewAngle({ ...below, pitch: 0, yaw: 90 }, overhead)).toBeCloseTo(90, 6);
448 const up = flashHits(g, [{ ...below, pitch: -90 }], [FLOOR])[0]?.seconds ?? 0;
449 const level = flashHits(g, [{ ...below, pitch: 0 }], [FLOOR])[0]?.seconds ?? 0;
450 expect(up).toBeGreaterThan(level);
451 expect(up).toBeCloseTo(FLASH.flash.maxBlind * (1 - 200 / FLASH.flash.radius), 6);
452 });
453
454 test("a flash is bought alongside the HE, one of each, and thrown on its own key", () => {
455 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 13);
456 world.me.money = 5000;
457 expect(buy(world, world.me, "he")).toBe(true);
458 expect(buy(world, world.me, "flash")).toBe(true);
459 // Carried alongside, not instead of: both are in the pocket at once.
460 expect(world.me.pocket).toEqual({ he: CARRY.he, flash: CARRY.flash });
461 expect(world.me.money).toBe(5000 - HE.price - FLASH.price);
462 // One of each a round: the second of either is refused and costs nothing.
463 expect(buy(world, world.me, "flash")).toBe(false);
464 expect(buy(world, world.me, "he")).toBe(false);
465 expect(world.me.money).toBe(5000 - HE.price - FLASH.price);
466 // And neither went into your hands.
467 expect(world.me.weapon).toBe(world.me.secondary);
468
469 for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
470 // The flash key throws the flash and leaves the HE where it was.
471 for (let t = 0; t < 32; t++) update(world, TICK, { ...NO_INPUT, throwing: "flash" });
472 expect(world.grenades.length).toBe(1);
473 expect(world.grenades[0]?.weapon).toBe("flash");
474 expect(world.me.pocket).toEqual({ he: 1, flash: 0 });
475 });
476
477 /** Everyone but `p` put out of the round, so only the flash can change anything. */
478 const alone = (world: World, p: Player): void => {
479 for (const other of world.players) if (other !== p) other.alive = false;
480 };
481
482 test("a flash that goes off blinds everyone looking at it, and the clock burns it down", () => {
483 // One a side, and both bodies held still, alive and whole for the second and
484 // a half the fuse burns: what is under test is who the flash reaches.
485 const world = ready(37, 1);
486 const me = world.me;
487 const victim = world.players.find((p) => p.team === "t" && p.alive) as Player;
488 // Standing next to each other in the open, looking at one another, with the
489 // flash between them: what is under test is the blinding, not the throw.
490 victim.pos = [me.pos[0] + 64, me.pos[1], me.pos[2]];
491 victim.yaw = 180;
492 me.yaw = 0;
493 const where: Vec3 = [me.pos[0] + 32, me.pos[1], me.pos[2] + EYE];
494 me.pocket.flash = 1;
495 expect(throwOne(world, me, "flash")).toBe(true);
496 for (let t = 0; t < 3 * TICKS_PER_SECOND && world.grenades.length > 0; t++) {
497 for (const g of world.grenades) {
498 g.pos = [where[0], where[1], where[2]];
499 g.vel = [0, 0, 0];
500 }
501 victim.pos = [me.pos[0] + 64, me.pos[1], me.pos[2]];
502 victim.yaw = 180;
503 victim.health = 100;
504 me.health = 100;
505 me.yaw = 0;
506 update(world, TICK, NO_INPUT);
507 }
508 // Both of them were looking at it, so both are blinded: light is not on a side.
509 expect(victim.blind).toBeGreaterThan(0);
510 expect(me.blind).toBeGreaterThan(0);
511 expect(me.blind).toBeLessThanOrEqual(FLASH.flash.maxBlind);
512 // It was heard as a flash, not as a blast, and it drew its own pop.
513 expect(heard(world, (e) => e.kind === "sound" && e.name === "flash")).toBe(true);
514 expect(heard(world, (e) => e.kind === "sound" && e.name === "blast")).toBe(false);
515 expect(heard(world, (e) => e.kind === "blast" && e.weapon === "flash")).toBe(true);
516 // Nobody was hurt and nothing went in the feed: a flash is a chance, not a kill.
517 expect(victim.health).toBe(100);
518 expect(world.feed.length).toBe(0);
519
520 // And it burns down on the simulation's clock, a tick at a time, to nothing.
521 const left = me.blind;
522 update(world, TICK, NO_INPUT);
523 expect(me.blind).toBeCloseTo(left - TICK, 10);
524 for (let t = 0; t < 3 * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
525 expect(me.blind).toBe(0);
526 expect(world.match.phase === "live" || world.match.phase === "over").toBe(true);
527 });
528
529 test("two flashes in the face are not twice the blindness: neither passes the ceiling", () => {
530 const world = ready(39, 1);
531 const me = world.me;
532 alone(world, me);
533 me.yaw = 0; // looking straight at where each of them goes off, forty units out
534 const max = FLASH.flash.maxBlind;
535 /** Go off at the eye, and step until it is gone. */
536 const pop = (): void => {
537 const g = throwGrenade(
538 me.id, me.team, "flash", [me.pos[0] + 40, me.pos[1], me.pos[2] + EYE - NADE_HULL.stand / 2], [0, 0, 0], [],
539 );
540 g.fuse = TICK;
541 world.grenades.push(g);
542 while (world.grenades.length > 0) update(world, TICK, NO_INPUT);
543 };
544 pop();
545 const first = me.blind;
546 expect(first).toBeGreaterThan(max * 0.9);
547 expect(first).toBeLessThanOrEqual(max);
548 // A second one on top of the first: the longer of the two, never the sum.
549 pop();
550 expect(me.blind).toBeLessThanOrEqual(max);
551 expect(me.blind).toBeGreaterThan(max * 0.9);
552 expect(me.blind).toBeLessThan(first * 2);
553 });
src/client/grenade.ts +100 −18
@@ -1,5 +1,10 @@
11 /**
2 * The thrown grenade: a body, a fuse and a blast.
2 * The thrown grenade: a body, a fuse, and whatever it does when the fuse runs
3 * out. Two kinds are thrown today — the HE and the flashbang — and the only
4 * thing in this file that knows which is `Grenade.weapon`: the flight, the
5 * bounce, the fuse and the sounds are one piece of machinery both use, and what
6 * arrives at the far end is `blastHits` for one and `flashHits` for the other.
7 * A third kind is a row in `weapons.ts` and a function here.
38 *
49 * There is no solver in this file. A grenade in flight is a `Body` like a
510 * player is, and it is moved by the same `move` in `src/engine/physics.ts` —
@@ -12,20 +17,25 @@
1217 * 64 Hz `world.ts` steps at — so it is the same number of ticks on a phone as
1318 * on a desktop, and the same number in a replay as in a match.
1419 *
15 * The blast reaches what it can see and nothing else. `blastHits` traces from
16 * the middle of the explosion to the middle of each chest through the engine's
17 * own `visible`, so a wall, a crate or a corner takes all of it: a grenade
18 * cannot kill through a wall here, which is the property the test of that name
19 * holds.
20 * Neither kind reaches anything it cannot see. `blastHits` and `flashHits` both
21 * trace from the middle of the detonation through the engine's own `visible`,
22 * so a wall, a crate or a corner takes all of it: a grenade cannot kill through
23 * a wall here and a flash cannot blind through one, which are the properties
24 * the two tests of that name hold.
2025 */
2126
22 import { dist, move, stuck, visible, type Body, type Box, type Vec3, type Wish } from "../engine/index.ts";
27 import {
28 angleVectors, clamp, dist, dot, eyeHeight, move, normalize, stuck, sub, visible,
29 type Body, type Box, type Vec3, type Wish,
30 } from "../engine/index.ts";
2331 import { hitboxes, type Actor } from "./combat.ts";
24 import { NADE_HULL, NADE_MOVE } from "./movement.ts";
25 import { blastDamage, HE, throughArmor } from "./weapons.ts";
32 import { CS_HULL, NADE_HULL, NADE_MOVE } from "./movement.ts";
33 import { blastDamage, blindSeconds, fuseOf, WEAPONS, throughArmor, type ThrownId } from "./weapons.ts";
2634
27 /** A grenade in the world: a body, whose it is, and how long it has left. */
35 /** A grenade in the world: a body, which kind it is, whose it is, and how long it has left. */
2836 export type Grenade = Body & {
37 /** Which thrown weapon this is — what happens at zero on the fuse. */
38 weapon: ThrownId;
2939 /** The player id that threw it — who the kill is credited to. */
3040 owner: number;
3141 team: "t" | "ct";
@@ -54,8 +64,13 @@ export const THROW_LIFT = 10;
5464 /** How far in front of the eye it appears, in units: out of the hand, not the face. */
5565 const HAND = 18;
5666
57 /** The sounds a grenade makes, in the order one round of it happens. */
58 export type GrenadeSound = "throw" | "bounce" | "blast";
67 /**
68 * The sounds a grenade makes, in the order one round of it happens. The first
69 * two belong to every thrown thing; the last of them is the kind's own — the
70 * HE's `blast`, the flashbang's `flash` — which is why this is one widening
71 * type and not a second one per grenade.
72 */
73 export type GrenadeSound = "throw" | "bounce" | "blast" | "flash";
5974
6075 /**
6176 * Below this the knock of a bounce is not worth a sound: a grenade rolling to
@@ -65,14 +80,17 @@ export type GrenadeSound = "throw" | "bounce" | "blast";
6580 const HEARD = 90;
6681
6782 /**
68 * A grenade leaving a hand at `from` along `dir`. It appears a little ahead of
69 * the eye so it is not thrown from inside the thrower's own head — unless that
70 * would put it inside a wall, in which case it starts at the eye, because a
71 * grenade that spawns inside geometry is a grenade that falls through the map.
83 * A grenade of kind `weapon` leaving a hand at `from` along `dir`. It appears a
84 * little ahead of the eye so it is not thrown from inside the thrower's own head
85 * — unless that would put it inside a wall, in which case it starts at the eye,
86 * because a grenade that spawns inside geometry is a grenade that falls through
87 * the map. The fuse is the kind's own and comes from the weapon table; nothing
88 * about the flight does, which is why both kinds fly identically.
7289 */
7390 export function throwGrenade(
7491 owner: number,
7592 team: "t" | "ct",
93 weapon: ThrownId,
7694 from: Vec3,
7795 dir: Vec3,
7896 boxes: Box[],
@@ -90,9 +108,10 @@ export function throwGrenade(
90108 onGround: false,
91109 ducking: false,
92110 duckAmount: 0,
111 weapon,
93112 owner,
94113 team,
95 fuse: HE.blast.fuse,
114 fuse: fuseOf(WEAPONS[weapon]),
96115 };
97116 }
98117
@@ -142,7 +161,8 @@ export type Caught = { actor: Actor; distance: number; health: number; armor: nu
142161 * not hurt the person who threw it.
143162 */
144163 export function blastHits(g: Grenade, actors: Actor[], boxes: Box[]): Caught[] {
145 const blast = HE.blast;
164 const blast = WEAPONS[g.weapon].blast;
165 if (blast === undefined) return [];
146166 const centre = blastCentre(g);
147167 const caught: Caught[] = [];
148168 for (const actor of actors) {
@@ -156,3 +176,65 @@ export function blastHits(g: Grenade, actors: Actor[], boxes: Box[]): Caught[] {
156176 }
157177 return caught;
158178 }
179
180 // --- the flash ------------------------------------------------------------
181
182 /** What a flash needs on top of a body: which way the eyes were pointing. */
183 export type Looker = Actor & { pitch: number };
184
185 /** The eye a flash is seen with — the same height a bullet leaves from. */
186 const eyeOf = (a: Looker): Vec3 => [a.pos[0], a.pos[1], a.pos[2] + eyeHeight(a, CS_HULL)];
187
188 /**
189 * Degrees between where `a` was looking and `at`. The whole view direction, not
190 * just the yaw: a flash at your feet is one you are looking away from, and a
191 * player who was staring at the floor should keep their sight.
192 */
193 export function viewAngle(a: Looker, at: Vec3): number {
194 const to = sub(at, eyeOf(a));
195 if (dist(at, eyeOf(a)) < 1e-6) return 0;
196 const look = angleVectors(a.yaw, a.pitch).forward;
197 return (Math.acos(clamp(dot(look, normalize(to)), -1, 1)) * 180) / Math.PI;
198 }
199
200 /**
201 * Below this a flash is a flicker, not a blinding: a tenth of a second is six
202 * ticks, less than a frame of white, and not worth taking anybody's screen for.
203 */
204 export const MIN_BLIND = 0.1;
205
206 /** One body a flash reached, and for how long it took their sight. */
207 export type Blinded = { actor: Looker; seconds: number; angle: number; distance: number };
208
209 /**
210 * Who this flash blinds and for how long. Three questions in this order, and
211 * the cheap ones first: was there a line at all (the engine's `visible`, eye to
212 * pop — a wall takes all of it), how far away was it, and how much of the view
213 * it went off in. `blindSeconds` in `weapons.ts` is the curve; this is the
214 * geometry it is fed.
215 *
216 * Unlike the blast, a flash does not care whose side you are on. It is light in
217 * a room: it blinds the thrower's team, the thrower, and the enemy alike, which
218 * is CS's rule and the only one that makes throwing one a decision.
219 *
220 * Anything under `MIN_BLIND` is dropped rather than returned as a flicker
221 * nobody can act on.
222 */
223 export function flashHits(g: Grenade, actors: Looker[], boxes: Box[]): Blinded[] {
224 const flash = WEAPONS[g.weapon].flash;
225 if (flash === undefined) return [];
226 const centre = blastCentre(g);
227 const blinded: Blinded[] = [];
228 for (const actor of actors) {
229 if (!actor.alive) continue;
230 const eye = eyeOf(actor);
231 const distance = dist(centre, eye);
232 if (distance >= flash.radius) continue;
233 if (!visible(centre, eye, boxes)) continue;
234 const angle = viewAngle(actor, centre);
235 const seconds = blindSeconds(flash, angle, distance);
236 if (seconds < MIN_BLIND) continue;
237 blinded.push({ actor, seconds, angle, distance });
238 }
239 return blinded;
240 }
src/client/hud.test.ts +57 −1
@@ -20,7 +20,7 @@
2020 */
2121
2222 import { expect, test } from "bun:test";
23 import { drawHud } from "./hud.ts";
23 import { drawHud, whiteout } from "./hud.ts";
2424 import { recorder, type Call, type Recorder } from "./paint.test-helper.ts";
2525 import { DE_FORGE } from "./map.ts";
2626 import { SHORT } from "./match.ts";
@@ -566,3 +566,59 @@ test("the same canvas turned sideways is drawn at the new size, not the one befo
566566 const bar = after.find((c) => c.op === "fillRect") as Call;
567567 expect(bar.args[2]).toBe(Math.round(240 * 0.7));
568568 });
569
570 // --- flashed ---------------------------------------------------------------
571
572 /** Every full-screen wash this frame drew, with the brush that drew it. */
573 const washes = (paint: Recorder, width = WIDTH, height = HEIGHT): Call[] =>
574 paint.calls.filter((c) => c.op === "fillRect" && c.args[2] === width && c.args[3] === height);
575
576 /** The opacity of an `rgba(...)` fill, or 0 for anything else. */
577 const opacity = (fill: string): number => Number(/rgba\([^)]*,([^,)]+)\)$/.exec(fill)?.[1] ?? 0);
578
579 test("a flashed player gets white over the world, and the crosshair back before it ends", () => {
580 const max = 1.6;
581 // Unflashed there is no wash at all: this costs a frame nothing.
582 expect(washes(draw(scene()))).toEqual([]);
583
584 // The white is read off the simulation's own number, in seconds.
585 const full = washes(draw(scene((w) => { (w.me as Player).blind = max; })));
586 expect(full.length).toBe(1);
587 expect(opacity(full[0]?.fill ?? "")).toBeCloseTo(whiteout(max), 6);
588 // It never quite reaches one: the world is always a ghost behind it.
589 expect(whiteout(max)).toBeLessThan(1);
590 expect(whiteout(max)).toBeGreaterThan(0.85);
591
592 // It fades, and it is gone with the blindness.
593 expect(whiteout(0.45)).toBeLessThan(whiteout(0.9));
594 expect(whiteout(0.9)).toBe(whiteout(max)); // held at full while there is time left
595 expect(washes(draw(scene((w) => { (w.me as Player).blind = 0; })))).toEqual([]);
596
597 // The crosshair is drawn *under* the wash and is readable again for the last
598 // half second of any flash: at 0.45 s left the white is under a half.
599 expect(whiteout(0.45)).toBeLessThan(0.5);
600 const half = draw(scene((w) => { (w.me as Player).blind = 0.45; }));
601 const wash = washes(half)[0] as Call;
602 const crosshair = half.calls.findIndex((c) => c.op === "stroke");
603 expect(crosshair).toBeGreaterThanOrEqual(0);
604 expect(half.calls.indexOf(wash)).toBeGreaterThan(crosshair);
605
606 // And the numbers go on top of it: a flashed player still reads their health.
607 const flashed = draw(scene((w) => { (w.me as Player).blind = max; }));
608 const health = flashed.calls.findIndex((c) => c.op === "fillText" && c.args[0] === "100");
609 expect(health).toBeGreaterThan(flashed.calls.indexOf(washes(flashed)[0] as Call));
610 });
611
612 test("the pocket line names each grenade you are carrying, and both at once", () => {
613 const none = draw(scene());
614 expect(words(none).some((t) => t.includes("HE"))).toBe(false);
615 const both = draw(scene((w) => {
616 (w.me as Player).pocket = { he: 1, flash: 1 };
617 }));
618 const named = words(both).find((t) => t.includes("HE")) ?? "";
619 expect(named).toContain("HE");
620 expect(named).toContain("flash");
621 const only = draw(scene((w) => { (w.me as Player).pocket = { he: 0, flash: 1 }; }));
622 const line = words(only).find((t) => t.includes("flash")) ?? "";
623 expect(line).not.toContain("HE");
624 });
src/client/hud.ts +36 −3
@@ -78,6 +78,26 @@ function toolsFor(ctx: Paint, width: number, height: number): Overlay {
7878 return tools;
7979 }
8080
81 /**
82 * The white-out, as opacity: how much white is over the world with `blind`
83 * seconds of a flash left to run —
84 *
85 * alpha(b) = PEAK · min(1, b / WHITEOUT_FADE)
86 *
87 * Two deliberate limits, because a full-screen white-out is the easiest thing
88 * in this game to make unplayable. It never reaches 1, so the world is always a
89 * ghost behind it rather than gone. And it falls to nothing over the last
90 * `WHITEOUT_FADE` seconds, so the crosshair under it is readable again once
91 * alpha drops under about a half — at `b = 0.49 s` — whatever the flash was
92 * worth. The longest a flash can be is `FLASH.maxBlind`, 1.6 seconds, of which
93 * at most the first 0.7 is full white.
94 */
95 export const whiteout = (blind: number): number => PEAK * Math.min(1, blind / WHITEOUT_FADE);
96
97 /** The most white there ever is, and the seconds it takes to clear. */
98 const PEAK = 0.92;
99 const WHITEOUT_FADE = 0.9;
100
81101 /**
82102 * Paint the HUD for this frame. `view` is whose eyes the camera is in — you, or
83103 * the player you are watching once you are dead — and it is what every number
@@ -138,6 +158,16 @@ export function drawHud(
138158 ctx.stroke();
139159 }
140160
161 // --- flashed: white over the world, and nothing else. The blindness itself
162 // is the simulation's — `view.blind`, in seconds, burned down at 64 Hz — so
163 // this is only how it looks and a bot flashed by the same grenade suffers the
164 // same number. It goes on after the crosshair and before the numbers: it
165 // dims the crosshair as it should and never hides the clock or your health.
166 if (view.blind > 0) {
167 ctx.fillStyle = `rgba(255,255,255,${whiteout(view.blind).toFixed(3)})`;
168 ctx.fillRect(0, 0, width, height);
169 }
170
141171 const scale = ui.scale;
142172 const big = ui.font(30);
143173 const mid = ui.font(19);
@@ -177,10 +207,13 @@ export function drawHud(
177207 const ammo = view.ammo[weapon.id];
178208 const ammoText = weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`;
179209 ui.text(ammoText, width - pad, height - pad, { align: "right", font: big, fill: WHITE });
180 // What is in your hands, and then what is in your pockets: the HE you bought
181 // and the bomb, each named only while you are carrying it.
210 // What is in your hands, and then what is in your pockets: the grenades you
211 // bought and the bomb, each named only while you are carrying it.
182212 ui.text(
183 weapon.name + (view.grenades > 0 ? " · HE" : "") + (view.hasBomb ? " · bomb" : ""),
213 weapon.name +
214 (view.pocket.he > 0 ? " · HE" : "") +
215 (view.pocket.flash > 0 ? " · flash" : "") +
216 (view.hasBomb ? " · bomb" : ""),
184217 width - pad, height - pad - Math.round(30 * scale),
185218 { align: "right", font: small, fill: GREY },
186219 );
src/client/main.test.ts +1 −1
@@ -459,7 +459,7 @@ test("every mesh name a scene of this game can place is handed in at constructio
459459 // table misses is an exception on the frame a player first holds that gun.
460460 const meshes = gameMeshes();
461461 expect(Object.keys(meshes).sort()).toEqual([
462 "bomb", "grenade", "knife", "level", "pistol", "player", "rifle", "smg", "sniper",
462 "bomb", "flash", "grenade", "knife", "level", "pistol", "player", "rifle", "smg", "sniper",
463463 ]);
464464 // Every kind a weapon can be, and the kind `scene.ts` falls back to.
465465 for (const weapon of Object.values(WEAPONS)) expect(meshes[weapon.kind]).toBeDefined();
src/client/main.ts +3 −1
@@ -484,7 +484,9 @@ export function boot(host: Host): Frame {
484484 */
485485 export function gameMeshes(level: Level = DE_FORGE): Record<string, Mesh> {
486486 const kinds = new Set(Object.values(WEAPONS).map((weapon) => weapon.kind));
487 const names = ["level", "player", "bomb", ...kinds];
487 // `grenade` arrives as a weapon kind and is the HE's body in the world; the
488 // flashbang shares that kind and needs its own shell, so it is named here.
489 const names = ["level", "player", "bomb", "flash", ...kinds];
488490 return Object.fromEntries(names.map((name) => [name, meshFor(name, level)]));
489491 }
490492
src/client/models.ts +15 −3
@@ -55,10 +55,21 @@ export function bombMesh(): Mesh {
5555 * middle, built about its own origin so it needs no rotation to be drawn in
5656 * flight or at rest. Eight units across — twice the four-unit hull the solver
5757 * moves it with, so it looks like an object and not like a pixel.
58 *
59 * The flashbang is the same shell in a different shape: taller, narrower and
60 * banded top and bottom, the way CS's is, so the two are told apart in the air
61 * at a glance. Both are the one recipe because both are one thrown thing.
5862 */
59 export function grenadeMesh(): Mesh {
63 export function grenadeMesh(kind: "he" | "flash" = "he"): Mesh {
6064 const b = builder();
6165 const SHELL = 7, BAND = 8;
66 if (kind === "flash") {
67 cube(b, 0, 0, 3, 5, 5, 10, SHELL); // the canister
68 cube(b, 0, 0, 1, 6, 6, 2, BAND); // the band around its foot
69 cube(b, 0, 0, 6, 6, 6, 2, BAND); // and the one around its shoulder
70 cube(b, 0, 0, 8.5, 2.5, 2.5, 2, BAND); // the spoon on top
71 return finish(b);
72 }
6273 cube(b, 0, 0, 2, 7, 7, 7, SHELL);
6374 cube(b, 0, 0, 2, 8, 8, 3, BAND);
6475 cube(b, 0, 0, 6, 2.5, 2.5, 2, BAND); // the spoon on top
@@ -88,8 +99,8 @@ export function viewModelMesh(kind: string): Mesh {
8899 }
89100
90101 /**
91 * A mesh by the name a scene calls it: `level`, `player`, `bomb`, `grenade`, or a weapon
92 * kind for the gun in your hands. `main.ts` builds one of each at start-up and
102 * A mesh by the name a scene calls it: `level`, `player`, `bomb`, `grenade`,
103 * `flash`, or a weapon kind for the gun in your hands. `main.ts` builds one of each at start-up and
93104 * hands them to the engine's renderer, which keeps them by name and never
94105 * learns what a name is made of — that is this file, and this is the one line a
95106 * second game's model table would replace.
@@ -102,5 +113,6 @@ export function meshFor(name: string, level: Level): Mesh {
102113 if (name === "player") return playerMesh();
103114 if (name === "bomb") return bombMesh();
104115 if (name === "grenade") return grenadeMesh();
116 if (name === "flash") return grenadeMesh("flash");
105117 return viewModelMesh(name);
106118 }
src/client/replay.test.ts +40 −36
@@ -34,51 +34,55 @@ const SECONDS = 40;
3434 const TICKS = SECONDS * TICKS_PER_SECOND;
3535
3636 /**
37 * Forty seconds of a five-a-side round on de_forge, seed 7. Re-pinned for
38 * #142, which put a grenade in the game: what moved is the *hash*, not the
39 * play. The snapshot now carries what is in each pocket and every live
40 * grenade's body and fuse, so every second of every run differs from the
41 * numbers before it — and the same forty seconds folded with the old field
42 * list on this code still comes to the old digest,
43 * 6e2532e264dedc1b0e627305cc8dc6adc7c9331467ba99ed85b8dbd00c5288a0, which is
44 * how this branch knows nobody's footing changed. Nothing in this script
45 * throws one: the bots do not buy grenades and the scripted person never
46 * presses the key.
37 * Forty seconds of a five-a-side round on de_forge, seed 7. Re-pinned for #164,
38 * which put a second grenade in the game, and for exactly the reason #142 was
39 * re-pinned before it: what moved is the *hash*, not the play. A pocket is two
40 * counts now rather than one, every body carries the seconds of a flash it
41 * cannot see through, and a live grenade carries which kind it is — five fields
42 * where there were two — so every second of every run differs from the numbers
43 * before it. The same forty seconds folded with the *old* field list on this
44 * code still comes to the old digest,
45 * da283f931bb75db712cb9b526edf2a03215f6e77b2f5c0038f3d60a0e5b918b7, which is
46 * how this branch knows nobody's footing changed. Nothing in this script throws
47 * anything: the bots buy no grenades and the scripted person never presses
48 * either key, so no flash goes off and no `blind` is ever non-zero here.
4749 */
48 const ROUND = "da283f931bb75db712cb9b526edf2a03215f6e77b2f5c0038f3d60a0e5b918b7";
50 const ROUND = "ca91ea4cb43109a961fa2b63d44bc8d27de4e426b79016341ee3e5e0e6a7043e";
4951 const ROUND_SECONDS = [
50 "5b61fdcfd222", "59c85df8e999", "bae4c0a2cbdc", "343fa534be10",
51 "e8589eb745a5", "30bd545dec3d", "2c703b733da0", "a9e24b3caf72",
52 "185f9f1906fc", "4ce7203de7de", "d829688db383", "0c08923df153",
53 "875524011970", "5ca4afe0f422", "4b2ef1d2dbcd", "032232a64550",
54 "354c3e0df525", "1031fc49f939", "3bf4adca46af", "3da55a96f080",
55 "6b2e493c9776", "184b9c1a17f3", "044a57e21e01", "d8b98952eaa0",
56 "dab17116ca26", "7085bccf2aba", "224b3006a906", "5b4a3353cd1c",
57 "55475c7735f5", "86da5b34ffc9", "8f961f47b849", "9217ef0cb1b7",
58 "205d3c20bd95", "1ebf1980b791", "925eb8f6ae25", "764c3b4fb751",
59 "ba6077a43a06", "628a19392265", "e4443444e72d", "58dd1da0f322",
52 "5978706f2efd", "aa6aa9a5a3c9", "f822e864db93", "6f1cf4de1926",
53 "25923b9cb132", "83b06bc2430e", "b97f8d621af5", "c1b94afedda0",
54 "700c15a0f6c1", "8c0ad0d13658", "d7aad60e3ff3", "d4e5187b49b4",
55 "60730fe44cc8", "a4c501cdf06e", "e23f99650039", "8f0fd8311650",
56 "3e2a50a35374", "0eaec9fa5595", "a4da64d03f4f", "a741df777b49",
57 "a9e376a00a92", "97551fd34e2a", "1342599e6fe1", "b3805e3f7b45",
58 "54fedea960e5", "867ccb00fbfd", "853c878b3121", "f723b6926421",
59 "adca551d5dcd", "dc6e57a96c4b", "5612a3feff88", "302de76251c8",
60 "237a92008fdd", "108e5db94381", "ab4ecb1715fb", "7cbeb300896f",
61 "2afdbc98a409", "0db10ea3eb14", "069dfe802cf2", "fca970b88b16",
6062 ];
6163
6264 /**
6365 * Forty seconds of duels in `src/rl/arena.ts`, seed 4242, against the
64 * scripted opponent, resetting each time one of them dies. Re-pinned for #142
66 * scripted opponent, resetting each time one of them dies. Re-pinned for #164
6567 * for exactly the reason above and no other: the arena buys no grenades and
66 * `toInput` never sets the throw, so the duel itself is untouched and the old
67 * field list on this code still folds to
68 * 5cdb7419bdd67eff8e59436620d6bf3c65169d3f0477114d16b634c6b1bc79b6.
68 * `toInput` never sets the throw, nothing in it is ever blinded, and every
69 * number a flash scales in `bots.ts` is multiplied by exactly one while
70 * `blind` is zero — so the duel itself is untouched, and the old field list on
71 * this code still folds to
72 * 37ea4cc90ecb55e2489ee1f219654b563a16e93893a6222c6e17f23f56b3c5d3.
6973 */
70 const DUEL = "37ea4cc90ecb55e2489ee1f219654b563a16e93893a6222c6e17f23f56b3c5d3";
74 const DUEL = "bffdda30cf85fd8b2ea4840ad4b53e8041054411fcb2d6adcd7537be8f1ebbef";
7175 const DUEL_SECONDS = [
72 "4c57d7bdcb7f", "f374d023c8e8", "74ee330614b4", "d8dde79a3ad4",
73 "c70ef481676b", "fb9c0f703423", "50117aa42276", "5315650d699a",
74 "2fdd772b5129", "05a0b2d22059", "dbf2a92b9c48", "218603e90510",
75 "a1037674ca1d", "e624412df2fd", "76fdd5cf444e", "2786cf745579",
76 "a591f5256ee9", "7d1f1738def2", "2aca39a01dc9", "8367c172a66e",
77 "745adf940e76", "0d5939fa2fc4", "6a7277642008", "420709562891",
78 "1bd36e889bb5", "46245dfc87e8", "813ad43a79ab", "6775f1835415",
79 "3baaf6e14bcb", "14a0dd1d2f63", "9448b5334bcb", "62731f350fb4",
80 "a71a4fb1ea25", "6a7209feb590", "a003206a0750", "9ae62ae7335e",
81 "a08e0da54e22", "56f94459be4f", "5f9be2613a9f", "6bd3759350d2",
76 "f3d6534f1590", "3f1e2479a841", "43b52fff16ef", "bd2c6c29eaf3",
77 "0c965cb1929c", "7572a5087ec4", "c4b9c83c357b", "b352f5a86d06",
78 "1e533cbaf327", "090b9d34147d", "a2f17103d7cf", "6831ee8d38c1",
79 "258eb2af7c8b", "9bb124c5f5fd", "fdac71af56c4", "1ae133a994ad",
80 "1d96558e6129", "989f1591cc59", "03836444fe3d", "2442e7241118",
81 "4795750cc613", "8c40d6315858", "6da5618d9b4d", "217b93298e47",
82 "49c8cc22abaa", "1f1706709ae2", "faa170572b54", "bd67caf0320a",
83 "a89d510d047b", "645baf0eb839", "36eaedb9bb79", "ddc41eb233e9",
84 "45377fd1cdea", "b0796f4fa162", "02afbd2180fd", "02bd15880c74",
85 "4649a72e207a", "78940559b08f", "2144ea37bcab", "ebd6cf00bfa7",
8286 ];
8387
8488 const ROUND_PIN: Pin = { label: "ten-bot round", constant: "ROUND", digest: ROUND, seconds: ROUND_SECONDS };
src/client/replay.ts +9 −2
@@ -53,8 +53,12 @@ export const PLAYER_FIELDS: { name: string; of: (p: Player) => number }[] = [
5353 { name: "duckAmount", of: (p) => p.duckAmount },
5454 // -1 for a weapon that has no magazine (the knife), so a switch still shows.
5555 { name: "mag", of: (p) => p.ammo[p.weapon]?.mag ?? -1 },
56 // What is in the pocket: bought, thrown, and lost with the body that held it.
57 { name: "grenades", of: (p) => p.grenades },
56 // What is in each pocket: bought, thrown, and lost with the body that held it.
57 { name: "he", of: (p) => p.pocket.he },
58 { name: "flash", of: (p) => p.pocket.flash },
59 // How long this body cannot see for. It is simulation, not presentation: the
60 // white-out a person gets and the shaky hands a bot gets are both read off it.
61 { name: "blind", of: (p) => p.blind },
5862 ];
5963
6064 /**
@@ -70,6 +74,9 @@ export const GRENADE_FIELDS: { name: string; of: (g: Grenade) => number }[] = [
7074 { name: "vel.y", of: (g) => g.vel[1] },
7175 { name: "vel.z", of: (g) => g.vel[2] },
7276 { name: "fuse", of: (g) => g.fuse },
77 // Which kind it is — an HE and a flashbang fly identically and end very
78 // differently, so two otherwise equal ticks are not the same state.
79 { name: "kind", of: (g) => (g.weapon === "flash" ? 1 : 0) },
7380 ];
7481
7582 /** Everything hashed about the match. */
src/client/scene.ts +18 −7
@@ -22,7 +22,7 @@ import {
2222 type Camera, type Scene as Frame, type Vec3,
2323 } from "../engine/index.ts";
2424 import { CS_HULL } from "./movement.ts";
25 import { HE, WEAPONS } from "./weapons.ts";
25 import { FLASH, HE, WEAPONS } from "./weapons.ts";
2626 import type { Player, World } from "./world.ts";
2727
2828 export type Tint = [number, number, number];
@@ -60,6 +60,9 @@ export const INSTANCE_CAPACITY = 32;
6060
6161 /** How long a blast is drawn for, in seconds, and in how many spokes. */
6262 const BLAST_SECONDS = 0.45;
63
64 /** A flash is a shorter, brighter thing than a blast: it is over in a blink. */
65 const FLASH_SECONDS = 0.2;
6366 const BLAST_SPOKES = 16;
6467
6568 /** The horizon, and the colour distance fades into. */
@@ -150,7 +153,7 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play
150153 // the yaw is the simulated time, so a frame decides nothing and a paused
151154 // one turns it no further.
152155 for (const g of world.grenades)
153 place(into, "grenade", g.pos, g.onGround ? 0 : world.time * 540, 1, WHITE);
156 place(into, g.weapon === "flash" ? "flash" : "grenade", g.pos, g.onGround ? 0 : world.time * 540, 1, WHITE);
154157
155158 // --- tracers, sparks and the planted bomb's beam, all as lines
156159 // Read by index, oldest first: the effects are the engine's ring and a
@@ -182,10 +185,17 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play
182185 );
183186 }
184187 } else if (effect.kind === "blast" && age < BLAST_SECONDS) {
185 // The blast, drawn as what it is: a star of light that runs out to the
186 // radius the damage actually reaches and fades as it goes.
187 const t = age / BLAST_SECONDS;
188 const k = HE.blast.radius * Math.min(1, t * 2.2);
188 // A grenade going off, drawn as what it is: a star of light that runs out
189 // to the radius that kind actually reaches and fades as it goes. The
190 // flash is the shorter, whiter, faster one and it is drawn to its own
191 // radius — which is why the reach is read off the weapon and not typed
192 // twice — but it never brightens the screen from out here: what it costs
193 // the person looking at it is `blind` in the simulation, and the HUD's.
194 const flash = effect.weapon === "flash";
195 const seconds = flash ? FLASH_SECONDS : BLAST_SECONDS;
196 if (age >= seconds) continue;
197 const t = age / seconds;
198 const k = (flash ? FLASH.flash.radius * 0.25 : HE.blast.radius) * Math.min(1, t * 2.2);
189199 const fade = 1 - t;
190200 for (let s = 0; s < BLAST_SPOKES; s++) {
191201 const a = (s / BLAST_SPOKES) * Math.PI * 2;
@@ -194,7 +204,8 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play
194204 into,
195205 effect.pos,
196206 at(TO, effect.pos[0] + Math.cos(a) * k, effect.pos[1] + Math.sin(a) * k, effect.pos[2] + lift * k),
197 colour(NEAR, 1, 0.95, 0.8 * fade), colour(FAR, 1, 0.5 * fade, 0.1 * fade),
207 flash ? colour(NEAR, 1, 1, 1) : colour(NEAR, 1, 0.95, 0.8 * fade),
208 flash ? colour(FAR, fade, fade, fade) : colour(FAR, 1, 0.5 * fade, 0.1 * fade),
198209 );
199210 }
200211 } else if (effect.kind === "blood" && age < 0.3) {
src/client/weapons.test.ts +57 −1
@@ -1,5 +1,7 @@
11 import { expect, test } from "bun:test";
2 import { buyable, computeDamage, inaccuracy, recoilOffset, WEAPONS } from "./weapons.ts";
2 import {
3 blindSeconds, buyable, computeDamage, FLASH, HE, inaccuracy, isThrown, recoilOffset, WEAPONS,
4 } from "./weapons.ts";
35
46 test("an AK chest shot through armour is survivable, a headshot is not", () => {
57 const ak = WEAPONS.ak47;
@@ -62,3 +64,57 @@ test("the buy menu only offers what your side carries", () => {
6264 expect(ct).not.toContain("ak47");
6365 expect(buyable("t").map((w) => w.id)).toContain("ak47");
6466 });
67
68 // --- the flashbang --------------------------------------------------------
69
70 test("the flashbang is in the table with its own numbers, and no bullet ones", () => {
71 expect(WEAPONS.flash).toBe(FLASH);
72 expect(FLASH.price).toBe(200); // dollars
73 expect(FLASH.kind).toBe("grenade");
74 expect(FLASH.flash).toEqual({
75 fuse: 1.6, // seconds
76 radius: 1200, // units
77 maxBlind: 1.6, // seconds, the ceiling
78 fullAngle: 30, // degrees
79 awayAngle: 135, // degrees
80 });
81 // It hurts nobody: no damage, no falloff, no magazine, and no blast at all.
82 expect(FLASH.damage).toBe(0);
83 expect(FLASH.magazine).toBe(0);
84 expect(FLASH.blast).toBeUndefined();
85 expect(HE.flash).toBeUndefined();
86 // Both are thrown, and nothing else in the table is.
87 expect(Object.values(WEAPONS).filter((w) => isThrown(w.id)).map((w) => w.id).sort())
88 .toEqual(["flash", "he"]);
89 // Adding it did not move a gun's spray: the patterns are seeded by index and
90 // the thrown weapons are last, so the AK's tenth bullet is the AK's tenth.
91 expect(recoilOffset("ak47", 9)).toEqual(recoilOffset("ak47", 9));
92 expect(recoilOffset("flash", 3)).toEqual([0, 0]);
93 });
94
95 test("the blind curve is the one in the comment, at three angles and two distances", () => {
96 const f = FLASH.flash;
97 const at = (angle: number, distance: number): number => blindSeconds(f, angle, distance);
98 // In the face, at the centre: the ceiling, and it is 1.6 seconds and not three.
99 expect(at(0, 0)).toBeCloseTo(1.6, 10);
100 expect(f.maxBlind).toBe(1.6);
101 // Three angles, at the centre. 30° is still the whole of it — the cone where
102 // it fills your view. 82.5° is exactly half way from 30 to 135, so half.
103 // 135° is over your shoulder: nothing, however close it went off.
104 expect(at(30, 0)).toBeCloseTo(1.6, 10);
105 expect(at(82.5, 0)).toBeCloseTo(0.8, 10);
106 expect(at(135, 0)).toBe(0);
107 expect(at(180, 0)).toBe(0);
108 // The same three at 600 units — half the radius, so half of each again.
109 expect(at(0, 600)).toBeCloseTo(0.8, 10);
110 expect(at(82.5, 600)).toBeCloseTo(0.4, 10);
111 expect(at(135, 600)).toBe(0);
112 // And at the radius and past it there is nothing to see.
113 expect(at(0, 1200)).toBe(0);
114 expect(at(0, 2000)).toBe(0);
115 // Between the two angles it is a straight line: 60° is 75/105 of the way.
116 expect(at(60, 0)).toBeCloseTo(1.6 * (75 / 105), 10);
117 // And it never runs backwards: further and wider is always less.
118 expect(at(45, 300)).toBeGreaterThan(at(60, 300));
119 expect(at(45, 300)).toBeGreaterThan(at(45, 400));
120 });
src/client/weapons.ts +88 −13
@@ -7,19 +7,28 @@
77 * they are deterministic, so the AK's first ten bullets are the same ten
88 * bullets every round, which is the property that makes spray control a skill.
99 *
10 * Eight of the nine are guns. The ninth is the HE grenade, which is in the
11 * same table because it is bought in the same menu and named in the same kill
12 * feed, and which carries a `blast` instead of a magazine: how it flies and
13 * what it does when the fuse runs out is `src/client/grenade.ts`.
10 * Eight of the ten are guns. The other two are thrown: the HE grenade and the
11 * flashbang, which are in the same table because they are bought in the same
12 * menu and named in the same kill feed, and which carry a `blast` or a `flash`
13 * instead of a magazine. How one flies is `src/client/grenade.ts`; what it does
14 * when the fuse runs out is a `Blast` or a `Flash` below and nothing else, which
15 * is what lets a third thrown thing be a row in this table rather than a branch
16 * in the world.
1417 */
1518
16 import { rng } from "../engine/index.ts";
19 import { clamp, rng } from "../engine/index.ts";
1720
1821 export type WeaponId =
19 | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp" | "he";
22 | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp" | "he" | "flash";
23
24 /** The weapons that are thrown rather than fired: the two kinds in a pocket. */
25 export type ThrownId = "he" | "flash";
26
27 /** Is this a weapon somebody throws? Narrows the id as well as answering. */
28 export const isThrown = (id: WeaponId): id is ThrownId => id === "he" || id === "flash";
2029
2130 /**
22 * What a thrown weapon does when its fuse runs out. Only the grenade has one,
31 * What a thrown weapon does when its fuse runs out. Only the HE has one,
2332 * and everything about the explosion a simulation needs is these four numbers.
2433 */
2534 export type Blast = {
@@ -29,6 +38,19 @@ export type Blast = {
2938 armorPenetration: number; // 0..1, the share that reaches health through armour
3039 };
3140
41 /**
42 * What a flashbang does when its fuse runs out: it takes sight, not health, and
43 * these five numbers are the whole of it. Nothing here is a hit point and
44 * nothing here is armour — a vest does not help you look away.
45 */
46 export type Flash = {
47 fuse: number; // seconds from leaving the hand to the pop
48 radius: number; // units; at and past this it does nothing at all
49 maxBlind: number; // seconds, dead centre and looking straight at it: the ceiling
50 fullAngle: number; // degrees; inside this cone of the view it is the whole screen
51 awayAngle: number; // degrees; at or past this it went off behind you and is nothing
52 };
53
3254 export type Weapon = {
3355 id: WeaponId;
3456 name: string;
@@ -49,13 +71,27 @@ export type Weapon = {
4971 recoil: number; // degrees of climb per shot, scaled by the pattern
5072 zoom?: number; // scoped field of view
5173 automatic: boolean;
52 /** Set on a thrown weapon, and on nothing else. */
74 /** Set on the HE, and on nothing else. */
5375 blast?: Blast;
76 /** Set on the flashbang, and on nothing else. */
77 flash?: Flash;
5478 };
5579
5680 /** The HE grenade, by the name everything that throws one calls it. */
5781 export const HE: Weapon & { blast: Blast } = { id: "he", name: "HE Grenade", kind: "grenade", team: "both", price: 300, damage: 0, armorPenetration: 0, rangeModifier: 1, rpm: 0, magazine: 0, reserve: 0, reloadTime: 0, killReward: 300, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false, blast: { damage: 98, radius: 350, fuse: 1.5, armorPenetration: 0.5 } };
5882
83 /**
84 * The flashbang. CS's $200, on a 1.6-second fuse, and it matters a great deal
85 * further than the HE does — 1,200 units against 350 — because light carries
86 * and shrapnel does not. `maxBlind` is the ceiling and it is deliberately
87 * short: 1.6 seconds of white is a fight you lost, and three would be a tab you
88 * closed. The two angles are the curve's, and `blindSeconds` below is the curve.
89 */
90 export const FLASH: Weapon & { flash: Flash } = { id: "flash", name: "Flashbang", kind: "grenade", team: "both", price: 200, damage: 0, armorPenetration: 0, rangeModifier: 1, rpm: 0, magazine: 0, reserve: 0, reloadTime: 0, killReward: 300, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false, flash: { fuse: 1.6, radius: 1200, maxBlind: 1.6, fullAngle: 30, awayAngle: 135 } };
91
92 /** The fuse on a thrown weapon, in seconds: the one number both kinds have. */
93 export const fuseOf = (weapon: Weapon): number => weapon.blast?.fuse ?? weapon.flash?.fuse ?? 0;
94
5995 const WEAPON_LIST: Weapon[] = [
6096 { id: "knife", name: "Knife", kind: "knife", team: "both", price: 0, damage: 55, armorPenetration: 0.85, rangeModifier: 1, rpm: 240, magazine: 0, reserve: 0, reloadTime: 0, killReward: 1500, carrySpeed: 250, spread: 0, moveSpread: 0, recoil: 0, automatic: true },
6197 { id: "glock", name: "Glock-18", kind: "pistol", team: "t", price: 200, damage: 30, armorPenetration: 0.47, rangeModifier: 0.75, rpm: 400, magazine: 20, reserve: 120, reloadTime: 2.1, killReward: 300, carrySpeed: 240, spread: 0.22, moveSpread: 1.6, recoil: 0.7, automatic: false },
@@ -65,12 +101,15 @@ const WEAPON_LIST: Weapon[] = [
65101 { id: "ak47", name: "AK-47", kind: "rifle", team: "t", price: 2700, damage: 36, armorPenetration: 0.775, rangeModifier: 0.98, rpm: 600, magazine: 30, reserve: 90, reloadTime: 2.5, killReward: 300, carrySpeed: 215, spread: 0.16, moveSpread: 3.4, recoil: 1.5, automatic: true },
66102 { id: "m4a4", name: "M4A4", kind: "rifle", team: "ct", price: 3100, damage: 33, armorPenetration: 0.7, rangeModifier: 0.97, rpm: 666, magazine: 30, reserve: 90, reloadTime: 3.1, killReward: 300, carrySpeed: 225, spread: 0.15, moveSpread: 3.2, recoil: 1.25, automatic: true },
67103 { id: "awp", name: "AWP", kind: "sniper", team: "both", price: 4750, damage: 115, armorPenetration: 0.975, rangeModifier: 0.99, rpm: 41, magazine: 10, reserve: 30, reloadTime: 3.7, killReward: 100, carrySpeed: 200, spread: 0.05, moveSpread: 12, recoil: 3.5, zoom: 40, automatic: false },
68 // The one thrown weapon. Its bullet numbers are the empty ones — it is never
69 // held, never fired and never reloaded — and everything it does is in
70 // `blast`: CS's $300 HE, 98 hit points at the centre falling off over 350
71 // units, on a 1.5-second fuse, with armour stopping about half of it. It is
72 // last in the list on purpose: the spray patterns below are seeded by index.
104 // The thrown weapons. Their bullet numbers are the empty ones — neither is
105 // ever held, fired or reloaded — and everything they do is in `blast` and
106 // `flash`: CS's $300 HE, 98 hit points at the centre falling off over 350
107 // units on a 1.5-second fuse with armour stopping about half of it, and CS's
108 // $200 flashbang, which takes sight instead. They are last in the list on
109 // purpose: the spray patterns below are seeded by index, so a thrown weapon
110 // added here never moves a gun's pattern.
73111 HE,
112 FLASH,
74113 ];
75114
76115 export const WEAPONS: Record<WeaponId, Weapon> = Object.fromEntries(
@@ -191,3 +230,39 @@ export function blastDamage(blast: Blast, distance: number): number {
191230 if (distance >= blast.radius) return 0;
192231 return blast.damage * (1 - distance / blast.radius);
193232 }
233
234 /**
235 * How long a flash blinds somebody for, in seconds: how much of it they could
236 * see, and nothing else. `angle` is the degrees between where they were looking
237 * and where it went off, `distance` the units between the eye and the pop —
238 *
239 * blind(θ, d) = maxBlind · look(θ) · reach(d)
240 *
241 * look(θ) = 1 θ ≤ fullAngle
242 * = (awayAngle − θ) / (awayAngle − fullAngle) in between
243 * = 0 θ ≥ awayAngle
244 *
245 * reach(d) = 1 − d / radius, clamped to [0, 1] zero at and past radius
246 *
247 * `reach` is the HE's own falloff shape (Source's `RadiusDamage`: a straight
248 * line to nothing at the radius) because a flash fades with distance for the
249 * same reason and there is no case for a second shape. `look` is this game's:
250 * inside 30° the pop fills the view and costs the full time, and at 135° it is
251 * over your shoulder and costs nothing, with a straight line between.
252 *
253 * At 1.6 s, 1,200 units, 30° and 135° that is: 1.6 s in the face; 0.8 s at 82.5°
254 * or at 600 units; 0.4 s at both; and nothing at all at 135° however close it
255 * went off, or at 1,200 units however squarely you were looking at it.
256 *
257 * Whether there was a line of sight at all is a different question, and not this
258 * function's: `src/client/grenade.ts` asks the engine's `traceWorld` first, the
259 * same way the blast does.
260 */
261 export function blindSeconds(flash: Flash, angle: number, distance: number): number {
262 const look =
263 angle <= flash.fullAngle ? 1 :
264 angle >= flash.awayAngle ? 0 :
265 (flash.awayAngle - angle) / (flash.awayAngle - flash.fullAngle);
266 const reach = clamp(1 - distance / flash.radius, 0, 1);
267 return flash.maxBlind * look * reach;
268 }
src/client/world.ts +92 −38
@@ -13,7 +13,7 @@
1313
1414 import { applyDamage, applyHit, traceBullet, type Actor } from "./combat.ts";
1515 import {
16 blastCentre, blastHits, stepGrenade, throwGrenade, THROW_LIFT,
16 blastCentre, blastHits, flashHits, stepGrenade, throwGrenade, THROW_LIFT,
1717 type Grenade, type GrenadeSound,
1818 } from "./grenade.ts";
1919 import { siteAt, type Level } from "./level.ts";
@@ -25,8 +25,8 @@ import {
2525 import * as rules from "./match.ts";
2626 import { CS_HULL, CS_MOVE } from "./movement.ts";
2727 import {
28 ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset,
29 type WeaponId, type Weapon,
28 ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, isThrown, recoilOffset,
29 type ThrownId, type WeaponId, type Weapon,
3030 } from "./weapons.ts";
3131 import { botBuy, botThink, type BotBrain } from "./bots.ts";
3232
@@ -46,15 +46,27 @@ export type Player = Actor & Body & {
4646 ammo: Record<string, Ammo>;
4747 defuseKit: boolean;
4848 hasBomb: boolean;
49 /** HE grenades in the pocket: at most `MAX_GRENADES`, thrown one at a time. */
50 grenades: number;
49 /**
50 * What is in the pocket, by kind: at most `CARRY[kind]` of each, thrown one
51 * at a time. A pocket rather than a count because a flashbang is carried
52 * alongside the HE and not instead of it.
53 */
54 pocket: Record<ThrownId, number>;
55 /**
56 * Seconds of blindness left, from a flash this player could see. It burns
57 * down on the simulation's clock like a fuse does, it is the same number for
58 * a person and for a bot, and it is what the white-out and the bot's shaky
59 * hands are both read off — so the two can never disagree about what happened.
60 */
61 blind: number;
5162 nextShotAt: number;
5263 reloadUntil: number;
5364 shotIndex: number;
5465 lastShotAt: number;
5566 scoped: boolean;
5667 firedThisPress: boolean;
57 threwThisPress: boolean;
68 /** Which kind the throw key was last held on, so a press throws exactly one. */
69 threwThisPress: ThrownId | null;
5870 punch: [number, number]; // view kick, decaying
5971 kills: number;
6072 deaths: number;
@@ -78,7 +90,8 @@ export type Effect =
7890 | { kind: "blood"; at: number; pos: Vec3 }
7991 | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean }
8092 | { kind: "hitmark"; at: number; headshot: boolean }
81 | { kind: "blast"; at: number; pos: Vec3 }
93 /** A grenade going off, of whichever kind: the frame draws each its own way. */
94 | { kind: "blast"; at: number; pos: Vec3; weapon: ThrownId }
8295 | { kind: "sound"; at: number; pos: Vec3; name: GrenadeSound };
8396
8497 /**
@@ -116,17 +129,17 @@ export type Input = {
116129 use: boolean;
117130 scope: boolean;
118131 /**
119 * Throw the grenade in your pocket. It is one more field, and a bot that
120 * never sets it — every bot in this game, today — plays exactly as it did
121 * before there were grenades.
132 * Which kind of grenade to throw out of your pocket, or null for neither. It
133 * is one more field, and a bot that never sets it — every bot in this game,
134 * today — plays exactly as it did before there were grenades.
122135 */
123 throwGrenade: boolean;
136 throwing: ThrownId | null;
124137 switchTo: WeaponId | null;
125138 };
126139
127140 export const NO_INPUT: Input = {
128141 forward: 0, side: 0, jump: false, duck: false, walk: false,
129 fire: false, reload: false, use: false, scope: false, throwGrenade: false, switchTo: null,
142 fire: false, reload: false, use: false, scope: false, throwing: null, switchTo: null,
130143 };
131144
132145 export type World = {
@@ -174,9 +187,9 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player
174187 money: 800,
175188 weapon: secondary, primary: null, secondary,
176189 ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) },
177 defuseKit: false, hasBomb: false, grenades: 0,
190 defuseKit: false, hasBomb: false, pocket: { he: 0, flash: 0 }, blind: 0,
178191 nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false,
179 firedThisPress: false, threwThisPress: false,
192 firedThisPress: false, threwThisPress: null,
180193 punch: [0, 0],
181194 kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0,
182195 };
@@ -252,8 +265,10 @@ export function startRound(world: World): void {
252265 p.health = 100;
253266 // A grenade survives the round its owner did, like their gun does; one they
254267 // died holding is left on the floor with them.
255 if (!p.alive) p.grenades = 0;
268 if (!p.alive) p.pocket = { he: 0, flash: 0 };
256269 p.alive = true;
270 // A flash does not carry into the next round, however late it went off.
271 p.blind = 0;
257272 p.duckAmount = 0;
258273 p.hasBomb = false;
259274 p.shotIndex = 0;
@@ -323,8 +338,12 @@ export function priceOf(item: BuyItem, player: Player): number {
323338 return WEAPONS[item]?.price ?? Infinity;
324339 }
325340
326 /** How many HE grenades a player may carry. CS allows one, and so does this. */
327 export const MAX_GRENADES = 1;
341 /**
342 * How many of each thrown weapon a player may carry into a round. One HE and
343 * one flashbang — two grenades, bought separately in the freeze, each refused
344 * once the pocket holds its limit, and both gone with the body that held them.
345 */
346 export const CARRY: Record<ThrownId, number> = { he: 1, flash: 1 };
328347
329348 export const canBuy = (world: World, player: Player): boolean =>
330349 world.match.phase === "freeze" && player.alive;
@@ -349,10 +368,11 @@ export function buy(world: World, player: Player, item: BuyItem): boolean {
349368 if (weapon === undefined || weapon.kind === "knife") return false;
350369 if (weapon.team !== "both" && weapon.team !== player.team) return false;
351370 // A grenade goes in a pocket, not in your hands: it does not become the
352 // weapon you are holding and it does not displace the gun you bought.
353 if (weapon.kind === "grenade") {
354 if (player.grenades >= MAX_GRENADES) return false;
355 player.grenades += 1;
371 // weapon you are holding and it does not displace the gun you bought, and
372 // one kind in there does not crowd out the other.
373 if (isThrown(item)) {
374 if (player.pocket[item] >= CARRY[item]) return false;
375 player.pocket[item] += 1;
356376 player.money -= price;
357377 return true;
358378 }
@@ -464,18 +484,19 @@ function onKill(world: World, killer: Player, victim: Player, weapon: WeaponId,
464484 // --- grenades -------------------------------------------------------------
465485
466486 /**
467 * Throw the grenade in this player's pocket, if there is one and the round
468 * will have it. It leaves the eye along the view, tilted `THROW_LIFT` degrees
469 * up the way CS tilts one, and from there it belongs to `grenade.ts` and the
470 * engine's solver — nothing here steers it.
487 * Throw the grenade of kind `kind` in this player's pocket, if there is one and
488 * the round will have it. It leaves the eye along the view, tilted `THROW_LIFT`
489 * degrees up the way CS tilts one, and from there it belongs to `grenade.ts`
490 * and the engine's solver — nothing here steers it, and nothing here knows
491 * which kind it was beyond taking it out of the right pocket.
471492 */
472 export function throwOne(world: World, p: Player): boolean {
473 if (!p.alive || p.grenades <= 0) return false;
493 export function throwOne(world: World, p: Player, kind: ThrownId = "he"): boolean {
494 if (!p.alive || p.pocket[kind] <= 0) return false;
474495 if (world.match.phase === "freeze" || world.match.phase === "over") return false;
475 p.grenades -= 1;
496 p.pocket[kind] -= 1;
476497 const from = eyePos(p);
477498 const dir = angleVectors(p.yaw, p.pitch - THROW_LIFT).forward;
478 world.grenades.push(throwGrenade(p.id, p.team, from, dir, world.level.boxes));
499 world.grenades.push(throwGrenade(p.id, p.team, kind, from, dir, world.level.boxes));
479500 record(world, { kind: "sound", at: world.time, pos: from, name: "throw" });
480501 return true;
481502 }
@@ -484,15 +505,25 @@ const heard = (world: World, pos: Vec3, name: GrenadeSound): void => {
484505 record(world, { kind: "sound", at: world.time, pos: [pos[0], pos[1], pos[2]], name });
485506 };
486507
508 /**
509 * A grenade whose fuse ran out: the pop everyone sees and hears, and then
510 * whatever this kind of grenade does. The dispatch is the one place in the
511 * world that knows there is more than one kind of thrown thing.
512 */
513 function detonate(world: World, g: Grenade): void {
514 const centre = blastCentre(g);
515 record(world, { kind: "blast", at: world.time, pos: centre, weapon: g.weapon });
516 heard(world, centre, g.weapon === "flash" ? "flash" : "blast");
517 if (g.weapon === "flash") return blind(world, g);
518 explode(world, g, centre);
519 }
520
487521 /**
488522 * What a blast does: damage by distance to everything it can see, through
489523 * armour, credited to whoever threw it — a hit marker if that was you and a
490524 * line in the feed with the grenade named if it killed.
491525 */
492 function detonate(world: World, g: Grenade): void {
493 const centre = blastCentre(g);
494 record(world, { kind: "blast", at: world.time, pos: centre });
495 heard(world, centre, "blast");
526 function explode(world: World, g: Grenade, centre: Vec3): void {
496527 const thrower = world.players.find((p) => p.id === g.owner);
497528 for (const caught of blastHits(g, world.players, world.level.boxes)) {
498529 const victim = caught.actor as Player;
@@ -505,6 +536,22 @@ function detonate(world: World, g: Grenade): void {
505536 }
506537 }
507538
539 /**
540 * What a flash does: it takes sight, and only from those who had a line to it.
541 * `flashHits` decides how long from the angle and the distance; the longer of
542 * what is left and what just arrived wins, so two flashes are not four seconds
543 * of white — a second one cannot make the first one worse than its own ceiling.
544 *
545 * Nobody is hurt, nothing is credited and there is no line in the feed: a flash
546 * is not a kill, it is a chance at one.
547 */
548 function blind(world: World, g: Grenade): void {
549 for (const caught of flashHits(g, world.players, world.level.boxes)) {
550 const victim = caught.actor as Player;
551 victim.blind = Math.max(victim.blind, caught.seconds);
552 }
553 }
554
508555 /** Every live grenade, one tick older: moved, heard, and detonated on time. */
509556 function updateGrenades(world: World, dt: number): void {
510557 if (world.grenades.length === 0) return;
@@ -612,10 +659,12 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
612659 }
613660 }
614661 if (!input.fire) p.firedThisPress = false;
615 // One throw a press, like a semi-automatic trigger: holding the key down
616 // does not empty a pocket that only ever holds one anyway.
617 if (input.throwGrenade && !p.threwThisPress) throwOne(world, p);
618 p.threwThisPress = input.throwGrenade;
662 // One throw a press, like a semi-automatic trigger: holding a key down does
663 // not empty a pocket that only ever holds one of that kind anyway. Asking for
664 // the other kind while the first is held is a new press and throws it, which
665 // is the only sensible reading of two keys for two pockets.
666 if (input.throwing !== null && input.throwing !== p.threwThisPress) throwOne(world, p, input.throwing);
667 p.threwThisPress = input.throwing;
619668 if (world.time - p.lastShotAt > 0.35) p.shotIndex = 0;
620669 p.punch = [p.punch[0] * Math.pow(0.02, dt), p.punch[1] * Math.pow(0.02, dt)];
621670 updateBomb(world, p, input, dt);
@@ -645,7 +694,7 @@ export function update(world: World, dt: number, input: Input): void {
645694 p.armor = 0;
646695 p.helmet = false;
647696 p.defuseKit = false;
648 p.grenades = 0;
697 p.pocket = { he: 0, flash: 0 };
649698 p.secondary = p.team === "t" ? "glock" : "usp";
650699 p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) };
651700 p.weapon = p.secondary;
@@ -658,6 +707,11 @@ export function update(world: World, dt: number, input: Input): void {
658707 }
659708 if (world.match.phase === "freeze" && before === "over") startRound(world);
660709
710 // A flash burns down on the same clock its fuse did — the same number of
711 // ticks on a phone as on a desktop, and the same in a replay as in a match —
712 // and it does so for everybody, standing or dead, before anyone acts on it.
713 for (const p of world.players) if (p.blind > 0) p.blind = Math.max(0, p.blind - dt);
714
661715 applyInput(world, world.me, world.me.alive ? input : NO_INPUT, dt);
662716 for (const p of world.players) {
663717 if (!p.bot || !p.alive) continue;
src/games.ts +14 −1
@@ -235,6 +235,19 @@ export type Game = {
235235 * the ceiling did not move — but like the range's after #88 that is the thinnest
236236 * headroom on this page, and the next thing the shooter gains will want it
237237 * raised. Neither other bundle moved a byte: nothing outside `src/client/` did.
238 * #164 put the second grenade in the game — a flashbang through the machinery
239 * #142 built, which is one `Grenade.weapon`, one `Flash` row in the weapon
240 * table, `blindSeconds` and `flashHits`, a `blind` on every body, the white-out
241 * in the HUD, the flashed bot in `bots.ts`, a mesh, a sound and a key. The app
242 * grew 2,361 raw / 742 gzipped bytes for all of it, measured on top of #176:
243 * 86,909 → 89,270 raw and 39,626 → 40,368 gzipped. That is past the raw half of
244 * the 88,000 / 40,500 #142 set — the line above about that being the thinnest
245 * headroom on this page was right, and this is the pull that wanted it raised —
246 * so the ceiling is 92,000 / 42,000 here, today's size plus about three per
247 * cent of raw and four of gzip, which is room for an honest feature and not
248 * room for a stray import of something large. Neither other bundle moved a
249 * byte: nothing outside `src/client/` and `src/pages/game.ts` changed here, and
250 * neither of them imports either. Measured 2026-09-20, with #176 merged in.
238251 */
239252 export const GAMES: readonly Game[] = [
240253 {
@@ -242,7 +255,7 @@ export const GAMES: readonly Game[] = [
242255 href: "/",
243256 label: "cs",
244257 entry: "src/client/main.ts",
245 ceiling: { raw: 88_000, gzip: 40_500 },
258 ceiling: { raw: 92_000, gzip: 42_000 },
246259 blurb:
247260 "a defusal shooter in the browser, with bots that learned to aim, bomb rounds, an economy, maps carved from a seed and the leaderboard this server keeps.",
248261 },
src/pages/game.ts +6 −2
@@ -91,7 +91,7 @@ const menu = (visitor: Who): string => `
9191 to your site is the walk to theirs.</p>
9292 <p class="hint" id="prep">Preparing textures…</p>
9393 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
94 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw a grenade ·
94 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang ·
9595 <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy ·
9696 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen ·
9797 <kbd>Esc</kbd> pause</p>
@@ -124,6 +124,7 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions
124124 [
125125 { action: "reload", label: "reload" },
126126 { action: "grenade", label: "nade" },
127 { action: "flash", label: "flash" },
127128 { action: "buy", label: "buy" },
128129 { action: "fire", label: "fire" },
129130 ],
@@ -142,7 +143,10 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions
142143 (friction, air acceleration, 18-unit steps, strafe jumping), CS damage with armour and
143144 hitgroups, fixed spray patterns you can learn, a round clock, a bomb, the buy economy and an
144145 HE grenade that bounces off the walls on the same solver the players walk on — 98 damage at
145 the centre falling off over 350 units, on a 1.5-second fuse, and only to what it can see.
146 the centre falling off over 350 units, on a 1.5-second fuse, and only to what it can see —
147 and a flashbang thrown the same way, which takes sight rather than health: up to 1.6 seconds
148 of it, by the angle you were looking at it from and how far away it went off, and none at all
149 through a wall or over your shoulder.
146150 Everything you see is generated — the map, the textures, the models and the sounds — so the
147151 whole game is one small download and no asset files.</p>
148152 <p>The bots path to a site and plant or defuse on their own, but they do not aim by rule:
static/app.467be451f6.js added +58

Diff not shown — a line 71,064 characters wide (limit 2,000). +58

View file
static/app.9a24651e0a.js deleted −58

Diff not shown — a line 68,704 characters wide (limit 2,000). −58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs