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

forked from trevato/cs · proposes

The first grenade: an HE you buy, throw and hear, thrown through the engine's own solver #157

Merged opened this

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

Merged

torque/cs-engine:torque/142-the-first-grenade-an-he-you-buy-throw-an → main 2 commits fork

  • Merged. torque/142-the-first-grenade-an-he-you-buy-throw-an was merged into main as d838f36.

Review

Sign in to review this change.
28 files changed +1075 −134
README.md +11 −3
@@ -102,6 +102,7 @@ whatever the name field said, and a result posted without one is a **guest**.
102102
103103 <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch,
104104 <kbd>Shift</kbd> walk, mouse aims and fires, <kbd>R</kbd> reload,
105 <kbd>G</kbd> throws the HE grenade in your pocket, if you bought one,
105106 <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze
106107 time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns, <kbd>Tab</kbd> shows
107108 the scoreboard, <kbd>F</kbd> puts the stage on the whole screen and
@@ -298,12 +299,19 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
298299 rooms, corridors, two sites, two spawns and cover, every cell stamped twice
299300 so the map is its own opponent's map turned half a circle, and a gate that
300301 refuses a layout a round cannot be played on),
301 `movement` (Counter-Strike's numbers, and nothing else: `CS_MOVE`, `CS_HULL`
302 and `fallDamage`, handed to the engine's mover on every call — the solver
302 `movement` (Counter-Strike's numbers, and nothing else: `CS_MOVE`, `CS_HULL`,
303 `NADE_MOVE`, `NADE_HULL` and `fallDamage`, handed to the engine's mover on
304 every call — the solver
303305 itself went behind the door in #123 and holds none of them, and
304306 `movement.test.ts` is now only about whether this game still feels like this
305307 game, the generality having moved to `src/engine/physics.test.ts`), `weapons`,
306 `combat`, `match`
308 `combat`,
309 `grenade` (the one thrown thing: a body the engine's own solver moves on the
310 grenade's tuning — 0.4 of CS's gravity, 0.45 elasticity, its own four-unit
311 hull — a fuse that burns on the simulation's clock, and a blast that falls
312 off in a straight line from 98 at the centre to nothing at 350 units and
313 reaches only what `traceWorld` says it can see),
314 `match`
307315 (the rules),
308316 `world` (the simulation),
309317 `bots`, `observe`, `action`, `weights`, `models`, `materials`,
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: 82_000, gzip: 38_000 } },
40 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 88_000, gzip: 40_500 } },
4141 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 33_000, gzip: 14_000 } },
4242 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 10_800, gzip: 5_100 } },
4343 ]);
src/client/action.ts +2
@@ -65,6 +65,8 @@ 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,
6870 switchTo,
6971 };
7072 }
src/client/audio.ts +33
@@ -6,10 +6,13 @@
66 */
77
88 import { attenuate, createSoundDevice, dist, type SoundDevice, type Vec3 } from "../engine/index.ts";
9 import type { GrenadeSound } from "./grenade.ts";
910 import { WEAPONS, type WeaponId } from "./weapons.ts";
1011
1112 export type Audio = {
1213 shot(weapon: WeaponId, at: Vec3, listener: Vec3): void;
14 /** The three noises one grenade makes: leaving the hand, landing, going off. */
15 grenade(sound: GrenadeSound, at: Vec3, listener: Vec3): void;
1316 hit(headshot: boolean): void;
1417 beep(pitch: number, gain?: number): void;
1518 resume(): void;
@@ -22,12 +25,22 @@ const TONE: Record<string, { cut: number; thump: number; length: number }> = {
2225 rifle: { cut: 1500, thump: 110, length: 0.24 },
2326 sniper: { cut: 1000, thump: 80, length: 0.5 },
2427 knife: { cut: 4200, thump: 400, length: 0.08 },
28 // Never heard: a grenade is thrown rather than fired, and its three noises
29 // are below. It is here so the table covers every kind a weapon can be.
30 grenade: { cut: 2600, thump: 300, length: 0.1 },
2531 };
2632
2733 /** Full volume within 120 units, and audible across the map after that. */
2834 const EARSHOT = 900;
2935 const CLOSE = 120;
3036
37 /**
38 * A blast carries much further than a gunshot and is loud over a much wider
39 * circle: full volume out to 300 units and still worth hearing at 2,400.
40 */
41 const BLAST_EARSHOT = 2400;
42 const BLAST_CLOSE = 300;
43
3144 /** The shot a weapon makes: its filter colour, its thump and how long it rings. */
3245 export const shotTone = (weapon: WeaponId): { cut: number; thump: number; length: number } =>
3346 TONE[WEAPONS[weapon]?.kind ?? "rifle"] ?? (TONE.rifle as { cut: number; thump: number; length: number });
@@ -62,6 +75,26 @@ export function createAudio(device: SoundDevice = createSoundDevice()): Audio {
6275 length: tone.length,
6376 });
6477 },
78 grenade(sound, at, listener) {
79 const far = dist(at, listener);
80 if (sound === "throw") {
81 // A short upward whoosh of air: quiet, and only near you.
82 device.noiseBurst({ frequency: 2400, gain: 0.14 * loudness(at, listener), length: 0.16, q: 0.7, rate: 1.4 });
83 return;
84 }
85 if (sound === "bounce") {
86 // A hard tap: the shell against concrete, tight and short.
87 const volume = loudness(at, listener);
88 device.noiseBurst({ frequency: 1200, gain: 0.22 * volume, length: 0.07, q: 4, rate: 1 });
89 device.tone({ frequency: 260, slideTo: 150, gain: 0.16 * volume, length: 0.08 });
90 return;
91 }
92 // The blast: a wide, low body of noise with a thump under it that falls
93 // an octave and a half as it goes.
94 const volume = attenuate(far, BLAST_EARSHOT, BLAST_CLOSE);
95 device.noiseBurst({ frequency: 360, gain: 0.7 * volume, length: 1.1, q: 0.35, rate: 0.6 });
96 device.tone({ frequency: 130, slideTo: 42, gain: 0.55 * volume, length: 0.85 });
97 },
6598 hit(headshot) {
6699 audio.beep(headshot ? 1400 : 900, 0.08);
67100 },
src/client/binding.test.ts +2 −1
@@ -17,12 +17,13 @@ test("the bindings are the ones the keyboard has always had", () => {
1717 expect(CS_KEYS.KeyR).toBe("reload");
1818 expect(CS_KEYS.KeyE).toBe("use");
1919 expect(CS_KEYS.KeyB).toBe("buy");
20 expect(CS_KEYS.KeyG).toBe("grenade");
2021 expect(CS_KEYS.Tab).toBe("scoreboard");
2122 expect(CS_KEYS.Digit1).toBe("slot1");
2223 expect(CS_KEYS.Digit3).toBe("slot3");
2324 expect(CS_MOUSE[0]).toBe("fire");
2425 expect(CS_MOUSE[2]).toBe("scope");
25 expect([...CS_TAP]).toEqual(["jump", "reload", "buy"]);
26 expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade"]);
2627 // F is the page's, not the world's: `main.ts` hears it on `onAction` and asks
2728 // the browser for the screen, and nothing about an `Input` changes for it.
2829 expect(CS_KEYS.KeyF).toBe("fullscreen");
src/client/binding.ts +3 −2
@@ -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", KeyB: "buy", Tab: "scoreboard",
16 KeyR: "reload", KeyE: "use", KeyG: "grenade", 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"] as const;
27 export const CS_TAP = ["jump", "reload", "buy", "grenade"] as const;
2828
2929 export type Intent = {
3030 input: Input;
@@ -51,6 +51,7 @@ 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"),
5455 switchTo: null,
5556 };
5657 return {
src/client/combat.ts +22 −8
@@ -81,14 +81,17 @@ export function traceBullet(
8181 return { kind: "none", point: to };
8282 }
8383
84 /** Apply a hit and say what it cost: health off, armour off, and whether it killed. */
85 export function applyHit(
84 /**
85 * Take `health` off a body and `armor` off its vest, and say what it cost and
86 * whether it killed. Where the two numbers came from — a bullet's hitgroup and
87 * range, or a blast's distance — is the caller's business; what a body does
88 * about them is the same either way, and is this.
89 */
90 export function applyDamage(
8691 target: Actor,
87 weapon: Weapon,
88 group: Hitgroup,
89 distance: number,
90 ): { damage: number; killed: boolean; headshot: boolean } {
91 const { health, armor } = computeDamage(weapon, distance, group, target.armor, target.helmet);
92 health: number,
93 armor: number,
94 ): { damage: number; killed: boolean } {
9295 const dealt = Math.min(health, target.health);
9396 target.armor = Math.max(0, target.armor - armor);
9497 target.health -= health;
@@ -97,5 +100,16 @@ export function applyHit(
97100 target.health = 0;
98101 target.alive = false;
99102 }
100 return { damage: dealt, killed, headshot: group === "head" };
103 return { damage: dealt, killed };
104 }
105
106 /** Apply a hit and say what it cost: health off, armour off, and whether it killed. */
107 export function applyHit(
108 target: Actor,
109 weapon: Weapon,
110 group: Hitgroup,
111 distance: number,
112 ): { damage: number; killed: boolean; headshot: boolean } {
113 const { health, armor } = computeDamage(weapon, distance, group, target.armor, target.helmet);
114 return { ...applyDamage(target, health, armor), headshot: group === "head" };
101115 }
src/client/generate.test.ts +6 −1
@@ -214,8 +214,13 @@ test("a bot walks a map nobody wrote, on that map's own cells", () => {
214214 * generator — a room more, a crate fewer — moves it, and that is the point:
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.
217 *
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.
217222 */
218 const GENERATED_ROUND = "95aedb752f57a576d996a90212f973a4233a2906c2625c12a89b3b2222ef6f2e";
223 const GENERATED_ROUND = "48a1d4466065e045b8933b3fbf5ece767a825a60165e6455b4e310a8d7dd5cae";
219224
220225 test("a seeded round on a generated map plays exactly as it played", () => {
221226 const level = generateLevel(5);
src/client/grenade.test.ts added +333
@@ -0,0 +1,333 @@
1 /**
2 * The grenade, checked by hand.
3 *
4 * The flight is arithmetic and is asserted as arithmetic: the solver takes
5 * both half-kicks of gravity before it moves a body down, which is
6 * semi-implicit Euler, so after `n` ticks of `dt` a body thrown level from
7 * `z0` at `v` is at
8 *
9 * x = v · n · dt z = z0 − g · dt² · n(n+1)/2
10 *
11 * — half a tick of gravity below the continuous parabola, and exact to the
12 * bit. Everything below is that formula, the bounce (0.45 of what it arrived
13 * with, reversed), and the blast's own numbers: 98 in the lap, 49 at half the
14 * radius, nothing at 350.
15 */
16
17 import { expect, test } from "bun:test";
18 import { DE_FORGE } from "./map.ts";
19 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";
23 import type { Actor } from "./combat.ts";
24 import type { Box, Vec3 } from "../engine/index.ts";
25 import { digest, TICK, withoutEntropy } from "./replay.ts";
26 import {
27 buy, createWorld, eyePos, MAX_GRENADES, NO_INPUT, throwOne, update,
28 type Input, type Player, type World,
29 } from "./world.ts";
30
31 const FLOOR: Box = { min: [-4000, -4000, -64], max: [4000, 4000, 0], mat: 0 };
32 const EAST_WALL: Box = { min: [300, -4000, 0], max: [364, 4000, 400], mat: 0 };
33
34 /** A body at `pos`, made the way a test can read. */
35 const actor = (pos: Vec3, over: Partial<Actor> = {}): Actor => ({
36 id: 9, team: "t", pos, yaw: 0, duckAmount: 0, health: 100, armor: 0, helmet: false, alive: true,
37 ...over,
38 });
39
40 const EAST: Vec3 = [1, 0, 0];
41
42 test("the grenade is in the buy menu with CS's numbers, and carries a blast", () => {
43 expect(WEAPONS.he).toBe(HE);
44 expect(HE.price).toBe(300);
45 expect(HE.blast).toEqual({ damage: 98, radius: 350, fuse: 1.5, armorPenetration: 0.5 });
46 // Its own tuning, and not the player's: 0.4 of CS's gravity, a bounce, and
47 // no step — a grenade does not climb an eighteen-unit ledge like a person.
48 expect(NADE_MOVE.gravity).toBe(320);
49 expect(NADE_MOVE.restitution).toBe(0.45);
50 expect(NADE_MOVE.stepSize).toBe(0);
51 expect(NADE_HULL.stand).toBe(4);
52 });
53
54 test("a grenade thrown level flies where the arithmetic says", () => {
55 const g = throwGrenade(0, "ct", [0, 0, 64], EAST, [FLOOR]);
56 // It leaves the hand eighteen units ahead of the eye, at 750 u/s.
57 expect(g.pos).toEqual([18, 0, 64]);
58 expect(g.vel).toEqual([THROW_SPEED, 0, 0]);
59 const gravity = NADE_MOVE.gravity;
60 for (let n = 1; n <= 16; n++) {
61 stepGrenade(g, TICK, [FLOOR]);
62 expect(g.pos[0]).toBeCloseTo(18 + THROW_SPEED * n * TICK, 6);
63 expect(g.pos[2]).toBeCloseTo(64 - gravity * TICK * TICK * ((n * (n + 1)) / 2), 6);
64 expect(g.fuse).toBeCloseTo(HE.blast.fuse - n * TICK, 10);
65 }
66 // A quarter of a second in: 187.5 units along, and 10.2 units of drop.
67 const quarter = 16;
68 expect(g.pos[0] - 18).toBeCloseTo(THROW_SPEED * quarter * TICK, 6);
69 expect(64 - g.pos[2]).toBeCloseTo(gravity * TICK * TICK * ((quarter * (quarter + 1)) / 2), 6);
70 });
71
72 test("a grenade thrown at a wall comes back with 0.45 of what it arrived with", () => {
73 const g = throwGrenade(0, "ct", [0, 0, 64], EAST, [FLOOR, EAST_WALL]);
74 let bounces = 0;
75 let after = 0;
76 for (let n = 0; n < 64 && bounces === 0; n++) {
77 if (stepGrenade(g, TICK, [FLOOR, EAST_WALL])) {
78 bounces += 1;
79 after = g.vel[0];
80 }
81 }
82 expect(bounces).toBe(1);
83 // 750 out, 0.45 of it back: −337.5 u/s, and it is against the wall's face,
84 // its own two-unit radius short of it.
85 expect(after).toBeCloseTo(-THROW_SPEED * NADE_MOVE.restitution, 6);
86 expect(g.pos[0]).toBeCloseTo(EAST_WALL.min[0] - NADE_HULL.radius, 6);
87 });
88
89 test("a grenade dropped on the floor bounces, then comes to rest on it", () => {
90 const g = throwGrenade(0, "ct", [0, 0, 200], [0, 0, -1], [FLOOR]);
91 let bounces = 0;
92 for (let n = 0; n < 64 * 6; n++) if (stepGrenade(g, TICK, [FLOOR])) bounces += 1;
93 expect(bounces).toBeGreaterThan(0);
94 expect(g.onGround).toBe(true);
95 expect(g.pos[2]).toBe(0);
96 expect(Math.hypot(g.vel[0], g.vel[1], g.vel[2])).toBeLessThan(1);
97 });
98
99 test("the blast falls off in a straight line to nothing at the radius", () => {
100 const blast = HE.blast;
101 expect(blastDamage(blast, 0)).toBeCloseTo(98, 10);
102 expect(blastDamage(blast, blast.radius / 2)).toBeCloseTo(49, 10);
103 expect(blastDamage(blast, blast.radius)).toBe(0);
104 expect(blastDamage(blast, blast.radius + 1)).toBe(0);
105 expect(blastDamage(blast, 87.5)).toBeCloseTo(73.5, 10);
106 });
107
108 test("the blast is 98 at the centre, 49 at half the radius, and armour halves it", () => {
109 // The grenade at the feet of a body standing at the origin: the centre of
110 // the blast is 2 units up and the middle of the chest is 54, so 52 units is
111 // as close as a standing body gets — 98 · (1 − 52/350) = 83.4.
112 const g = throwGrenade(0, "ct", [0, 0, 0], [0, 0, 0], [FLOOR]);
113 const at = (x: number, over: Partial<Actor> = {}): { health: number; armor: number } => {
114 const hits = blastHits(g, [actor([x, 0, 0], over)], []);
115 return { health: hits[0]?.health ?? 0, armor: hits[0]?.armor ?? 0 };
116 };
117 const centre = blastCentre(g);
118 const chest = 0.75 * 72; // the middle of the chest box, 62%..88% of a 72-unit hull
119 const near = Math.hypot(0, chest - centre[2]);
120 expect(at(0).health).toBe(Math.round(blastDamage(HE.blast, near)));
121 expect(at(0).health).toBe(83);
122
123 // Half the radius away along the floor: √(175² + 52²) = 182.6 units, which
124 // is 98 · (1 − 182.6/350) = 46.9.
125 const half = Math.hypot(175, chest - centre[2]);
126 expect(at(175).health).toBe(Math.round(blastDamage(HE.blast, half)));
127 expect(at(175).health).toBe(47);
128
129 // Past it, nothing at all — no entry, not a zero.
130 expect(blastHits(g, [actor([400, 0, 0])], [])).toEqual([]);
131
132 // Armour takes half of what it stops, like every other source of damage:
133 // 83.44 becomes 42 through the vest, and the vest wears 21 down.
134 expect(at(0, { armor: 100 })).toEqual({ health: 42, armor: 21 });
135 });
136
137 test("a grenade behind a crate hurts nobody on the far side", () => {
138 const g = throwGrenade(0, "ct", [0, 0, 0], [0, 0, 0], [FLOOR]);
139 const behind = actor([200, 0, 0]);
140 // In the open, 200 units is well inside the radius and it hurts.
141 expect(blastHits(g, [behind], [FLOOR])[0]?.health).toBeGreaterThan(0);
142 // A crate between the two takes all of it: `traceWorld` decides, not distance.
143 const crate: Box = { min: [100, -80, 0], max: [140, 80, 90], mat: 0 };
144 expect(blastHits(g, [behind], [FLOOR, crate])).toEqual([]);
145 // And the man standing beside the crate, with nothing in the way, still dies.
146 expect(blastHits(g, [actor([200, 200, 0])], [FLOOR, crate])[0]?.health).toBeGreaterThan(0);
147 });
148
149 test("the blast does not reach the thrower's own side", () => {
150 const g = throwGrenade(0, "ct", [0, 0, 0], [0, 0, 0], [FLOOR]);
151 expect(blastHits(g, [actor([0, 0, 0], { team: "ct" })], [FLOOR])).toEqual([]);
152 expect(blastHits(g, [actor([0, 0, 0], { team: "t" })], [FLOOR]).length).toBe(1);
153 });
154
155 // --- in a round -----------------------------------------------------------
156
157 const TICKS_PER_SECOND = 64;
158
159 /** A world past the freeze, with `me` holding one grenade and nothing else to do. */
160 function ready(seed: number, teamSize = 5): World {
161 const world = createWorld(DE_FORGE, SHORT, "me", "ct", teamSize, seed);
162 world.me.money = 5000;
163 expect(buy(world, world.me, "he")).toBe(true);
164 for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
165 expect(world.match.phase).toBe("live");
166 return world;
167 }
168
169 test("a grenade is bought like anything else, one at a time, and goes in a pocket", () => {
170 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 11);
171 world.me.money = 5000;
172 const before = world.me.weapon;
173 expect(buy(world, world.me, "he")).toBe(true);
174 expect(world.me.grenades).toBe(MAX_GRENADES);
175 expect(world.me.money).toBe(5000 - HE.price);
176 // It did not become the gun in your hands and it did not displace one.
177 expect(world.me.weapon).toBe(before);
178 expect(world.me.primary).toBe(null);
179 // One a round, CS's rule: the second is refused and costs nothing.
180 expect(buy(world, world.me, "he")).toBe(false);
181 expect(world.me.money).toBe(5000 - HE.price);
182 });
183
184 test("the throw key throws one, once a press, and never during the freeze", () => {
185 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 12);
186 world.me.money = 5000;
187 buy(world, world.me, "he");
188 const held: Input = { ...NO_INPUT, throwGrenade: true };
189 // Frozen: the key does nothing and the grenade stays in the pocket.
190 update(world, TICK, held);
191 expect(world.grenades.length).toBe(0);
192 expect(world.me.grenades).toBe(1);
193
194 for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
195 // Live, and held down for half a second: exactly one leaves the hand.
196 for (let t = 0; t < 32; t++) update(world, TICK, held);
197 expect(world.grenades.length).toBe(1);
198 expect(world.me.grenades).toBe(0);
199 expect(world.grenades[0]?.owner).toBe(world.me.id);
200 // And the throw was heard where the hand was.
201 expect(world.effects.some((e) => e.kind === "sound" && e.name === "throw")).toBe(true);
202 });
203
204 test("the fuse is the same number of ticks whatever the frame rate", () => {
205 /** Step `world` in frames of `perFrame` ticks; return the tick it went off on. */
206 const fuseTicks = (perFrame: number): number => {
207 const world = ready(21);
208 world.me.pitch = THROW_LIFT; // aim down by the lift: a dead level throw
209 expect(throwOne(world, world.me)).toBe(true);
210 let ticks = 0;
211 while (world.grenades.length > 0 && ticks < 64 * 5) {
212 for (let f = 0; f < perFrame; f++) {
213 update(world, TICK, NO_INPUT);
214 ticks += 1;
215 }
216 }
217 return ticks;
218 };
219 // 1.5 seconds of 64 Hz ticks is 96 of them, and a frame that batches four of
220 // them at a time — a sixteen-frame-a-second phone — detonates on the same one.
221 expect(fuseTicks(1)).toBe(Math.ceil(HE.blast.fuse * TICKS_PER_SECOND));
222 expect(fuseTicks(4)).toBe(fuseTicks(1));
223 });
224
225 test("a blast that hurts someone marks the hit, and one that kills names the grenade", () => {
226 // One a side, so the only thing that can hurt anybody here is the grenade:
227 // nobody on this player's team exists to shoot, and this player never fires.
228 const world = ready(31, 1);
229 const victim = world.players.find((p) => p.team === "t") as Player;
230 const where: Vec3 = [victim.pos[0], victim.pos[1], victim.pos[2]];
231 victim.armor = 0;
232 const kills = world.me.kills;
233 expect(throwOne(world, world.me)).toBe(true);
234 let healthBefore = victim.health;
235 let damageBefore = world.me.damage;
236 for (let t = 0; t < 2 * TICKS_PER_SECOND && world.grenades.length > 0; t++) {
237 // Hold the victim still and the grenade at their feet: what is under test
238 // is the blast, not whether a thrown one can be aimed at a moving bot.
239 victim.pos = [where[0], where[1], where[2]];
240 for (const g of world.grenades) {
241 g.pos = [where[0], where[1], where[2]];
242 g.vel = [0, 0, 0];
243 }
244 healthBefore = victim.health;
245 damageBefore = world.me.damage;
246 update(world, TICK, NO_INPUT);
247 }
248
249 expect(victim.health).toBeLessThan(healthBefore);
250 expect(world.me.damage).toBeGreaterThan(damageBefore);
251 expect(world.effects.some((e) => e.kind === "hitmark")).toBe(true);
252 expect(world.effects.some((e) => e.kind === "blast")).toBe(true);
253 expect(world.effects.some((e) => e.kind === "sound" && e.name === "blast")).toBe(true);
254 // 83 hit points at the feet of a body with no vest is not a kill on its own,
255 // so a kill is only asserted when the blast actually finished them.
256 if (!victim.alive) {
257 expect(world.me.kills).toBe(kills + 1);
258 const entry = world.feed[world.feed.length - 1];
259 expect(entry?.weapon).toBe("he");
260 expect(WEAPONS[entry?.weapon ?? "knife"].name).toBe("HE Grenade");
261 }
262 });
263
264 test("a blast that finishes a hurt man puts the grenade in the feed", () => {
265 const world = ready(33, 1);
266 const victim = world.players.find((p) => p.team === "t") as Player;
267 victim.health = 20; // one blast away, whatever the falloff says
268 const where: Vec3 = [victim.pos[0], victim.pos[1], victim.pos[2]];
269 expect(throwOne(world, world.me)).toBe(true);
270 for (let t = 0; t < 2 * TICKS_PER_SECOND && world.grenades.length > 0; t++) {
271 victim.pos = [where[0], where[1], where[2]];
272 for (const g of world.grenades) {
273 g.pos = [where[0], where[1], where[2]];
274 g.vel = [0, 0, 0];
275 }
276 update(world, TICK, NO_INPUT);
277 }
278 expect(victim.alive).toBe(false);
279 expect(world.me.kills).toBe(1);
280 const entry = world.feed[world.feed.length - 1];
281 expect(entry?.weapon).toBe("he");
282 expect(entry?.killer).toBe(world.me.name);
283 expect(WEAPONS.he.name).toBe("HE Grenade");
284 });
285
286 test("a grenade is drawn where the body is, and forgotten when it goes off", () => {
287 const world = ready(41);
288 expect(throwOne(world, world.me)).toBe(true);
289 const g = world.grenades[0];
290 if (g === undefined) throw new Error("the throw made no grenade");
291 const start: Vec3 = [g.pos[0], g.pos[1], g.pos[2]];
292 for (let t = 0; t < 16; t++) update(world, TICK, NO_INPUT);
293 expect(world.grenades.length).toBe(1);
294 expect(g.pos).not.toEqual(start);
295 for (let t = 0; t < 2 * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
296 expect(world.grenades.length).toBe(0);
297 });
298
299 test("a round with a grenade in it is still bit-for-bit reproducible", () => {
300 const TICKS = 8 * TICKS_PER_SECOND;
301 // A script that buys one in the freeze and throws it the moment it is live.
302 const script = [
303 { atTick: 0, input: {}, buy: "he" as const },
304 { atTick: 5 * TICKS_PER_SECOND, input: { throwGrenade: true } },
305 { atTick: 5 * TICKS_PER_SECOND + 8, input: {} },
306 ];
307 const run = (): ReturnType<typeof digest> =>
308 withoutEntropy(() => {
309 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7);
310 world.me.money = 5000;
311 return digest(world, TICKS, script);
312 });
313 const quiet = withoutEntropy(() => digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), TICKS));
314 const a = run();
315 const b = run();
316 expect(a.digest).toBe(b.digest);
317 expect(a.seconds).toEqual(b.seconds);
318 // And it is a different run from the one where nobody threw anything: the
319 // grenade is in the hash, so a thrown one cannot hide.
320 expect(a.digest).not.toBe(quiet.digest);
321 });
322
323 test("an input that never sets the throw plays exactly as it did before", () => {
324 // The whole of what a bot had to learn for this feature: nothing.
325 expect(NO_INPUT.throwGrenade).toBe(false);
326 const world = ready(51);
327 world.me.grenades = 1;
328 const before = eyePos(world.me);
329 for (let t = 0; t < 64; t++) update(world, TICK, NO_INPUT);
330 expect(world.grenades.length).toBe(0);
331 expect(world.me.grenades).toBe(1);
332 expect(before.length).toBe(3);
333 });
src/client/grenade.ts added +158
@@ -0,0 +1,158 @@
1 /**
2 * The thrown grenade: a body, a fuse and a blast.
3 *
4 * There is no solver in this file. A grenade in flight is a `Body` like a
5 * player is, and it is moved by the same `move` in `src/engine/physics.ts` —
6 * handed `NADE_MOVE` and `NADE_HULL` from `src/client/movement.ts` instead of
7 * the player's pair, which is the whole of why it floats, bounces and comes to
8 * rest instead of walking. It has its own four-unit hull on purpose: sharing
9 * the player's would let it step eighteen units up a ledge like a person.
10 *
11 * The fuse burns on the simulation's clock — `dt` seconds a tick, at the fixed
12 * 64 Hz `world.ts` steps at — so it is the same number of ticks on a phone as
13 * on a desktop, and the same number in a replay as in a match.
14 *
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 */
21
22 import { dist, move, stuck, visible, type Body, type Box, type Vec3, type Wish } from "../engine/index.ts";
23 import { hitboxes, type Actor } from "./combat.ts";
24 import { NADE_HULL, NADE_MOVE } from "./movement.ts";
25 import { blastDamage, HE, throughArmor } from "./weapons.ts";
26
27 /** A grenade in the world: a body, whose it is, and how long it has left. */
28 export type Grenade = Body & {
29 /** The player id that threw it — who the kill is credited to. */
30 owner: number;
31 team: "t" | "ct";
32 /** Seconds left on the fuse. It detonates on the tick this reaches zero. */
33 fuse: number;
34 };
35
36 /**
37 * A grenade wishes for nothing: it has no legs. The same object every tick,
38 * because it never changes and a frame allocates nothing.
39 */
40 const STILL: Wish = {
41 forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: 0,
42 };
43
44 /** How fast it leaves the hand, in u/s. CS's full throw is 750. */
45 export const THROW_SPEED = 750;
46
47 /**
48 * Degrees above the crosshair a throw goes. CS tilts a throw up by ten so that
49 * a grenade thrown at a wall across the room arrives at head height rather
50 * than at your feet; aim ten degrees down and it flies dead level.
51 */
52 export const THROW_LIFT = 10;
53
54 /** How far in front of the eye it appears, in units: out of the hand, not the face. */
55 const HAND = 18;
56
57 /** The sounds a grenade makes, in the order one round of it happens. */
58 export type GrenadeSound = "throw" | "bounce" | "blast";
59
60 /**
61 * Below this the knock of a bounce is not worth a sound: a grenade rolling to
62 * a stop taps the floor every tick and nobody wants to hear that. In u/s of
63 * the component that reversed.
64 */
65 const HEARD = 90;
66
67 /**
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.
72 */
73 export function throwGrenade(
74 owner: number,
75 team: "t" | "ct",
76 from: Vec3,
77 dir: Vec3,
78 boxes: Box[],
79 ): Grenade {
80 const ahead: Vec3 = [
81 from[0] + dir[0] * HAND,
82 from[1] + dir[1] * HAND,
83 from[2] + dir[2] * HAND,
84 ];
85 const clear = !stuck(ahead, NADE_HULL.stand, boxes, NADE_HULL);
86 const pos: Vec3 = clear ? ahead : [from[0], from[1], from[2]];
87 return {
88 pos,
89 vel: [dir[0] * THROW_SPEED, dir[1] * THROW_SPEED, dir[2] * THROW_SPEED],
90 onGround: false,
91 ducking: false,
92 duckAmount: 0,
93 owner,
94 team,
95 fuse: HE.blast.fuse,
96 };
97 }
98
99 /**
100 * One tick of a grenade: the engine's solver moves it, the fuse burns by `dt`.
101 * True when it hit something hard enough to be heard — which is a component of
102 * its velocity coming back the other way, since a bounce is the only thing
103 * that can reverse one.
104 */
105 export function stepGrenade(g: Grenade, dt: number, boxes: Box[]): boolean {
106 const vx = g.vel[0], vy = g.vel[1], vz = g.vel[2];
107 move(g, STILL, dt, boxes, NADE_MOVE, NADE_HULL);
108 g.fuse -= dt;
109 return (
110 (vx * g.vel[0] < 0 && Math.abs(vx) > HEARD) ||
111 (vy * g.vel[1] < 0 && Math.abs(vy) > HEARD) ||
112 (vz * g.vel[2] < 0 && Math.abs(vz) > HEARD)
113 );
114 }
115
116 /** The middle of the explosion: the middle of the grenade, not its feet. */
117 export const blastCentre = (g: Grenade): Vec3 => [g.pos[0], g.pos[1], g.pos[2] + NADE_HULL.stand / 2];
118
119 /** What the blast is aimed at on a body: the middle of the chest box. */
120 function chestOf(actor: Actor): Vec3 {
121 const parts = hitboxes(actor);
122 const chest = parts.find((p) => p.group === "chest") ?? parts[0];
123 if (chest === undefined) return [actor.pos[0], actor.pos[1], actor.pos[2]];
124 return [
125 (chest.min[0] + chest.max[0]) / 2,
126 (chest.min[1] + chest.max[1]) / 2,
127 (chest.min[2] + chest.max[2]) / 2,
128 ];
129 }
130
131 /** One body the blast reached, and what it cost them. */
132 export type Caught = { actor: Actor; distance: number; health: number; armor: number };
133
134 /**
135 * Who this blast reaches and for how much: the falloff in `weapons.ts` decides
136 * the damage by distance, `throughArmor` takes the vest's share of it like
137 * every other source of damage in this game, and the engine's `visible`
138 * decides whether any of it arrives at all.
139 *
140 * The thrower's own side is left out — this match has no friendly fire, the
141 * same rule a bullet is under in `world.ts` — which also means a grenade does
142 * not hurt the person who threw it.
143 */
144 export function blastHits(g: Grenade, actors: Actor[], boxes: Box[]): Caught[] {
145 const blast = HE.blast;
146 const centre = blastCentre(g);
147 const caught: Caught[] = [];
148 for (const actor of actors) {
149 if (!actor.alive || actor.team === g.team) continue;
150 const chest = chestOf(actor);
151 const distance = dist(centre, chest);
152 const raw = blastDamage(blast, distance);
153 if (raw <= 0) continue;
154 if (!visible(centre, chest, boxes)) continue;
155 caught.push({ actor, distance, ...throughArmor(raw, actor.armor, blast.armorPenetration) });
156 }
157 return caught;
158 }
src/client/hud.ts +7 −2
@@ -166,8 +166,13 @@ export function drawHud(
166166 const ammo = view.ammo[weapon.id];
167167 const ammoText = weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`;
168168 ui.text(ammoText, width - pad, height - pad, { align: "right", font: big, fill: WHITE });
169 ui.text(weapon.name + (view.hasBomb ? " · bomb" : ""), width - pad, height - pad - Math.round(30 * scale),
170 { align: "right", font: small, fill: GREY });
169 // What is in your hands, and then what is in your pockets: the HE you bought
170 // and the bomb, each named only while you are carrying it.
171 ui.text(
172 weapon.name + (view.grenades > 0 ? " · HE" : "") + (view.hasBomb ? " · bomb" : ""),
173 width - pad, height - pad - Math.round(30 * scale),
174 { align: "right", font: small, fill: GREY },
175 );
171176 if (world.time < view.reloadUntil)
172177 ui.text("reloading", width - pad, height - pad - Math.round(50 * scale), { align: "right", font: small, fill: "#f0c060" });
173178
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", "knife", "level", "pistol", "player", "rifle", "smg", "sniper",
462 "bomb", "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 +1
@@ -396,6 +396,7 @@ export function boot(host: Host): Frame {
396396 for (const effect of current.effects) {
397397 if (effect.at <= lastSound) continue;
398398 if (effect.kind === "shot") audio.shot(effect.weapon, effect.pos, eyePos(ears));
399 else if (effect.kind === "sound") audio.grenade(effect.name, effect.pos, eyePos(ears));
399400 else if (effect.kind === "hitmark") audio.hit(effect.headshot);
400401 }
401402 lastSound = current.time;
src/client/models.test.ts +19 −3
@@ -1,5 +1,5 @@
11 /**
2 * The four recipes, pinned before `models.ts` is handed to an engine renderer
2 * The five recipes, pinned before `models.ts` is handed to an engine renderer
33 * that will not check any of this. What a renderer and the atlas actually
44 * require is asserted here — the attribute lengths agree, every index is inside
55 * the vertex count, every layer is a real layer of the array texture, nothing
@@ -20,7 +20,7 @@ import type { Mesh } from "../engine/index.ts";
2020 import type { Box } from "./level.ts";
2121 import { DE_FORGE } from "./map.ts";
2222 import { LAYERS } from "./materials.ts";
23 import { bombMesh, levelMesh, meshFor, playerMesh, viewModelMesh } from "./models.ts";
23 import { bombMesh, grenadeMesh, levelMesh, meshFor, playerMesh, viewModelMesh } from "./models.ts";
2424 import { CS_HULL } from "./movement.ts";
2525 import { WEAPONS } from "./weapons.ts";
2626
@@ -153,20 +153,36 @@ test("a kind nobody carries falls back to a gun rather than throwing", () => {
153153 expect(data(viewModelMesh(""))).toBe(data(fallback));
154154 });
155155
156 test("meshFor names the four recipes and nothing else", () => {
156 test("meshFor names the five recipes and nothing else", () => {
157157 expect(data(meshFor("level", DE_FORGE))).toBe(data(levelMesh(DE_FORGE.boxes)));
158158 expect(data(meshFor("player", DE_FORGE))).toBe(data(playerMesh()));
159159 expect(data(meshFor("bomb", DE_FORGE))).toBe(data(bombMesh()));
160 // `grenade` is a thing in the world rather than a gun in your hands, so it
161 // is its own recipe and not a view model — it is the one weapon kind that
162 // names a body a scene places out there.
163 expect(data(meshFor("grenade", DE_FORGE))).toBe(data(grenadeMesh()));
160164 for (const kind of new Set(Object.values(WEAPONS).map((w) => w.kind))) {
165 if (kind === "grenade") continue;
161166 expect(data(meshFor(kind, DE_FORGE))).toBe(data(viewModelMesh(kind)));
162167 }
163168 });
164169
170 test("a grenade is a small body, and a different one from the bomb", () => {
171 const nade = grenadeMesh();
172 expect(wellFormed(nade).vertices).toBeGreaterThan(0);
173 expect(data(nade)).not.toBe(data(bombMesh()));
174 // Eight units across at its widest: twice the hull the solver moves it with,
175 // and small enough to read as an object beside a 72-unit player.
176 const xs = [...nade.position].filter((_, i) => i % 3 === 0);
177 expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(8, 6);
178 });
179
165180 test("a recipe called twice gives equal data in a new object every time", () => {
166181 const calls: (() => Mesh)[] = [
167182 () => levelMesh([oneBox]),
168183 () => playerMesh(),
169184 () => bombMesh(),
185 () => grenadeMesh(),
170186 () => viewModelMesh("rifle"),
171187 ];
172188 for (const call of calls) {
src/client/models.ts +17 −1
@@ -50,6 +50,21 @@ export function bombMesh(): Mesh {
5050 return finish(b);
5151 }
5252
53 /**
54 * A grenade, in the world: a small dark body with a lighter band around its
55 * middle, built about its own origin so it needs no rotation to be drawn in
56 * flight or at rest. Eight units across — twice the four-unit hull the solver
57 * moves it with, so it looks like an object and not like a pixel.
58 */
59 export function grenadeMesh(): Mesh {
60 const b = builder();
61 const SHELL = 7, BAND = 8;
62 cube(b, 0, 0, 2, 7, 7, 7, SHELL);
63 cube(b, 0, 0, 2, 8, 8, 3, BAND);
64 cube(b, 0, 0, 6, 2.5, 2.5, 2, BAND); // the spoon on top
65 return finish(b);
66 }
67
5368 /**
5469 * A first-person weapon, in view space: X right, Y up, Z back, so the barrel
5570 * runs down -Z and the model needs no rotation at all.
@@ -73,7 +88,7 @@ export function viewModelMesh(kind: string): Mesh {
7388 }
7489
7590 /**
76 * A mesh by the name a scene calls it: `level`, `player`, `bomb`, or a weapon
91 * A mesh by the name a scene calls it: `level`, `player`, `bomb`, `grenade`, or a weapon
7792 * kind for the gun in your hands. `main.ts` builds one of each at start-up and
7893 * hands them to the engine's renderer, which keeps them by name and never
7994 * learns what a name is made of — that is this file, and this is the one line a
@@ -86,5 +101,6 @@ export function meshFor(name: string, level: Level): Mesh {
86101 if (name === "level") return levelMesh(level.boxes);
87102 if (name === "player") return playerMesh();
88103 if (name === "bomb") return bombMesh();
104 if (name === "grenade") return grenadeMesh();
89105 return viewModelMesh(name);
90106 }
src/client/movement.ts +36
@@ -29,8 +29,44 @@ export const CS_MOVE: MoveTuning = {
2929 jumpVelocity: 301.993,
3030 stepSize: 18,
3131 duckRate: 5,
32 restitution: 0, // a person does not bounce off a wall; they slide along it
3233 };
3334
35 /**
36 * A thrown grenade is a body too, and this is the pair the same solver is
37 * handed for one: CS:GO's grenade projectile falls at 0.4 of the world's
38 * gravity and keeps 0.45 of the speed it meets a surface with
39 * (`SetGravity( 0.4 )`, `SetElasticity( 0.45 )` in `hegrenade_projectile`),
40 * which is why a nade floats a little and bounces twice before it settles.
41 *
42 * The friction is what brings it to rest once it is lying on the floor, and
43 * the step size is zero on purpose: a grenade that shared the player's tuning
44 * would climb an eighteen-unit ledge like a person walking up it.
45 *
46 * A grenade never wishes to move — `src/client/grenade.ts` hands the solver a
47 * wish that is still on every tick — so the speeds and the crouch below are
48 * the numbers in a `MoveTuning` that have no job here, and they are zero
49 * rather than a pretend value nobody reads.
50 */
51 export const NADE_MOVE: MoveTuning = {
52 gravity: 800 * 0.4, // u/s², 0.4 of CS's gravity
53 maxSpeed: 0,
54 duckSpeed: 0,
55 walkSpeed: 0,
56 accelerate: 0,
57 airAccelerate: 0,
58 airSpeedCap: 0,
59 friction: 4, // per second, once it is on the ground: it rolls, then stops
60 stopSpeed: 30, // u/s under which friction takes the rest of it at once
61 jumpVelocity: 0,
62 stepSize: 0,
63 duckRate: 0,
64 restitution: 0.45,
65 };
66
67 /** A grenade's own hull: a four-unit cube with no eyes, not a person's 32×32×72. */
68 export const NADE_HULL: Hull = { radius: 2, stand: 4, duck: 4, eyeStand: 2, eyeDuck: 2 };
69
3470 /**
3571 * Fall damage, CS-style: nothing under ~580 u/s, then it bites. This is a curve
3672 * this game chose and not a consequence of the solver, which is why it is here
src/client/replay.test.ts +37 −27
@@ -34,41 +34,51 @@ 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. Computed on
38 * 06a3c81 ("One page shell serves both games"), the commit this branch left
39 * main at, before anything in this pull touched the tree.
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.
4047 */
41 const ROUND = "6e2532e264dedc1b0e627305cc8dc6adc7c9331467ba99ed85b8dbd00c5288a0";
48 const ROUND = "da283f931bb75db712cb9b526edf2a03215f6e77b2f5c0038f3d60a0e5b918b7";
4249 const ROUND_SECONDS = [
43 "9e65233c0d48", "aa3a9181fd4e", "7c4010b39595", "f1f17242e53a",
44 "f60445aeab1c", "fe6db21f4254", "6a9cb307907d", "0d04a4981ebb",
45 "4f5b67b31f3a", "1143cc644fa5", "233dfe9b86ad", "a484e4d8c88a",
46 "a8def47d1078", "edc047a03760", "041e88d4a21a", "7d1355c2c4e7",
47 "5612f8126e48", "da03b727e727", "f11d1891e25d", "dd54b431fcf4",
48 "fdd3cb238ea2", "44a94744ba5f", "51aafcbd7178", "87b3d8a4f92c",
49 "1c7e703d3c31", "022fc11cab00", "b7da2d1b042c", "2aecf3498845",
50 "65a47f51d197", "793d4cc2982f", "f7e5296e6308", "cefff31fc624",
51 "ef1a5a248bc6", "d655548a346b", "fe852048d7cd", "d35a992a9295",
52 "83ddecae01bf", "adb7eefdf1f4", "fd307b97235e", "0c623d45e867",
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",
5360 ];
5461
5562 /**
5663 * Forty seconds of duels in `src/rl/arena.ts`, seed 4242, against the
57 * scripted opponent, resetting each time one of them dies. Computed on the
58 * same commit, 06a3c81.
64 * scripted opponent, resetting each time one of them dies. Re-pinned for #142
65 * 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.
5969 */
60 const DUEL = "5cdb7419bdd67eff8e59436620d6bf3c65169d3f0477114d16b634c6b1bc79b6";
70 const DUEL = "37ea4cc90ecb55e2489ee1f219654b563a16e93893a6222c6e17f23f56b3c5d3";
6171 const DUEL_SECONDS = [
62 "4b602f465cc3", "55bd03b39724", "7ed7a3e9852c", "384c7656b58c",
63 "d5a4fed56e37", "0c14ed3d20b0", "fb4abcb5a49a", "5cd40562c219",
64 "21d1d1268ed9", "8a09b3475552", "8926d65beaf0", "60f7da994999",
65 "4c87374025da", "7700baee5ede", "fe59251327c2", "85c8b4f5851d",
66 "9ddcbb06870b", "ef4346bb917d", "701b38b7cc85", "7b0310352ead",
67 "9a7977b11907", "368b58be4235", "e7a36f9aa08e", "214a8b8bff24",
68 "0d20594c6069", "ba43b863d3cc", "3cfebac1c436", "c5f40350e06c",
69 "a13f1ee2bf59", "657dd44ce153", "79e2aee250d0", "67c57043bc04",
70 "2fa20867e8c7", "f25307c5968c", "e36ece4c4588", "14525727cbb2",
71 "3e8043af606f", "5b20a5d42233", "955040d49339", "d2053b7c791c",
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",
7282 ];
7383
7484 const ROUND_PIN: Pin = { label: "ten-bot round", constant: "ROUND", digest: ROUND, seconds: ROUND_SECONDS };
src/client/replay.ts +24 −3
@@ -9,8 +9,8 @@
99 * moved behaviour; a deliberate change to the simulation moves it on purpose
1010 * and updates the constant in the same pull.
1111 *
12 * What is hashed is the simulation and nothing else — PLAYER_FIELDS and
13 * MATCH_FIELDS below are the whole list. Effects, the kill feed and the
12 * What is hashed is the simulation and nothing else — PLAYER_FIELDS,
13 * GRENADE_FIELDS and MATCH_FIELDS below are the whole list. Effects, the kill feed and the
1414 * messages are presentation: they would make the digest fragile and would
1515 * catch nothing the state does not already catch.
1616 *
@@ -22,6 +22,7 @@
2222
2323 import { compare as complain, fold as foldTicks, quantise } from "../engine/index.ts";
2424 import { buy, NO_INPUT, update, type BuyItem, type Input, type Player, type World } from "./world.ts";
25 import type { Grenade } from "./grenade.ts";
2526 import type { MatchState, Phase } from "./match.ts";
2627
2728 export const TICK = 1 / 64;
@@ -52,6 +53,23 @@ export const PLAYER_FIELDS: { name: string; of: (p: Player) => number }[] = [
5253 { name: "duckAmount", of: (p) => p.duckAmount },
5354 // -1 for a weapon that has no magazine (the knife), so a switch still shows.
5455 { 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 },
58 ];
59
60 /**
61 * Everything hashed about a grenade in the air. They come and go inside a
62 * round, so the count goes in first: a tick with one grenade resting and a
63 * tick with none are different states and hash differently.
64 */
65 export const GRENADE_FIELDS: { name: string; of: (g: Grenade) => number }[] = [
66 { name: "pos.x", of: (g) => g.pos[0] },
67 { name: "pos.y", of: (g) => g.pos[1] },
68 { name: "pos.z", of: (g) => g.pos[2] },
69 { name: "vel.x", of: (g) => g.vel[0] },
70 { name: "vel.y", of: (g) => g.vel[1] },
71 { name: "vel.z", of: (g) => g.vel[2] },
72 { name: "fuse", of: (g) => g.fuse },
5573 ];
5674
5775 /** Everything hashed about the match. */
@@ -66,6 +84,8 @@ export const MATCH_FIELDS: { name: string; of: (m: MatchState) => number }[] = [
6684 export function snapshot(world: World): string {
6785 const parts: string[] = [];
6886 for (const p of world.players) for (const field of PLAYER_FIELDS) parts.push(quantise(field.of(p)));
87 parts.push(quantise(world.grenades.length));
88 for (const g of world.grenades) for (const field of GRENADE_FIELDS) parts.push(quantise(field.of(g)));
6989 for (const field of MATCH_FIELDS) parts.push(quantise(field.of(world.match)));
7090 return `${parts.join(",")}\n`;
7191 }
@@ -192,7 +212,8 @@ export function compare(run: Run, pin: Pin): string | null {
192212 file: "src/client/replay.test.ts",
193213 list: `${pin.constant}_SECONDS`,
194214 hashes:
195 `${PLAYER_FIELDS.map((f) => f.name).join(", ")} per player ` +
215 `${PLAYER_FIELDS.map((f) => f.name).join(", ")} per player, ` +
216 `${GRENADE_FIELDS.map((f) => f.name).join(", ")} per live grenade, ` +
196217 `and ${MATCH_FIELDS.map((f) => f.name).join(", ")}`,
197218 every: TICKS_PER_SECOND,
198219 unit: "second",
src/client/scene.ts +26 −1
@@ -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 { WEAPONS } from "./weapons.ts";
25 import { HE, WEAPONS } from "./weapons.ts";
2626 import type { Player, World } from "./world.ts";
2727
2828 export type Tint = [number, number, number];
@@ -58,6 +58,10 @@ export const LINE_CAPACITY = 4096;
5858 */
5959 export const INSTANCE_CAPACITY = 32;
6060
61 /** How long a blast is drawn for, in seconds, and in how many spokes. */
62 const BLAST_SECONDS = 0.45;
63 const BLAST_SPOKES = 16;
64
6165 /** The horizon, and the colour distance fades into. */
6266 export const SKY: Tint = [0.66, 0.73, 0.82];
6367
@@ -142,6 +146,11 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play
142146 }
143147 if (world.bomb.state === "planted" || world.bomb.state === "dropped")
144148 place(into, "bomb", world.bomb.pos, 0, 1, WHITE);
149 // Every grenade in the air or lying on the floor. It tumbles as it flies:
150 // the yaw is the simulated time, so a frame decides nothing and a paused
151 // one turns it no further.
152 for (const g of world.grenades)
153 place(into, "grenade", g.pos, g.onGround ? 0 : world.time * 540, 1, WHITE);
145154
146155 // --- tracers, sparks and the planted bomb's beam, all as lines
147156 for (const effect of world.effects) {
@@ -168,6 +177,22 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play
168177 colour(NEAR, 0.95, 0.9, 0.75), colour(FAR, 0.8, 0.7, 0.5),
169178 );
170179 }
180 } else if (effect.kind === "blast" && age < BLAST_SECONDS) {
181 // The blast, drawn as what it is: a star of light that runs out to the
182 // radius the damage actually reaches and fades as it goes.
183 const t = age / BLAST_SECONDS;
184 const k = HE.blast.radius * Math.min(1, t * 2.2);
185 const fade = 1 - t;
186 for (let s = 0; s < BLAST_SPOKES; s++) {
187 const a = (s / BLAST_SPOKES) * Math.PI * 2;
188 const lift = Math.sin(s * 2.4) * 0.5;
189 pushLine(
190 into,
191 effect.pos,
192 at(TO, effect.pos[0] + Math.cos(a) * k, effect.pos[1] + Math.sin(a) * k, effect.pos[2] + lift * k),
193 colour(NEAR, 1, 0.95, 0.8 * fade), colour(FAR, 1, 0.5 * fade, 0.1 * fade),
194 );
195 }
171196 } else if (effect.kind === "blood" && age < 0.3) {
172197 const k = 18 * (1 - age / 0.3);
173198 pushLine(
src/client/weapons.ts +69 −9
@@ -6,17 +6,33 @@
66 * The spray patterns are generated once from a per-weapon seed, not copied:
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.
9 *
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`.
914 */
1015
1116 import { rng } from "../engine/index.ts";
1217
1318 export type WeaponId =
14 | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp";
19 | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp" | "he";
20
21 /**
22 * What a thrown weapon does when its fuse runs out. Only the grenade has one,
23 * and everything about the explosion a simulation needs is these four numbers.
24 */
25 export type Blast = {
26 damage: number; // hit points at the centre of the blast, unarmoured
27 radius: number; // units; the damage falls to nothing at this distance
28 fuse: number; // seconds from leaving the hand to the detonation
29 armorPenetration: number; // 0..1, the share that reaches health through armour
30 };
1531
1632 export type Weapon = {
1733 id: WeaponId;
1834 name: string;
19 kind: "knife" | "pistol" | "smg" | "rifle" | "sniper";
35 kind: "knife" | "pistol" | "smg" | "rifle" | "sniper" | "grenade";
2036 team: "t" | "ct" | "both";
2137 price: number;
2238 damage: number;
@@ -33,8 +49,13 @@ export type Weapon = {
3349 recoil: number; // degrees of climb per shot, scaled by the pattern
3450 zoom?: number; // scoped field of view
3551 automatic: boolean;
52 /** Set on a thrown weapon, and on nothing else. */
53 blast?: Blast;
3654 };
3755
56 /** The HE grenade, by the name everything that throws one calls it. */
57 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 } };
58
3859 const WEAPON_LIST: Weapon[] = [
3960 { 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 },
4061 { 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 },
@@ -44,6 +65,12 @@ const WEAPON_LIST: Weapon[] = [
4465 { 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 },
4566 { 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 },
4667 { 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.
73 HE,
4774 ];
4875
4976 export const WEAPONS: Record<WeaponId, Weapon> = Object.fromEntries(
@@ -108,6 +135,27 @@ export function inaccuracy(weapon: Weapon, state: Accuracy): number {
108135 export const HITGROUP = { head: 4, chest: 1, stomach: 1.25, arm: 1, leg: 0.75 } as const;
109136 export type Hitgroup = keyof typeof HITGROUP;
110137
138 /**
139 * What `damage` costs a body wearing `armor`, CS's way: the weapon's armour
140 * penetration is the share that reaches health, and the vest takes half of what
141 * it stopped — until it runs out, after which what is left goes straight
142 * through. One function because a bullet and a blast divide it the same way,
143 * and a body should not care which one arrived.
144 */
145 export function throughArmor(
146 damage: number,
147 armor: number,
148 penetration: number,
149 ): { health: number; armor: number } {
150 let health = damage * penetration;
151 let armorDamage = (damage - health) * 0.5;
152 if (armorDamage > armor) {
153 armorDamage = armor;
154 health = damage - armor * 2;
155 }
156 return { health: Math.max(0, Math.round(health)), armor: Math.round(armorDamage) };
157 }
158
111159 /**
112160 * CS's damage sum: range falloff, then the hitgroup, then armour. Armour takes
113161 * half of what it stops; a helmet is the only thing that helps a headshot.
@@ -123,11 +171,23 @@ export function computeDamage(
123171 damage *= HITGROUP[group];
124172 const protectedHere = armor > 0 && (group !== "head" || helmet) && group !== "leg";
125173 if (!protectedHere) return { health: Math.round(damage), armor: 0 };
126 let health = damage * weapon.armorPenetration;
127 let armorDamage = (damage - health) * 0.5;
128 if (armorDamage > armor) {
129 armorDamage = armor;
130 health = damage - armor * 2;
131 }
132 return { health: Math.max(0, Math.round(health)), armor: Math.round(armorDamage) };
174 return throughArmor(damage, armor, weapon.armorPenetration);
175 }
176
177 /**
178 * The blast, before armour: the full damage at the centre, falling off in a
179 * straight line to nothing at the radius —
180 *
181 * damage(d) = damage · (1 − d / radius), zero at and past the radius
182 *
183 * which is Source's own `RadiusDamage` falloff (`falloff = damage / radius`,
184 * subtracted per unit of distance). At 98 and 350 units that is 98 in the lap,
185 * 49 at 175 units and nothing at all from 350 away.
186 *
187 * Whether the blast reaches at all is a different question, and not this
188 * function's: `src/client/grenade.ts` asks the engine's `traceWorld` first.
189 */
190 export function blastDamage(blast: Blast, distance: number): number {
191 if (distance >= blast.radius) return 0;
192 return blast.damage * (1 - distance / blast.radius);
133193 }
src/client/world.ts +110 −8
@@ -1,13 +1,21 @@
11 /**
22 * The world: ten players, one bomb, one round at a time. Everything that can
3 * happen in a round happens here — buying, moving, shooting, planting,
4 * defusing, dying — and the match rules in match.ts decide what it means.
3 * happen in a round happens here — buying, moving, shooting, throwing,
4 * planting, defusing, dying — and the match rules in match.ts decide what it
5 * means. A thrown grenade is the one thing in a round that is not a person:
6 * `src/client/grenade.ts` says how one flies and what its blast reaches, and
7 * what a blast *means* — the damage, the hit marker, the line in the feed — is
8 * here with every other kind of harm.
59 *
610 * The simulation runs at a fixed 64 ticks a second and knows nothing about
711 * WebGL, so it can be stepped in a test without a browser.
812 */
913
10 import { applyHit, traceBullet, type Actor } from "./combat.ts";
14 import { applyDamage, applyHit, traceBullet, type Actor } from "./combat.ts";
15 import {
16 blastCentre, blastHits, stepGrenade, throwGrenade, THROW_LIFT,
17 type Grenade, type GrenadeSound,
18 } from "./grenade.ts";
1119 import { siteAt, type Level } from "./level.ts";
1220 import {
1321 angleVectors, angleDelta, clamp, dist, eyeHeight, hullHeight, move, normalize, rng, sub, visible,
@@ -37,12 +45,15 @@ export type Player = Actor & Body & {
3745 ammo: Record<string, Ammo>;
3846 defuseKit: boolean;
3947 hasBomb: boolean;
48 /** HE grenades in the pocket: at most `MAX_GRENADES`, thrown one at a time. */
49 grenades: number;
4050 nextShotAt: number;
4151 reloadUntil: number;
4252 shotIndex: number;
4353 lastShotAt: number;
4454 scoped: boolean;
4555 firedThisPress: boolean;
56 threwThisPress: boolean;
4657 punch: [number, number]; // view kick, decaying
4758 kills: number;
4859 deaths: number;
@@ -66,7 +77,8 @@ export type Effect =
6677 | { kind: "blood"; at: number; pos: Vec3 }
6778 | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean }
6879 | { kind: "hitmark"; at: number; headshot: boolean }
69 | { kind: "sound"; at: number; pos: Vec3; name: string };
80 | { kind: "blast"; at: number; pos: Vec3 }
81 | { kind: "sound"; at: number; pos: Vec3; name: GrenadeSound };
7082
7183 export type FeedEntry = { at: number; killer: string; victim: string; weapon: WeaponId; headshot: boolean; killerTeam: Team };
7284
@@ -80,12 +92,18 @@ export type Input = {
8092 reload: boolean;
8193 use: boolean;
8294 scope: boolean;
95 /**
96 * Throw the grenade in your pocket. It is one more field, and a bot that
97 * never sets it — every bot in this game, today — plays exactly as it did
98 * before there were grenades.
99 */
100 throwGrenade: boolean;
83101 switchTo: WeaponId | null;
84102 };
85103
86104 export const NO_INPUT: Input = {
87105 forward: 0, side: 0, jump: false, duck: false, walk: false,
88 fire: false, reload: false, use: false, scope: false, switchTo: null,
106 fire: false, reload: false, use: false, scope: false, throwGrenade: false, switchTo: null,
89107 };
90108
91109 export type World = {
@@ -95,6 +113,8 @@ export type World = {
95113 time: number;
96114 bomb: Bomb;
97115 effects: Effect[];
116 /** Every grenade in the air or on the floor right now, oldest first. */
117 grenades: Grenade[];
98118 feed: FeedEntry[];
99119 messages: { at: number; text: string }[];
100120 random: () => number;
@@ -126,8 +146,9 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player
126146 money: 800,
127147 weapon: secondary, primary: null, secondary,
128148 ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) },
129 defuseKit: false, hasBomb: false,
130 nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false, firedThisPress: false,
149 defuseKit: false, hasBomb: false, grenades: 0,
150 nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false,
151 firedThisPress: false, threwThisPress: false,
131152 punch: [0, 0],
132153 kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0,
133154 };
@@ -164,7 +185,7 @@ export function createWorld(
164185 const world: World = {
165186 match, players, me, time: 0,
166187 bomb: { state: "carried", pos: [0, 0, 0], site: null, plantProgress: 0, defuseProgress: 0, defuser: null },
167 effects: [], feed: [], messages: [],
188 effects: [], grenades: [], feed: [], messages: [],
168189 random, level, bombPlanted: false, pendingBuy: false,
169190 };
170191 startRound(world);
@@ -193,6 +214,9 @@ export function startRound(world: World): void {
193214 p.pos = [at[0], at[1], 2];
194215 p.vel = [0, 0, 0];
195216 p.health = 100;
217 // A grenade survives the round its owner did, like their gun does; one they
218 // died holding is left on the floor with them.
219 if (!p.alive) p.grenades = 0;
196220 p.alive = true;
197221 p.duckAmount = 0;
198222 p.hasBomb = false;
@@ -216,6 +240,7 @@ export function startRound(world: World): void {
216240 world.bomb = { state: "carried", pos: [...carrier.pos] as Vec3, site: null, plantProgress: 0, defuseProgress: 0, defuser: null };
217241 }
218242 world.bombPlanted = false;
243 world.grenades = [];
219244 world.feed = [];
220245 for (const p of world.players) if (p.bot) botBuy(world, p);
221246 }
@@ -262,6 +287,9 @@ export function priceOf(item: BuyItem, player: Player): number {
262287 return WEAPONS[item]?.price ?? Infinity;
263288 }
264289
290 /** How many HE grenades a player may carry. CS allows one, and so does this. */
291 export const MAX_GRENADES = 1;
292
265293 export const canBuy = (world: World, player: Player): boolean =>
266294 world.match.phase === "freeze" && player.alive;
267295
@@ -284,6 +312,14 @@ export function buy(world: World, player: Player, item: BuyItem): boolean {
284312 const weapon = WEAPONS[item];
285313 if (weapon === undefined || weapon.kind === "knife") return false;
286314 if (weapon.team !== "both" && weapon.team !== player.team) return false;
315 // A grenade goes in a pocket, not in your hands: it does not become the
316 // weapon you are holding and it does not displace the gun you bought.
317 if (weapon.kind === "grenade") {
318 if (player.grenades >= MAX_GRENADES) return false;
319 player.grenades += 1;
320 player.money -= price;
321 return true;
322 }
287323 if (weapon.kind === "pistol") player.secondary = item;
288324 else player.primary = item;
289325 player.ammo[item] = emptyAmmo(item);
@@ -389,6 +425,64 @@ function onKill(world: World, killer: Player, victim: Player, weapon: WeaponId,
389425 checkRoundEnd(world);
390426 }
391427
428 // --- grenades -------------------------------------------------------------
429
430 /**
431 * Throw the grenade in this player's pocket, if there is one and the round
432 * will have it. It leaves the eye along the view, tilted `THROW_LIFT` degrees
433 * up the way CS tilts one, and from there it belongs to `grenade.ts` and the
434 * engine's solver — nothing here steers it.
435 */
436 export function throwOne(world: World, p: Player): boolean {
437 if (!p.alive || p.grenades <= 0) return false;
438 if (world.match.phase === "freeze" || world.match.phase === "over") return false;
439 p.grenades -= 1;
440 const from = eyePos(p);
441 const dir = angleVectors(p.yaw, p.pitch - THROW_LIFT).forward;
442 world.grenades.push(throwGrenade(p.id, p.team, from, dir, world.level.boxes));
443 world.effects.push({ kind: "sound", at: world.time, pos: from, name: "throw" });
444 return true;
445 }
446
447 const heard = (world: World, pos: Vec3, name: GrenadeSound): void => {
448 world.effects.push({ kind: "sound", at: world.time, pos: [pos[0], pos[1], pos[2]], name });
449 };
450
451 /**
452 * What a blast does: damage by distance to everything it can see, through
453 * armour, credited to whoever threw it — a hit marker if that was you and a
454 * line in the feed with the grenade named if it killed.
455 */
456 function detonate(world: World, g: Grenade): void {
457 const centre = blastCentre(g);
458 world.effects.push({ kind: "blast", at: world.time, pos: centre });
459 heard(world, centre, "blast");
460 const thrower = world.players.find((p) => p.id === g.owner);
461 for (const caught of blastHits(g, world.players, world.level.boxes)) {
462 const victim = caught.actor as Player;
463 const result = applyDamage(victim, caught.health, caught.armor);
464 world.effects.push({ kind: "blood", at: world.time, pos: [victim.pos[0], victim.pos[1], victim.pos[2] + 40] });
465 if (thrower === undefined) continue;
466 thrower.damage += result.damage;
467 if (thrower === world.me) world.effects.push({ kind: "hitmark", at: world.time, headshot: false });
468 if (result.killed) onKill(world, thrower, victim, "he", false);
469 }
470 }
471
472 /** Every live grenade, one tick older: moved, heard, and detonated on time. */
473 function updateGrenades(world: World, dt: number): void {
474 if (world.grenades.length === 0) return;
475 let spent = false;
476 for (const g of world.grenades) {
477 if (stepGrenade(g, dt, world.level.boxes)) heard(world, g.pos, "bounce");
478 if (g.fuse <= 0) {
479 detonate(world, g);
480 spent = true;
481 }
482 }
483 if (spent) world.grenades = world.grenades.filter((g) => g.fuse > 0);
484 }
485
392486 // --- the bomb -------------------------------------------------------------
393487
394488 const PLANT_RANGE = 64;
@@ -482,6 +576,10 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
482576 }
483577 }
484578 if (!input.fire) p.firedThisPress = false;
579 // One throw a press, like a semi-automatic trigger: holding the key down
580 // does not empty a pocket that only ever holds one anyway.
581 if (input.throwGrenade && !p.threwThisPress) throwOne(world, p);
582 p.threwThisPress = input.throwGrenade;
485583 if (world.time - p.lastShotAt > 0.35) p.shotIndex = 0;
486584 p.punch = [p.punch[0] * Math.pow(0.02, dt), p.punch[1] * Math.pow(0.02, dt)];
487585 updateBomb(world, p, input, dt);
@@ -511,6 +609,7 @@ export function update(world: World, dt: number, input: Input): void {
511609 p.armor = 0;
512610 p.helmet = false;
513611 p.defuseKit = false;
612 p.grenades = 0;
514613 p.secondary = p.team === "t" ? "glock" : "usp";
515614 p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) };
516615 p.weapon = p.secondary;
@@ -528,6 +627,9 @@ export function update(world: World, dt: number, input: Input): void {
528627 if (!p.bot || !p.alive) continue;
529628 applyInput(world, p, botThink(world, p, dt), dt);
530629 }
630 // After everyone has moved, so a grenade is solved against where people
631 // ended the tick rather than where they started it.
632 updateGrenades(world, dt);
531633 checkRoundEnd(world);
532634 const cutoff = world.time - 2;
533635 if (world.effects.length > 0) world.effects = world.effects.filter((e) => e.at > cutoff);
src/engine/physics.test.ts +58
@@ -50,6 +50,7 @@ const WALKER: MoveTuning = {
5050 jumpVelocity: 300,
5151 stepSize: 20,
5252 duckRate: 4,
53 restitution: 0, // a walker does not bounce: it stops dead and slides
5354 };
5455
5556 /** Half the gravity, a higher jump, a faster body, twice the step. */
@@ -66,8 +67,13 @@ const DRIFTER: MoveTuning = {
6667 jumpVelocity: 450,
6768 stepSize: WALKER.stepSize * 2,
6869 duckRate: 2,
70 restitution: 0,
6971 };
7072
73 /** The third profile, and the only bouncy one: half of what it arrives with,
74 * given back the other way, and no friction to argue with it afterwards. */
75 const RUBBER: MoveTuning = { ...WALKER, restitution: 0.5, friction: 0 };
76
7177 /** A 40×40×64 hull. */
7278 const SHORT: Hull = { radius: 20, stand: 64, duck: 40, eyeStand: 56, eyeDuck: 34 };
7379
@@ -366,3 +372,55 @@ test("turning while accelerating in the air gains speed a straight line cannot",
366372 while (!straight.onGround) move(straight, wish(WALKER, { forward: 1 }), TICK, [FLOOR], WALKER, SHORT);
367373 expect(Math.hypot(straight.vel[0], straight.vel[1])).toBeCloseTo(WALKER.maxSpeed, 6);
368374 });
375
376 // --- restitution -----------------------------------------------------------
377
378 test("restitution is the share a blocked axis gives back, and a walker has none", () => {
379 // A tall wall and a body flying at it in mid-air, so neither friction nor the
380 // ground snap has anything to say about the velocity under test.
381 const wall: Box = { min: [300, -400, -64], max: [400, 400, 500], mat: 1 };
382 const into = (tuning: MoveTuning): { vel: number; pos: number } => {
383 const b = body([200, 0, 200]);
384 b.vel = [400, 0, 0];
385 b.onGround = false;
386 for (let tick = 0; tick < 40; tick++) move(b, wish(tuning), TICK, [FLOOR, wall], tuning, SHORT);
387 return { vel: b.vel[0], pos: b.pos[0] };
388 };
389 // A walker keeps pushing into the wall and slides along it: the velocity it
390 // met the wall with is exactly the velocity it still has. Both profiles above
391 // are walkers, and neither is moved by the arithmetic below.
392 for (const { name, tuning } of PROFILES) {
393 expect(tuning.restitution, name).toBe(0);
394 expect(into(tuning).vel, name).toBe(400);
395 expect(into(tuning).pos, name).toBeCloseTo(300 - SHORT.radius, 9);
396 }
397 // Half of it back, the other way, out of the same call and the same solver:
398 // 400 × 0.5 = 200 u/s away from the wall, and the face is behind it now.
399 expect(into(RUBBER).vel).toBe(-400 * RUBBER.restitution);
400 expect(into(RUBBER).pos).toBeLessThan(300 - SHORT.radius);
401 });
402
403 test("a bounce off the floor is the arrival speed reversed and scaled, and the contact tolerance ends it", () => {
404 const b = body([0, 0, 200]);
405 b.onGround = false;
406 let arrived = 0, left = 0;
407 for (let tick = 0; tick < 64 * 20; tick++) {
408 const before = b.vel[2];
409 move(b, wish(RUBBER), TICK, [FLOOR], RUBBER, SHORT);
410 if (arrived === 0 && before < 0 && b.vel[2] > 0) { arrived = before; left = b.vel[2]; }
411 }
412 // Exactly the share the tuning names, and the other way. The speed it met
413 // the floor with is the speed it started the tick with less one whole tick
414 // of gravity — the solver takes both half-kicks before it moves a body down
415 // — so the bounce is −(v − g·dt) · restitution and nothing else.
416 expect(arrived).toBeLessThan(0);
417 const met = arrived - RUBBER.gravity * TICK;
418 expect(left).toBeCloseTo(-met * RUBBER.restitution, 10);
419 // And it ends up asleep on the floor rather than jittering above it: a body
420 // that arrives slower than the solver's own contact tolerance lets it fall
421 // is caught by the floor instead of bouncing off it, which is what brings a
422 // bouncing thing to rest even where nothing takes its energy away.
423 expect(b.onGround).toBe(true);
424 expect(b.pos[2]).toBe(0);
425 expect(b.vel[2]).toBe(0);
426 });
src/engine/physics.ts +18 −1
@@ -42,6 +42,14 @@ export type MoveTuning = {
4242 stepSize: number;
4343 /** How fast a crouch closes and opens, in duck fractions per second. */
4444 duckRate: number;
45 /**
46 * The share of the speed a blocked axis gives back, reversed: 0.45 is a body
47 * that keeps a bit less than half of what it arrived with, 0 is one that
48 * bounces not at all. A walker's is 0 and the axis is left exactly as it was,
49 * which is what sliding along a wall while still pushing into it is; anything
50 * above 0 is reflected, which is what a thrown thing does.
51 */
52 restitution: number;
4553 };
4654
4755 /**
@@ -217,8 +225,17 @@ export function move(
217225 }
218226 }
219227
228 // A bouncing body leaves a wall with `restitution` of the speed it met it
229 // with, reversed. A walker has none and is left alone: zeroing the component
230 // it is pushing into the wall with would be a change of feel, not of bounce.
231 if (tuning.restitution > 0) {
232 if (blockedX) body.vel[0] *= -tuning.restitution;
233 if (blockedY) body.vel[1] *= -tuning.restitution;
234 }
235
220236 if (!body.onGround) body.vel[2] -= tuning.gravity * dt * 0.5;
221 if (slideAxis(body.pos, 2, body.vel[2] * dt, height, boxes, hull)) body.vel[2] = 0;
237 if (slideAxis(body.pos, 2, body.vel[2] * dt, height, boxes, hull))
238 body.vel[2] = tuning.restitution > 0 ? -body.vel[2] * tuning.restitution : 0;
222239
223240 // On the ground means "a floor within `CONTACT` units of the feet" — and
224241 // landing snaps to it, so a player never rests a hair above what they touched.
src/games.ts +16 −1
@@ -196,6 +196,21 @@ export type Game = {
196196 * furniture instead of two, and the app fits 82,000 / 38,000 with 2,063 raw and
197197 * 864 gzipped to spare, so the ceiling did not move. Neither other bundle
198198 * changed a byte: nothing outside `src/client/hud.ts` did.
199 * #142 put the first grenade in the game — `src/client/grenade.ts`, a thrown
200 * body the engine's own solver moves, its mesh, its three sounds and the blast
201 * that reaches only what it can see — and the app grew 4,199 raw / 1,404
202 * gzipped bytes for it on top of the HUD move above: the module itself, the
203 * grenade in the weapon table, the pocket and the throw in `world.ts`, the
204 * body and the star of light in `scene.ts`, the recipes in `audio.ts` and the
205 * key in `binding.ts`. It did not fit 82,000/38,000 — it passed the gzip
206 * ceiling by 578 bytes — and the ceiling is raised here to 88,000/40,500,
207 * today's 84,136 raw / 38,540 gzipped plus about five per cent, which is room
208 * for an honest feature and not room for a stray import of something large.
209 * The range did not move a byte. The example is 9,735 raw again, the same
210 * number to the byte, and differs only in which short names the minifier
211 * handed out over a module graph that changed shape — it ships no grenade, no
212 * world and no weapons at all. Measured 2026-09-20 after #142 was merged with
213 * #152 and #158, on the bundles this branch commits.
199214 */
200215 export const GAMES: readonly Game[] = [
201216 {
@@ -203,7 +218,7 @@ export const GAMES: readonly Game[] = [
203218 href: "/",
204219 label: "cs",
205220 entry: "src/client/main.ts",
206 ceiling: { raw: 82_000, gzip: 38_000 },
221 ceiling: { raw: 88_000, gzip: 40_500 },
207222 blurb:
208223 "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.",
209224 },
src/pages/game.ts +6 −2
@@ -90,7 +90,8 @@ const menu = (visitor: Who): string => `
9090 to your site is the walk to theirs.</p>
9191 <p class="hint" id="prep">Preparing textures…</p>
9292 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
93 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy ·
93 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw a grenade ·
94 <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy ·
9495 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen ·
9596 <kbd>Esc</kbd> pause</p>
9697 <p class="hint">Dead, you watch a teammate until the round ends: fire for the next player,
@@ -121,6 +122,7 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions
121122 ],
122123 [
123124 { action: "reload", label: "reload" },
125 { action: "grenade", label: "nade" },
124126 { action: "buy", label: "buy" },
125127 { action: "fire", label: "fire" },
126128 ],
@@ -137,7 +139,9 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions
137139 <h2>What this is</h2>
138140 <p>A defusal shooter written for the browser: WebGL2 drawn by hand, Source-style movement
139141 (friction, air acceleration, 18-unit steps, strafe jumping), CS damage with armour and
140 hitgroups, fixed spray patterns you can learn, a round clock, a bomb and the buy economy.
142 hitgroups, fixed spray patterns you can learn, a round clock, a bomb, the buy economy and an
143 HE grenade that bounces off the walls on the same solver the players walk on — 98 damage at
144 the centre falling off over 350 units, on a 1.5-second fuse, and only to what it can see.
141145 Everything you see is generated — the map, the textures, the models and the sounds — so the
142146 whole game is one small download and no asset files.</p>
143147 <p>The bots path to a site and plant or defuse on their own, but they do not aim by rule:
static/app.024b05a3b5.js deleted −58

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

View file
static/app.d5f3ab2fca.js added +58

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

View file
static/example.f6197b684a.js → static/example.d8198a26c0.js renamed +1 −1

Diff not shown — a line 9,725 characters wide (limit 2,000). +1 −1

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs