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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/52-the-range-makes-its-own-noise-a-shot-a-h → main 1 commit fork
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: | ||
| 100 | 100 | <kbd>Enter</kbd> starts a thirty second run. Plates pop up down the lane, each |
| 101 | 101 | worth 100 to 200 points by how far away it stood; the panel keeps the score, the |
| 102 | 102 | 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 | |
| 105 | 108 | on the 2D horizon it shipped with — a plate as a square drawn from the angle it |
| 106 | 109 | subtends — and the panel says which of the two you are looking at. |
| 107 | 110 | |
| @@ -228,6 +231,23 @@ subtends — and the panel says which of the two you are looking at. | ||
| 228 | 231 | game gets that furniture without copying a line out of the shooter; and `main.ts` |
| 229 | 232 | wires the engine's `loop` and `controls` to the shell's ids, steps the run in |
| 230 | 233 | 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. | |
| 231 | 251 | **The range paints its own world**, and it is data before it is a picture: |
| 232 | 252 | `level.ts` is one room — a floor, four walls and a ceiling as six overlapping |
| 233 | 253 | 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. | ||
| 336 | 356 | | bundle | raw | ceiling | gzipped | ceiling | |
| 337 | 357 | | ------- | ------ | ------- | ------- | ------- | |
| 338 | 358 | | `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 | | |
| 340 | 360 | |
| 341 | 361 | The ceilings are today's sizes plus about 10%. Gzip is measured at level 9 |
| 342 | 362 | 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 | ||
| 37 | 37 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 38 | 38 | expect(ENTRIES).toEqual([ |
| 39 | 39 | { 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 } }, | |
| 41 | 41 | ]); |
| 42 | 42 | }); |
| 43 | 43 |
scripts/build.ts +6 −1
| @@ -44,12 +44,17 @@ export type Entry = { name: string; entry: string; ceiling: Ceiling }; | ||
| 44 | 44 | * 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, |
| 45 | 45 | * the clock and the score, the shot traced through the engine's `rayBox`, and |
| 46 | 46 | * 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. | |
| 47 | 52 | * Raising a ceiling is allowed, and it is a line in the pull that says what was |
| 48 | 53 | * added and why. |
| 49 | 54 | */ |
| 50 | 55 | export const ENTRIES: Entry[] = [ |
| 51 | 56 | { 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 } }, | |
| 53 | 58 | ]; |
| 54 | 59 | |
| 55 | 60 | /** |
src/pages/range.ts +13
| @@ -29,6 +29,11 @@ const NOTE = ` | ||
| 29 | 29 | <kbd>Shift</kbd> walk · <kbd>F</kbd> or the mouse fires · <kbd>R</kbd> reload · |
| 30 | 30 | <kbd>Enter</kbd> start · <kbd>Esc</kbd> gives the mouse back. On a touch screen the left of |
| 31 | 31 | 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> | |
| 32 | 37 | `; |
| 33 | 38 | |
| 34 | 39 | export function rangePage(options: PageOptions): string { |
| @@ -81,6 +86,14 @@ export function rangePage(options: PageOptions): string { | ||
| 81 | 86 | by a test with no browser in the room. Still missing is the walking: the eye stands at the |
| 82 | 87 | firing line until the engine's physics lands. If your browser has no WebGL2, the flat |
| 83 | 88 | 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> | |
| 84 | 97 | <p>Nothing on this page is stored. There is no endpoint, no table and no cookie behind it: |
| 85 | 98 | the leaderboard, the database and the terminal belong to |
| 86 | 99 | <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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | 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"; | |
| 10 | 11 | |
| 11 | 12 | // The entry boots itself when it is imported, so it is imported into a page |
| 12 | 13 | // 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", | ||
| 341 | 342 | } |
| 342 | 343 | }); |
| 343 | 344 | |
| 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 | ||
| 344 | 509 | test("a browser with no WebGL2 keeps the game on the 2D horizon, and says so", () => { |
| 345 | 510 | const { p, undo } = booted(); |
| 346 | 511 | try { |
src/range/dom.ts +88 −2
| @@ -126,6 +126,85 @@ export function glRecorder(): GlRecorder { | ||
| 126 | 126 | return gl; |
| 127 | 127 | } |
| 128 | 128 | |
| 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 | ||
| 129 | 208 | export type FakeCanvas = FakeElement & { |
| 130 | 209 | clientWidth: number; |
| 131 | 210 | clientHeight: number; |
| @@ -172,6 +251,8 @@ export type FakePage = { | ||
| 172 | 251 | window: Fake & { devicePixelRatio: number }; |
| 173 | 252 | scene: FakeCanvas; |
| 174 | 253 | hud: FakeCanvas; |
| 254 | /** The `AudioContext` this page would give the engine's sound device. */ | |
| 255 | audio: FakeAudio; | |
| 175 | 256 | by(id: string): FakeElement; |
| 176 | 257 | buttons: FakeElement[]; |
| 177 | 258 | /** The frames asked for but not yet run, oldest first. */ |
| @@ -195,6 +276,7 @@ export function page(options: { has2d?: boolean; webgl?: boolean } = {}): FakePa | ||
| 195 | 276 | ["look", element("look")], ["fps", element("fps")], |
| 196 | 277 | ]); |
| 197 | 278 | const frames: ((now: number) => void)[] = []; |
| 279 | const audio = audioCounter(); | |
| 198 | 280 | let clock = 1000; |
| 199 | 281 | |
| 200 | 282 | const document = { |
| @@ -207,7 +289,7 @@ export function page(options: { has2d?: boolean; webgl?: boolean } = {}): FakePa | ||
| 207 | 289 | const window = { ...fake(), devicePixelRatio: 1 }; |
| 208 | 290 | |
| 209 | 291 | return { |
| 210 | document, window, scene, hud, buttons, frames, | |
| 292 | document, window, scene, hud, buttons, frames, audio, | |
| 211 | 293 | by: (id) => elements.get(id) as FakeElement, |
| 212 | 294 | step(ms) { |
| 213 | 295 | clock += ms; |
| @@ -217,9 +299,13 @@ export function page(options: { has2d?: boolean; webgl?: boolean } = {}): FakePa | ||
| 217 | 299 | install() { |
| 218 | 300 | const globals = globalThis as Record<string, unknown>; |
| 219 | 301 | 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 | ]; | |
| 221 | 306 | globals.document = document; |
| 222 | 307 | globals.window = window; |
| 308 | globals.AudioContext = audio.Ctor; | |
| 223 | 309 | globals.requestAnimationFrame = (callback: (now: number) => void): number => frames.push(callback); |
| 224 | 310 | globals.cancelAnimationFrame = (): void => void frames.splice(0, frames.length); |
| 225 | 311 | globals.matchMedia = (): { matches: boolean } => ({ matches: false }); |
src/range/main.ts +47 −4
| @@ -4,7 +4,14 @@ | ||
| 4 | 4 | * actions and the view's turn, `targets.ts` is the game those two play, |
| 5 | 5 | * `scene.ts` is the frame it looks like and the engine's renderer draws that — |
| 6 | 6 | * 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. | |
| 8 | 15 | * |
| 9 | 16 | * A browser with no WebGL2 keeps the game rather than losing it: `createRenderer` |
| 10 | 17 | * hands back null, the scene canvas takes a 2D context instead and |
| @@ -16,10 +23,11 @@ | ||
| 16 | 23 | * `independence.test.ts` reads these files to make sure it stays that way. |
| 17 | 24 | */ |
| 18 | 25 | |
| 26 | import { createRangeAudio } from "./audio.ts"; | |
| 19 | 27 | import { RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, heldActions, readIntent } from "./binding.ts"; |
| 20 | 28 | import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts"; |
| 21 | 29 | 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"; | |
| 23 | 31 | import { |
| 24 | 32 | angleVectors, attachButtons, clamp, createControls, createLoop, createRenderer, |
| 25 | 33 | } from "../engine/index.ts"; |
| @@ -27,6 +35,9 @@ import { | ||
| 27 | 35 | /** The range's own step: 50 Hz, deliberately not the shooter's 64. */ |
| 28 | 36 | const TICK = 1 / 50; |
| 29 | 37 | |
| 38 | /** How many of the run's last whole seconds are counted out loud. */ | |
| 39 | const LAST_SECONDS = 3; | |
| 40 | ||
| 30 | 41 | /** A seed for the next run: the clock, which nobody has to remember or type. */ |
| 31 | 42 | const freshSeed = (): number => (Date.now() & 0xffff) >>> 0; |
| 32 | 43 | |
| @@ -79,6 +90,10 @@ export function boot(): void { | ||
| 79 | 90 | { keys: RANGE_KEYS, mouse: RANGE_MOUSE }, |
| 80 | 91 | ); |
| 81 | 92 | 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(); | |
| 82 | 97 | const loop = createLoop({ tick: TICK }, { |
| 83 | 98 | requestAnimationFrame: (callback) => requestAnimationFrame(callback), |
| 84 | 99 | cancelAnimationFrame: (handle) => cancelAnimationFrame(handle), |
| @@ -156,14 +171,38 @@ export function boot(): void { | ||
| 156 | 171 | if (intent.start && !starting) { |
| 157 | 172 | keepBest(); |
| 158 | 173 | 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(); | |
| 159 | 178 | } |
| 160 | 179 | starting = intent.start; |
| 161 | 180 | |
| 162 | 181 | // 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. | |
| 164 | 186 | const wasLive = run.phase === "live"; |
| 187 | const clockBefore = run.clock; | |
| 188 | const spawnedBefore = run.spawned; | |
| 165 | 189 | 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 | } | |
| 167 | 206 | |
| 168 | 207 | // One shot per press, down the barrel: the direction is the view's, and |
| 169 | 208 | // nothing else goes into it. Where the bullet stopped is the tracer's far |
| @@ -171,6 +210,10 @@ export function boot(): void { | ||
| 171 | 210 | if (intent.fire && !firing) { |
| 172 | 211 | const shot = shoot(run, EYE, angleVectors(view.yaw, view.pitch).forward); |
| 173 | 212 | 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); | |
| 174 | 217 | trail.to[0] = shot.point[0]; |
| 175 | 218 | trail.to[1] = shot.point[1]; |
| 176 | 219 | 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 filestatic/range.81ebd406a7.js deleted −58
Diff not shown — a line 14,645 characters wide (limit 2,000). −58
View file
new above
offline