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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/135-the-round-is-audible-footsteps-the-bomb → main 1 commit fork
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 | ||
| 36 | 36 | two thrown grenades — an HE, and a flashbang that blinds by how much of it you |
| 37 | 37 | could see (the line, the angle and the distance, for at most 1.6 seconds) — |
| 38 | 38 | 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`. | |
| 40 | 45 | - **Two kinds of map.** `de_forge` is drawn by hand, cell by cell; the other |
| 41 | 46 | kind is carved from a number by `src/client/generate.ts` — pick *generated* |
| 42 | 47 | 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 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { createAudio, loudness, shotTone } from "./audio.ts"; |
| 3 | 3 | import type { Burst, SoundDevice, Tone } from "../engine/index.ts"; |
| 4 | import type { Sound } from "./world.ts"; | |
| 4 | 5 | |
| 5 | 6 | /** A device that plays nothing and writes down everything. */ |
| 6 | 7 | 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", () => { | ||
| 47 | 48 | expect(loudness([4000, 0, 0], [0, 0, 0])).toBeLessThan(0.3); |
| 48 | 49 | }); |
| 49 | 50 | |
| 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 | ||
| 50 | 120 | test("a headshot beeps higher than a body hit", () => { |
| 51 | 121 | const device = recorder(); |
| 52 | 122 | const audio = createAudio(device); |
| @@ -63,6 +133,7 @@ test("muting the voice mutes the device under it", () => { | ||
| 63 | 133 | audio.muted = true; |
| 64 | 134 | expect(device.muted).toBe(true); |
| 65 | 135 | audio.shot("ak47", [0, 0, 0], [0, 0, 0]); |
| 136 | audio.play("step", [0, 0, 0], [0, 0, 0]); | |
| 66 | 137 | audio.beep(700); |
| 67 | 138 | expect(device.bursts).toEqual([]); |
| 68 | 139 | expect(device.tones).toEqual([]); |
src/client/audio.ts +91 −7
| @@ -3,21 +3,33 @@ | ||
| 3 | 3 | * low thump, and each weapon kind gets its own colour, length and thump; a hit |
| 4 | 4 | * and a buy are short tones. Nothing is downloaded, and the engine's device |
| 5 | 5 | * 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. | |
| 6 | 14 | */ |
| 7 | 15 | |
| 8 | 16 | import { attenuate, createSoundDevice, dist, type SoundDevice, type Vec3 } from "../engine/index.ts"; |
| 9 | import type { GrenadeSound } from "./grenade.ts"; | |
| 10 | 17 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 18 | import type { Sound } from "./world.ts"; | |
| 11 | 19 | |
| 12 | 20 | export type Audio = { |
| 13 | 21 | shot(weapon: WeaponId, at: Vec3, listener: Vec3): void; |
| 14 | 22 | /** |
| 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. | |
| 19 | 31 | */ |
| 20 | grenade(sound: GrenadeSound, at: Vec3, listener: Vec3): void; | |
| 32 | play(sound: Sound, at: Vec3, listener: Vec3, own?: boolean): void; | |
| 21 | 33 | hit(headshot: boolean): void; |
| 22 | 34 | beep(pitch: number, gain?: number): void; |
| 23 | 35 | resume(): void; |
| @@ -46,6 +58,38 @@ const CLOSE = 120; | ||
| 46 | 58 | const BLAST_EARSHOT = 2400; |
| 47 | 59 | const BLAST_CLOSE = 300; |
| 48 | 60 | |
| 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 | ||
| 49 | 93 | /** The shot a weapon makes: its filter colour, its thump and how long it rings. */ |
| 50 | 94 | export const shotTone = (weapon: WeaponId): { cut: number; thump: number; length: number } => |
| 51 | 95 | 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 { | ||
| 80 | 124 | length: tone.length, |
| 81 | 125 | }); |
| 82 | 126 | }, |
| 83 | grenade(sound, at, listener) { | |
| 127 | play(sound, at, listener, own = false) { | |
| 84 | 128 | 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 | } | |
| 85 | 169 | if (sound === "throw") { |
| 86 | 170 | // A short upward whoosh of air: quiet, and only near you. |
| 87 | 171 | 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 @@ | ||
| 11 | 11 | |
| 12 | 12 | import { expect, test } from "bun:test"; |
| 13 | 13 | 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, | |
| 16 | 16 | } from "./bomb.ts"; |
| 17 | 17 | import { DE_FORGE } from "./map.ts"; |
| 18 | 18 | 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", () => { | ||
| 184 | 184 | p.pos[0] = 999; // a copy, not the player's own array |
| 185 | 185 | expect(bomb.pos[0]).toBe(12); |
| 186 | 186 | }); |
| 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 = { | ||
| 37 | 37 | plantProgress: number; |
| 38 | 38 | defuseProgress: number; |
| 39 | 39 | 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; | |
| 40 | 46 | }; |
| 41 | 47 | |
| 42 | 48 | /** 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; | ||
| 59 | 65 | /** A fresh bomb, in somebody's hands, at `pos`. */ |
| 60 | 66 | export const newBomb = (pos: Vec3 = [0, 0, 0]): Bomb => ({ |
| 61 | 67 | 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, | |
| 63 | 69 | }); |
| 64 | 70 | |
| 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 | ||
| 65 | 99 | /** Hand the bomb to this player at the top of a round: it starts where they do. */ |
| 66 | 100 | export function giveBomb(carrier: Carrier): Bomb { |
| 67 | 101 | 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 | ||
| 187 | 187 | expect(stage.by("fps").textContent).toContain("ms"); |
| 188 | 188 | }); |
| 189 | 189 | |
| 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 | ||
| 190 | 212 | test("one frame of 1/30 s runs two 64 Hz steps and no more, and banks the rest", () => { |
| 191 | 213 | const stage = playing(); |
| 192 | 214 | stage.step(FRAME); |
src/client/main.ts +4 −1
| @@ -619,13 +619,16 @@ export function boot(host: Host): Frame { | ||
| 619 | 619 | * Sound for whatever happened in the ticks we just ran, heard from `ears` — |
| 620 | 620 | * the player the camera is behind. Dead, that is whoever you are watching: |
| 621 | 621 | * 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. | |
| 622 | 625 | */ |
| 623 | 626 | function playSounds(current: World, ears: Player): void { |
| 624 | 627 | for (let i = 0; i < current.effects.count; i++) { |
| 625 | 628 | const effect = effectAt(current.effects, i); |
| 626 | 629 | if (effect === undefined || effect.at <= lastSound) continue; |
| 627 | 630 | 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); | |
| 629 | 632 | else if (effect.kind === "hitmark") audio.hit(effect.headshot); |
| 630 | 633 | } |
| 631 | 634 | lastSound = current.time; |
src/client/stage.test-helper.ts +4 −3
| @@ -211,9 +211,10 @@ export function fakeStage( | ||
| 211 | 211 | muted: false, |
| 212 | 212 | resume: () => void sounds.push("resume"), |
| 213 | 213 | 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}`), | |
| 217 | 218 | hit: (headshot) => void sounds.push(headshot ? "hit:head" : "hit"), |
| 218 | 219 | beep: () => void sounds.push("beep"), |
| 219 | 220 | }; |
src/client/world.test.ts +204 −3
| @@ -1,17 +1,80 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { createAudio } from "./audio.ts"; | |
| 3 | import { beepInterval } from "./bomb.ts"; | |
| 2 | 4 | import { siteAt } from "./level.ts"; |
| 3 | 5 | import { DE_FORGE } from "./map.ts"; |
| 4 | 6 | import { SHORT } from "./match.ts"; |
| 5 | 7 | import { WEAPONS } from "./weapons.ts"; |
| 6 | 8 | 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"; | |
| 9 | 14 | |
| 10 | 15 | const TICK = 1 / 64; |
| 11 | 16 | const run = (world: World, seconds: number, input: Input = NO_INPUT): void => { |
| 12 | 17 | for (let t = 0; t < seconds; t += TICK) update(world, TICK, input); |
| 13 | 18 | }; |
| 14 | 19 | |
| 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 | ||
| 15 | 78 | test("a world starts ten players, one bomb and a freeze time", () => { |
| 16 | 79 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 1); |
| 17 | 80 | 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 | ||
| 85 | 148 | test("a defusing CT wins the round, and the money follows the rules", () => { |
| 86 | 149 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 13); |
| 87 | 150 | 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 }; | |
| 89 | 152 | world.bombPlanted = true; |
| 90 | 153 | world.match.phase = "planted"; |
| 91 | 154 | world.match.timer = 30; |
| @@ -115,6 +178,144 @@ test("a new round puts everyone back with full health and a full magazine", () = | ||
| 115 | 178 | expect(world.players.filter((p) => p.hasBomb).length).toBe(1); |
| 116 | 179 | }); |
| 117 | 180 | |
| 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 | ||
| 118 | 319 | test("a match plays itself to the end inside its own time", () => { |
| 119 | 320 | const world = createWorld(DE_FORGE, { ...SHORT, winScore: 2, freezeTime: 1, roundTime: 40, endDelay: 1 }, "me", "ct", 5, 23); |
| 120 | 321 | world.me.alive = false; |
src/client/world.ts +96 −11
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | * WebGL, so it can be stepped in a test without a browser. |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import { dropBomb, giveBomb, newBomb, updateBomb, type Bomb } from "./bomb.ts"; | |
| 17 | import { beepDue, dropBomb, giveBomb, newBomb, updateBomb, type Bomb } from "./bomb.ts"; | |
| 18 | 18 | import { applyDamage, applyHit, traceBullet, type Actor } from "./combat.ts"; |
| 19 | 19 | import { emptyAmmo, type Ammo } from "./economy.ts"; |
| 20 | 20 | import { |
| @@ -71,6 +71,13 @@ export type Player = Actor & Body & { | ||
| 71 | 71 | /** Which kind the throw key was last held on, so a press throws exactly one. */ |
| 72 | 72 | threwThisPress: ThrownId | null; |
| 73 | 73 | 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; | |
| 74 | 81 | kills: number; |
| 75 | 82 | deaths: number; |
| 76 | 83 | headshots: number; |
| @@ -78,6 +85,16 @@ export type Player = Actor & Body & { | ||
| 78 | 85 | spawnAt: number; |
| 79 | 86 | }; |
| 80 | 87 | |
| 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 | ||
| 81 | 98 | export type Effect = |
| 82 | 99 | | { kind: "tracer"; from: Vec3; to: Vec3; at: number } |
| 83 | 100 | | { kind: "impact"; at: number; pos: Vec3; normal: Vec3 } |
| @@ -86,7 +103,8 @@ export type Effect = | ||
| 86 | 103 | | { kind: "hitmark"; at: number; headshot: boolean } |
| 87 | 104 | /** A grenade going off, of whichever kind: the frame draws each its own way. */ |
| 88 | 105 | | { 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 }; | |
| 90 | 108 | |
| 91 | 109 | /** |
| 92 | 110 | * How long anything that happened is remembered for, in seconds. Every kind of |
| @@ -101,7 +119,10 @@ export const EFFECT_LIFE = 2; | ||
| 101 | 119 | * How many effects those two seconds may hold. A 5v5 with every player holding |
| 102 | 120 | * the trigger down peaks at 138 live at once, counted by stepping a round; the |
| 103 | 121 | * 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 | |
| 105 | 126 | * well past both on purpose: it is more than `scene.ts` can draw in one frame |
| 106 | 127 | * anyway, so what limits a busy frame is the line budget there and never how |
| 107 | 128 | * 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 | ||
| 186 | 207 | defuseKit: false, hasBomb: false, pocket: { he: 0, flash: 0 }, blind: 0, |
| 187 | 208 | nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false, |
| 188 | 209 | firedThisPress: false, threwThisPress: null, |
| 189 | punch: [0, 0], | |
| 210 | punch: [0, 0], stride: 0, | |
| 190 | 211 | kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0, |
| 191 | 212 | }; |
| 192 | 213 | } |
| @@ -246,6 +267,50 @@ export function createWorld( | ||
| 246 | 267 | */ |
| 247 | 268 | const record = (world: World, e: Effect): void => pushEffect(world.effects, e, e.at); |
| 248 | 269 | |
| 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 | ||
| 249 | 314 | export const teammates = (world: World, team: Team): Player[] => world.players.filter((p) => p.team === team); |
| 250 | 315 | export const alive = (world: World, team: Team): Player[] => |
| 251 | 316 | world.players.filter((p) => p.team === team && p.alive); |
| @@ -283,6 +348,7 @@ export function startRound(world: World): void { | ||
| 283 | 348 | p.hasBomb = false; |
| 284 | 349 | p.shotIndex = 0; |
| 285 | 350 | p.punch = [0, 0]; |
| 351 | p.stride = 0; | |
| 286 | 352 | p.scoped = false; |
| 287 | 353 | p.spawnAt = world.time; |
| 288 | 354 | p.yaw = p.team === "t" ? 90 : -90; |
| @@ -415,6 +481,9 @@ function onKill(world: World, killer: Player, victim: Player, weapon: WeaponId, | ||
| 415 | 481 | victim.alive = false; |
| 416 | 482 | victim.deaths += 1; |
| 417 | 483 | 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); | |
| 418 | 487 | killer.kills += 1; |
| 419 | 488 | if (headshot) killer.headshots += 1; |
| 420 | 489 | 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 | ||
| 440 | 509 | const from = eyePos(p); |
| 441 | 510 | const dir = angleVectors(p.yaw, p.pitch - THROW_LIFT).forward; |
| 442 | 511 | 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); | |
| 444 | 513 | return true; |
| 445 | 514 | } |
| 446 | 515 | |
| 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 | ||
| 451 | 516 | /** |
| 452 | 517 | * A grenade whose fuse ran out: the pop everyone sees and hears, and then |
| 453 | 518 | * 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 => { | ||
| 456 | 521 | function detonate(world: World, g: Grenade): void { |
| 457 | 522 | const centre = blastCentre(g); |
| 458 | 523 | 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); | |
| 460 | 525 | if (g.weapon === "flash") return blind(world, g); |
| 461 | 526 | explode(world, g, centre); |
| 462 | 527 | } |
| @@ -500,7 +565,7 @@ function updateGrenades(world: World, dt: number): void { | ||
| 500 | 565 | if (world.grenades.length === 0) return; |
| 501 | 566 | let spent = false; |
| 502 | 567 | 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); | |
| 504 | 569 | if (g.fuse <= 0) { |
| 505 | 570 | detonate(world, g); |
| 506 | 571 | spent = true; |
| @@ -522,12 +587,26 @@ function bombTick(world: World, p: Player, use: boolean, dt: number): void { | ||
| 522 | 587 | if (event === null) return; |
| 523 | 588 | if (event.type === "planted") { |
| 524 | 589 | world.bombPlanted = true; |
| 590 | heard(world, world.bomb.pos, "plant", p.id); | |
| 525 | 591 | say(world, `The bomb has been planted at ${event.site}.`); |
| 526 | 592 | } else { |
| 593 | heard(world, world.bomb.pos, "defuse", p.id); | |
| 527 | 594 | finishRound(world, "ct", "defused"); |
| 528 | 595 | } |
| 529 | 596 | } |
| 530 | 597 | |
| 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 | ||
| 531 | 610 | // --- the tick ------------------------------------------------------------- |
| 532 | 611 | |
| 533 | 612 | 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 | ||
| 552 | 631 | maxSpeed: Math.min(CS_MOVE.maxSpeed, w.carrySpeed), |
| 553 | 632 | }; |
| 554 | 633 | 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); | |
| 555 | 637 | finishReload(world, p); |
| 556 | 638 | if (input.reload) reload(world, p); |
| 557 | 639 | 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 { | ||
| 623 | 705 | // After everyone has moved, so a grenade is solved against where people |
| 624 | 706 | // ended the tick rather than where they started it. |
| 625 | 707 | 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); | |
| 626 | 711 | checkRoundEnd(world); |
| 627 | 712 | // What happened more than `EFFECT_LIFE` ago, dropped in place: the ring is |
| 628 | 713 | // 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 = { | ||
| 303 | 303 | * the 96,000 / 43,500 #185 raised with 2,286 raw and 961 gzipped bytes to |
| 304 | 304 | * spare, so no ceiling moved. Neither other bundle changed a byte: the range |
| 305 | 305 | * 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. | |
| 306 | 318 | */ |
| 307 | 319 | export const GAMES: readonly Game[] = [ |
| 308 | 320 | { |
static/app.0a342a74a2.js deleted −58
Diff not shown — a line 74,812 characters wide (limit 2,000). −58
View filestatic/app.6a9982560a.js added +58
Diff not shown — a line 76,328 characters wide (limit 2,000). +58
View file
new above
offline