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

forked from trevato/cs · proposes

The round is audible: footsteps, the bomb's beep, the plant and the defuse — through the effect that already exists #208

Merged opened this

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

Merged

torque/cs-engine:torque/135-the-round-is-audible-footsteps-the-bomb → main 1 commit fork

  • Merged. torque/135-the-round-is-audible-footsteps-the-bomb was merged into main as 334086e.

Review

Sign in to review this change.
13 files changed +633 −87
README.md +6 −1
@@ -36,7 +36,12 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
3636 two thrown grenades — an HE, and a flashbang that blinds by how much of it you
3737 could see (the line, the angle and the distance, for at most 1.6 seconds) —
3838 a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and
39 loss bonuses. The leaderboard is a real SQLite table under `FORGE_APP_DATA`.
39 loss bonuses. The round is audible: a footstep as a foot lands — not while you
40 walk with <kbd>Shift</kbd> and not while you duck, which is what walking is for
41 — the bomb beeping faster as its fuse runs down, the plant, the cut wire and a
42 body going down, each placed where it happened and heard from the eye the
43 camera is behind. A footstep is at full volume inside 256 Source units and a
44 quarter of that at 1,024; your own boots are a third of somebody else's. The leaderboard is a real SQLite table under `FORGE_APP_DATA`.
4045 - **Two kinds of map.** `de_forge` is drawn by hand, cell by cell; the other
4146 kind is carved from a number by `src/client/generate.ts` — pick *generated*
4247 on the menu and the match id is the seed, so every match is a layout nobody
src/client/audio.test.ts +71
@@ -1,6 +1,7 @@
11 import { expect, test } from "bun:test";
22 import { createAudio, loudness, shotTone } from "./audio.ts";
33 import type { Burst, SoundDevice, Tone } from "../engine/index.ts";
4 import type { Sound } from "./world.ts";
45
56 /** A device that plays nothing and writes down everything. */
67 function recorder(): SoundDevice & { bursts: Burst[]; tones: Tone[]; resumes: number } {
@@ -47,6 +48,75 @@ test("a shot across the map is quieter than one at your feet", () => {
4748 expect(loudness([4000, 0, 0], [0, 0, 0])).toBeLessThan(0.3);
4849 });
4950
51 /**
52 * The gain the recipe asks for at a distance, by hand: `attenuate` is
53 * `min(1, earshot / distance)`, so a footstep's 256-unit earshot is full gain
54 * at 256 units and a quarter of it at 1,024 — four times the distance, a
55 * quarter the volume. The numbers on the right are written out rather than
56 * computed from the constants, because a test that recomputes the code proves
57 * nothing about what a player hears.
58 */
59 test("a footstep is full gain at 256 Source units and a quarter of that at 1,024", () => {
60 const device = recorder();
61 const audio = createAudio(device);
62 audio.play("step", [0, 0, 0], [256, 0, 0]);
63 audio.play("step", [1024, 0, 0], [0, 0, 0]);
64 const near = device.bursts[0] as Burst, far = device.bursts[1] as Burst;
65 expect(near.gain).toBeCloseTo(0.3, 10); // 0.3 × min(1, 256/256)
66 expect(far.gain).toBeCloseTo(0.075, 10); // 0.3 × 256/1024
67 // Past a site and a half it is under a tenth of what it was: an enemy across
68 // the map is not information, and this is the distance that says so.
69 audio.play("step", [2560, 0, 0], [0, 0, 0]);
70 expect((device.bursts[2] as Burst).gain).toBeCloseTo(0.03, 10); // 0.3 × 256/2560
71 });
72
73 test("your own boots are quieter than somebody else's taken in the same place", () => {
74 const device = recorder();
75 const audio = createAudio(device);
76 audio.play("step", [0, 0, 0], [0, 0, 0], true);
77 audio.play("step", [0, 0, 0], [0, 0, 0], false);
78 const mine = device.bursts[0] as Burst, theirs = device.bursts[1] as Burst;
79 expect(mine.gain).toBeCloseTo(0.105, 10); // 0.3 × 0.35, and no distance at all
80 expect(theirs.gain).toBeCloseTo(0.3, 10);
81 expect(mine.gain).toBeLessThan(theirs.gain / 2);
82 });
83
84 test("every noise a round makes has a recipe, and a far one is quieter than a near one", () => {
85 const names: Sound[] = ["step", "beep", "plant", "defuse", "death", "throw", "bounce", "blast", "flash"];
86 for (const name of names) {
87 const device = recorder();
88 const audio = createAudio(device);
89 audio.play(name, [0, 0, 0], [0, 0, 0]);
90 const bursts = [...device.bursts], tones = [...device.tones];
91 expect(bursts.length + tones.length).toBeGreaterThan(0);
92 expect([...bursts, ...tones].every((node) => node.gain > 0)).toBe(true);
93
94 // The same noise three thousand units away, node for node: every one of
95 // them is quieter, and none of them went missing on the way.
96 audio.play(name, [3000, 0, 0], [0, 0, 0]);
97 const far = [...device.bursts.slice(bursts.length), ...device.tones.slice(tones.length)];
98 const near = [...bursts, ...tones];
99 expect(far.length).toBe(near.length);
100 for (let i = 0; i < far.length; i++)
101 expect((far[i] as Burst).gain).toBeLessThan((near[i] as Burst).gain);
102 }
103 });
104
105 test("the bomb counting is a square tone, and the plant climbs where the defuse falls", () => {
106 const device = recorder();
107 const audio = createAudio(device);
108 audio.play("beep", [0, 0, 0], [0, 0, 0]);
109 const beep = device.tones[0] as Tone;
110 expect(beep.type).toBe("square");
111 expect(device.bursts.length).toBe(0); // nothing under it: it has to cut through
112
113 audio.play("plant", [0, 0, 0], [0, 0, 0]);
114 audio.play("defuse", [0, 0, 0], [0, 0, 0]);
115 const plant = device.tones[1] as Tone, defuse = device.tones[2] as Tone;
116 expect(plant.slideTo as number).toBeGreaterThan(plant.frequency);
117 expect(defuse.slideTo as number).toBeLessThan(defuse.frequency);
118 });
119
50120 test("a headshot beeps higher than a body hit", () => {
51121 const device = recorder();
52122 const audio = createAudio(device);
@@ -63,6 +133,7 @@ test("muting the voice mutes the device under it", () => {
63133 audio.muted = true;
64134 expect(device.muted).toBe(true);
65135 audio.shot("ak47", [0, 0, 0], [0, 0, 0]);
136 audio.play("step", [0, 0, 0], [0, 0, 0]);
66137 audio.beep(700);
67138 expect(device.bursts).toEqual([]);
68139 expect(device.tones).toEqual([]);
src/client/audio.ts +91 −7
@@ -3,21 +3,33 @@
33 * low thump, and each weapon kind gets its own colour, length and thump; a hit
44 * and a buy are short tones. Nothing is downloaded, and the engine's device
55 * only opens a context once the person has clicked something.
6 *
7 * Everything the round itself makes — a foot landing, the bomb's beep, the
8 * plant, the cut wire, a body going down — is one recipe per name under `play`,
9 * positioned through the engine's `attenuate` from the ear the frame is
10 * listening with. The distances are in Source units (1 unit ≈ 1.9 cm) and are
11 * the numbers below: a sound is at its recipe's full gain inside its earshot
12 * and falls as `earshot / distance` past it, so a footstep at 256 units is as
13 * loud as a footstep can be and the same one at 1,024 is a quarter of that.
614 */
715
816 import { attenuate, createSoundDevice, dist, type SoundDevice, type Vec3 } from "../engine/index.ts";
9 import type { GrenadeSound } from "./grenade.ts";
1017 import { WEAPONS, type WeaponId } from "./weapons.ts";
18 import type { Sound } from "./world.ts";
1119
1220 export type Audio = {
1321 shot(weapon: WeaponId, at: Vec3, listener: Vec3): void;
1422 /**
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.
23 * One noise of the round, by name, placed where it happened and heard from
24 * `listener`. `own` is true when the body that made it is the body these ears
25 * belong to, which is the whole of why your own boots are quieter than
26 * somebody else's standing where you are.
27 *
28 * One path widened by a name rather than a voice per noise: a grenade leaving
29 * the hand and a foot landing are the same two nodes with different numbers
30 * in them, and the day a sixth round noise is wanted it is a `case` here.
1931 */
20 grenade(sound: GrenadeSound, at: Vec3, listener: Vec3): void;
32 play(sound: Sound, at: Vec3, listener: Vec3, own?: boolean): void;
2133 hit(headshot: boolean): void;
2234 beep(pitch: number, gain?: number): void;
2335 resume(): void;
@@ -46,6 +58,38 @@ const CLOSE = 120;
4658 const BLAST_EARSHOT = 2400;
4759 const BLAST_CLOSE = 300;
4860
61 /**
62 * A footstep, in Source units: full gain inside 256 (about five metres, a room)
63 * and `256 / distance` past it, so a body at 1,024 units — the far side of a
64 * site — is heard at a quarter of that, and one at 2,560 at a tenth, which is
65 * under the noise of a round being fought. That is the distance the game
66 * intends: an enemy crossing a site is information, an enemy across the map is
67 * not.
68 */
69 const STEP_EARSHOT = 256;
70 const STEP_CLOSE = 48;
71 const STEP_GAIN = 0.3;
72
73 /**
74 * What your own boots are worth beside somebody else's: a little over a third.
75 * They are the only footsteps in the game that are always at zero distance, so
76 * without this they would be the loudest thing in every round — and the whole
77 * point of the sound is hearing the other one.
78 */
79 const OWN_STEP = 0.35;
80
81 /**
82 * The bomb: the beep, the plant and the cut wire. 600 units of full gain is the
83 * site and the ways on to it — a defuse behind you is meant to be heard from
84 * where you are holding — and it falls off from there like everything else.
85 */
86 const BOMB_EARSHOT = 600;
87 const BOMB_CLOSE = 64;
88
89 /** A body going down: heard across the room it fell in, not across the map. */
90 const DEATH_EARSHOT = 400;
91 const DEATH_CLOSE = 64;
92
4993 /** The shot a weapon makes: its filter colour, its thump and how long it rings. */
5094 export const shotTone = (weapon: WeaponId): { cut: number; thump: number; length: number } =>
5195 TONE[WEAPONS[weapon]?.kind ?? "rifle"] ?? (TONE.rifle as { cut: number; thump: number; length: number });
@@ -80,8 +124,48 @@ export function createAudio(device: SoundDevice = createSoundDevice()): Audio {
80124 length: tone.length,
81125 });
82126 },
83 grenade(sound, at, listener) {
127 play(sound, at, listener, own = false) {
84128 const far = dist(at, listener);
129 if (sound === "step") {
130 // A foot landing: a scuff of grit through a wide filter with a soft
131 // thump under it. The rate is jittered so a body at a dead run is a
132 // walk and not one sample played twelve times.
133 const volume = attenuate(far, STEP_EARSHOT, STEP_CLOSE) * (own ? OWN_STEP : 1);
134 device.noiseBurst({ frequency: 520, gain: STEP_GAIN * volume, length: 0.1, q: 1.1, rate: 0.85 + Math.random() * 0.3 });
135 device.tone({ frequency: 95, slideTo: 55, gain: 0.16 * volume, length: 0.09 });
136 return;
137 }
138 if (sound === "beep") {
139 // The bomb, counting. A square tone and nothing under it: it has to cut
140 // through a firefight, and it is the one sound here that repeats.
141 const volume = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
142 device.tone({ frequency: 2200, gain: 0.2 * volume, length: 0.07, type: "square", floor: 0.0005, tail: 0.01 });
143 return;
144 }
145 if (sound === "plant") {
146 // The bomb going down: the clunk of it landing, and a tone that climbs
147 // — the round just turned over, and it turned the terrorists' way.
148 const volume = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
149 device.noiseBurst({ frequency: 1400, gain: 0.3 * volume, length: 0.09, q: 5, rate: 1 });
150 device.tone({ frequency: 300, slideTo: 900, gain: 0.26 * volume, length: 0.3 });
151 return;
152 }
153 if (sound === "defuse") {
154 // The wire, cut: a bright snip and a tone that falls away under it. It
155 // is the plant's noise backwards on purpose — the same event, undone.
156 const volume = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
157 device.noiseBurst({ frequency: 2600, gain: 0.26 * volume, length: 0.07, q: 6, rate: 1 });
158 device.tone({ frequency: 900, slideTo: 300, gain: 0.26 * volume, length: 0.3 });
159 return;
160 }
161 if (sound === "death") {
162 // A body hitting the floor: low, wide, short, with a thump that falls
163 // away. No cry — the game has no voices, and a fall carries further.
164 const volume = attenuate(far, DEATH_EARSHOT, DEATH_CLOSE);
165 device.noiseBurst({ frequency: 240, gain: 0.34 * volume, length: 0.35, q: 0.8, rate: 0.8 });
166 device.tone({ frequency: 110, slideTo: 50, gain: 0.24 * volume, length: 0.3 });
167 return;
168 }
85169 if (sound === "throw") {
86170 // A short upward whoosh of air: quiet, and only near you.
87171 device.noiseBurst({ frequency: 2400, gain: 0.14 * loudness(at, listener), length: 0.16, q: 0.7, rate: 1.4 });
src/client/bomb.test.ts +30 −2
@@ -11,8 +11,8 @@
1111
1212 import { expect, test } from "bun:test";
1313 import {
14 defuseTime, dropBomb, giveBomb, newBomb, updateBomb, useProgress,
15 type BombState, type Carrier, type Round,
14 BEEP_FAST, BEEP_SLOW, beepDue, beepInterval, defuseTime, dropBomb, giveBomb, newBomb, updateBomb,
15 useProgress, type BombState, type Carrier, type Round,
1616 } from "./bomb.ts";
1717 import { DE_FORGE } from "./map.ts";
1818 import { newMatch, PLANT_REWARD, SHORT, type Phase } from "./match.ts";
@@ -184,3 +184,31 @@ test("the bomb starts in the hands of whoever was given it", () => {
184184 p.pos[0] = 999; // a copy, not the player's own array
185185 expect(bomb.pos[0]).toBe(12);
186186 });
187
188 test("the fuse beeps slower at the top than at the bottom, by a straight line", () => {
189 // A forty-second fuse: 0.9 s between beeps at the plant, 0.12 + 0.78/2 at
190 // half, and 0.12 at the moment it goes off.
191 expect(beepInterval(40, 40)).toBeCloseTo(BEEP_SLOW, 10);
192 expect(beepInterval(20, 40)).toBeCloseTo(0.51, 10);
193 expect(beepInterval(0, 40)).toBeCloseTo(BEEP_FAST, 10);
194 // A clock that has run past its own end, or a rule set with no fuse at all,
195 // is the fastest beep rather than a division by zero.
196 expect(beepInterval(-5, 40)).toBeCloseTo(BEEP_FAST, 10);
197 expect(beepInterval(1, 0)).toBeCloseTo(BEEP_FAST, 10);
198 });
199
200 test("a bomb beeps on the tick it is planted, and then on its own clock", () => {
201 const bomb = newBomb();
202 // The first call is due at once: a bomb that has just gone down says so.
203 expect(beepDue(bomb, 40, 40, TICK)).toBe(true);
204 let beeps = 0;
205 for (let t = 0; t < 0.9 - TICK; t += TICK) if (beepDue(bomb, 40, 40, TICK)) beeps += 1;
206 expect(beeps).toBe(0);
207 expect(beepDue(bomb, 40, 40, TICK)).toBe(true);
208 // With a second of fuse left it beeps seven times a second instead of once:
209 // the first of the two below is still the 0.9 s that was set when the fuse
210 // was full, and everything after it is 0.12 + 0.78/40 apart.
211 let fast = 0;
212 for (let t = 0; t < 2; t += TICK) if (beepDue(bomb, 1, 40, TICK)) fast += 1;
213 expect(fast).toBeGreaterThan(5);
214 });
src/client/bomb.ts +35 −1
@@ -37,6 +37,12 @@ export type Bomb = {
3737 plantProgress: number;
3838 defuseProgress: number;
3939 defuser: Carrier | null;
40 /**
41 * Seconds until the next beep. It is the bomb's own clock and nothing reads
42 * it back — a beep is information, not a rule — so it is here with the fuse
43 * rather than in the frame, which would beep at whatever rate the screen runs.
44 */
45 beepIn: number;
4046 };
4147
4248 /** The round as the bomb sees it: itself, the clock, and the ground it is on. */
@@ -59,9 +65,37 @@ export const PICKUP_RANGE = 48;
5965 /** A fresh bomb, in somebody's hands, at `pos`. */
6066 export const newBomb = (pos: Vec3 = [0, 0, 0]): Bomb => ({
6167 state: "carried", pos: [pos[0], pos[1], pos[2]],
62 site: null, plantProgress: 0, defuseProgress: 0, defuser: null,
68 site: null, plantProgress: 0, defuseProgress: 0, defuser: null, beepIn: 0,
6369 });
6470
71 /**
72 * How long between two beeps, in seconds, with `left` of a `whole`-second fuse
73 * still to run. It is a straight line from `BEEP_SLOW` at the moment of the
74 * plant to `BEEP_FAST` at zero, because the beep is how long you have: a
75 * defuser who can hear it does not need to see the clock. A forty-second fuse
76 * beeps every 0.9 s at the top, every 0.51 s at half, and eight times a second
77 * in the last moment of it.
78 */
79 export const BEEP_SLOW = 0.9;
80 export const BEEP_FAST = 0.12;
81
82 export const beepInterval = (left: number, whole: number): number =>
83 BEEP_FAST + (BEEP_SLOW - BEEP_FAST) * clamp(whole <= 0 ? 0 : left / whole, 0, 1);
84
85 /**
86 * One tick of the fuse's clock: true on the tick a beep is due, and the next
87 * one is set from how much fuse is left at that moment. Called once a tick for
88 * the one bomb — never once per player — and it makes no sound itself: the
89 * world turns a true into an effect, the way it does with everything else that
90 * can be heard.
91 */
92 export function beepDue(bomb: Bomb, left: number, whole: number, dt: number): boolean {
93 bomb.beepIn -= dt;
94 if (bomb.beepIn > 0) return false;
95 bomb.beepIn = beepInterval(left, whole);
96 return true;
97 }
98
6599 /** Hand the bomb to this player at the top of a round: it starts where they do. */
66100 export function giveBomb(carrier: Carrier): Bomb {
67101 carrier.hasBomb = true;
src/client/main.test.ts +22
@@ -187,6 +187,28 @@ test("clicking play starts a match: the menu goes, the pointer is asked for, the
187187 expect(stage.by("fps").textContent).toContain("ms");
188188 });
189189
190 test("nothing is heard before a match, and then your own boots are heard as your own", () => {
191 const stage = painted(start(fakeStage()));
192 // Nine frames of painting an atlas in front of a menu: the voice has not
193 // been asked for a single thing, so no audio device has been opened.
194 expect(stage.sounds).toEqual([]);
195
196 stage.by("play").fire("click");
197 const world = (stage.step(FRAME), worldOf(stage)) as World;
198 world.match.phase = "live";
199 world.match.timer = 60;
200 stage.lock(true);
201 stage.hold("KeyW", true);
202 // A second and a bit of running: a stride is 85 units and the cap is 250 u/s,
203 // so feet land, and they land as the ears' own — the frame plays every sound
204 // from the eye it is drawing with.
205 for (let frame = 0; frame < 40; frame++) stage.step(FRAME);
206 // Both kinds arrive: the bots walking out of their spawns are somebody
207 // else's feet, and the ones under the camera are marked as the ears' own.
208 expect(stage.sounds).toContain("sound:step:own");
209 expect(stage.sounds).toContain("sound:step");
210 });
211
190212 test("one frame of 1/30 s runs two 64 Hz steps and no more, and banks the rest", () => {
191213 const stage = playing();
192214 stage.step(FRAME);
src/client/main.ts +4 −1
@@ -619,13 +619,16 @@ export function boot(host: Host): Frame {
619619 * Sound for whatever happened in the ticks we just ran, heard from `ears` —
620620 * the player the camera is behind. Dead, that is whoever you are watching:
621621 * a round heard from your own corpse is the other half of a broken spectate.
622 * Which is also why "your own" footstep is the one `ears` took and not the
623 * one your body took: whose ears these are is the frame's question, and the
624 * world answers a different one — who made the noise.
622625 */
623626 function playSounds(current: World, ears: Player): void {
624627 for (let i = 0; i < current.effects.count; i++) {
625628 const effect = effectAt(current.effects, i);
626629 if (effect === undefined || effect.at <= lastSound) continue;
627630 if (effect.kind === "shot") audio.shot(effect.weapon, effect.pos, eyePos(ears));
628 else if (effect.kind === "sound") audio.grenade(effect.name, effect.pos, eyePos(ears));
631 else if (effect.kind === "sound") audio.play(effect.name, effect.pos, eyePos(ears), effect.by === ears.id);
629632 else if (effect.kind === "hitmark") audio.hit(effect.headshot);
630633 }
631634 lastSound = current.time;
src/client/stage.test-helper.ts +4 −3
@@ -211,9 +211,10 @@ export function fakeStage(
211211 muted: false,
212212 resume: () => void sounds.push("resume"),
213213 shot: (weapon) => void sounds.push(`shot:${weapon}`),
214 // The stub was missing this one: a stage that boots the game without it is
215 // a stage where the first grenade throws `audio.grenade is not a function`.
216 grenade: (sound) => void sounds.push(`grenade:${sound}`),
214 // Every noise the round makes goes through this one, and it is written down
215 // with whether the ears that heard it are the ones that made it: a stage
216 // that boots the game without it is a stage where the first footstep throws.
217 play: (sound, _at, _listener, own) => void sounds.push(own === true ? `sound:${sound}:own` : `sound:${sound}`),
217218 hit: (headshot) => void sounds.push(headshot ? "hit:head" : "hit"),
218219 beep: () => void sounds.push("beep"),
219220 };
src/client/world.test.ts +204 −3
@@ -1,17 +1,80 @@
11 import { expect, test } from "bun:test";
2 import { createAudio } from "./audio.ts";
3 import { beepInterval } from "./bomb.ts";
24 import { siteAt } from "./level.ts";
35 import { DE_FORGE } from "./map.ts";
46 import { SHORT } from "./match.ts";
57 import { WEAPONS } from "./weapons.ts";
68 import { buy } from "./economy.ts";
7 import { createWorld, currentWeapon, NO_INPUT, shoot, startRound, update, type Input, type World } from "./world.ts";
8 import type { Vec3 } from "../engine/index.ts";
9 import {
10 applyInput, createWorld, currentWeapon, EFFECT_CAPACITY, NO_INPUT, shoot, startRound, update,
11 type Input, type Player, type Sound, type World,
12 } from "./world.ts";
13 import { effectAt, pushEffect, type Burst, type SoundDevice, type Tone, type Vec3 } from "../engine/index.ts";
914
1015 const TICK = 1 / 64;
1116 const run = (world: World, seconds: number, input: Input = NO_INPUT): void => {
1217 for (let t = 0; t < seconds; t += TICK) update(world, TICK, input);
1318 };
1419
20 /** One noise the world made, as the frame reads it out of the ring. */
21 type Heard = { name: Sound; at: number; by: number };
22
23 /**
24 * Ears on a world: everything it has said since the last time they listened,
25 * drained exactly the way `playSounds` in `main.ts` drains it. They have to
26 * listen as the round is played and not at the end of it, because the ring
27 * remembers `EFFECT_LIFE` seconds and a round is longer than two.
28 */
29 function ears(world: World): { played: Heard[]; listen(): void } {
30 let last = world.time;
31 const played: Heard[] = [];
32 return {
33 played,
34 listen(): void {
35 for (let i = 0; i < world.effects.count; i++) {
36 const e = effectAt(world.effects, i);
37 if (e === undefined || e.kind !== "sound" || e.at <= last) continue;
38 played.push({ name: e.name, at: e.at, by: e.by });
39 }
40 last = world.time;
41 },
42 };
43 }
44
45 /** A device that plays nothing and counts what it was asked for. */
46 function recorder(): SoundDevice & { bursts: Burst[]; tones: Tone[] } {
47 const device = {
48 muted: false,
49 bursts: [] as Burst[],
50 tones: [] as Tone[],
51 resume() {},
52 noiseBurst(burst: Burst) { device.bursts.push(burst); },
53 tone(tone: Tone) { device.tones.push(tone); },
54 };
55 return device;
56 }
57
58 /** Run the world with ears on it, listening after every tick. */
59 const listen = (world: World, seconds: number, input: Input = NO_INPUT, also?: () => void): Heard[] => {
60 const heard = ears(world);
61 for (let t = 0; t < seconds; t += TICK) {
62 update(world, TICK, input);
63 also?.();
64 heard.listen();
65 }
66 return heard.played;
67 };
68
69 /** Everything but these two out of the round, so a test hears only what it drove. */
70 function only(world: World, alive: Player[]): void {
71 for (const p of world.players) {
72 if (alive.includes(p)) continue;
73 p.alive = false;
74 p.health = 0;
75 }
76 }
77
1578 test("a world starts ten players, one bomb and a freeze time", () => {
1679 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 1);
1780 expect(world.players.length).toBe(10);
@@ -85,7 +148,7 @@ test("the terrorists plant, and a planted bomb keeps the round alive past the cl
85148 test("a defusing CT wins the round, and the money follows the rules", () => {
86149 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 13);
87150 run(world, SHORT.freezeTime + 0.2);
88 world.bomb = { state: "planted", pos: [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2], site: "B", plantProgress: 0, defuseProgress: 0, defuser: null };
151 world.bomb = { state: "planted", pos: [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2], site: "B", plantProgress: 0, defuseProgress: 0, defuser: null, beepIn: 0 };
89152 world.bombPlanted = true;
90153 world.match.phase = "planted";
91154 world.match.timer = 30;
@@ -115,6 +178,144 @@ test("a new round puts everyone back with full health and a full magazine", () =
115178 expect(world.players.filter((p) => p.hasBomb).length).toBe(1);
116179 });
117180
181 test("a round is audible: the plant, the bomb counting, and the wire cut, in that order", () => {
182 const world = createWorld(DE_FORGE, SHORT, "me", "t", 5, 11);
183 const ct = world.players.find((p) => p.team === "ct") as Player;
184 // Two bodies in this round and nobody else, so what is heard below is what
185 // these two did: `bot` off means `update` thinks for neither of them, and the
186 // test drives the defuser itself with the same `applyInput` a bot goes through.
187 ct.bot = false;
188 only(world, [world.me, ct]);
189 run(world, SHORT.freezeTime + 0.2);
190
191 for (const p of world.players) p.hasBomb = false;
192 world.me.hasBomb = true;
193 world.me.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 2];
194 ct.defuseKit = true;
195 ct.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1] + 400, 2]; // out of range until it is their turn
196
197 // Four seconds of holding use on the site: the plant lands after plantTime,
198 // and from that tick the bomb starts counting.
199 const openedAt = world.time;
200 const planting = listen(world, SHORT.plantTime + 4, { ...NO_INPUT, use: true });
201 expect(world.bomb.state).toBe("planted");
202 expect(planting[0]?.name).toBe("plant");
203 expect(planting[0]?.by).toBe(world.me.id);
204 expect(planting[0]?.at as number).toBeCloseTo(openedAt + SHORT.plantTime, 1);
205 // The bomb beeps from the tick it went down, and nobody made that noise.
206 expect(planting.slice(1).every((s) => s.name === "beep" && s.by === -1)).toBe(true);
207 expect(planting[1]?.at).toBe(planting[0]?.at);
208
209 // The gaps are the fuse's own arithmetic, a tick either way, and each one is
210 // shorter than the last: forty seconds of fuse beeps every 0.9 s at the top.
211 const beeps = planting.filter((s) => s.name === "beep").map((s) => s.at);
212 expect(beeps.length).toBeGreaterThan(3);
213 for (let i = 1; i < beeps.length; i++) {
214 const gap = (beeps[i] as number) - (beeps[i - 1] as number);
215 expect(gap).toBeCloseTo(beepInterval(SHORT.bombTime - (beeps[i] as number) + (beeps[0] as number), SHORT.bombTime), 1);
216 if (i > 1) expect(gap).toBeLessThan((beeps[i - 1] as number) - (beeps[i - 2] as number) + 1e-9);
217 }
218
219 // The defuser, on the bomb with a kit, driven by the test: five seconds of
220 // use and the wire is cut, which is the last thing this round says.
221 const startedAt = world.time;
222 const cutting = listen(world, SHORT.defuseTimeWithKit + 0.5, NO_INPUT, () => {
223 if (world.bomb.state !== "planted") return;
224 ct.pos = [world.bomb.pos[0], world.bomb.pos[1], world.bomb.pos[2]];
225 applyInput(world, ct, { ...NO_INPUT, use: true }, TICK);
226 });
227 expect(world.bomb.state).toBe("defused");
228 const last = cutting[cutting.length - 1] as Heard;
229 expect(last.name).toBe("defuse");
230 expect(last.by).toBe(ct.id);
231 expect(last.at).toBeCloseTo(startedAt + SHORT.defuseTimeWithKit, 1);
232 expect(cutting.filter((s) => s.name === "defuse").length).toBe(1);
233
234 // And every one of them reaches a device: the names the world says are the
235 // names the voice has recipes for, played in the order they were said.
236 const device = recorder();
237 const audio = createAudio(device);
238 const said = [...planting, ...cutting];
239 for (const sound of said) audio.play(sound.name, [0, 0, 0], [0, 0, 0]);
240 expect(device.bursts.length + device.tones.length).toBeGreaterThanOrEqual(said.length);
241 });
242
243 test("a body running is heard; one walking or ducking is not", () => {
244 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 3);
245 // One body on each side, so the round does not end under the test, and the
246 // other one thinks for itself about nothing: every step below is the person's.
247 const enemy = world.players.find((p) => p.team === "t") as Player;
248 enemy.bot = false;
249 only(world, [world.me, enemy]);
250 run(world, SHORT.freezeTime + 0.2);
251 const stand = (): void => {
252 world.me.pos = [DE_FORGE.spawns.ct[0]?.[0] ?? 0, DE_FORGE.spawns.ct[0]?.[1] ?? 0, 2];
253 world.me.vel = [0, 0, 0];
254 world.me.stride = 0;
255 };
256
257 stand();
258 const running = listen(world, 2, { ...NO_INPUT, forward: 1 }).filter((s) => s.name === "step");
259 // Two seconds at the run cap is 500 units of ground, and a stride is 85 of
260 // them: five or six feet land, all of them this body's own.
261 expect(running.length).toBeGreaterThanOrEqual(4);
262 expect(running.every((s) => s.by === world.me.id)).toBe(true);
263
264 stand();
265 const walking = listen(world, 2, { ...NO_INPUT, forward: 1, walk: true }).filter((s) => s.name === "step");
266 expect(walking).toEqual([]);
267
268 stand();
269 const ducking = listen(world, 2, { ...NO_INPUT, forward: 1, duck: true }).filter((s) => s.name === "step");
270 expect(ducking).toEqual([]);
271
272 // Walking does not bank a step to spend the moment the key comes up: the
273 // first one after Shift is a full stride away, not the next tick.
274 stand();
275 const after = listen(world, 2, { ...NO_INPUT, forward: 1, walk: true });
276 expect(after.filter((s) => s.name === "step")).toEqual([]);
277 const released = listen(world, 0.2, { ...NO_INPUT, forward: 1 }).filter((s) => s.name === "step");
278 expect(released).toEqual([]);
279 });
280
281 test("a kill is heard where the body fell", () => {
282 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 19);
283 run(world, SHORT.freezeTime + 0.2);
284 const victim = world.players.find((p) => p.team === "t") as Player;
285 only(world, [world.me, victim]);
286 victim.bot = false;
287 // Half a second of standing in front of somebody on one health and pulling
288 // the trigger: the pistol fires when it is ready and the body goes down.
289 const played = listen(world, 0.5, NO_INPUT, () => {
290 if (!victim.alive) return;
291 victim.pos = [world.me.pos[0] + 60, world.me.pos[1], world.me.pos[2] ];
292 victim.health = 1;
293 world.me.yaw = 0;
294 world.me.pitch = 0;
295 shoot(world, world.me);
296 });
297 const death = played.find((s) => s.name === "death");
298 expect(victim.alive).toBe(false);
299 expect(death?.by).toBe(victim.id);
300 expect(played.filter((s) => s.name === "death").length).toBe(1);
301 });
302
303 test("a burst of footsteps cannot push a hit marker out of the ring", () => {
304 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 29);
305 run(world, SHORT.freezeTime + 0.2);
306 // The marker for a hit that just landed, pushed the way the world pushes one.
307 const marker = { kind: "hitmark" as const, at: world.time, headshot: true };
308 pushEffect(world.effects, marker, marker.at);
309 // Ten bodies running for the loudest second and nine tenths the ring can
310 // hold — EFFECT_LIFE is two, so nothing here has expired yet.
311 run(world, 1.9, { ...NO_INPUT, forward: 1, side: 1 });
312
313 let found = false;
314 for (let i = 0; i < world.effects.count; i++) if (effectAt(world.effects, i) === marker) found = true;
315 expect(found).toBe(true);
316 expect(world.effects.count).toBeLessThan(EFFECT_CAPACITY / 4);
317 });
318
118319 test("a match plays itself to the end inside its own time", () => {
119320 const world = createWorld(DE_FORGE, { ...SHORT, winScore: 2, freezeTime: 1, roundTime: 40, endDelay: 1 }, "me", "ct", 5, 23);
120321 world.me.alive = false;
src/client/world.ts +96 −11
@@ -14,7 +14,7 @@
1414 * WebGL, so it can be stepped in a test without a browser.
1515 */
1616
17 import { dropBomb, giveBomb, newBomb, updateBomb, type Bomb } from "./bomb.ts";
17 import { beepDue, dropBomb, giveBomb, newBomb, updateBomb, type Bomb } from "./bomb.ts";
1818 import { applyDamage, applyHit, traceBullet, type Actor } from "./combat.ts";
1919 import { emptyAmmo, type Ammo } from "./economy.ts";
2020 import {
@@ -71,6 +71,13 @@ export type Player = Actor & Body & {
7171 /** Which kind the throw key was last held on, so a press throws exactly one. */
7272 threwThisPress: ThrownId | null;
7373 punch: [number, number]; // view kick, decaying
74 /**
75 * Source units walked since this body's last foot landed. It is a clock for
76 * the footstep below and nothing reads it back into the simulation, which is
77 * why it is not one of the fields `replay.ts` hashes: a sound is something
78 * the round says, not something it is.
79 */
80 stride: number;
7481 kills: number;
7582 deaths: number;
7683 headshots: number;
@@ -78,6 +85,16 @@ export type Player = Actor & Body & {
7885 spawnAt: number;
7986 };
8087
88 /**
89 * Every noise a round can make, by name: the grenade's four, and the round's
90 * own five. A footstep is one name and not two — whether it is *your* boot is a
91 * question about the ears it reaches, which only the frame knows, so the effect
92 * carries who made it and `audio.ts` decides how loud that is.
93 *
94 * `beep` has no body behind it: it is the bomb, and `by` is -1 for it.
95 */
96 export type Sound = GrenadeSound | "step" | "beep" | "plant" | "defuse" | "death";
97
8198 export type Effect =
8299 | { kind: "tracer"; from: Vec3; to: Vec3; at: number }
83100 | { kind: "impact"; at: number; pos: Vec3; normal: Vec3 }
@@ -86,7 +103,8 @@ export type Effect =
86103 | { kind: "hitmark"; at: number; headshot: boolean }
87104 /** A grenade going off, of whichever kind: the frame draws each its own way. */
88105 | { kind: "blast"; at: number; pos: Vec3; weapon: ThrownId }
89 | { kind: "sound"; at: number; pos: Vec3; name: GrenadeSound };
106 /** Something heard, where it happened, and the id of whoever made it (-1: nobody). */
107 | { kind: "sound"; at: number; pos: Vec3; name: Sound; by: number };
90108
91109 /**
92110 * How long anything that happened is remembered for, in seconds. Every kind of
@@ -101,7 +119,10 @@ export const EFFECT_LIFE = 2;
101119 * How many effects those two seconds may hold. A 5v5 with every player holding
102120 * the trigger down peaks at 138 live at once, counted by stepping a round; the
103121 * arithmetic bound is higher — a hundred shots a second, up to four events each
104 * — so call the loudest two seconds this game can have eight hundred. 4096 is
122 * — so call the loudest two seconds this game can have eight hundred. The
123 * round's own noises are a rounding error beside that: ten bodies at a dead run
124 * land a foot every `STEP_DISTANCE / 250` of a second, which is 59 footsteps in
125 * two seconds, and the bomb beeps at most 17 times in two. 4096 is
105126 * well past both on purpose: it is more than `scene.ts` can draw in one frame
106127 * anyway, so what limits a busy frame is the line budget there and never how
107128 * much of what happened is still remembered. Past it the oldest event is
@@ -186,7 +207,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player
186207 defuseKit: false, hasBomb: false, pocket: { he: 0, flash: 0 }, blind: 0,
187208 nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false,
188209 firedThisPress: false, threwThisPress: null,
189 punch: [0, 0],
210 punch: [0, 0], stride: 0,
190211 kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0,
191212 };
192213 }
@@ -246,6 +267,50 @@ export function createWorld(
246267 */
247268 const record = (world: World, e: Effect): void => pushEffect(world.effects, e, e.at);
248269
270 /**
271 * Something heard at `pos`, made by player `by` — -1 when nobody made it. The
272 * position is copied because the caller's is usually a body's own array, which
273 * moves on the next tick, and an effect the frame reads later must be where the
274 * noise was and not where the body is now.
275 */
276 const heard = (world: World, pos: Vec3, name: Sound, by: number): void => {
277 record(world, { kind: "sound", at: world.time, pos: [pos[0], pos[1], pos[2]], name, by });
278 };
279
280 /**
281 * Source units between one foot landing and the next. A stride is about 1.5 m,
282 * which is 85 Source units at 1 unit ≈ 1.9 cm, so a body at the 250 u/s run cap
283 * puts a foot down every 0.34 s — Counter-Strike's cadence — and one slowed by
284 * a rifle or by a corner takes proportionally longer to cover the ground.
285 */
286 export const STEP_DISTANCE = 85;
287
288 /**
289 * The ground speed under which a foot lands too softly to carry, in u/s. It is
290 * above the 130 u/s walk on purpose: what makes walking silent is the branch
291 * below, and this is what keeps a body sliding to a halt from squeezing out one
292 * last step it did not take.
293 */
294 export const STEP_SPEED = 140;
295
296 /**
297 * A foot landing, if one did. This is the whole of what walking is *for*: hold
298 * Shift or crouch and the stride clock is held at zero, so no step is emitted
299 * and none is banked to be emitted the moment the key comes up. In the air
300 * there is nothing to land on yet, and a body under `STEP_SPEED` is not running.
301 */
302 function footstep(world: World, p: Player, quiet: boolean, dt: number): void {
303 const speed = Math.hypot(p.vel[0], p.vel[1]);
304 if (quiet || !p.onGround || p.duckAmount > 0.5 || speed < STEP_SPEED) {
305 p.stride = 0;
306 return;
307 }
308 p.stride += speed * dt;
309 if (p.stride < STEP_DISTANCE) return;
310 p.stride -= STEP_DISTANCE;
311 heard(world, p.pos, "step", p.id);
312 }
313
249314 export const teammates = (world: World, team: Team): Player[] => world.players.filter((p) => p.team === team);
250315 export const alive = (world: World, team: Team): Player[] =>
251316 world.players.filter((p) => p.team === team && p.alive);
@@ -283,6 +348,7 @@ export function startRound(world: World): void {
283348 p.hasBomb = false;
284349 p.shotIndex = 0;
285350 p.punch = [0, 0];
351 p.stride = 0;
286352 p.scoped = false;
287353 p.spawnAt = world.time;
288354 p.yaw = p.team === "t" ? 90 : -90;
@@ -415,6 +481,9 @@ function onKill(world: World, killer: Player, victim: Player, weapon: WeaponId,
415481 victim.alive = false;
416482 victim.deaths += 1;
417483 victim.scoped = false;
484 // A fight that ends behind you is a thing you heard: the body goes down where
485 // it stood, whoever shot it and whichever of the two of them you can see.
486 heard(world, victim.pos, "death", victim.id);
418487 killer.kills += 1;
419488 if (headshot) killer.headshots += 1;
420489 killer.money = rules.capMoney(killer.money + WEAPONS[weapon].killReward, world.match.rules);
@@ -440,14 +509,10 @@ export function throwOne(world: World, p: Player, kind: ThrownId = "he"): boolea
440509 const from = eyePos(p);
441510 const dir = angleVectors(p.yaw, p.pitch - THROW_LIFT).forward;
442511 world.grenades.push(throwGrenade(p.id, p.team, kind, from, dir, world.level.boxes));
443 record(world, { kind: "sound", at: world.time, pos: from, name: "throw" });
512 heard(world, from, "throw", p.id);
444513 return true;
445514 }
446515
447 const heard = (world: World, pos: Vec3, name: GrenadeSound): void => {
448 record(world, { kind: "sound", at: world.time, pos: [pos[0], pos[1], pos[2]], name });
449 };
450
451516 /**
452517 * A grenade whose fuse ran out: the pop everyone sees and hears, and then
453518 * whatever this kind of grenade does. The dispatch is the one place in the
@@ -456,7 +521,7 @@ const heard = (world: World, pos: Vec3, name: GrenadeSound): void => {
456521 function detonate(world: World, g: Grenade): void {
457522 const centre = blastCentre(g);
458523 record(world, { kind: "blast", at: world.time, pos: centre, weapon: g.weapon });
459 heard(world, centre, g.weapon === "flash" ? "flash" : "blast");
524 heard(world, centre, g.weapon === "flash" ? "flash" : "blast", g.owner);
460525 if (g.weapon === "flash") return blind(world, g);
461526 explode(world, g, centre);
462527 }
@@ -500,7 +565,7 @@ function updateGrenades(world: World, dt: number): void {
500565 if (world.grenades.length === 0) return;
501566 let spent = false;
502567 for (const g of world.grenades) {
503 if (stepGrenade(g, dt, world.level.boxes)) heard(world, g.pos, "bounce");
568 if (stepGrenade(g, dt, world.level.boxes)) heard(world, g.pos, "bounce", g.owner);
504569 if (g.fuse <= 0) {
505570 detonate(world, g);
506571 spent = true;
@@ -522,12 +587,26 @@ function bombTick(world: World, p: Player, use: boolean, dt: number): void {
522587 if (event === null) return;
523588 if (event.type === "planted") {
524589 world.bombPlanted = true;
590 heard(world, world.bomb.pos, "plant", p.id);
525591 say(world, `The bomb has been planted at ${event.site}.`);
526592 } else {
593 heard(world, world.bomb.pos, "defuse", p.id);
527594 finishRound(world, "ct", "defused");
528595 }
529596 }
530597
598 /**
599 * The bomb, ticking. The beep is the one sound in this game that is not an
600 * event — it is a clock — and what it carries is two things at once: where the
601 * bomb is, and how much of the fuse is left, which is why `bomb.ts` quickens it
602 * as the fuse runs down. Nobody made it, so it is heard from nobody: `by` is -1.
603 */
604 function bombBeep(world: World, dt: number): void {
605 if (world.bomb.state !== "planted" || world.match.phase !== "planted") return;
606 if (beepDue(world.bomb, world.match.timer, world.match.rules.bombTime, dt))
607 heard(world, world.bomb.pos, "beep", -1);
608 }
609
531610 // --- the tick -------------------------------------------------------------
532611
533612 export function applyInput(world: World, p: Player, input: Input, dt: number): void {
@@ -552,6 +631,9 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
552631 maxSpeed: Math.min(CS_MOVE.maxSpeed, w.carrySpeed),
553632 };
554633 move(p, wish, dt, world.level.boxes, CS_MOVE, CS_HULL);
634 // Where the foot lands is decided by where the body ended up, so this is read
635 // off the mover and not off the wish that went into it.
636 footstep(world, p, wish.walk, dt);
555637 finishReload(world, p);
556638 if (input.reload) reload(world, p);
557639 if (w.zoom !== undefined && input.scope !== p.scoped && world.time > p.nextShotAt - 0.05) p.scoped = input.scope;
@@ -623,6 +705,9 @@ export function update(world: World, dt: number, input: Input): void {
623705 // After everyone has moved, so a grenade is solved against where people
624706 // ended the tick rather than where they started it.
625707 updateGrenades(world, dt);
708 // Once a tick, and not once per player: `bombTick` runs ten times and the
709 // fuse is one clock.
710 bombBeep(world, dt);
626711 checkRoundEnd(world);
627712 // What happened more than `EFFECT_LIFE` ago, dropped in place: the ring is
628713 // the engine's, so a busy tick rewrites indices instead of building an array.
src/games.ts +12
@@ -303,6 +303,18 @@ export type Game = {
303303 * the 96,000 / 43,500 #185 raised with 2,286 raw and 961 gzipped bytes to
304304 * spare, so no ceiling moved. Neither other bundle changed a byte: the range
305305 * posts nothing and ships none of this. Measured 2026-09-20.
306 * #135 made the round audible — a footstep as a foot lands, the bomb's beep
307 * quickening as the fuse runs down, the plant, the cut wire and a body going
308 * down — and the app grew 1,516 raw / 467 gzipped bytes for it: 93,714 → 95,230
309 * raw and 42,539 → 43,006 gzipped. That is five recipes in `src/client/audio.ts`
310 * and the distances they are heard over, the stride clock and the four emitters
311 * in `src/client/world.ts`, and the fuse's cadence in `src/client/bomb.ts`; no
312 * module was added and nothing was downloaded, because a sound here is arithmetic
313 * like everything else. It fits the 96,000 / 43,500 #185 raised with 770 raw and
314 * 494 gzipped bytes to spare, so the ceiling is held — but that is now the
315 * thinnest headroom on this page, and the next thing the shooter gains will want
316 * it raised. Neither other bundle moved a byte: nothing outside `src/client/`
317 * changed. Measured 2026-09-21.
306318 */
307319 export const GAMES: readonly Game[] = [
308320 {
static/app.0a342a74a2.js deleted −58

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

View file
static/app.6a9982560a.js added +58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs