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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/221-the-range-hears-the-lane-the-second-game → main 1 commit fork
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. | ||
| 832 | 832 | | a tick rate | 64 Hz | 50 Hz | |
| 833 | 833 | | mesh recipes | `src/client/models.ts` | `src/range/level.ts` — a room and a plate | |
| 834 | 834 | | 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 | | |
| 836 | 836 | | renderer assets | an atlas and eight meshes, from `src/client/main.ts` | an atlas and two meshes, from `src/range/scene.ts` | |
| 837 | 837 | | a frame as data | `src/client/scene.ts` | `src/range/scene.ts` | |
| 838 | 838 | | 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 | |
| 839 | 839 | | 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 | | |
| 841 | 841 | | 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` | |
| 842 | 842 | | 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 | |
| 843 | 843 |
src/pages/range.ts +12 −5
| @@ -93,9 +93,11 @@ const NOTE = ` | ||
| 93 | 93 | buttons do the rest.</p> |
| 94 | 94 | <p class="hint">It makes its own noise, through the engine's sound device and the range's own |
| 95 | 95 | 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> | |
| 99 | 101 | `; |
| 100 | 102 | |
| 101 | 103 | export function rangePage(db: Database, options: PageOptions): string { |
| @@ -184,8 +186,13 @@ ${boardsHtml(db)} | ||
| 184 | 186 | and the clock running out. The shooter's sounds are not reused: this crack is higher, a third |
| 185 | 187 | as long and has no thump under it, because a range bay is not a firefight. Distance is the |
| 186 | 188 | 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> | |
| 189 | 196 | <p>A finished run is posted, and that is the second thing this game proves: the server can |
| 190 | 197 | hold a game it was not built for. The page sends one small object when the clock runs out — |
| 191 | 198 | 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 @@ | ||
| 6 | 6 | * and this one wants to read nodes as well as calls. |
| 7 | 7 | * |
| 8 | 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. | |
| 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. | |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 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"; | |
| 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"; | |
| 16 | 18 | |
| 17 | 19 | type Event = { kind: string; value: number; time: number }; |
| 18 | 20 | |
| 19 | 21 | type Param = { value: number }; |
| 20 | 22 | |
| 21 | 23 | 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; | |
| 23 | 25 | threshold: Param; knee: Param; ratio: Param; attack: Param; release: Param; |
| 24 | 26 | }; |
| 25 | 27 | |
| 26 | 28 | /** One `connect` the device made: the node it left, and the node it reached. */ |
| 27 | 29 | type Edge = [from: string, to: string]; |
| 28 | 30 | |
| 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 } = {}): { | |
| 31 | 40 | Ctor: Ctor; log: Event[]; nodes: Node[]; edges: Edge[]; readonly contexts: number; |
| 32 | 41 | } { |
| 33 | 42 | const log: Event[] = []; |
| @@ -86,15 +95,18 @@ function fakeAudio(): { | ||
| 86 | 95 | createBiquadFilter() { return node("filter"); } |
| 87 | 96 | createGain() { return node("gain"); } |
| 88 | 97 | 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. | |
| 93 | 101 | 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. | |
| 96 | 104 | createDynamicsCompressor() { return node("limiter"); } |
| 97 | 105 | } |
| 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; | |
| 98 | 110 | return { |
| 99 | 111 | Ctor: Fake as unknown as Ctor, |
| 100 | 112 | log, |
| @@ -105,10 +117,10 @@ function fakeAudio(): { | ||
| 105 | 117 | } |
| 106 | 118 | |
| 107 | 119 | /** The range's voice over a device over a fake context, and everything it wrote. */ |
| 108 | function audio(): { | |
| 120 | function audio(options: { panner?: boolean } = {}): { | |
| 109 | 121 | log: Event[]; nodes: Node[]; edges: Edge[]; readonly contexts: number; voice: RangeAudio; |
| 110 | 122 | } { |
| 111 | const fake = fakeAudio(); | |
| 123 | const fake = fakeAudio(options); | |
| 112 | 124 | return { |
| 113 | 125 | log: fake.log, |
| 114 | 126 | nodes: fake.nodes, |
| @@ -129,6 +141,19 @@ const gains = (log: Event[]): number[] => of(log, "gain.gain.set").map((event) = | ||
| 129 | 141 | |
| 130 | 142 | const nodesOf = (nodes: Node[], kind: string): Node[] => nodes.filter((node) => node.kind === kind); |
| 131 | 143 | |
| 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 | ||
| 132 | 157 | // --- the device is opened on the first sound ------------------------------ |
| 133 | 158 | |
| 134 | 159 | 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", () => | ||
| 136 | 161 | expect(a.contexts).toBe(0); |
| 137 | 162 | a.voice.shot(); |
| 138 | 163 | 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); | |
| 141 | 166 | a.voice.tick(); |
| 142 | 167 | a.voice.over(); |
| 143 | 168 | 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", ( | ||
| 162 | 187 | |
| 163 | 188 | test("a hit is one clean tone, and a plate down the lane is quieter than one near", () => { |
| 164 | 189 | const near = audio(); |
| 165 | near.voice.hit(200); | |
| 190 | near.voice.hit(down(200), EAR, 0); | |
| 166 | 191 | expect(of(near.log, "osc.start").length).toBe(1); |
| 167 | 192 | expect(of(near.log, "source.start")).toEqual([]); |
| 168 | 193 | expect(nodesOf(near.nodes, "osc")[0]?.type).toBe("sine"); |
| 169 | 194 | expect(length(near.log, "osc")).toBeCloseTo(HIT.length + 0.02, 9); // the tail holds the node open |
| 170 | 195 | |
| 171 | 196 | const far = audio(); |
| 172 | far.voice.hit(1200); | |
| 197 | far.voice.hit(down(1200), EAR, 0); | |
| 173 | 198 | const [close] = gains(near.log); |
| 174 | 199 | const [away] = gains(far.log); |
| 175 | 200 | 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 | ||
| 179 | 204 | |
| 180 | 205 | test("a plate standing up is a knock that drops, and it carries distance too", () => { |
| 181 | 206 | 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); | |
| 184 | 209 | expect(of(a.log, "osc.frequency.set")[0]?.value).toBe(POP.frequency); |
| 185 | 210 | expect(of(a.log, "osc.frequency.ramp")[0]?.value).toBe(POP.slideTo); |
| 186 | 211 | 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" | ||
| 222 | 247 | test("every voice of every verb ends at one bus, and the bus at the destination", () => { |
| 223 | 248 | const a = audio(); |
| 224 | 249 | 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); | |
| 227 | 252 | a.voice.tick(); |
| 228 | 253 | a.voice.over(); |
| 229 | 254 | 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" | ||
| 233 | 258 | // One edge into the speaker in the whole run, and it leaves the limiter. |
| 234 | 259 | expect(a.edges.filter(([, to]) => to === "destination")).toEqual([[limiter.id, "destination"]]); |
| 235 | 260 | 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. | |
| 237 | 265 | const envelopes = a.nodes.filter((node) => node.kind === "gain" && node.id !== bus.id); |
| 238 | 266 | 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 | } | |
| 240 | 274 | }); |
| 241 | 275 | |
| 242 | 276 | /** |
| @@ -278,8 +312,8 @@ test("every gain the module asks for is a real gain: above zero, never over one" | ||
| 278 | 312 | const a = audio(); |
| 279 | 313 | for (const distance of [0, 200, 320, 700, 1200, 1536, 4096]) { |
| 280 | 314 | 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); | |
| 283 | 317 | a.voice.tick(); |
| 284 | 318 | a.voice.over(); |
| 285 | 319 | } |
| @@ -293,8 +327,136 @@ test("every gain the module asks for is a real gain: above zero, never over one" | ||
| 293 | 327 | for (const event of of(a.log, "gain.gain.ramp")) expect(event.value).toBeGreaterThan(0); |
| 294 | 328 | }); |
| 295 | 329 | |
| 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 | ||
| 296 | 430 | // --- a browser that says no ----------------------------------------------- |
| 297 | 431 | |
| 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 | ||
| 298 | 460 | test("a browser that refuses an AudioContext is a quiet page, not a broken one", () => { |
| 299 | 461 | const Refuses = class { |
| 300 | 462 | constructor() { |
| @@ -305,8 +467,8 @@ test("a browser that refuses an AudioContext is a quiet page, not a broken one", | ||
| 305 | 467 | expect(() => { |
| 306 | 468 | voice.resume(); |
| 307 | 469 | voice.shot(); |
| 308 | voice.hit(400); | |
| 309 | voice.pop(400); | |
| 470 | voice.hit(down(400), EAR, 0); | |
| 471 | voice.pop(down(400), EAR, 0); | |
| 310 | 472 | voice.tick(); |
| 311 | 473 | voice.over(); |
| 312 | 474 | }).not.toThrow(); |
src/range/audio.ts +56 −13
| @@ -11,25 +11,41 @@ | ||
| 11 | 11 | * protection on, so the crack here is higher, a third as long and has no thump |
| 12 | 12 | * at all, and nothing in this file was tuned to sound like Counter-Strike. |
| 13 | 13 | * |
| 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 | * | |
| 14 | 23 | * Plain numbers in, sound out: nothing here reads a clock, a canvas or the |
| 15 | 24 | * game. `main.ts` decides when each verb happens and this decides what it |
| 16 | 25 | * sounds like, which is why the whole module is tested with no browser in the |
| 17 | 26 | * room against a fake `AudioContext`. |
| 18 | 27 | */ |
| 19 | 28 | |
| 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"; | |
| 21 | 32 | |
| 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 | */ | |
| 23 | 39 | export type RangeAudio = { |
| 24 | /** One trigger pull, hit or miss. */ | |
| 40 | /** One trigger pull, hit or miss. Yours, so it is centre. */ | |
| 25 | 41 | 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. */ | |
| 31 | 47 | tick(): void; |
| 32 | /** The clock running out. Once per run. */ | |
| 48 | /** The clock running out. Once per run, and centre. */ | |
| 33 | 49 | over(): void; |
| 34 | 50 | /** |
| 35 | 51 | * Open or wake the device on a gesture the person made. Safe to call often, |
| @@ -57,6 +73,31 @@ export const CLOSE = 160; | ||
| 57 | 73 | /** How loud something `distance` units down the lane is, 0..1. */ |
| 58 | 74 | export const loudness = (distance: number): number => attenuate(distance, EARSHOT, CLOSE); |
| 59 | 75 | |
| 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 | ||
| 60 | 101 | /** |
| 61 | 102 | * The recipes, as data, so the test names these numbers instead of copying |
| 62 | 103 | * 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 | ||
| 114 | 155 | }), |
| 115 | 156 | ), |
| 116 | 157 | |
| 117 | hit: (distance) => | |
| 158 | hit: (at, ear, yaw) => | |
| 118 | 159 | quietly(() => |
| 119 | 160 | device.tone({ |
| 120 | 161 | frequency: HIT.frequency, |
| 121 | gain: HIT.gain * loudness(distance), | |
| 162 | gain: HIT.gain * loudness(segmentLength(ear, at)), | |
| 122 | 163 | length: HIT.length, |
| 123 | 164 | tail: 0.02, |
| 165 | pan: sideOf(at, ear, yaw), | |
| 124 | 166 | }), |
| 125 | 167 | ), |
| 126 | 168 | |
| 127 | pop: (distance) => | |
| 169 | pop: (at, ear, yaw) => | |
| 128 | 170 | quietly(() => |
| 129 | 171 | device.tone({ |
| 130 | 172 | frequency: POP.frequency, |
| 131 | 173 | slideTo: POP.slideTo, |
| 132 | gain: POP.gain * loudness(distance), | |
| 174 | gain: POP.gain * loudness(segmentLength(ear, at)), | |
| 133 | 175 | length: POP.length, |
| 134 | 176 | type: "triangle", |
| 177 | pan: sideOf(at, ear, yaw), | |
| 135 | 178 | }), |
| 136 | 179 | ), |
| 137 | 180 |
src/range/boot.test.ts +67 −1
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | 14 | import { expect, test } from "bun:test"; |
| 15 | import { HIT, POP, TICK } from "./audio.ts"; | |
| 15 | import { HIT, LANE_PAN, POP, TICK } from "./audio.ts"; | |
| 16 | 16 | import { page, type GlRecorder, type RangePage, type Voice } from "./stage.test-helper.ts"; |
| 17 | 17 | |
| 18 | 18 | // 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 | ||
| 509 | 509 | expect(latest(p, "pop-up")).toContain("1/1 hits"); |
| 510 | 510 | expect(sincePlayed(p, played).filter((sound) => sound !== "pop")).toEqual(["crack", "hit"]); |
| 511 | 511 | |
| 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 | ||
| 512 | 521 | // The trigger held down is one shot, so it is one crack and not one a frame. |
| 513 | 522 | played = sounds(p).length; |
| 514 | 523 | 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 | ||
| 524 | 533 | } |
| 525 | 534 | }); |
| 526 | 535 | |
| 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 | ||
| 527 | 593 | test("a miss cracks and nothing more, and a trigger outside a run is silent", () => { |
| 528 | 594 | const { p, undo } = booted(); |
| 529 | 595 | try { |
src/range/main.ts +19 −5
| @@ -22,6 +22,13 @@ | ||
| 22 | 22 | * seconds, and the phase turned `over`. None of them is a comparison that |
| 23 | 23 | * would be true for sixty frames in a row. |
| 24 | 24 | * |
| 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 | * | |
| 25 | 32 | * A person walks here, on the range's own numbers: `movement.ts` holds the |
| 26 | 33 | * tuning and the hull, the engine's `move` steps the body through |
| 27 | 34 | * `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"; | ||
| 48 | 55 | import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts"; |
| 49 | 56 | import { buildRangeScene, createRangeScene, rangeAssets, type Trail } from "./scene.ts"; |
| 50 | 57 | import { |
| 51 | DRILLS, EYE, createRun, nextDrill, report, setDrill, shoot, start, step, | |
| 58 | DRILLS, EYE, centreOf, createRun, nextDrill, report, setDrill, shoot, start, step, | |
| 52 | 59 | type DrillName, type Target, |
| 53 | 60 | } from "./targets.ts"; |
| 54 | 61 | import { |
| 55 | 62 | angleVectors, attachButtons, clamp, createControls, createEscape, createFullscreen, createLoop, |
| 56 | createRenderer, move, type KeyboardLock, | |
| 63 | createRenderer, move, type KeyboardLock, type Vec3, | |
| 57 | 64 | } from "../engine/index.ts"; |
| 58 | 65 | |
| 59 | 66 | /** The range's own step: 50 Hz, deliberately not the shooter's 64. */ |
| @@ -123,6 +130,9 @@ export function boot(): void { | ||
| 123 | 130 | // `createSoundDevice` builds its `AudioContext` on the first sound, so a page |
| 124 | 131 | // that is looked at and never played stays a page that made no sound. |
| 125 | 132 | 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]; | |
| 126 | 136 | const loop = createLoop({ tick: TICK }, { |
| 127 | 137 | requestAnimationFrame: (callback) => requestAnimationFrame(callback), |
| 128 | 138 | cancelAnimationFrame: (handle) => cancelAnimationFrame(handle), |
| @@ -312,10 +322,12 @@ export function boot(): void { | ||
| 312 | 322 | } |
| 313 | 323 | rangeEye(body, view.eye); |
| 314 | 324 | 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. | |
| 316 | 328 | for (let i = 0; i < run.targets.length; i++) { |
| 317 | 329 | 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); | |
| 319 | 331 | } |
| 320 | 332 | // The last three whole seconds, each counted as the clock passes it. |
| 321 | 333 | for (let second = LAST_SECONDS; second >= 1; second--) |
| @@ -339,8 +351,10 @@ export function boot(): void { | ||
| 339 | 351 | if (shot !== null) { |
| 340 | 352 | // A shot the run refused is null, so a trigger pulled before Enter or |
| 341 | 353 | // 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. | |
| 342 | 356 | audio.shot(); |
| 343 | if (shot.target !== null) audio.hit(shot.distance); | |
| 357 | if (shot.target !== null) audio.hit(shot.point, view.eye, view.yaw); | |
| 344 | 358 | // The tracer leaves the eye it was fired from, which is not the firing |
| 345 | 359 | // line once a person has walked anywhere. |
| 346 | 360 | trail.from[0] = view.eye[0]; |
src/range/scene.ts +2 −4
| @@ -24,7 +24,7 @@ import { | ||
| 24 | 24 | } from "../engine/index.ts"; |
| 25 | 25 | import { roomMesh, targetMesh } from "./level.ts"; |
| 26 | 26 | 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"; | |
| 28 | 28 | |
| 29 | 29 | export type Tint = [number, number, number]; |
| 30 | 30 | |
| @@ -192,9 +192,7 @@ export function buildRangeScene( | ||
| 192 | 192 | for (const target of run.targets) { |
| 193 | 193 | const box = target.box; |
| 194 | 194 | 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); | |
| 198 | 196 | const left = target.span > 0 ? target.life / target.span : 0; |
| 199 | 197 | place(into, MESH.PLATE, PLACE, half / PLATE_HALF, left > FADING ? PLATE_LIT : GOING); |
| 200 | 198 | } |
src/range/stage.test-helper.ts +36 −8
| @@ -87,6 +87,12 @@ export type Voice = { | ||
| 87 | 87 | frequency: number; |
| 88 | 88 | /** The peak gain the envelope started from. */ |
| 89 | 89 | 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; | |
| 90 | 96 | }; |
| 91 | 97 | |
| 92 | 98 | export type FakeAudio = { |
| @@ -99,7 +105,11 @@ export type FakeAudio = { | ||
| 99 | 105 | }; |
| 100 | 106 | |
| 101 | 107 | 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 }; | |
| 103 | 113 | const voices: Voice[] = []; |
| 104 | 114 | const param = (set: (value: number) => void) => { |
| 105 | 115 | const it = { |
| @@ -132,8 +142,15 @@ export function audioCounter(): FakeAudio { | ||
| 132 | 142 | release: param(() => {}), |
| 133 | 143 | gain: param((value) => void (state.gain = value)), |
| 134 | 144 | 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 | }, | |
| 137 | 154 | stop: () => {}, |
| 138 | 155 | }; |
| 139 | 156 | return made; |
| @@ -154,11 +171,22 @@ export function audioCounter(): FakeAudio { | ||
| 154 | 171 | createBiquadFilter() { return node("filter"); } |
| 155 | 172 | createGain() { return node("gain"); } |
| 156 | 173 | 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 | } | |
| 162 | 190 | // And a compressor, for the same reason: every voice the engine's device |
| 163 | 191 | // starts goes to one master bus and out through a limiter on it, so a page |
| 164 | 192 | // 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 = { | ||
| 216 | 216 | distance: number; |
| 217 | 217 | }; |
| 218 | 218 | |
| 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 | ||
| 219 | 235 | /** Before the first run, during one, and after the clock ran out. */ |
| 220 | 236 | export type Phase = "ready" | "live" | "over"; |
| 221 | 237 | |
| @@ -386,10 +402,7 @@ export function step(run: Run, dt: number): void { | ||
| 386 | 402 | box.min[2] += vel[2] * dt; box.max[2] += vel[2] * dt; |
| 387 | 403 | // A plate that moved is a plate at another distance, and the readers of |
| 388 | 404 | // 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)); | |
| 393 | 406 | } |
| 394 | 407 | const escape = run.drill.escape; |
| 395 | 408 | run.targets = run.targets.filter( |
new above
offline