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

forked from trevato/cs

The range hears the lane: the second game picks up the engine pan the shooter got #288

Merged opened this

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

Merged

vesper/cs-engine:vesper/221-the-range-hears-the-lane-the-second-game → main 1 commit fork

  • Merged. vesper/221-the-range-hears-the-lane-the-second-game was merged into main as 8de3410.

Review

Sign in to review this change.
9 files changed +402 −71
src/engine/GUIDE.md +2 −2
@@ -832,12 +832,12 @@ rows, an entry aside, and issue #130 is where it gets one.
832832 | a tick rate | 64 Hz | 50 Hz |
833833 | mesh recipes | `src/client/models.ts` | `src/range/level.ts` — a room and a plate |
834834 | texture recipes | `src/client/materials.ts` | `src/range/paint.ts` — three 64² layers |
835 | sound recipes | `src/client/audio.ts` | none yet |
835 | sound recipes | `src/client/audio.ts` — a gun per weapon kind, the round's own noises, and `panOf` straight through | `src/range/audio.ts` — five: a crack, a hit, a knock, a tick and the clock running out, the two out in the lane placed with `panOf` and softened to 0.7 of it |
836836 | renderer assets | an atlas and eight meshes, from `src/client/main.ts` | an atlas and two meshes, from `src/range/scene.ts` |
837837 | a frame as data | `src/client/scene.ts` | `src/range/scene.ts` |
838838 | a pinned run | `src/client/replay.ts` — a script of held inputs, a round and a duel | `src/range/pin.ts` — a script of trigger pulls, one digest per drill |
839839 | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays |
840 | the mover's tuning and hull | `movement.CS_MOVE` and `movement.CS_HULL` in `src/client/movement.ts` | none — nobody walks in the range, the eye stands still |
840 | the mover's tuning and hull | `movement.CS_MOVE` and `movement.CS_HULL` in `src/client/movement.ts` | `movement.RANGE_MOVE` and `movement.RANGE_HULL` in `src/range/movement.ts` — its own numbers, the same solver |
841841 | the rest of the tuning | `src/client/weapons.ts`, and `movement.fallDamage` | its own: `targets.STANDARD`, `targets.EYE` and the drills beside them in `src/range/targets.ts` |
842842 | a policy's observation, heads and weights | `src/client/observe.ts`, `src/client/action.ts`, `src/client/weights.ts` | none — the range has no agent and bundles none of the net |
843843
src/pages/range.ts +12 −5
@@ -93,9 +93,11 @@ const NOTE = `
9393 buttons do the rest.</p>
9494 <p class="hint">It makes its own noise, through the engine's sound device and the range's own
9595 recipes: a short dry crack, a clean tone for a hit, a knock as a plate stands up — quieter
96 the farther down the lane it is — a tick for each of the last three seconds and one falling
97 tone when the clock runs out. None of the shooter's sounds is reused. Nothing is heard until
98 you start a run: the browser opens no audio device before that.</p>
96 the farther down the lane it is, and on the side of the lane it stood up on — a tick for
97 each of the last three seconds and one falling tone when the clock runs out. The plates are
98 where the stereo is: turn towards one and it moves to the middle. None of the shooter's
99 sounds is reused. Nothing is heard until you start a run: the browser opens no audio device
100 before that.</p>
99101 `;
100102
101103 export function rangePage(db: Database, options: PageOptions): string {
@@ -184,8 +186,13 @@ ${boardsHtml(db)}
184186 and the clock running out. The shooter's sounds are not reused: this crack is higher, a third
185187 as long and has no thump under it, because a range bay is not a firefight. Distance is the
186188 engine's <code>attenuate</code> on this room's own numbers, so a plate hit at 1,200 units is a
187 quarter the volume of one at 200. The device is opened on the gesture that starts a run and
188 never before it — a page you only look at opens no audio at all.</p>
189 quarter the volume of one at 200, and direction is the engine's <code>panOf</code> against
190 the eye you are standing behind and the way it is facing: a plate that stands up on the left
191 of the lane knocks on the left, one sliding across the lane crosses the ear with it, and
192 looking at either centres it. Only the plates are out there — your own crack, the ticks and
193 the tone that ends the run are the range's own voice and stay in the middle. The device is
194 opened on the gesture that starts a run and never before it — a page you only look at opens
195 no audio at all.</p>
189196 <p>A finished run is posted, and that is the second thing this game proves: the server can
190197 hold a game it was not built for. The page sends one small object when the clock runs out —
191198 the drill, the score, the hits, the shots, the seed and the thirty seconds — to
src/range/audio.test.ts +191 −29
@@ -6,28 +6,37 @@
66 * and this one wants to read nodes as well as calls.
77 *
88 * 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.
9 * a verb asks for, at what gain, for how long, that distance is in the gain at
10 * all, and which side of the ear it came out of.
1111 */
1212
1313 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";
14 import { createSoundDevice, type Ctor, type Vec3 } from "../engine/index.ts";
15 import {
16 CRACK, HIT, LANE_PAN, OVER, POP, TICK, createRangeAudio, loudness, sideOf, type RangeAudio,
17 } from "./audio.ts";
1618
1719 type Event = { kind: string; value: number; time: number };
1820
1921 type Param = { value: number };
2022
2123 type Node = {
22 kind: string; id: string; type: string; frequency: Param; gain: Param; Q: Param;
24 kind: string; id: string; type: string; pan: Param; frequency: Param; gain: Param; Q: Param;
2325 threshold: Param; knee: Param; ratio: Param; attack: Param; release: Param;
2426 };
2527
2628 /** One `connect` the device made: the node it left, and the node it reached. */
2729 type Edge = [from: string, to: string];
2830
29 /** The smallest thing that answers like an `AudioContext`, and remembers. */
30 function fakeAudio(): {
31 /**
32 * The smallest thing that answers like an `AudioContext`, and remembers.
33 *
34 * `panner: false` is a context with no `createStereoPanner` on it at all: the
35 * oldest Safari, which the engine's device answers by sending the voice
36 * straight to the bus. The range has to keep playing on it, so this fake can
37 * be one.
38 */
39 function fakeAudio(options: { panner?: boolean } = {}): {
3140 Ctor: Ctor; log: Event[]; nodes: Node[]; edges: Edge[]; readonly contexts: number;
3241 } {
3342 const log: Event[] = [];
@@ -86,15 +95,18 @@ function fakeAudio(): {
8695 createBiquadFilter() { return node("filter"); }
8796 createGain() { return node("gain"); }
8897 createOscillator() { return node("osc"); }
89 // A browser has one, so this does too. The range places no sound yet — it
90 // passes no `pan`, and the device builds no panner for a voice without one
91 // — but a double that answers less than the thing it doubles is how a
92 // thrown frame hides until the day somebody adds the argument.
98 // Where the plates are heard from: the two verbs that happen out in the
99 // lane pass a `pan`, and the device builds one of these for each of them.
100 // The three that are the range's own voice pass none, and build none.
93101 createStereoPanner() { return node("panner"); }
94 // And a compressor, for the same reason: the engine's device puts one on
95 // the end of its bus, so this range is heard through one too.
102 // And a compressor: the engine's device puts one on the end of its bus, so
103 // this range is heard through one too.
96104 createDynamicsCompressor() { return node("limiter"); }
97105 }
106 // A browser without the panner has no method at all, rather than one that
107 // answers null: that is the shape `src/engine/sound.ts` tests for.
108 if (options.panner === false)
109 delete (Fake.prototype as { createStereoPanner?: () => unknown }).createStereoPanner;
98110 return {
99111 Ctor: Fake as unknown as Ctor,
100112 log,
@@ -105,10 +117,10 @@ function fakeAudio(): {
105117 }
106118
107119 /** The range's voice over a device over a fake context, and everything it wrote. */
108 function audio(): {
120 function audio(options: { panner?: boolean } = {}): {
109121 log: Event[]; nodes: Node[]; edges: Edge[]; readonly contexts: number; voice: RangeAudio;
110122 } {
111 const fake = fakeAudio();
123 const fake = fakeAudio(options);
112124 return {
113125 log: fake.log,
114126 nodes: fake.nodes,
@@ -129,6 +141,19 @@ const gains = (log: Event[]): number[] => of(log, "gain.gain.set").map((event) =
129141
130142 const nodesOf = (nodes: Node[], kind: string): Node[] => nodes.filter((node) => node.kind === kind);
131143
144 /** Every pan the module placed a voice at, in order. One entry per panned voice. */
145 const pans = (nodes: Node[]): number[] => nodesOf(nodes, "panner").map((node) => node.pan.value);
146
147 /**
148 * The ear these tests listen with: on the firing line, an eye above the floor,
149 * looking straight down the lane. Yaw 0 is +X, so the lane runs away in +X and
150 * the ear's right is −Y — a plate at +Y is on the left of it.
151 */
152 const EAR: Vec3 = [0, 0, 64];
153
154 /** A plate standing `distance` units straight down the lane, dead ahead. */
155 const down = (distance: number): Vec3 => [distance, 0, 64];
156
132157 // --- the device is opened on the first sound ------------------------------
133158
134159 test("no context is built until a verb makes a sound, and then only one", () => {
@@ -136,8 +161,8 @@ test("no context is built until a verb makes a sound, and then only one", () =>
136161 expect(a.contexts).toBe(0);
137162 a.voice.shot();
138163 expect(a.contexts).toBe(1);
139 a.voice.hit(300);
140 a.voice.pop(300);
164 a.voice.hit(down(300), EAR, 0);
165 a.voice.pop(down(300), EAR, 0);
141166 a.voice.tick();
142167 a.voice.over();
143168 expect(a.contexts).toBe(1);
@@ -162,14 +187,14 @@ test("a shot is one dry crack: a burst, no thump, and shorter than a rifle's", (
162187
163188 test("a hit is one clean tone, and a plate down the lane is quieter than one near", () => {
164189 const near = audio();
165 near.voice.hit(200);
190 near.voice.hit(down(200), EAR, 0);
166191 expect(of(near.log, "osc.start").length).toBe(1);
167192 expect(of(near.log, "source.start")).toEqual([]);
168193 expect(nodesOf(near.nodes, "osc")[0]?.type).toBe("sine");
169194 expect(length(near.log, "osc")).toBeCloseTo(HIT.length + 0.02, 9); // the tail holds the node open
170195
171196 const far = audio();
172 far.voice.hit(1200);
197 far.voice.hit(down(1200), EAR, 0);
173198 const [close] = gains(near.log);
174199 const [away] = gains(far.log);
175200 expect(close).toBeCloseTo(HIT.gain, 9); // inside earshot: full volume
@@ -179,8 +204,8 @@ test("a hit is one clean tone, and a plate down the lane is quieter than one nea
179204
180205 test("a plate standing up is a knock that drops, and it carries distance too", () => {
181206 const a = audio();
182 a.voice.pop(320);
183 a.voice.pop(1200);
207 a.voice.pop(down(320), EAR, 0);
208 a.voice.pop(down(1200), EAR, 0);
184209 expect(of(a.log, "osc.frequency.set")[0]?.value).toBe(POP.frequency);
185210 expect(of(a.log, "osc.frequency.ramp")[0]?.value).toBe(POP.slideTo);
186211 expect(nodesOf(a.nodes, "osc")[0]?.type).toBe("triangle");
@@ -222,8 +247,8 @@ test("the clock running out is one falling tone, once each time it is asked for"
222247 test("every voice of every verb ends at one bus, and the bus at the destination", () => {
223248 const a = audio();
224249 a.voice.shot();
225 a.voice.hit(400);
226 a.voice.pop(400);
250 a.voice.hit(down(400), EAR, 0);
251 a.voice.pop(down(400), EAR, 0);
227252 a.voice.tick();
228253 a.voice.over();
229254 const bus = a.nodes[0] as Node;
@@ -233,10 +258,19 @@ test("every voice of every verb ends at one bus, and the bus at the destination"
233258 // One edge into the speaker in the whole run, and it leaves the limiter.
234259 expect(a.edges.filter(([, to]) => to === "destination")).toEqual([[limiter.id, "destination"]]);
235260 expect(a.edges).toContainEqual([bus.id, limiter.id]);
236 // Every envelope — one a voice, five voices — ends on that one bus.
261 // Every envelope — one a voice, five voices — reaches that one bus: the
262 // three the range says itself go straight to it, and the two out in the lane
263 // reach it through the panner that placed them, which is the only node
264 // between a voice and the bus in this game.
237265 const envelopes = a.nodes.filter((node) => node.kind === "gain" && node.id !== bus.id);
238266 expect(envelopes.length).toBe(5);
239 for (const envelope of envelopes) expect(a.edges).toContainEqual([envelope.id, bus.id]);
267 const placed = new Map(nodesOf(a.nodes, "panner").map((node) => [node.id, node]));
268 for (const envelope of envelopes) {
269 const [, to] = a.edges.find(([from]) => from === envelope.id) as Edge;
270 if (to === bus.id) continue;
271 expect(placed.has(to)).toBe(true);
272 expect(a.edges).toContainEqual([to, bus.id]);
273 }
240274 });
241275
242276 /**
@@ -278,8 +312,8 @@ test("every gain the module asks for is a real gain: above zero, never over one"
278312 const a = audio();
279313 for (const distance of [0, 200, 320, 700, 1200, 1536, 4096]) {
280314 a.voice.shot();
281 a.voice.hit(distance);
282 a.voice.pop(distance);
315 a.voice.hit(down(distance), EAR, 0);
316 a.voice.pop(down(distance), EAR, 0);
283317 a.voice.tick();
284318 a.voice.over();
285319 }
@@ -293,8 +327,136 @@ test("every gain the module asks for is a real gain: above zero, never over one"
293327 for (const event of of(a.log, "gain.gain.ramp")) expect(event.value).toBeGreaterThan(0);
294328 });
295329
330 // --- which side of the ear it came from ------------------------------------
331
332 /**
333 * The plates are spread left and right of the lane on purpose — the pop-up
334 * drill stands them anywhere in ±420 across and the tracking drill slides them
335 * out to ±560 — so which side one is on is half of what its knock is worth.
336 * The ear is the eye the person is standing behind and the heading it faces,
337 * both of which move, and `panOf` is the engine's arithmetic on the two.
338 */
339 test("a plate on the left of the lane knocks on the left, and one on the right on the right", () => {
340 const a = audio();
341 a.voice.pop([400, 300, 64], EAR, 0); // +Y is left of an ear facing +X
342 a.voice.pop([400, -300, 64], EAR, 0);
343 a.voice.pop(down(400), EAR, 0);
344 const [left, right, ahead] = pans(a.nodes);
345 expect(left).toBeLessThan(0);
346 expect(right).toBeGreaterThan(0);
347 // The same plate, mirrored: the same side, the other way round.
348 expect(right).toBeCloseTo(-(left as number), 9);
349 // And one straight down the lane is dead centre, which is still a panner:
350 // the voice was placed, and the place it was put is the middle.
351 expect(ahead).toBe(0);
352 });
353
354 test("the tone of a plate going down comes from where the bullet stopped", () => {
355 const a = audio();
356 a.voice.hit([500, -260, 64], EAR, 0);
357 a.voice.hit([500, 260, 64], EAR, 0);
358 const [right, left] = pans(a.nodes);
359 expect(right).toBeGreaterThan(0.2);
360 expect(left).toBeCloseTo(-(right as number), 9);
361 // It is the hit tone that moved, not a second voice: one plate down is one
362 // oscillator, and this is it.
363 expect(of(a.log, "osc.start").length).toBe(2);
364 });
365
366 test("a plate sliding across the lane moves across the ear with it", () => {
367 const a = audio();
368 // One tracking plate crossing at 260 units a second, heard every half second
369 // of the two and a half it lives: it starts left of the lane and ends right.
370 for (const across of [325, 195, 65, -65, -195, -325]) a.voice.pop([600, across, 64], EAR, 0);
371 const placed = pans(a.nodes);
372 expect(placed.length).toBe(6);
373 for (let i = 1; i < placed.length; i++)
374 expect(placed[i] as number).toBeGreaterThan(placed[i - 1] as number);
375 expect(placed[0]).toBeLessThan(-0.2);
376 expect(placed[placed.length - 1]).toBeGreaterThan(0.2);
377 });
378
379 test("the ear's heading is what the pan is against: looking at a plate centres it", () => {
380 const at: Vec3 = [400, 300, 64];
381 // Straight at it: 36.87° off the lane, which is where the view has to be
382 // turned to put this plate under the crosshair.
383 const onto = (Math.atan2(300, 400) * 180) / Math.PI;
384 const a = audio();
385 a.voice.pop(at, EAR, 0);
386 a.voice.pop(at, EAR, onto);
387 a.voice.pop(at, EAR, onto + 60);
388 const [ahead, facing, past] = pans(a.nodes);
389 // Looking down the lane it is well to the left: 300 over 500, softened.
390 expect(ahead).toBeCloseTo(-0.6 * LANE_PAN, 9);
391 expect(facing).toBeCloseTo(0, 9);
392 // Turned sixty degrees past it, the same plate is on the other ear.
393 expect(past).toBeGreaterThan(0.4);
394 });
395
396 /**
397 * And the three that are not out in the lane. Your own crack comes from the
398 * rifle in your hands, the ticks and the tone that ends the run come from the
399 * clock on the panel: none of them happened anywhere, so none of them is
400 * placed anywhere, and the device builds no panner for any of them.
401 */
402 test("what the range says itself is never placed: the crack, the ticks and the run ending", () => {
403 const a = audio();
404 a.voice.shot();
405 a.voice.tick();
406 a.voice.tick();
407 a.voice.over();
408 expect(nodesOf(a.nodes, "panner")).toEqual([]);
409 const bus = a.nodes[0] as Node;
410 const envelopes = a.nodes.filter((node) => node.kind === "gain" && node.id !== bus.id);
411 expect(envelopes.length).toBe(4);
412 for (const envelope of envelopes) expect(a.edges).toContainEqual([envelope.id, bus.id]);
413 });
414
415 test("the lane is softened: the widest plate a drill can stand is a side, not a speaker", () => {
416 expect(LANE_PAN).toBeLessThan(1);
417 // The widest angle this game has: a tracking plate at the ±560 it retires
418 // at, standing at the 360 that drill spawns nearest.
419 const widest = sideOf([360, -560, 64], EAR, 0);
420 expect(widest).toBeCloseTo((560 / Math.hypot(360, 560)) * LANE_PAN, 9);
421 expect(widest).toBeGreaterThan(0.5);
422 expect(widest).toBeLessThan(0.6);
423 // Nothing anywhere passes it: the raw pan is a component over a length, so
424 // it is inside ±1, and this is that times the softening.
425 for (const [lane, across] of [[0, 4096], [1, 0], [-320, -320], [0, 0], [1200, 560]])
426 expect(Math.abs(sideOf([lane as number, across as number, 64], EAR, 0)))
427 .toBeLessThanOrEqual(LANE_PAN);
428 });
429
296430 // --- a browser that says no -----------------------------------------------
297431
432 /**
433 * The oldest Safari has no `createStereoPanner` at all. The engine's device
434 * answers that by sending the voice straight to the bus (#217), and what that
435 * has to mean here is the range exactly as it was before this pan existed:
436 * five sounds, all of them centred, and no thrown frame.
437 */
438 test("a browser with no panner plays the whole lane centred instead of throwing", () => {
439 const a = audio({ panner: false });
440 expect(() => {
441 a.voice.shot();
442 a.voice.hit([500, -300, 64], EAR, 0);
443 a.voice.pop([500, 300, 64], EAR, 0);
444 a.voice.tick();
445 a.voice.over();
446 }).not.toThrow();
447 expect(nodesOf(a.nodes, "panner")).toEqual([]);
448 const bus = a.nodes[0] as Node;
449 const envelopes = a.nodes.filter((node) => node.kind === "gain" && node.id !== bus.id);
450 expect(envelopes.length).toBe(5);
451 for (const envelope of envelopes) expect(a.edges).toContainEqual([envelope.id, bus.id]);
452 // The gains are the gains they always were: what that browser loses is the
453 // side a sound is on, not the sound.
454 expect(gains(a.log)).toEqual([
455 CRACK.gain, HIT.gain * loudness(Math.hypot(500, 300)), POP.gain * loudness(Math.hypot(500, 300)),
456 TICK.gain, OVER.gain,
457 ]);
458 });
459
298460 test("a browser that refuses an AudioContext is a quiet page, not a broken one", () => {
299461 const Refuses = class {
300462 constructor() {
@@ -305,8 +467,8 @@ test("a browser that refuses an AudioContext is a quiet page, not a broken one",
305467 expect(() => {
306468 voice.resume();
307469 voice.shot();
308 voice.hit(400);
309 voice.pop(400);
470 voice.hit(down(400), EAR, 0);
471 voice.pop(down(400), EAR, 0);
310472 voice.tick();
311473 voice.over();
312474 }).not.toThrow();
src/range/audio.ts +56 −13
@@ -11,25 +11,41 @@
1111 * protection on, so the crack here is higher, a third as long and has no thump
1212 * at all, and nothing in this file was tuned to sound like Counter-Strike.
1313 *
14 * **Two of the five are out in the lane and three are yours.** A plate knocking
15 * as it stands up and the tone of one going down happen somewhere — they take
16 * the point they happened at, the eye that hears them and the heading that eye
17 * faces, and the engine's `panOf` puts them on the side of the ear they are
18 * actually on. The crack of your own rifle, the last three ticks of the clock
19 * and the tone that ends the run are the range's own voice rather than
20 * something in the room: they are passed no pan at all, the device builds them
21 * no panner, and they stay dead centre wherever you are looking.
22 *
1423 * Plain numbers in, sound out: nothing here reads a clock, a canvas or the
1524 * game. `main.ts` decides when each verb happens and this decides what it
1625 * sounds like, which is why the whole module is tested with no browser in the
1726 * room against a fake `AudioContext`.
1827 */
1928
20 import { attenuate, createSoundDevice, type SoundDevice } from "../engine/index.ts";
29 import {
30 attenuate, createSoundDevice, panOf, segmentLength, type SoundDevice, type Vec3,
31 } from "../engine/index.ts";
2132
22 /** What the range can make a noise about. Distances are units from the eye. */
33 /**
34 * What the range can make a noise about. The two that happen out in the lane
35 * take where they happened and the ear that hears them: `ear` is the eye's
36 * point and `yaw` is the heading it faces in degrees, 0 = +X, which is the
37 * angle `angleVectors` takes and the one `main.ts` turns the view by.
38 */
2339 export type RangeAudio = {
24 /** One trigger pull, hit or miss. */
40 /** One trigger pull, hit or miss. Yours, so it is centre. */
2541 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. */
42 /** A plate taken down, at the point the bullet stopped. */
43 hit(at: Vec3, ear: Vec3, yaw: number): void;
44 /** A plate standing up, at its centre. */
45 pop(at: Vec3, ear: Vec3, yaw: number): void;
46 /** One of the last three seconds of the run going by. The clock's, so centre. */
3147 tick(): void;
32 /** The clock running out. Once per run. */
48 /** The clock running out. Once per run, and centre. */
3349 over(): void;
3450 /**
3551 * Open or wake the device on a gesture the person made. Safe to call often,
@@ -57,6 +73,31 @@ export const CLOSE = 160;
5773 /** How loud something `distance` units down the lane is, 0..1. */
5874 export const loudness = (distance: number): number => attenuate(distance, EARSHOT, CLOSE);
5975
76 /**
77 * How wide the lane is allowed to get in the ear: what `panOf`'s answer is
78 * multiplied by before a voice carries it. This is the range's softening and
79 * not the shooter's — the shooter passes `panOf` straight through, because a
80 * body walking past its ear at 40 units really is beside you and a hard pan is
81 * the truth about it.
82 *
83 * Nothing here is beside you. The nearest a plate stands is 320 units and every
84 * one of them is down one lane in front of the firing line, so the widest a raw
85 * pan gets is the tracking drill's plate at its ±560 escape and 360 deep —
86 * 560/670, about 0.84 — and the pop-up drill's ±420 at 320 deep is 0.79. Played
87 * at full width those two are nearly the same edge, and both are a plate a
88 * small turn of the head puts on the screen. 0.7 makes them 0.59 and 0.56: a
89 * firm side, with the other speaker still in it, which is what a bay sounds
90 * like and what leaves a turn something to centre.
91 */
92 export const LANE_PAN = 0.7;
93
94 /**
95 * Which side of the ear something at `at` is on, −1 left to +1 right, softened
96 * for a lane. Front and back are not in it and cannot be: two speakers say left
97 * and right, so a plate dead ahead and one dead behind are both centre.
98 */
99 export const sideOf = (at: Vec3, ear: Vec3, yaw: number): number => panOf(at, ear, yaw) * LANE_PAN;
100
60101 /**
61102 * The recipes, as data, so the test names these numbers instead of copying
62103 * them. Every gain is a peak before the envelope decays it, and every one is
@@ -114,24 +155,26 @@ export function createRangeAudio(device: SoundDevice = createSoundDevice()): Ran
114155 }),
115156 ),
116157
117 hit: (distance) =>
158 hit: (at, ear, yaw) =>
118159 quietly(() =>
119160 device.tone({
120161 frequency: HIT.frequency,
121 gain: HIT.gain * loudness(distance),
162 gain: HIT.gain * loudness(segmentLength(ear, at)),
122163 length: HIT.length,
123164 tail: 0.02,
165 pan: sideOf(at, ear, yaw),
124166 }),
125167 ),
126168
127 pop: (distance) =>
169 pop: (at, ear, yaw) =>
128170 quietly(() =>
129171 device.tone({
130172 frequency: POP.frequency,
131173 slideTo: POP.slideTo,
132 gain: POP.gain * loudness(distance),
174 gain: POP.gain * loudness(segmentLength(ear, at)),
133175 length: POP.length,
134176 type: "triangle",
177 pan: sideOf(at, ear, yaw),
135178 }),
136179 ),
137180
src/range/boot.test.ts +67 −1
@@ -12,7 +12,7 @@
1212 */
1313
1414 import { expect, test } from "bun:test";
15 import { HIT, POP, TICK } from "./audio.ts";
15 import { HIT, LANE_PAN, POP, TICK } from "./audio.ts";
1616 import { page, type GlRecorder, type RangePage, type Voice } from "./stage.test-helper.ts";
1717
1818 // The entry boots itself when it is imported, so it is imported into a page
@@ -509,6 +509,15 @@ test("a run makes its own noise: one device, a plate popping up, a crack and a h
509509 expect(latest(p, "pop-up")).toContain("1/1 hits");
510510 expect(sincePlayed(p, played).filter((sound) => sound !== "pop")).toEqual(["crack", "hit"]);
511511
512 // The turn put that plate under the crosshair, so the tone of it going
513 // down is where the eye is looking: centre, give or take the plate's own
514 // width. The crack that took it down is the range's own voice and is not
515 // placed at all, whichever way the view happens to be pointing.
516 const took = p.audio.voices.filter((voice) => nameOf(voice) === "hit").at(-1);
517 expect(Math.abs(took?.pan ?? 1)).toBeLessThan(0.15);
518 for (const voice of p.audio.voices)
519 if (nameOf(voice) === "crack") expect(voice.pan).toBeNull();
520
512521 // The trigger held down is one shot, so it is one crack and not one a frame.
513522 played = sounds(p).length;
514523 p.step(20);
@@ -524,6 +533,63 @@ test("a run makes its own noise: one device, a plate popping up, a crack and a h
524533 }
525534 });
526535
536 /** The side every voice of `name` since there were `from` of them came out on. */
537 const placedSince = (p: RangePage, from: number, name: string): (number | null)[] =>
538 p.audio.voices.slice(from).filter((voice) => nameOf(voice) === name).map((voice) => voice.pan);
539
540 /**
541 * The whole page, hearing the lane. The plates stand anywhere within ±420 of a
542 * lane between 320 and 1,200 units long, so no plate is more than 53° off it:
543 * turn the view a square ninety degrees and every plate that can stand up is
544 * on one side of the ear, whatever seed the clock handed this run. That is what
545 * makes this a test and not a coin toss — nothing here knows where the plates
546 * went, only which half of the room they must be in.
547 */
548 test("turning the view moves the lane across the ear, and your own voice stays centre", () => {
549 const { p, undo } = booted();
550 try {
551 p.by("look").fire("click");
552 p.document.pointerLockElement = p.scene;
553 p.document.fire("pointerlockchange");
554 p.window.fire("keydown", { code: "Enter" });
555 p.step(20);
556 p.window.fire("keyup", { code: "Enter" });
557
558 // A negative movement turns the view left, so the lane is now on the right.
559 p.window.fire("mousemove", { movementX: -90 / PER_COUNT, movementY: 0 });
560 let played = p.audio.voices.length;
561 for (let i = 0; i < 100; i++) p.step(20);
562 const right = placedSince(p, played, "pop");
563 expect(right.length).toBeGreaterThan(0);
564 for (const pan of right) expect(pan as number).toBeGreaterThan(0);
565
566 // Half a turn the other way, and the same lane is on the other ear.
567 p.window.fire("mousemove", { movementX: 180 / PER_COUNT, movementY: 0 });
568 played = p.audio.voices.length;
569 for (let i = 0; i < 100; i++) p.step(20);
570 const left = placedSince(p, played, "pop");
571 expect(left.length).toBeGreaterThan(0);
572 for (const pan of left) expect(pan as number).toBeLessThan(0);
573
574 // Run the clock out for the ticks and the tone that ends it, and then the
575 // rule over the whole run: what happened out in the lane came out placed
576 // and inside the softening, and what the range says itself came out with
577 // no side at all — no panner was built for it.
578 for (let i = 0; i < 200; i++) p.step(160);
579 expect(latest(p, "run over")).toContain("run over");
580 const said = p.audio.voices.filter((voice) => nameOf(voice) !== "pop" && nameOf(voice) !== "hit");
581 expect(said.length).toBeGreaterThan(0);
582 for (const voice of p.audio.voices) {
583 if (nameOf(voice) === "pop" || nameOf(voice) === "hit") {
584 expect(typeof voice.pan).toBe("number");
585 expect(Math.abs(voice.pan as number)).toBeLessThanOrEqual(LANE_PAN);
586 } else expect(voice.pan).toBeNull();
587 }
588 } finally {
589 undo();
590 }
591 });
592
527593 test("a miss cracks and nothing more, and a trigger outside a run is silent", () => {
528594 const { p, undo } = booted();
529595 try {
src/range/main.ts +19 −5
@@ -22,6 +22,13 @@
2222 * seconds, and the phase turned `over`. None of them is a comparison that
2323 * would be true for sixty frames in a row.
2424 *
25 * Two of those sounds are out in the lane and are heard from where the body is
26 * standing and which way it is looking: the plate's centre and the point the
27 * bullet stopped go to `audio.ts` with `view.eye` and `view.yaw`, so a plate on
28 * the left knocks on the left and looking at one centres it. The other three —
29 * your crack, the clock's ticks and the tone that ends the run — are handed no
30 * ear at all, because they did not happen anywhere.
31 *
2532 * A person walks here, on the range's own numbers: `movement.ts` holds the
2633 * tuning and the hull, the engine's `move` steps the body through
2734 * `RANGE_ROOM`'s six slabs every tick, and the eye the engine's `eyeHeight`
@@ -48,12 +55,12 @@ import { createPoster, NOTHING_POSTED, type Posts } from "./post.ts";
4855 import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts";
4956 import { buildRangeScene, createRangeScene, rangeAssets, type Trail } from "./scene.ts";
5057 import {
51 DRILLS, EYE, createRun, nextDrill, report, setDrill, shoot, start, step,
58 DRILLS, EYE, centreOf, createRun, nextDrill, report, setDrill, shoot, start, step,
5259 type DrillName, type Target,
5360 } from "./targets.ts";
5461 import {
5562 angleVectors, attachButtons, clamp, createControls, createEscape, createFullscreen, createLoop,
56 createRenderer, move, type KeyboardLock,
63 createRenderer, move, type KeyboardLock, type Vec3,
5764 } from "../engine/index.ts";
5865
5966 /** The range's own step: 50 Hz, deliberately not the shooter's 64. */
@@ -123,6 +130,9 @@ export function boot(): void {
123130 // `createSoundDevice` builds its `AudioContext` on the first sound, so a page
124131 // that is looked at and never played stays a page that made no sound.
125132 const audio = createRangeAudio();
133 // The one vector a frame writes a plate's centre into before handing it to
134 // the ear: a knock is a sound at a point, and a frame allocates nothing.
135 const heard: Vec3 = [0, 0, 0];
126136 const loop = createLoop({ tick: TICK }, {
127137 requestAnimationFrame: (callback) => requestAnimationFrame(callback),
128138 cancelAnimationFrame: (handle) => cancelAnimationFrame(handle),
@@ -312,10 +322,12 @@ export function boot(): void {
312322 }
313323 rangeEye(body, view.eye);
314324 if (wasLive) {
315 // A plate that was not there before this frame stood up in it.
325 // A plate that was not there before this frame stood up in it, and it is
326 // heard from where the body is standing and facing this tick: a plate
327 // that stood up on the left of the lane knocks on the left.
316328 for (let i = 0; i < run.targets.length; i++) {
317329 const target = run.targets[i] as Target;
318 if (target.id > spawnedBefore) audio.pop(target.distance);
330 if (target.id > spawnedBefore) audio.pop(centreOf(target, heard), view.eye, view.yaw);
319331 }
320332 // The last three whole seconds, each counted as the clock passes it.
321333 for (let second = LAST_SECONDS; second >= 1; second--)
@@ -339,8 +351,10 @@ export function boot(): void {
339351 if (shot !== null) {
340352 // A shot the run refused is null, so a trigger pulled before Enter or
341353 // after the clock ran out is silent as well as harmless.
354 // The crack is yours and stays centre; the tone of the plate is out
355 // there, at the point the bullet stopped, and is placed from this eye.
342356 audio.shot();
343 if (shot.target !== null) audio.hit(shot.distance);
357 if (shot.target !== null) audio.hit(shot.point, view.eye, view.yaw);
344358 // The tracer leaves the eye it was fired from, which is not the firing
345359 // line once a person has walked anywhere.
346360 trail.from[0] = view.eye[0];
src/range/scene.ts +2 −4
@@ -24,7 +24,7 @@ import {
2424 } from "../engine/index.ts";
2525 import { roomMesh, targetMesh } from "./level.ts";
2626 import { RANGE_LAYERS, RANGE_TEX_SIZE, rangeAtlas } from "./paint.ts";
27 import { EYE, STANDARD, type Run } from "./targets.ts";
27 import { EYE, STANDARD, centreOf, type Run } from "./targets.ts";
2828
2929 export type Tint = [number, number, number];
3030
@@ -192,9 +192,7 @@ export function buildRangeScene(
192192 for (const target of run.targets) {
193193 const box = target.box;
194194 const half = (box.max[0] - box.min[0]) / 2;
195 PLACE[0] = (box.min[0] + box.max[0]) / 2;
196 PLACE[1] = (box.min[1] + box.max[1]) / 2;
197 PLACE[2] = (box.min[2] + box.max[2]) / 2;
195 centreOf(target, PLACE);
198196 const left = target.span > 0 ? target.life / target.span : 0;
199197 place(into, MESH.PLATE, PLACE, half / PLATE_HALF, left > FADING ? PLATE_LIT : GOING);
200198 }
src/range/stage.test-helper.ts +36 −8
@@ -87,6 +87,12 @@ export type Voice = {
8787 frequency: number;
8888 /** The peak gain the envelope started from. */
8989 gain: number;
90 /**
91 * Where between the speakers it was placed, −1 left to +1 right, or null for
92 * a voice that was not placed at all — which is how the page says a sound is
93 * the range's own rather than something out in the lane.
94 */
95 pan: number | null;
9096 };
9197
9298 export type FakeAudio = {
@@ -99,7 +105,11 @@ export type FakeAudio = {
99105 };
100106
101107 export function audioCounter(): FakeAudio {
102 const state = { contexts: 0, gain: 0 };
108 // `placed` is the pan of the panner built since the last voice started, or
109 // null when none was: the device builds a voice's panner and sets it before
110 // it starts the voice, and builds none at all for a sound with no pan, so
111 // reading it at `start` and clearing it there is how a row gets its side.
112 const state = { contexts: 0, gain: 0, placed: null as number | null };
103113 const voices: Voice[] = [];
104114 const param = (set: (value: number) => void) => {
105115 const it = {
@@ -132,8 +142,15 @@ export function audioCounter(): FakeAudio {
132142 release: param(() => {}),
133143 gain: param((value) => void (state.gain = value)),
134144 connect: (next: unknown) => next,
135 start: () =>
136 void voices.push({ kind: kind as Voice["kind"], frequency: made.frequency.value, gain: state.gain }),
145 start: () => {
146 voices.push({
147 kind: kind as Voice["kind"],
148 frequency: made.frequency.value,
149 gain: state.gain,
150 pan: state.placed,
151 });
152 state.placed = null;
153 },
137154 stop: () => {},
138155 };
139156 return made;
@@ -154,11 +171,22 @@ export function audioCounter(): FakeAudio {
154171 createBiquadFilter() { return node("filter"); }
155172 createGain() { return node("gain"); }
156173 createOscillator() { return node("osc"); }
157 // A browser has one, so this does too. The range places no sound yet — it
158 // passes no `pan`, and the device builds no panner for a voice without one
159 // — but a double that answers less than the thing it doubles is how a
160 // thrown frame hides until the day somebody adds the argument.
161 createStereoPanner() { return node("panner"); }
174 // The two sounds that happen out in the lane — a plate standing up and one
175 // going down — carry a pan, and the device builds one of these for each of
176 // them. The value it is set to is the side the next voice started is on.
177 createStereoPanner() {
178 const made = node("panner");
179 // The device assigns `pan.value` rather than scheduling it, so the side
180 // is caught with a setter: it lands in `placed`, and the next voice to
181 // start is the one it belongs to.
182 made.pan = {
183 get value() { return state.placed ?? 0; },
184 set value(side: number) { state.placed = side; },
185 setValueAtTime: (side: number) => void (state.placed = side),
186 exponentialRampToValueAtTime: () => {},
187 };
188 return made;
189 }
162190 // And a compressor, for the same reason: every voice the engine's device
163191 // starts goes to one master bus and out through a limiter on it, so a page
164192 // whose context could not build one would be a page this counter never
src/range/targets.ts +17 −4
@@ -216,6 +216,22 @@ export type Target = {
216216 distance: number;
217217 };
218218
219 /**
220 * Where a plate is, written into `out` and handed back. A plate is a box and
221 * everything that wants to point at one — the renderer placing a mesh, the
222 * readout drawing a blip, the sound deciding which side of the ear it is on —
223 * wants its centre, so the arithmetic is here once and the caller brings the
224 * vector it is written into: a frame that asks for four centres allocates
225 * nothing.
226 */
227 export function centreOf(target: Target, out: Vec3): Vec3 {
228 const box = target.box;
229 out[0] = (box.min[0] + box.max[0]) / 2;
230 out[1] = (box.min[1] + box.max[1]) / 2;
231 out[2] = (box.min[2] + box.max[2]) / 2;
232 return out;
233 }
234
219235 /** Before the first run, during one, and after the clock ran out. */
220236 export type Phase = "ready" | "live" | "over";
221237
@@ -386,10 +402,7 @@ export function step(run: Run, dt: number): void {
386402 box.min[2] += vel[2] * dt; box.max[2] += vel[2] * dt;
387403 // A plate that moved is a plate at another distance, and the readers of
388404 // that — the panel, the sound of a hit — want where it is now.
389 CENTRE[0] = (box.min[0] + box.max[0]) / 2;
390 CENTRE[1] = (box.min[1] + box.max[1]) / 2;
391 CENTRE[2] = (box.min[2] + box.max[2]) / 2;
392 target.distance = segmentLength(EYE, CENTRE);
405 target.distance = segmentLength(EYE, centreOf(target, CENTRE));
393406 }
394407 const escape = run.drill.escape;
395408 run.targets = run.targets.filter(

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs