Back to Sickman Survival
SOURCE / PINNED RELEASE

Made of little things.

Sickman Survival

Release
7335c9fdf0a8…
Author-recorded commit
cbd2eeafa70d…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-8804151df7f

Archive hash verified: 591bab4addcbe5fc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/audio.ts
import type { Blast, Game, Meteor } from './game.ts';
import type { Mole } from './moles.ts';

/**
 * Procedural sound: every effect and ambience bed is synthesised with Web Audio
 * at runtime, so the single-file napplet ships no audio assets and fetches
 * nothing. Browsers only start audio after a user gesture, so the context is
 * created on the first key, tap or click (see `unlock`).
 */

type Ctor = typeof AudioContext;

interface Listener {
  x: number;
  y: number;
  /** Cells below the original ground line. */
  depth: number;
}

const MAX_WHISTLES = 5;

export class Sound {
  private ctx: AudioContext | null = null;
  private master!: GainNode;
  private sfx!: GainNode;
  private reverbSend!: GainNode;
  private surfaceBed!: GainNode;
  private caveBed!: GainNode;
  private windFilter!: BiquadFilterNode;
  private windGain!: GainNode;
  private stormGain!: GainNode;
  private digGain!: GainNode;
  private noise!: AudioBuffer;
  private brown!: AudioBuffer;

  private volume = 0.8;
  private muted = false;
  private paused = false;
  private under = 0;
  private storm = 0;
  private time = 0;
  private gust = 0;
  private nextDrip = 1;
  private nextBird = 2;
  private nextThunder = 6;
  private nextCreak = 8;
  private whistles = 0;
  private listener: Listener = { x: 0, y: 0, depth: 0 };
  /** One ear per split-screen pane; positional sounds are heard from the nearest. */
  private listeners: Listener[] = [this.listener];
  private terrainDepth: (x: number, y: number) => number = () => 0;

  get isMuted(): boolean {
    return this.muted;
  }

  /** Create or resume the audio context. Call from user-gesture handlers. */
  unlock(): void {
    if (!this.ctx) {
      const Audio = audioCtor();
      if (!Audio) return;
      try {
        this.ctx = new Audio({ latencyHint: 'interactive' });
      } catch {
        return;
      }
      this.build(this.ctx);
    }
    if (this.ctx.state === 'suspended' && !this.paused) void this.ctx.resume().catch(() => {});
  }

  setVolume(volume: number): void {
    this.volume = Math.max(0, Math.min(1, volume));
    this.applyMaster();
  }

  setMuted(muted: boolean): void {
    this.muted = muted;
    this.applyMaster();
  }

  /** Pausing freezes all sound, including tails that are still ringing. */
  setPaused(paused: boolean): void {
    this.paused = paused;
    if (!this.ctx) return;
    if (paused) void this.ctx.suspend().catch(() => {});
    else void this.ctx.resume().catch(() => {});
  }

  /** Per-frame: follow the stickman, mix surface/cave beds and schedule ambient one-shots. */
  update(game: Game, dt: number): void {
    const t = game.terrain;
    this.terrainDepth = (x, y) => t.depthAt(x, y);
    this.listeners = game.players.map((p) => {
      const x = game.focusX(p.slot);
      const y = game.focusY(p.slot);
      return { x, y, depth: t.depthAt(x, y) };
    });
    if (this.listeners.length === 0) this.listeners = [{ x: game.focusX(), y: game.focusY(), depth: 0 }];
    this.listener = this.listeners[0];
    const ctx = this.ctx;
    if (!ctx || this.paused || dt <= 0) return;
    this.time += dt;

    // The cave bed follows the share of players underground.
    let underTarget = 0;
    for (const l of this.listeners) underTarget += clamp((l.depth - 4) / 22, 0, 1) / this.listeners.length;
    this.under += (underTarget - this.under) * (1 - Math.exp(-dt * 2));
    this.storm = Math.min(1, game.stormTime() / 150);
    const now = ctx.currentTime;
    const surface = 1 - this.under;

    // Wind gusts: a slow wander plus occasional surges, stronger as the storm builds.
    this.gust = Math.max(0, this.gust - dt * 0.35);
    if (Math.random() < dt * (0.08 + this.storm * 0.25)) this.gust = Math.random() * 0.8 + 0.2;
    const sway = 0.5 + 0.5 * Math.sin(this.time * 0.37) * Math.sin(this.time * 0.11 + 1);
    this.windGain.gain.setTargetAtTime((0.05 + sway * 0.06 + this.gust * 0.12 + this.storm * 0.1) * surface, now, 0.4);
    this.windFilter.frequency.setTargetAtTime(260 + sway * 260 + this.gust * 600 + this.storm * 300, now, 0.5);
    this.stormGain.gain.setTargetAtTime((0.02 + this.storm * 0.22) * (surface * 0.8 + 0.2), now, 1);
    this.surfaceBed.gain.setTargetAtTime(0.25 + surface * 0.75, now, 0.3);
    this.caveBed.gain.setTargetAtTime(this.under, now, 0.4);
    this.reverbSend.gain.setTargetAtTime(this.under * 0.55, now, 0.4);

    // Something scratching through the rock nearby.
    let digging = 0;
    for (const m of game.moles) {
      if (m.state !== 'burrow') continue;
      digging = Math.max(digging, 1 - this.distance(m.x, m.y) / 170);
    }
    this.digGain.gain.setTargetAtTime(digging * (0.6 + Math.random() * 0.6) * 0.22, now, 0.05);

    if ((this.nextBird -= dt) <= 0) {
      this.nextBird = 2 + Math.random() * 6;
      if (this.storm < 0.45 && surface > 0.6) this.bird(1 - this.storm * 2);
    }
    if ((this.nextThunder -= dt) <= 0) {
      this.nextThunder = 14 - this.storm * 10 + Math.random() * 8;
      if (this.storm > 0.08) this.thunder(0.4 + this.storm * 0.6);
    }
    if ((this.nextDrip -= dt) <= 0) {
      this.nextDrip = 0.5 + Math.random() * 2.2;
      if (this.under > 0.15) this.drip();
    }
    if ((this.nextCreak -= dt) <= 0) {
      this.nextCreak = 7 + Math.random() * 12;
      if (this.under > 0.4) this.creak();
    }
  }

  // Effects ────────────────────────────────────────────────────────────────

  jump(): void {
    const ctx = this.live();
    if (!ctx) return;
    const now = ctx.currentTime;
    const pitch = 1 + (Math.random() - 0.5) * 0.12;
    const osc = this.osc('triangle', 260 * pitch, now);
    osc.frequency.exponentialRampToValueAtTime(560 * pitch, now + 0.09);
    this.envelope(osc, now, 0.004, 0.11, 0.09);
    this.noiseHit(now, 0.05, 1800, 'bandpass', 0.05);
  }

  land(impact: number): void {
    const ctx = this.live();
    if (!ctx) return;
    const now = ctx.currentTime;
    const k = clamp(impact / 900, 0.15, 1);
    this.noiseHit(now, 0.05 + k * 0.08, 500 + k * 400, 'lowpass', 0.14 * k + 0.04);
    const osc = this.osc('sine', 120, now);
    osc.frequency.exponentialRampToValueAtTime(45, now + 0.12);
    this.envelope(osc, now, 0.003, 0.18 * k, 0.14);
  }

  footstep(): void {
    const ctx = this.live();
    if (!ctx) return;
    const now = ctx.currentTime;
    const deep = this.under > 0.5;
    this.noiseHit(now, 0.035, deep ? 900 : 1400 + Math.random() * 900, 'bandpass', 0.035);
  }

  bounce(): void {
    const ctx = this.live();
    if (!ctx) return;
    const now = ctx.currentTime;
    const osc = this.osc('sine', 150, now);
    osc.frequency.exponentialRampToValueAtTime(520, now + 0.16);
    osc.frequency.exponentialRampToValueAtTime(380, now + 0.32);
    const wobble = this.osc('sine', 18, now);
    const depth = ctx.createGain();
    depth.gain.value = 40;
    wobble.connect(depth).connect(osc.frequency);
    wobble.start(now);
    wobble.stop(now + 0.4);
    this.envelope(osc, now, 0.005, 0.2, 0.34);
  }

  /** A falling whistle that drops in pitch until the expected impact. */
  meteor(m: Meteor): void {
    const ctx = this.live();
    if (!ctx || this.whistles >= MAX_WHISTLES) return;
    const impactX = m.x + m.vx * m.eta;
    const dx = Math.abs(impactX - this.nearest(impactX, 0, true).x);
    if (dx > 900) return;
    const now = ctx.currentTime;
    const duration = clamp(m.eta, 0.3, 4);
    const loud = (1 - dx / 900) ** 2 * 0.16;
    const src = this.noiseSource(now, duration + 0.1);
    const band = ctx.createBiquadFilter();
    band.type = 'bandpass';
    band.Q.value = 9;
    band.frequency.setValueAtTime(2600 + Math.random() * 600, now);
    band.frequency.exponentialRampToValueAtTime(500, now + duration);
    const gain = ctx.createGain();
    gain.gain.setValueAtTime(0.0001, now);
    gain.gain.exponentialRampToValueAtTime(loud * this.occlusion(impactX, -100), now + duration * 0.85);
    gain.gain.exponentialRampToValueAtTime(0.0001, now + duration + 0.08);
    src.connect(band).connect(gain).connect(this.panner(impactX)).connect(this.sfx);
    this.whistles++;
    src.onended = () => this.whistles--;
  }

  /** Layered blast: sub thump, roaring noise sweep, crackle and falling debris. */
  explosion(b: Blast): void {
    const ctx = this.live();
    if (!ctx) return;
    const now = ctx.currentTime;
    const d = this.distance(b.x, b.y);
    const size = clamp(b.radius / 40, 0.5, 1.4);
    const level = clamp(1.25 / (1 + d / 220), 0, 1);
    if (level < 0.02) return;
    const muffle = this.occlusion(b.x, b.y);
    const cutoff = clamp(9000 / (1 + d / 140), 300, 9000) * (muffle < 1 ? 0.35 : 1);
    const out = ctx.createBiquadFilter();
    out.type = 'lowpass';
    out.frequency.value = cutoff;
    out.connect(this.panner(b.x)).connect(this.sfx);

    // Sub thump.
    const sub = this.osc('sine', 95 / size, now);
    sub.frequency.exponentialRampToValueAtTime(28, now + 0.5 * size);
    this.envelope(sub, now, 0.005, 0.9 * level, 0.7 * size, out);

    // Roar: noise through a closing lowpass.
    const roar = this.noiseSource(now, 1.8 * size);
    const sweep = ctx.createBiquadFilter();
    sweep.type = 'lowpass';
    sweep.frequency.setValueAtTime(4200, now);
    sweep.frequency.exponentialRampToValueAtTime(140, now + 1.4 * size);
    const roarGain = ctx.createGain();
    roarGain.gain.setValueAtTime(0.0001, now);
    roarGain.gain.exponentialRampToValueAtTime(0.75 * level, now + 0.012);
    roarGain.gain.exponentialRampToValueAtTime(0.0001, now + 1.7 * size);
    roar.connect(sweep).connect(roarGain).connect(out);

    // Crack: the first instant, bright.
    const crack = this.noiseSource(now, 0.12);
    const high = ctx.createBiquadFilter();
    high.type = 'highpass';
    high.frequency.value = 1800;
    const crackGain = ctx.createGain();
    crackGain.gain.setValueAtTime(0.5 * level, now);
    crackGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.1);
    crack.connect(high).connect(crackGain).connect(out);

    // Debris pattering down afterwards.
    for (let i = Math.round(4 + size * 6); i > 0; i--) {
      const at = now + 0.25 + Math.random() * 0.9 * size;
      this.noiseHit(at, 0.02 + Math.random() * 0.03, 700 + Math.random() * 2400, 'bandpass', 0.12 * level * Math.random(), out);
    }

    // A close call leaves the ears ringing.
    if (b.proximity > 0.75) {
      const ring = this.osc('sine', 3400 + Math.random() * 300, now);
      this.envelope(ring, now + 0.05, 0.08, 0.025 * b.proximity, 1.8);
    }
  }

  moleSurface(m: Mole): void {
    const ctx = this.live();
    const level = this.near(m.x, m.y, 320);
    if (!ctx || level <= 0) return;
    const now = ctx.currentTime;
    const out = this.panner(m.x);
    out.connect(this.sfx);
    this.noiseHit(now, 0.18, 380, 'lowpass', 0.3 * level, out);
    this.noiseHit(now + 0.03, 0.08, 1200, 'bandpass', 0.12 * level, out);
    // A little squeak as it pops out.
    const squeak = this.osc('sine', 1300, now + 0.08);
    squeak.frequency.exponentialRampToValueAtTime(2100, now + 0.16);
    squeak.frequency.exponentialRampToValueAtTime(1500, now + 0.22);
    this.envelope(squeak, now + 0.08, 0.01, 0.06 * level, 0.15, out);
  }

  moleDive(m: Mole): void {
    const ctx = this.live();
    const level = this.near(m.x, m.y, 260);
    if (!ctx || level <= 0) return;
    const out = this.panner(m.x);
    out.connect(this.sfx);
    this.noiseHit(ctx.currentTime, 0.22, 300, 'lowpass', 0.22 * level, out);
  }

  /** Fizzing fuse plus beeps that speed up toward detonation. */
  moleArmed(m: Mole): void {
    const ctx = this.live();
    const level = this.near(m.x, m.y, 420);
    if (!ctx || level <= 0) return;
    const now = ctx.currentTime;
    const fuse = Math.max(0.05, m.fuse);
    const out = this.panner(m.x);
    out.connect(this.sfx);
    const hiss = this.noiseSource(now, fuse);
    const high = ctx.createBiquadFilter();
    high.type = 'highpass';
    high.frequency.setValueAtTime(3000, now);
    high.frequency.linearRampToValueAtTime(6000, now + fuse);
    const hissGain = ctx.createGain();
    hissGain.gain.setValueAtTime(0.03 * level, now);
    hissGain.gain.linearRampToValueAtTime(0.11 * level, now + fuse);
    hiss.connect(high).connect(hissGain).connect(out);
    if (fuse < 0.3) return; // chain-reaction fuses are too short for a warning
    // Beeps speed up toward detonation; the floor on the gap keeps the count finite.
    for (let at = 0, gap = 0.2; at < fuse - 0.04; at += gap, gap = Math.max(0.05, gap * 0.72)) {
      const beep = this.osc('square', 880 + (at / fuse) * 700, now + at);
      this.envelope(beep, now + at, 0.003, 0.09 * level, 0.05, out);
    }
  }

  death(): void {
    const ctx = this.live();
    if (!ctx) return;
    const now = ctx.currentTime;
    const osc = this.osc('triangle', 420, now + 0.05);
    osc.frequency.exponentialRampToValueAtTime(90, now + 0.6);
    this.envelope(osc, now + 0.05, 0.01, 0.07, 0.6);
  }

  /** Small UI click for start, pause and resume. */
  click(): void {
    const ctx = this.live();
    if (!ctx) return;
    const now = ctx.currentTime;
    const osc = this.osc('square', 660, now);
    osc.frequency.exponentialRampToValueAtTime(990, now + 0.04);
    this.envelope(osc, now, 0.002, 0.04, 0.05);
  }

  // Ambient one-shots ──────────────────────────────────────────────────────

  private bird(level: number): void {
    const ctx = this.live();
    if (!ctx) return;
    const now = ctx.currentTime;
    const out = this.panner(this.listener.x + (Math.random() - 0.5) * 600);
    out.connect(this.surfaceBed);
    const base = 2400 + Math.random() * 1400;
    for (let i = 0, n = 2 + Math.floor(Math.random() * 3); i < n; i++) {
      const at = now + i * (0.09 + Math.random() * 0.05);
      const chirp = this.osc('sine', base, at);
      chirp.frequency.exponentialRampToValueAtTime(base * (1.3 + Math.random() * 0.3), at + 0.05);
      this.envelope(chirp, at, 0.005, 0.025 * level, 0.07, out);
    }
  }

  private thunder(level: number): void {
    const ctx = this.live();
    if (!ctx) return;
    const now = ctx.currentTime;
    const duration = 2.5 + Math.random() * 2;
    const src = this.noiseSource(now, duration, this.brown);
    const low = ctx.createBiquadFilter();
    low.type = 'lowpass';
    low.frequency.value = 160 + Math.random() * 120;
    const gain = ctx.createGain();
    gain.gain.setValueAtTime(0.0001, now);
    gain.gain.exponentialRampToValueAtTime(0.35 * level, now + 0.3 + Math.random() * 0.5);
    gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
    src.connect(low).connect(gain).connect(this.surfaceBed);
  }

  private drip(): void {
    const ctx = this.live();
    if (!ctx) return;
    const now = ctx.currentTime;
    const out = this.panner(this.listener.x + (Math.random() - 0.5) * 500);
    out.connect(this.caveBed);
    const f = 1300 + Math.random() * 1500;
    const plink = this.osc('sine', f, now);
    plink.frequency.exponentialRampToValueAtTime(f * 0.55, now + 0.08);
    this.envelope(plink, now, 0.002, 0.06 + Math.random() * 0.05, 0.12, out);
  }

  /** Rock settling somewhere in the dark. */
  private creak(): void {
    const ctx = this.live();
    if (!ctx) return;
    const now = ctx.currentTime;
    const duration = 0.8 + Math.random() * 0.8;
    const src = this.noiseSource(now, duration, this.brown);
    const band = ctx.createBiquadFilter();
    band.type = 'bandpass';
    band.Q.value = 4;
    band.frequency.setValueAtTime(140 + Math.random() * 120, now);
    band.frequency.linearRampToValueAtTime(90, now + duration);
    const gain = ctx.createGain();
    gain.gain.setValueAtTime(0.0001, now);
    gain.gain.exponentialRampToValueAtTime(0.5, now + duration * 0.3);
    gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
    src.connect(band).connect(gain).connect(this.caveBed);
    for (let i = 0; i < 5; i++) {
      this.noiseHit(now + duration * (0.4 + Math.random() * 0.8), 0.02, 1500 + Math.random() * 1500, 'bandpass',
        0.03 * Math.random(), this.caveBed);
    }
  }

  // Graph ──────────────────────────────────────────────────────────────────

  private build(ctx: AudioContext): void {
    this.noise = makeNoise(ctx, 2, false);
    this.brown = makeNoise(ctx, 4, true);

    const compressor = ctx.createDynamicsCompressor();
    compressor.threshold.value = -14;
    compressor.ratio.value = 6;
    compressor.attack.value = 0.003;
    compressor.release.value = 0.25;
    this.master = ctx.createGain();
    this.master.connect(compressor).connect(ctx.destination);
    this.applyMaster();

    // Cave echo: a generated impulse response shared by effects and the cave bed.
    const reverb = ctx.createConvolver();
    reverb.buffer = makeImpulse(ctx, 2.4);
    reverb.connect(this.master);
    this.reverbSend = ctx.createGain();
    this.reverbSend.gain.value = 0;
    this.reverbSend.connect(reverb);

    this.sfx = ctx.createGain();
    this.sfx.connect(this.master);
    this.sfx.connect(this.reverbSend);

    // Surface bed: wind and a distant storm rumble.
    this.surfaceBed = ctx.createGain();
    this.surfaceBed.connect(this.master);
    const wind = this.loop(this.noise);
    this.windFilter = ctx.createBiquadFilter();
    this.windFilter.type = 'bandpass';
    this.windFilter.Q.value = 0.8;
    this.windFilter.frequency.value = 400;
    this.windGain = ctx.createGain();
    this.windGain.gain.value = 0;
    wind.connect(this.windFilter).connect(this.windGain).connect(this.surfaceBed);
    const rumble = this.loop(this.brown);
    const rumbleFilter = ctx.createBiquadFilter();
    rumbleFilter.type = 'lowpass';
    rumbleFilter.frequency.value = 90;
    this.stormGain = ctx.createGain();
    this.stormGain.gain.value = 0;
    rumble.connect(rumbleFilter).connect(this.stormGain).connect(this.surfaceBed);

    // Cave bed: a low hollow drone, echoing.
    this.caveBed = ctx.createGain();
    this.caveBed.gain.value = 0;
    this.caveBed.connect(this.master);
    const caveSend = ctx.createGain();
    caveSend.gain.value = 0.7;
    this.caveBed.connect(caveSend).connect(reverb);
    const drone = this.loop(this.brown);
    const droneFilter = ctx.createBiquadFilter();
    droneFilter.type = 'lowpass';
    droneFilter.frequency.value = 180;
    const droneGain = ctx.createGain();
    droneGain.gain.value = 0.22;
    drone.connect(droneFilter).connect(droneGain).connect(this.caveBed);

    // Moles digging nearby: a gritty scratch, gain driven per frame.
    const grit = this.loop(this.noise);
    const gritFilter = ctx.createBiquadFilter();
    gritFilter.type = 'bandpass';
    gritFilter.frequency.value = 700;
    gritFilter.Q.value = 1.4;
    this.digGain = ctx.createGain();
    this.digGain.gain.value = 0;
    grit.connect(gritFilter).connect(this.digGain).connect(this.sfx);
  }

  private applyMaster(): void {
    if (!this.ctx) return;
    const level = this.muted ? 0 : this.volume * 0.9;
    this.master.gain.setTargetAtTime(level, this.ctx.currentTime, 0.05);
  }

  /** The context, if sound should play right now. */
  private live(): AudioContext | null {
    const ctx = this.ctx;
    return ctx && ctx.state === 'running' && !this.muted && this.volume > 0 ? ctx : null;
  }

  private loop(buffer: AudioBuffer): AudioBufferSourceNode {
    const src = this.ctx!.createBufferSource();
    src.buffer = buffer;
    src.loop = true;
    src.start(0, Math.random() * buffer.duration);
    return src;
  }

  private noiseSource(at: number, duration: number, buffer = this.noise): AudioBufferSourceNode {
    const src = this.ctx!.createBufferSource();
    src.buffer = buffer;
    src.loop = true;
    src.start(at, Math.random() * buffer.duration);
    src.stop(at + duration + 0.05);
    return src;
  }

  private osc(type: OscillatorType, frequency: number, at: number): OscillatorNode {
    const osc = this.ctx!.createOscillator();
    osc.type = type;
    osc.frequency.setValueAtTime(frequency, at);
    return osc;
  }

  /** Attack/decay envelope into `out`; starts and stops the oscillator. */
  private envelope(
    osc: OscillatorNode, at: number, attack: number, peak: number, decay: number, out: AudioNode = this.sfx,
  ): void {
    const gain = this.ctx!.createGain();
    gain.gain.setValueAtTime(0.0001, at);
    gain.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), at + attack);
    gain.gain.exponentialRampToValueAtTime(0.0001, at + attack + decay);
    osc.connect(gain).connect(out);
    osc.start(at);
    osc.stop(at + attack + decay + 0.05);
  }

  private noiseHit(
    at: number, duration: number, frequency: number, type: BiquadFilterType, peak: number, out: AudioNode = this.sfx,
  ): void {
    const ctx = this.ctx!;
    const src = this.noiseSource(at, duration);
    const filter = ctx.createBiquadFilter();
    filter.type = type;
    filter.frequency.value = frequency;
    const gain = ctx.createGain();
    gain.gain.setValueAtTime(Math.max(0.0002, peak), at);
    gain.gain.exponentialRampToValueAtTime(0.0001, at + duration);
    src.connect(filter).connect(gain).connect(out);
  }

  private panner(x: number): AudioNode {
    const ctx = this.ctx!;
    // Split screen has no single left and right, so stereo narrows.
    const width = this.listeners.length > 1 ? 0.4 : 0.8;
    const pan = clamp((x - this.nearest(x, 0, true).x) / 320, -1, 1) * width;
    if (typeof ctx.createStereoPanner !== 'function') return ctx.createGain();
    const node = ctx.createStereoPanner();
    node.pan.value = pan;
    return node;
  }

  private distance(x: number, y: number): number {
    const l = this.nearest(x, y);
    return Math.hypot(x - l.x, y - l.y);
  }

  /** The listener closest to (x, y), or closest in x alone. */
  private nearest(x: number, y: number, xOnly = false): Listener {
    let best = this.listener;
    let bestDistance = Infinity;
    for (const l of this.listeners) {
      const d = xOnly ? Math.abs(x - l.x) : Math.hypot(x - l.x, y - l.y);
      if (d < bestDistance) {
        best = l;
        bestDistance = d;
      }
    }
    return best;
  }

  /** 0–1 loudness for a sound at (x, y) that fades out by `range` units. */
  private near(x: number, y: number, range: number): number {
    return clamp(1 - this.distance(x, y) / range, 0, 1) ** 1.5;
  }

  /** 1 when source and listener share the open air or the caves; less when rock is between them. */
  private occlusion(x: number, y: number): number {
    const sourceDeep = this.terrainDepth(x, y) > 6;
    const listenerDeep = this.nearest(x, y).depth > 6;
    return sourceDeep === listenerDeep ? 1 : 0.45;
  }
}

function audioCtor(): Ctor | null {
  const w = window as unknown as { AudioContext?: Ctor; webkitAudioContext?: Ctor };
  return w.AudioContext ?? w.webkitAudioContext ?? null;
}

function makeNoise(ctx: AudioContext, seconds: number, brown: boolean): AudioBuffer {
  const length = Math.floor(ctx.sampleRate * seconds);
  const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
  const data = buffer.getChannelData(0);
  let last = 0;
  for (let i = 0; i < length; i++) {
    const white = Math.random() * 2 - 1;
    if (brown) {
      last = (last + 0.02 * white) / 1.02;
      data[i] = last * 3.5;
    } else {
      data[i] = white;
    }
  }
  return buffer;
}

function makeImpulse(ctx: AudioContext, seconds: number): AudioBuffer {
  const length = Math.floor(ctx.sampleRate * seconds);
  const buffer = ctx.createBuffer(2, length, ctx.sampleRate);
  for (let c = 0; c < 2; c++) {
    const data = buffer.getChannelData(c);
    for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 3;
  }
  return buffer;
}

function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, value));
}