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

forked from trevato/cs · proposes

The range makes its own noise: a shot, a hit and the clock through the engine’s sound device #82

Merged opened this

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

Merged

torque/cs-engine:torque/52-the-range-makes-its-own-noise-a-shot-a-h → main 1 commit fork

  • Merged. torque/52-the-range-makes-its-own-noise-a-shot-a-h was merged into main as 0f584d0.

Review

Sign in to review this change.
11 files changed +807 −73
README.md +23 −3
@@ -100,8 +100,11 @@ At `/range` the same controls drive the second game, on its own bindings:
100100 <kbd>Enter</kbd> starts a thirty second run. Plates pop up down the lane, each
101101 worth 100 to 200 points by how far away it stood; the panel keeps the score, the
102102 clock and the accuracy, and the best of the visit. The room, the plates and the
103 tracer are drawn by the engine's renderer; there is no walking yet, so you stand
104 at the firing line and turn on the spot. A browser with no WebGL2 keeps the game
103 tracer are drawn by the engine's renderer and the range makes its own noise
104 through the engine's sound device — a dry crack, a tone for a hit, a knock as a
105 plate stands up, a tick for each of the last three seconds and one falling tone
106 when the clock runs out, none of them the shooter's; there is no walking yet, so
107 you stand at the firing line and turn on the spot. A browser with no WebGL2 keeps the game
105108 on the 2D horizon it shipped with — a plate as a square drawn from the angle it
106109 subtends — and the panel says which of the two you are looking at.
107110
@@ -228,6 +231,23 @@ subtends — and the panel says which of the two you are looking at.
228231 game gets that furniture without copying a line out of the shooter; and `main.ts`
229232 wires the engine's `loop` and `controls` to the shell's ids, steps the run in
230233 whole ticks and fires one shot per press along the engine's `angleVectors`.
234 **The range makes its own noise**, through the engine's `createSoundDevice`
235 and never the shooter's recipes: `audio.ts` is five verbs — `shot`, `hit`,
236 `pop`, `tick` and `over` — over one device, each a named recipe a test can
237 read, and the crack is 2,400 Hz for 70 ms with no thump under it against the
238 rifle's 1,500 Hz for 240 ms with one, because a range bay is not a firefight.
239 Distance is the engine's `attenuate` on this room's numbers (full volume
240 inside 320 units, so a plate hit at 1,200 is a quarter the gain of one at
241 200), every gain is inside 0..1 and a browser that refuses an `AudioContext`
242 is a quiet page rather than an exception. `main.ts` plays them off the game's
243 own facts and off crossings only — a plate whose id the last frame had not
244 reached, the shot `shoot` handed back, the clock passing each of the last
245 three whole seconds and the phase turning `over` — so a sound is one sound and
246 not one a frame, and nothing is heard while the run is `ready` or `over`. The
247 device is opened on the gesture that starts a run and never at boot:
248 `audio.test.ts` drives a fake `AudioContext` constructor of its own and
249 `boot.test.ts` counts the constructors the stub page handed out, which is zero
250 for a page nobody plays.
231251 **The range paints its own world**, and it is data before it is a picture:
232252 `level.ts` is one room — a floor, four walls and a ceiling as six overlapping
233253 slabs around a 1,792 × 1,280 × 320 unit lane, derived from four named numbers
@@ -336,7 +356,7 @@ subtends — and the panel says which of the two you are looking at.
336356 | bundle | raw | ceiling | gzipped | ceiling |
337357 | ------- | ------ | ------- | ------- | ------- |
338358 | `app` | 70,576 | 75,400 | 32,862 | 34,900 |
339 | `range` | 24,214 | 26,700 | 10,500 | 11,600 |
359 | `range` | 26,658 | 29,400 | 11,456 | 12,700 |
340360
341361 The ceilings are today's sizes plus about 10%. Gzip is measured at level 9
342362 and only at level 9, so the number does not move between a laptop and the
scripts/build.test.ts +1 −1
@@ -37,7 +37,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri
3737 test("the entry table is what the app ships today, each with the size it may not pass", () => {
3838 expect(ENTRIES).toEqual([
3939 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } },
40 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 26_700, gzip: 11_600 } },
40 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 29_400, gzip: 12_700 } },
4141 ]);
4242 });
4343
scripts/build.ts +6 −1
@@ -44,12 +44,17 @@ export type Entry = { name: string; entry: string; ceiling: Ceiling };
4444 * the tree-shaking working: it imports the door and none of the WebGL2 behind it. The range's ceiling was raised for #41, which gave it a game: the targets,
4545 * the clock and the score, the shot traced through the engine's `rayBox`, and
4646 * the blips the horizon draws them as.
47 * The range's ceiling was raised again for #52, which gave it sound: the
48 * engine's `sound` module and `src/range/audio.ts`'s five recipes together are
49 * 2,444 raw and 956 gzipped bytes. Measured 2026-09-18: range 26,658 raw /
50 * 11,456 gzipped, which fitted the old 26,700 / 11,600 by 42 bytes — not a
51 * ceiling anybody is holding — so it is today's size plus about 10% again.
4752 * Raising a ceiling is allowed, and it is a line in the pull that says what was
4853 * added and why.
4954 */
5055 export const ENTRIES: Entry[] = [
5156 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } },
52 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 26_700, gzip: 11_600 } },
57 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 29_400, gzip: 12_700 } },
5358 ];
5459
5560 /**
src/pages/range.ts +13
@@ -29,6 +29,11 @@ const NOTE = `
2929 <kbd>Shift</kbd> walk · <kbd>F</kbd> or the mouse fires · <kbd>R</kbd> reload ·
3030 <kbd>Enter</kbd> start · <kbd>Esc</kbd> gives the mouse back. On a touch screen the left of
3131 the view is a stick, the right looks around, and the buttons do the rest.</p>
32 <p class="hint">It makes its own noise, through the engine's sound device and the range's own
33 recipes: a short dry crack, a clean tone for a hit, a knock as a plate stands up — quieter
34 the farther down the lane it is — a tick for each of the last three seconds and one falling
35 tone when the clock runs out. None of the shooter's sounds is reused. Nothing is heard until
36 you start a run: the browser opens no audio device before that.</p>
3237 `;
3338
3439 export function rangePage(options: PageOptions): string {
@@ -81,6 +86,14 @@ export function rangePage(options: PageOptions): string {
8186 by a test with no browser in the room. Still missing is the walking: the eye stands at the
8287 firing line until the engine's physics lands. If your browser has no WebGL2, the flat
8388 horizon is still there and the panel says so.</p>
89 <p>You can hear it too, and that is the last part of the engine this page was not using: its
90 sound device, with five recipes of the range's own in <code>src/range/audio.ts</code> — the
91 crack, the hit, the knock of a plate standing up, the tick of each of the last three seconds
92 and the clock running out. The shooter's sounds are not reused: this crack is higher, a third
93 as long and has no thump under it, because a range bay is not a firefight. Distance is the
94 engine's <code>attenuate</code> on this room's own numbers, so a plate hit at 1,200 units is a
95 quarter the volume of one at 200. The device is opened on the gesture that starts a run and
96 never before it — a page you only look at opens no audio at all.</p>
8497 <p>Nothing on this page is stored. There is no endpoint, no table and no cookie behind it:
8598 the leaderboard, the database and the terminal belong to
8699 <a href="/">${escape("cs")}</a>, which is the game next door.</p>
src/range/audio.test.ts added +248
@@ -0,0 +1,248 @@
1 /**
2 * The range's five sounds, against a fake `AudioContext` of this test's own.
3 * It is the same shape `src/engine/sound.test.ts` drives the device with — a
4 * class that builds nodes which write down what they were asked for — written
5 * again here rather than imported, because that fake is that test's business
6 * and this one wants to read nodes as well as calls.
7 *
8 * Nothing here plays anything. What is checked is the recipe: how many voices
9 * a verb asks for, at what gain, for how long, and that distance is in the
10 * gain at all.
11 */
12
13 import { expect, test } from "bun:test";
14 import { createSoundDevice, type Ctor } from "../engine/index.ts";
15 import { CRACK, HIT, OVER, POP, TICK, createRangeAudio, loudness, type RangeAudio } from "./audio.ts";
16
17 type Event = { kind: string; value: number; time: number };
18
19 type Param = { value: number };
20
21 type Node = { kind: string; type: string; frequency: Param; gain: Param; Q: Param };
22
23 /** The smallest thing that answers like an `AudioContext`, and remembers. */
24 function fakeAudio(): { Ctor: Ctor; log: Event[]; nodes: Node[]; readonly contexts: number } {
25 const log: Event[] = [];
26 const nodes: Node[] = [];
27 const state = { contexts: 0 };
28 const param = (kind: string): Param => ({
29 value: 0,
30 setValueAtTime: (value: number, time: number) => log.push({ kind: `${kind}.set`, value, time }),
31 exponentialRampToValueAtTime: (value: number, time: number) =>
32 log.push({ kind: `${kind}.ramp`, value, time }),
33 }) as Param;
34 const node = (kind: string): Node => {
35 const made = {
36 kind,
37 type: "",
38 buffer: null as unknown,
39 frequency: param(`${kind}.frequency`),
40 playbackRate: param(`${kind}.rate`),
41 gain: param(`${kind}.gain`),
42 Q: param(`${kind}.q`),
43 connect: (next: unknown) => next,
44 start: (time: number) => log.push({ kind: `${kind}.start`, value: 0, time }),
45 stop: (time: number) => log.push({ kind: `${kind}.stop`, value: 0, time }),
46 };
47 nodes.push(made as unknown as Node);
48 return made as unknown as Node;
49 };
50 class Fake {
51 sampleRate = 48000;
52 currentTime = 0;
53 state = "running";
54 destination = { kind: "destination" };
55 constructor() {
56 state.contexts += 1;
57 }
58 resume(): void {}
59 createBuffer(_channels: number, length: number) {
60 const data = new Float32Array(length);
61 return { length, getChannelData: () => data };
62 }
63 createBufferSource() { return node("source"); }
64 createBiquadFilter() { return node("filter"); }
65 createGain() { return node("gain"); }
66 createOscillator() { return node("osc"); }
67 }
68 return {
69 Ctor: Fake as unknown as Ctor,
70 log,
71 nodes,
72 get contexts() { return state.contexts; },
73 };
74 }
75
76 /** The range's voice over a device over a fake context, and everything it wrote. */
77 function audio(): { log: Event[]; nodes: Node[]; readonly contexts: number; voice: RangeAudio } {
78 const fake = fakeAudio();
79 return {
80 log: fake.log,
81 nodes: fake.nodes,
82 get contexts() { return fake.contexts; },
83 voice: createRangeAudio(createSoundDevice(fake.Ctor)),
84 };
85 }
86
87 const of = (log: Event[], kind: string): Event[] => log.filter((event) => event.kind === kind);
88
89 /** How long one voice ran: its stop, less its start. The fake's clock is zero. */
90 const length = (log: Event[], kind: string): number =>
91 (of(log, `${kind}.stop`)[0]?.time ?? 0) - (of(log, `${kind}.start`)[0]?.time ?? 0);
92
93 /** Every peak gain the module asked the device for, in order. */
94 const gains = (log: Event[]): number[] => of(log, "gain.gain.set").map((event) => event.value);
95
96 const nodesOf = (nodes: Node[], kind: string): Node[] => nodes.filter((node) => node.kind === kind);
97
98 // --- the device is opened on the first sound ------------------------------
99
100 test("no context is built until a verb makes a sound, and then only one", () => {
101 const a = audio();
102 expect(a.contexts).toBe(0);
103 a.voice.shot();
104 expect(a.contexts).toBe(1);
105 a.voice.hit(300);
106 a.voice.pop(300);
107 a.voice.tick();
108 a.voice.over();
109 expect(a.contexts).toBe(1);
110 });
111
112 // --- the recipes ----------------------------------------------------------
113
114 test("a shot is one dry crack: a burst, no thump, and shorter than a rifle's", () => {
115 const a = audio();
116 a.voice.shot();
117 expect(of(a.log, "source.start").length).toBe(1);
118 // No second voice under it: the shooter's gun has a thump, this does not.
119 expect(of(a.log, "osc.start")).toEqual([]);
120 expect(gains(a.log)).toEqual([CRACK.gain]);
121 expect(length(a.log, "source")).toBeCloseTo(CRACK.length, 9);
122 expect(nodesOf(a.nodes, "filter")[0]?.frequency.value).toBe(CRACK.frequency);
123 // Higher and a third the length of `src/client/audio.ts`'s rifle (1,500 Hz,
124 // 240 ms), which is the point of having the range's own recipe at all.
125 expect(CRACK.frequency).toBeGreaterThan(2000);
126 expect(CRACK.length).toBeLessThan(0.08);
127 });
128
129 test("a hit is one clean tone, and a plate down the lane is quieter than one near", () => {
130 const near = audio();
131 near.voice.hit(200);
132 expect(of(near.log, "osc.start").length).toBe(1);
133 expect(of(near.log, "source.start")).toEqual([]);
134 expect(nodesOf(near.nodes, "osc")[0]?.type).toBe("sine");
135 expect(length(near.log, "osc")).toBeCloseTo(HIT.length + 0.02, 9); // the tail holds the node open
136
137 const far = audio();
138 far.voice.hit(1200);
139 const [close] = gains(near.log);
140 const [away] = gains(far.log);
141 expect(close).toBeCloseTo(HIT.gain, 9); // inside earshot: full volume
142 expect(away).toBeLessThan((close as number) / 2);
143 expect(away).toBeGreaterThan(0);
144 });
145
146 test("a plate standing up is a knock that drops, and it carries distance too", () => {
147 const a = audio();
148 a.voice.pop(320);
149 a.voice.pop(1200);
150 expect(of(a.log, "osc.frequency.set")[0]?.value).toBe(POP.frequency);
151 expect(of(a.log, "osc.frequency.ramp")[0]?.value).toBe(POP.slideTo);
152 expect(nodesOf(a.nodes, "osc")[0]?.type).toBe("triangle");
153 const [near, far] = gains(a.log);
154 expect(far).toBeLessThan(near as number);
155 expect(far).toBeGreaterThan(0);
156 });
157
158 test("a tick is one short tone, and three ticks are three of them", () => {
159 const a = audio();
160 a.voice.tick();
161 expect(of(a.log, "osc.start").length).toBe(1);
162 expect(length(a.log, "osc")).toBeCloseTo(TICK.length, 9);
163 expect(TICK.length).toBeLessThan(0.1);
164 a.voice.tick();
165 a.voice.tick();
166 expect(of(a.log, "osc.start").length).toBe(3);
167 expect(gains(a.log)).toEqual([TICK.gain, TICK.gain, TICK.gain]);
168 });
169
170 test("the clock running out is one falling tone, once each time it is asked for", () => {
171 const a = audio();
172 a.voice.over();
173 expect(of(a.log, "osc.start").length).toBe(1);
174 expect(of(a.log, "osc.frequency.set")[0]?.value).toBe(OVER.frequency);
175 expect(of(a.log, "osc.frequency.ramp")[0]?.value).toBe(OVER.slideTo);
176 expect(of(a.log, "osc.frequency.ramp")[0]?.value).toBeLessThan(OVER.frequency);
177 expect(length(a.log, "osc")).toBeCloseTo(OVER.length, 9);
178 });
179
180 // --- the arithmetic of the room -------------------------------------------
181
182 test("loudness is full at the near end of the lane and a quarter at the far end", () => {
183 expect(loudness(0)).toBe(1);
184 expect(loudness(200)).toBe(1);
185 expect(loudness(320)).toBe(1);
186 expect(loudness(1200)).toBeCloseTo(320 / 1200, 9);
187 let last = 1;
188 for (let d = 320; d <= 1536; d += 64) {
189 const now = loudness(d);
190 expect(now).toBeLessThanOrEqual(last);
191 expect(now).toBeGreaterThan(0);
192 last = now;
193 }
194 });
195
196 test("every gain the module asks for is a real gain: above zero, never over one", () => {
197 const a = audio();
198 for (const distance of [0, 200, 320, 700, 1200, 1536, 4096]) {
199 a.voice.shot();
200 a.voice.hit(distance);
201 a.voice.pop(distance);
202 a.voice.tick();
203 a.voice.over();
204 }
205 const asked = gains(a.log);
206 expect(asked.length).toBe(7 * 5);
207 for (const gain of asked) {
208 expect(gain).toBeGreaterThan(0);
209 expect(gain).toBeLessThanOrEqual(1);
210 }
211 // And the envelope always lands above zero, which an exponential ramp must.
212 for (const event of of(a.log, "gain.gain.ramp")) expect(event.value).toBeGreaterThan(0);
213 });
214
215 // --- a browser that says no -----------------------------------------------
216
217 test("a browser that refuses an AudioContext is a quiet page, not a broken one", () => {
218 const Refuses = class {
219 constructor() {
220 throw new Error("the user has not interacted with the document");
221 }
222 } as unknown as Ctor;
223 const voice = createRangeAudio(createSoundDevice(Refuses));
224 expect(() => {
225 voice.resume();
226 voice.shot();
227 voice.hit(400);
228 voice.pop(400);
229 voice.tick();
230 voice.over();
231 }).not.toThrow();
232 });
233
234 test("a page with no audio at all is quiet the same way", () => {
235 const voice = createRangeAudio(createSoundDevice(undefined));
236 expect(() => {
237 voice.resume();
238 voice.shot();
239 voice.over();
240 }).not.toThrow();
241 });
242
243 test("resuming opens the device on the gesture, and asks for no sound", () => {
244 const a = audio();
245 a.voice.resume();
246 expect(a.contexts).toBe(1);
247 expect(a.log).toEqual([]);
248 });
src/range/audio.ts added +154
@@ -0,0 +1,154 @@
1 /**
2 * The range's noise: five verbs and the numbers behind them. A shot is one dry
3 * crack, a hit is a clean tone, a plate standing up is a low knock, the last
4 * three seconds tick and the clock running out falls away.
5 *
6 * The device under it is the engine's — `createSoundDevice` opens one
7 * `AudioContext` on the first sound and never at load — and the recipes on top
8 * of it are this game's own. None of them is `src/client/audio.ts`: the
9 * shooter's rifle is a 1,500 Hz burst that rings for 240 ms with a thump
10 * sliding under it, which is a gun in a firefight; a range is a bay with ear
11 * protection on, so the crack here is higher, a third as long and has no thump
12 * at all, and nothing in this file was tuned to sound like Counter-Strike.
13 *
14 * Plain numbers in, sound out: nothing here reads a clock, a canvas or the
15 * game. `main.ts` decides when each verb happens and this decides what it
16 * sounds like, which is why the whole module is tested with no browser in the
17 * room against a fake `AudioContext`.
18 */
19
20 import { attenuate, createSoundDevice, type SoundDevice } from "../engine/index.ts";
21
22 /** What the range can make a noise about. Distances are units from the eye. */
23 export type RangeAudio = {
24 /** One trigger pull, hit or miss. */
25 shot(): void;
26 /** A plate taken down, at `distance` units. */
27 hit(distance: number): void;
28 /** A plate standing up, at `distance` units. */
29 pop(distance: number): void;
30 /** One of the last three seconds of the run going by. */
31 tick(): void;
32 /** The clock running out. Once per run. */
33 over(): void;
34 /**
35 * Open or wake the device on a gesture the person made. Safe to call often,
36 * and it never throws: a browser that refuses to start an `AudioContext` is
37 * a page with no sound, not a page with an exception in it.
38 */
39 resume(): void;
40 };
41
42 /**
43 * How far a sound carries here. `attenuate` is full volume inside `CLOSE` and
44 * falls off with the ratio after `EARSHOT`, and these two are the range's own
45 * room rather than the shooter's map: the lane is 1,536 units deep and the
46 * rules stand plates between 320 and 1,200 units away, so an earshot of 320
47 * makes the length of the lane a factor of four in loudness — a plate at 1,200
48 * is a quarter the gain of one at 200. The shooter's 900 would flatten it,
49 * because the whole range fits inside that radius at full volume.
50 *
51 * `CLOSE` is below anywhere a plate can stand; it is there so a sound that
52 * somehow arrives from zero units away is loud, not infinite.
53 */
54 export const EARSHOT = 320;
55 export const CLOSE = 160;
56
57 /** How loud something `distance` units down the lane is, 0..1. */
58 export const loudness = (distance: number): number => attenuate(distance, EARSHOT, CLOSE);
59
60 /**
61 * The recipes, as data, so the test names these numbers instead of copying
62 * them. Every gain is a peak before the envelope decays it, and every one is
63 * well inside 0..1 even at full volume — the range is five sounds, and they
64 * are mixed to sit under each other rather than to be loud.
65 */
66
67 /** The crack: short, dry, high. No thump, because this is a bay and not a map. */
68 export const CRACK = { frequency: 2400, gain: 0.22, length: 0.07, q: 1.4 } as const;
69
70 /** A plate going down: one clean sine, the only pure tone in the game. */
71 export const HIT = { frequency: 1180, gain: 0.16, length: 0.14 } as const;
72
73 /** A plate standing up: a knock that drops, quiet enough to be three at once. */
74 export const POP = { frequency: 240, slideTo: 150, gain: 0.14, length: 0.1 } as const;
75
76 /** One second going by, in the last three: short and out of the way. */
77 export const TICK = { frequency: 900, gain: 0.1, length: 0.05 } as const;
78
79 /** The clock running out: the tick's note, falling to nothing. */
80 export const OVER = { frequency: 900, slideTo: 180, gain: 0.18, length: 0.45 } as const;
81
82 /**
83 * Ask the device for a sound and let a browser that refuses one stay quiet: a
84 * constructor that throws before the person has gestured must not take the
85 * frame down with it. Nothing in the game changes either way.
86 */
87 function quietly(play: () => void): void {
88 try {
89 play();
90 } catch {
91 // No audio here. There is nothing to do about it and nothing to report.
92 }
93 }
94
95 /**
96 * The range's voice over a sound device, the engine's by default. A test hands
97 * in a device built on its own `AudioContext` constructor, which is how every
98 * number below is checked without a browser.
99 */
100 export function createRangeAudio(device: SoundDevice = createSoundDevice()): RangeAudio {
101 return {
102 resume: () => quietly(() => device.resume()),
103
104 shot: () =>
105 quietly(() =>
106 device.noiseBurst({
107 frequency: CRACK.frequency,
108 gain: CRACK.gain,
109 length: CRACK.length,
110 q: CRACK.q,
111 // A little detune on each repeat, so a fast string of shots does not
112 // phase into one buzzing note.
113 rate: 1 + Math.random() * 0.2,
114 }),
115 ),
116
117 hit: (distance) =>
118 quietly(() =>
119 device.tone({
120 frequency: HIT.frequency,
121 gain: HIT.gain * loudness(distance),
122 length: HIT.length,
123 tail: 0.02,
124 }),
125 ),
126
127 pop: (distance) =>
128 quietly(() =>
129 device.tone({
130 frequency: POP.frequency,
131 slideTo: POP.slideTo,
132 gain: POP.gain * loudness(distance),
133 length: POP.length,
134 type: "triangle",
135 }),
136 ),
137
138 tick: () =>
139 quietly(() =>
140 device.tone({ frequency: TICK.frequency, gain: TICK.gain, length: TICK.length, type: "triangle" }),
141 ),
142
143 over: () =>
144 quietly(() =>
145 device.tone({
146 frequency: OVER.frequency,
147 slideTo: OVER.slideTo,
148 gain: OVER.gain,
149 length: OVER.length,
150 type: "triangle",
151 }),
152 ),
153 };
154 }
src/range/boot.test.ts +169 −4
@@ -1,12 +1,13 @@
11 /**
22 * The entry itself, booted against the stub page in `dom.ts`: the ids it looks
3 * up, the listeners it attaches, the frames it asks for and what it draws when
4 * one runs. Nothing here is a browser, and nothing here is timed by a real
5 * clock — the test decides when a frame happens and how long it took.
3 * up, the listeners it attaches, the frames it asks for and what it draws and
4 * plays when one runs. Nothing here is a browser, and nothing here is timed by
5 * a real clock — the test decides when a frame happens and how long it took.
66 */
77
88 import { expect, test } from "bun:test";
9 import { page, type FakePage } from "./dom.ts";
9 import { HIT, POP, TICK } from "./audio.ts";
10 import { page, type FakePage, type Voice } from "./dom.ts";
1011
1112 // The entry boots itself when it is imported, so it is imported into a page
1213 // that is still loading: then every test below boots a fresh one on purpose.
@@ -341,6 +342,170 @@ test("every standing plate is one more draw, and a shot is a tracer for 80 ms",
341342 }
342343 });
343344
345 // --- what it sounds like --------------------------------------------------
346
347 /**
348 * Which of the range's five sounds this voice was. The recipes are imported
349 * rather than copied, so a number that moves in `audio.ts` moves here too —
350 * what this test holds is which sound happened when, not what it sounds like,
351 * which is `audio.test.ts`'s half of it.
352 */
353 const nameOf = (voice: Voice): string => {
354 if (voice.kind === "source") return "crack";
355 if (voice.frequency === HIT.frequency) return "hit";
356 if (voice.frequency === POP.frequency) return "pop";
357 return voice.gain === TICK.gain ? "tick" : "over";
358 };
359
360 /** Every sound the page has made, named, oldest first. */
361 const sounds = (p: FakePage): string[] => p.audio.voices.map(nameOf);
362
363 /** The sounds made since there were `from` of them. */
364 const sincePlayed = (p: FakePage, from: number): string[] => sounds(p).slice(from);
365
366 test("booting makes no sound, and a page nobody plays opens no AudioContext", () => {
367 const { p, undo } = booted();
368 try {
369 expect(p.audio.contexts).toBe(0);
370 // A hundred frames of looking around, capturing the mouse, and a trigger
371 // pulled before there is a run: the shot is refused, so there is nothing
372 // to hear and nothing to open a device for.
373 p.by("look").fire("click");
374 p.window.fire("keydown", { code: "KeyF" });
375 for (let i = 0; i < 100; i++) p.step(16);
376 p.window.fire("keyup", { code: "KeyF" });
377 expect(sounds(p)).toEqual([]);
378 expect(p.audio.contexts).toBe(0);
379 } finally {
380 undo();
381 }
382 });
383
384 test("a run makes its own noise: one device, a plate popping up, a crack and a hit", () => {
385 const { p, undo } = booted();
386 try {
387 p.by("look").fire("click");
388 p.document.pointerLockElement = p.scene;
389 p.document.fire("pointerlockchange");
390
391 p.window.fire("keydown", { code: "Enter" });
392 p.step(20); // the first plate stands up on the first tick
393 p.window.fire("keyup", { code: "Enter" });
394 expect(p.audio.contexts).toBe(1);
395 expect(sounds(p)).toEqual(["pop"]);
396 expect(standing(p)).toBe(1);
397
398 // A plate that is already up is not popped again: the frames in between
399 // are silent, however many of them there are.
400 let played = sounds(p).length;
401 p.step(20);
402 expect(sincePlayed(p, played)).toEqual([]);
403
404 // Turn onto the plate the scene drew and pull the trigger: the crack, and
405 // the hit under it.
406 const at = p.scene.paint?.calls.length ?? 0;
407 p.step(20);
408 const plate = platesDrawn(p, at)[0];
409 expect(plate).toBeDefined();
410 const [, left, top, size] = plate as [string, number, number, number];
411 const px = 450 / 90;
412 p.window.fire("mousemove", {
413 movementX: (left + size / 2 - 400) / px / PER_COUNT,
414 movementY: (top + size / 2 - 225) / px / PER_COUNT,
415 });
416 played = sounds(p).length;
417 p.window.fire("keydown", { code: "KeyF" });
418 p.step(20);
419 expect(latest(p, "score")).toContain("1/1 hits");
420 expect(sincePlayed(p, played).filter((sound) => sound !== "pop")).toEqual(["crack", "hit"]);
421
422 // The trigger held down is one shot, so it is one crack and not one a frame.
423 played = sounds(p).length;
424 p.step(20);
425 expect(sincePlayed(p, played)).not.toContain("crack");
426 // One device served the whole run, and every gain it asked for is a gain.
427 expect(p.audio.contexts).toBe(1);
428 for (const voice of p.audio.voices) {
429 expect(voice.gain).toBeGreaterThan(0);
430 expect(voice.gain).toBeLessThanOrEqual(1);
431 }
432 } finally {
433 undo();
434 }
435 });
436
437 test("a miss cracks and nothing more, and a trigger outside a run is silent", () => {
438 const { p, undo } = booted();
439 try {
440 p.window.fire("keydown", { code: "Enter" });
441 p.step(20);
442 p.window.fire("keyup", { code: "Enter" });
443
444 // Straight up at the ceiling: a shot, and no plate in front of it.
445 p.by("look").fire("click");
446 p.document.pointerLockElement = p.scene;
447 p.document.fire("pointerlockchange");
448 p.window.fire("mousemove", { movementX: 0, movementY: -80 / PER_COUNT });
449 let played = sounds(p).length;
450 p.window.fire("keydown", { code: "KeyF" });
451 p.step(20);
452 p.window.fire("keyup", { code: "KeyF" });
453 expect(latest(p, "score")).toContain("0/1 hits");
454 expect(sincePlayed(p, played).filter((sound) => sound !== "pop")).toEqual(["crack"]);
455
456 // Run the clock out, then pull the trigger: the run refuses the shot, so
457 // there is no crack for it either.
458 for (let i = 0; i < 190; i++) p.step(160);
459 expect(latest(p, "run over")).toContain("run over");
460 played = sounds(p).length;
461 p.window.fire("keydown", { code: "KeyF" });
462 p.step(20);
463 p.step(20);
464 expect(sincePlayed(p, played)).toEqual([]);
465 } finally {
466 undo();
467 }
468 });
469
470 test("the last three seconds tick once each, and the clock running out plays once", () => {
471 const { p, undo } = booted();
472 try {
473 p.window.fire("keydown", { code: "Enter" });
474 p.step(20);
475 p.window.fire("keyup", { code: "Enter" });
476
477 // The whole run, eight 50 Hz ticks to a frame, and then thirty seconds of
478 // frames after it: an ended run makes no sound at all, however long the
479 // page is left open on it.
480 for (let i = 0; i < 190; i++) p.step(160);
481 expect(latest(p, "run over")).toContain("run over");
482 const heard = sounds(p);
483 const after = heard.length;
484 for (let i = 0; i < 200; i++) p.step(160);
485 expect(sincePlayed(p, after)).toEqual([]);
486
487 // Three whole seconds crossed, three ticks, and one falling tone as the
488 // clock reached zero — not one of either per frame, of which there were
489 // 190. Plates keep standing up to the last second, so the four are read
490 // with the pops between them taken out.
491 expect(heard.filter((sound) => sound === "tick").length).toBe(3);
492 expect(heard.filter((sound) => sound === "over").length).toBe(1);
493 expect(heard.filter((sound) => sound === "tick" || sound === "over")).toEqual([
494 "tick", "tick", "tick", "over",
495 ]);
496
497 // And the next run counts its own three: a fresh clock, a fresh crossing.
498 p.window.fire("keydown", { code: "Enter" });
499 p.step(20);
500 p.window.fire("keyup", { code: "Enter" });
501 for (let i = 0; i < 190; i++) p.step(160);
502 expect(sincePlayed(p, after).filter((sound) => sound === "tick").length).toBe(3);
503 expect(sincePlayed(p, after).filter((sound) => sound === "over").length).toBe(1);
504 } finally {
505 undo();
506 }
507 });
508
344509 test("a browser with no WebGL2 keeps the game on the 2D horizon, and says so", () => {
345510 const { p, undo } = booted();
346511 try {
src/range/dom.ts +88 −2
@@ -126,6 +126,85 @@ export function glRecorder(): GlRecorder {
126126 return gl;
127127 }
128128
129 /**
130 * The page's audio, counted. What a test wants to know about sound here is not
131 * what it sounded like — `audio.test.ts` reads every number of that — but how
132 * many `AudioContext`s the page opened and how many voices it started: a page
133 * that boots must open none, and a sound must be one voice and not sixty.
134 */
135 export type Voice = {
136 /** "source" for a burst of noise, "osc" for a tone. */
137 kind: "source" | "osc";
138 /** The note a tone started on. A burst has none; its filter colours it. */
139 frequency: number;
140 /** The peak gain the envelope started from. */
141 gain: number;
142 };
143
144 export type FakeAudio = {
145 /** Constructors run. Zero is a page that has not made a sound yet. */
146 readonly contexts: number;
147 /** One entry per voice started, in the order they were asked for. */
148 voices: Voice[];
149 /** What `globalThis.AudioContext` is while this page is installed. */
150 Ctor: new () => unknown;
151 };
152
153 export function audioCounter(): FakeAudio {
154 const state = { contexts: 0, gain: 0 };
155 const voices: Voice[] = [];
156 const param = (set: (value: number) => void) => {
157 const it = {
158 value: 0,
159 setValueAtTime: (value: number) => {
160 it.value = value;
161 set(value);
162 },
163 exponentialRampToValueAtTime: () => {},
164 };
165 return it;
166 };
167 // The device sets a voice's gain before it starts it, so the last gain asked
168 // for is this voice's: that is how a burst and its envelope land in one row.
169 const node = (kind: string) => {
170 const made = {
171 type: "",
172 buffer: null as unknown,
173 frequency: param(() => {}),
174 playbackRate: param(() => {}),
175 Q: param(() => {}),
176 gain: param((value) => void (state.gain = value)),
177 connect: (next: unknown) => next,
178 start: () =>
179 void voices.push({ kind: kind as Voice["kind"], frequency: made.frequency.value, gain: state.gain }),
180 stop: () => {},
181 };
182 return made;
183 };
184 class Fake {
185 sampleRate = 48000;
186 currentTime = 0;
187 state = "running";
188 destination = { kind: "destination" };
189 constructor() {
190 state.contexts += 1;
191 }
192 resume(): void {}
193 createBuffer(_channels: number, length: number) {
194 return { length, getChannelData: () => new Float32Array(length) };
195 }
196 createBufferSource() { return node("source"); }
197 createBiquadFilter() { return node("filter"); }
198 createGain() { return node("gain"); }
199 createOscillator() { return node("osc"); }
200 }
201 return {
202 get contexts() { return state.contexts; },
203 voices,
204 Ctor: Fake as unknown as new () => unknown,
205 };
206 }
207
129208 export type FakeCanvas = FakeElement & {
130209 clientWidth: number;
131210 clientHeight: number;
@@ -172,6 +251,8 @@ export type FakePage = {
172251 window: Fake & { devicePixelRatio: number };
173252 scene: FakeCanvas;
174253 hud: FakeCanvas;
254 /** The `AudioContext` this page would give the engine's sound device. */
255 audio: FakeAudio;
175256 by(id: string): FakeElement;
176257 buttons: FakeElement[];
177258 /** The frames asked for but not yet run, oldest first. */
@@ -195,6 +276,7 @@ export function page(options: { has2d?: boolean; webgl?: boolean } = {}): FakePa
195276 ["look", element("look")], ["fps", element("fps")],
196277 ]);
197278 const frames: ((now: number) => void)[] = [];
279 const audio = audioCounter();
198280 let clock = 1000;
199281
200282 const document = {
@@ -207,7 +289,7 @@ export function page(options: { has2d?: boolean; webgl?: boolean } = {}): FakePa
207289 const window = { ...fake(), devicePixelRatio: 1 };
208290
209291 return {
210 document, window, scene, hud, buttons, frames,
292 document, window, scene, hud, buttons, frames, audio,
211293 by: (id) => elements.get(id) as FakeElement,
212294 step(ms) {
213295 clock += ms;
@@ -217,9 +299,13 @@ export function page(options: { has2d?: boolean; webgl?: boolean } = {}): FakePa
217299 install() {
218300 const globals = globalThis as Record<string, unknown>;
219301 const before = { ...globals };
220 const keys = ["document", "window", "requestAnimationFrame", "cancelAnimationFrame", "matchMedia", "performance"];
302 const keys = [
303 "document", "window", "requestAnimationFrame", "cancelAnimationFrame", "matchMedia",
304 "performance", "AudioContext",
305 ];
221306 globals.document = document;
222307 globals.window = window;
308 globals.AudioContext = audio.Ctor;
223309 globals.requestAnimationFrame = (callback: (now: number) => void): number => frames.push(callback);
224310 globals.cancelAnimationFrame = (): void => void frames.splice(0, frames.length);
225311 globals.matchMedia = (): { matches: boolean } => ({ matches: false });
src/range/main.ts +47 −4
@@ -4,7 +4,14 @@
44 * actions and the view's turn, `targets.ts` is the game those two play,
55 * `scene.ts` is the frame it looks like and the engine's renderer draws that —
66 * the range's own atlas and its own two meshes, handed over once here — with
7 * the readout's panel on the HUD canvas above it.
7 * the readout's panel on the HUD canvas above it, and `audio.ts` is what the
8 * same frame sounds like through the engine's sound device.
9 *
10 * Every sound is a crossing this file found in the game's own facts: a plate
11 * with an id the last frame had not reached stood up, the shot `shoot` handed
12 * back was taken and maybe hit, the clock passed one of the last three whole
13 * seconds, and the phase turned `over`. None of them is a comparison that
14 * would be true for sixty frames in a row.
815 *
916 * A browser with no WebGL2 keeps the game rather than losing it: `createRenderer`
1017 * hands back null, the scene canvas takes a 2D context instead and
@@ -16,10 +23,11 @@
1623 * `independence.test.ts` reads these files to make sure it stays that way.
1724 */
1825
26 import { createRangeAudio } from "./audio.ts";
1927 import { RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, heldActions, readIntent } from "./binding.ts";
2028 import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts";
2129 import { buildRangeScene, createRangeScene, rangeAssets, type Trail } from "./scene.ts";
22 import { EYE, createRun, report, shoot, start, step } from "./targets.ts";
30 import { EYE, createRun, report, shoot, start, step, type Target } from "./targets.ts";
2331 import {
2432 angleVectors, attachButtons, clamp, createControls, createLoop, createRenderer,
2533 } from "../engine/index.ts";
@@ -27,6 +35,9 @@ import {
2735 /** The range's own step: 50 Hz, deliberately not the shooter's 64. */
2836 const TICK = 1 / 50;
2937
38 /** How many of the run's last whole seconds are counted out loud. */
39 const LAST_SECONDS = 3;
40
3041 /** A seed for the next run: the clock, which nobody has to remember or type. */
3142 const freshSeed = (): number => (Date.now() & 0xffff) >>> 0;
3243
@@ -79,6 +90,10 @@ export function boot(): void {
7990 { keys: RANGE_KEYS, mouse: RANGE_MOUSE },
8091 );
8192 attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: RANGE_TAP });
93 // The range's own recipes over the engine's device. Nothing is opened here:
94 // `createSoundDevice` builds its `AudioContext` on the first sound, so a page
95 // that is looked at and never played stays a page that made no sound.
96 const audio = createRangeAudio();
8297 const loop = createLoop({ tick: TICK }, {
8398 requestAnimationFrame: (callback) => requestAnimationFrame(callback),
8499 cancelAnimationFrame: (handle) => cancelAnimationFrame(handle),
@@ -156,14 +171,38 @@ export function boot(): void {
156171 if (intent.start && !starting) {
157172 keepBest();
158173 start(run, freshSeed());
174 // The gesture that starts a run is the one a browser will open an
175 // `AudioContext` on, and the first plate stands up a tick later: opening
176 // it here rather than in that tick is the safe order. It never throws.
177 audio.resume();
159178 }
160179 starting = intent.start;
161180
162181 // The clock and the plates move in whole ticks, so a slow frame and a fast
163 // one play the same run.
182 // one play the same run. What the run did this frame is read from the two
183 // numbers taken before it — the clock and the plates spawned so far — so
184 // every sound below is a crossing and not a comparison that is true for
185 // sixty frames.
164186 const wasLive = run.phase === "live";
187 const clockBefore = run.clock;
188 const spawnedBefore = run.spawned;
165189 for (let i = 0; i < steps; i++) step(run, TICK);
166 if (wasLive && run.phase === "over") keepBest();
190 if (wasLive) {
191 // A plate that was not there before this frame stood up in it.
192 for (let i = 0; i < run.targets.length; i++) {
193 const target = run.targets[i] as Target;
194 if (target.id > spawnedBefore) audio.pop(target.distance);
195 }
196 // The last three whole seconds, each counted as the clock passes it.
197 for (let second = LAST_SECONDS; second >= 1; second--)
198 if (clockBefore > second && run.clock <= second) audio.tick();
199 }
200 // The clock reaching zero is the last thing heard, whatever else this
201 // frame was: the tick for the last second comes before it, not after.
202 if (wasLive && run.phase === "over") {
203 keepBest();
204 audio.over();
205 }
167206
168207 // One shot per press, down the barrel: the direction is the view's, and
169208 // nothing else goes into it. Where the bullet stopped is the tracer's far
@@ -171,6 +210,10 @@ export function boot(): void {
171210 if (intent.fire && !firing) {
172211 const shot = shoot(run, EYE, angleVectors(view.yaw, view.pitch).forward);
173212 if (shot !== null) {
213 // A shot the run refused is null, so a trigger pulled before Enter or
214 // after the clock ran out is silent as well as harmless.
215 audio.shot();
216 if (shot.target !== null) audio.hit(shot.distance);
174217 trail.to[0] = shot.point[0];
175218 trail.to[1] = shot.point[1];
176219 trail.to[2] = shot.point[2];
static/range.04a8ae5282.js added +58

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

View file
static/range.81ebd406a7.js deleted −58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs