Back to Shiftfield
SOURCE / PINNED RELEASE

Made of little things.

Shiftfield

Release
5a170c505f6b…
Author-recorded commit
a2e23e0171aa…
License
LICENSE
Author’s source reference
nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-e4fb6341ccd

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

src/sfx.ts
/* Minesweeper DX — Phase 4 "Juice": procedural sound effects.

 * Web Audio synthesis only: every sound is an oscillator/noise graph built at
 * play time. NO assets, NO network (docs/napplet-space.md: "Small embedded
 * data/blob audio and Web Audio are separate browser features, subject to
 * user activation and codec support" — we create the context on the first
 * real gesture and stay fully offline).

 * The mute preference is a UI setting owned by the caller: it lives in the
 * NAP-STORAGE settings key (`minesweeper-dx.settings.v1`, never localStorage
 * — the §4.2A save schema is frozen) and round-trips through onChanged.
 * Everything degrades silently when Web Audio is unavailable or the context
 * refuses to start — audio is decoration, never a dependency.
 */

export type SfxId =
  | 'reveal'
  | 'flag'
  | 'unflag'
  | 'shift'
  | 'shiftWarn'
  | 'awaken'
  | 'boom'
  | 'shielded'
  | 'ability'
  | 'deny'
  | 'clear'
  | 'ui';

export class Sfx {
  muted = false;
  /** Called after the user flips the toggle so the host can persist it. */
  onChanged: ((muted: boolean) => void) | null = null;

  private ctx: AudioContext | null = null;
  private master: GainNode | null = null;
  private broken = false;

  constructor(private readonly button: HTMLButtonElement | null) {
    if (this.button) {
      this.button.addEventListener('click', () => this.toggle());
      this.render();
    }
  }

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

  private toggle(): void {
    this.muted = !this.muted;
    this.render();
    if (!this.muted) this.play('ui');
    this.onChanged?.(this.muted);
  }

  private render(): void {
    if (!this.button) return;
    this.button.classList.toggle('muted', this.muted);
    this.button.setAttribute('aria-pressed', String(this.muted));
    this.button.title = this.muted
      ? 'Sound off — click to unmute'
      : 'Sound on — click to mute';
  }

  /** Lazily build the audio graph on first use (a user gesture has usually
   * happened by then). Returns null when audio is muted, broken, or the
   * browser has no usable AudioContext. */
  private ensure(): AudioContext | null {
    if (this.muted || this.broken) return null;
    if (this.ctx) {
      if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => {});
      return this.ctx;
    }
    try {
      const Ctor =
        window.AudioContext ??
        (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
      if (!Ctor) throw new Error('no AudioContext');
      this.ctx = new Ctor();
      this.master = this.ctx.createGain();
      this.master.gain.value = 0.5;
      this.master.connect(this.ctx.destination);
      return this.ctx;
    } catch {
      this.broken = true;
      return null;
    }
  }

  // ------------------------------ synth parts ------------------------------

  /** One pitched blip: exp-decaying sine/triangle/square from f0 to f1. */
  private tone(
    ctx: AudioContext,
    opts: {
      type?: OscillatorType;
      f0: number;
      f1?: number;
      t?: number;
      dur: number;
      gain?: number;
    },
  ): void {
    const { type = 'sine', f0, f1 = f0, dur } = opts;
    const at = (opts.t ?? 0);
    const osc = ctx.createOscillator();
    const g = ctx.createGain();
    osc.type = type;
    const now = ctx.currentTime;
    osc.frequency.setValueAtTime(Math.max(20, f0), now + at);
    osc.frequency.exponentialRampToValueAtTime(Math.max(20, f1), now + at + dur);
    g.gain.setValueAtTime(0.0001, now + at);
    g.gain.exponentialRampToValueAtTime(opts.gain ?? 0.25, now + at + 0.008);
    g.gain.exponentialRampToValueAtTime(0.0001, now + at + dur);
    osc.connect(g).connect(this.master!);
    osc.start(now + at);
    osc.stop(now + at + dur + 0.05);
  }

  /** Filtered noise burst (booms, swooshes) through an exp-decaying gain. */
  private noise(
    ctx: AudioContext,
    opts: {
      t?: number;
      dur: number;
      gain?: number;
      filter?: BiquadFilterType;
      f0: number;
      f1?: number;
      q?: number;
    },
  ): void {
    const { dur, filter = 'lowpass', f0, f1 = f0, q = 1 } = opts;
    const at = opts.t ?? 0;
    const now = ctx.currentTime;
    const frames = Math.max(1, Math.floor(ctx.sampleRate * dur));
    const buffer = ctx.createBuffer(1, frames, ctx.sampleRate);
    const data = buffer.getChannelData(0);
    for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
    const src = ctx.createBufferSource();
    src.buffer = buffer;
    const bq = ctx.createBiquadFilter();
    bq.type = filter;
    bq.Q.value = q;
    bq.frequency.setValueAtTime(Math.max(20, f0), now + at);
    bq.frequency.exponentialRampToValueAtTime(Math.max(20, f1), now + at + dur);
    const g = ctx.createGain();
    g.gain.setValueAtTime(opts.gain ?? 0.4, now + at);
    g.gain.exponentialRampToValueAtTime(0.0001, now + at + dur);
    src.connect(bq).connect(g).connect(this.master!);
    src.start(now + at);
    src.stop(now + at + dur + 0.05);
  }

  // --------------------------------- voices --------------------------------

  play(id: SfxId): void {
    const ctx = this.ensure();
    if (!ctx || !this.master) return;
    try {
      switch (id) {
        case 'reveal': {
          // soft scanner tick, slightly randomized so a chain never feels machine-gunned
          const f = 760 + Math.random() * 240;
          this.tone(ctx, { type: 'triangle', f0: f, f1: f * 0.6, dur: 0.09, gain: 0.10 });
          break;
        }
        case 'flag':
          this.tone(ctx, { type: 'square', f0: 420, f1: 660, dur: 0.07, gain: 0.10 });
          this.tone(ctx, { type: 'square', f0: 880, dur: 0.05, t: 0.06, gain: 0.05 });
          break;
        case 'unflag':
          this.tone(ctx, { type: 'square', f0: 520, f1: 220, dur: 0.09, gain: 0.09 });
          break;
        case 'shift':
          // distant metallic whoosh as the whole field slides one cell
          this.noise(ctx, { dur: 0.28, gain: 0.12, f0: 900, f1: 180, filter: 'bandpass', q: 1.4 });
          this.tone(ctx, { type: 'sine', f0: 130, f1: 90, dur: 0.22, gain: 0.12 });
          break;
        case 'shiftWarn':
          // Klaxon tick for the final turns of a shift cycle — two rising
          // square blips, unmistakable next to the reveal tick chain.
          this.tone(ctx, { type: 'square', f0: 520, f1: 780, dur: 0.10, gain: 0.12 });
          this.tone(ctx, { type: 'square', f0: 920, dur: 0.07, t: 0.13, gain: 0.09 });
          break;
        case 'awaken': {
          // The Shifter's awakening at depth 6 (user ruling 2026-10-28):
          // a threat, not a klaxon — sub-bass drop, a filtered rumble, and
          // a slightly-beating dissonant drone pair swelling underneath.
          this.tone(ctx, { type: 'sine', f0: 74, f1: 30, dur: 1.5, gain: 0.38 });
          this.noise(ctx, { dur: 1.4, gain: 0.22, f0: 220, f1: 32 });
          this.tone(ctx, { type: 'sawtooth', f0: 55.0, f1: 52.0, dur: 1.7, t: 0.12, gain: 0.05 });
          this.tone(ctx, { type: 'sawtooth', f0: 58.3, f1: 55.3, dur: 1.7, t: 0.12, gain: 0.05 });
          this.tone(ctx, { type: 'triangle', f0: 320, f1: 226, dur: 0.6, t: 0.55, gain: 0.045 });
          break;
        }
        case 'boom':
          this.noise(ctx, { dur: 0.7, gain: 0.6, f0: 1600, f1: 40 });
          this.tone(ctx, { type: 'sawtooth', f0: 150, f1: 28, dur: 0.6, gain: 0.4 });
          this.tone(ctx, { type: 'sine', f0: 60, f1: 24, dur: 0.9, t: 0.05, gain: 0.5 });
          break;
        case 'shielded':
          // absorbed blast: muffled thud + bright shield chime
          this.noise(ctx, { dur: 0.22, gain: 0.18, f0: 500, f1: 120 });
          this.tone(ctx, { type: 'sine', f0: 660, dur: 0.18, gain: 0.16 });
          this.tone(ctx, { type: 'sine', f0: 990, dur: 0.26, t: 0.07, gain: 0.14 });
          break;
        case 'ability':
          this.tone(ctx, { type: 'square', f0: 440, f1: 880, dur: 0.09, gain: 0.08 });
          this.tone(ctx, { type: 'square', f0: 660, f1: 1320, dur: 0.09, t: 0.07, gain: 0.06 });
          break;
        case 'deny':
          this.tone(ctx, { type: 'sawtooth', f0: 180, f1: 120, dur: 0.12, gain: 0.10 });
          break;
        case 'clear': {
          // major arpeggio — the sector is yours
          const notes = [523.25, 659.25, 783.99, 1046.5];
          notes.forEach((f, i) =>
            this.tone(ctx, { type: 'triangle', f0: f, dur: 0.3, t: i * 0.09, gain: 0.14 }),
          );
          break;
        }
        case 'ui':
          this.tone(ctx, { type: 'triangle', f0: 880, dur: 0.05, gain: 0.08 });
          break;
      }
    } catch {
      // Audio graph hiccups must never break the game.
    }
  }
}