/* Procedural arcade effects: dry impacts, delayed debris and a short shared room reflection. */
(function (D) {
  'use strict';
  const scriptSource = globalThis.document?.currentScript?.src;
  const crowdAsset = scriptSource && globalThis.URL ? new URL('../assets/audio/crowd-panic-ja.wav', scriptSource).href : 'assets/audio/crowd-panic-ja.wav';
  class Sound {
    constructor() {
      this.context = null;
      this.volume = 0.45;
      this.muted = false;
      this.ducked = false;
      this.lastBeat = -1;
      this.lastVoice = 0;
      this.active = false;
      this.maxVoices = 56;
      this.criticalReserve = 8; // Keep the explosion's bass and first impact available during glass-heavy bursts.
      this.critical = false;
      this.voices = new Map();
      this.voiceStops = new Map();
      this.voiceSerial = 0;
      this.eventTimes = new Map();
      this.lastFire = -Infinity;
      this.crowdLayers = [];
      this.crowdState = null;
      this.crowdInitialized = false;
      this.crowdPrimed = false;
      this.crowdFailed = false;
      this.crowdWanted = false;
      this.crowdTarget = 0;
    }
    unlock() {
      try {
        if (!this.context) {
          const AudioContext = globalThis.AudioContext || globalThis.webkitAudioContext;
          if (!AudioContext) return;
          const c = this.context = new AudioContext();
          this.master = c.createGain();
          this.master.gain.value = this.muted ? 0 : this.volume * (this.ducked ? .24 : 1);
          const compressor = c.createDynamicsCompressor();
          compressor.threshold.value = -19;
          compressor.knee.value = 16;
          compressor.ratio.value = 4;
          compressor.attack.value = .003;
          compressor.release.value = .18;
          this.master.connect(compressor).connect(c.destination);
          this.noiseBuffer = c.createBuffer(1, c.sampleRate * 2, c.sampleRate);
          const wave = this.noiseBuffer.getChannelData(0);
          for (let i = 0; i < wave.length; i++) wave[i] = Math.random() * 2 - 1;
          if (c.createConvolver) {
            const reverb = c.createConvolver(), impulse = c.createBuffer(2, Math.floor(c.sampleRate * .82), c.sampleRate);
            for (let channel = 0; channel < 2; channel++) {
              const samples = impulse.getChannelData(channel);
              for (let i = 0; i < samples.length; i++) {
                const seconds = i / c.sampleRate;
                samples[i] = seconds < .018 ? 0 : (Math.random() * 2 - 1) * Math.exp(-seconds * 9) * .44;
              }
            }
            reverb.buffer = impulse;
            const lowpass = c.createBiquadFilter(); lowpass.type = 'lowpass'; lowpass.frequency.value = 4200;
            this.reverbSend = c.createGain(); this.reverbSend.gain.value = .14;
            this.reverbSend.connect(reverb).connect(lowpass).connect(this.master);
          }
        }
        if (this.context.state === 'suspended') this.context.resume().catch(() => {});
        this.initCrowd();
        if (!this.crowdPrimed && !this.crowdFailed && !this.muted && this.volume > 0) {
          this.crowdPrimed = true;
          // Prime the local media in the same user gesture as AudioContext. The
          // bus is silent until a damaged building actually calls for a crowd.
          this.crowdLayers.forEach(layer => this.startCrowdLayer(layer));
        }
      } catch (_) { /* Sound failure must never stop the game. */ }
    }
    setVolume(value) {
      this.volume = Math.max(0, Math.min(1, Number.isFinite(Number(value)) ? Number(value) : 0));
      if (this.context) this.master.gain.setTargetAtTime(this.muted ? 0 : this.volume * (this.ducked ? .24 : 1), this.context.currentTime, 0.09);
      if (this.muted || this.volume === 0) this.stopCrowd();
      else if (this.crowdState) this.updateCrowd(this.crowdState);
    }
    setDucked(value) { this.ducked = !!value; this.setVolume(this.volume); }
    setMuted(value) { this.muted = !!value; this.setVolume(this.volume); }
    initCrowd() {
      if (this.crowdInitialized || !this.context) return;
      this.crowdInitialized = true;
      if (typeof globalThis.Audio !== 'function') return;
      try {
        const c = this.context;
        // file:// media can play normally while a MediaElementSource becomes
        // silent under opaque-origin rules. Direct media volume avoids that path.
        this.crowdDirect = globalThis.location?.protocol === 'file:' || crowdAsset.startsWith('file:') || !c.createMediaElementSource;
        this.crowdBus = c.createGain(); this.crowdBus.gain.value = 0;
        this.crowdBus.connect(this.master);
        const src = D.DATA?.audioCrowd?.src || crowdAsset;
        [0, 4.05, 8.1].forEach((offset, index) => {
          const media = new globalThis.Audio(src), gain = c.createGain();
          media.loop = true; media.preload = 'auto'; media.volume = this.crowdDirect ? 0 : 1;
          gain.gain.value = 0;
          const source = this.crowdDirect ? null : c.createMediaElementSource(media);
          source?.connect(gain).connect(this.crowdBus);
          const layer = { media, source, gain, offset, index, target: 0, token: 0, pending: false, active: false, voiceId: null, sought: false };
          this.crowdLayers.push(layer);
          media.addEventListener('loadedmetadata', () => this.seekCrowdLayer(layer));
          media.addEventListener('error', () => this.failCrowd());
        });
        globalThis.document?.addEventListener?.('visibilitychange', () => {
          if (globalThis.document.hidden) this.stopCrowd();
        });
        globalThis.addEventListener?.('pagehide', () => this.stopCrowd());
      } catch (_) { this.failCrowd(); }
    }
    seekCrowdLayer(layer, reset = false) {
      if ((!reset && layer.sought) || !Number.isFinite(layer.media.duration) || layer.media.duration <= 0) return;
      try { layer.media.currentTime = layer.offset % layer.media.duration; layer.sought = true; } catch (_) { /* Metadata may still be loading. */ }
    }
    startCrowdLayer(layer) {
      if (this.crowdFailed || layer.pending || layer.active) return;
      // Each continuous media layer occupies one ordinary voice, preserving the
      // reserved explosion slots and the same 56-voice ceiling as one-shot SFX.
      const previousPriority = this.critical; this.critical = false;
      try { layer.voiceId = this.reserve(Infinity, 0); } finally { this.critical = previousPriority; }
      if (layer.voiceId === null) return;
      layer.pending = true;
      const token = ++layer.token;
      this.seekCrowdLayer(layer);
      const ready = () => {
        if (layer.token !== token) return;
        globalThis.clearTimeout?.(layer.timeout);
        layer.pending = false; layer.active = true;
        if (!this.crowdWanted || !layer.target) this.stopCrowdLayer(layer);
      };
      const failed = () => { if (layer.token === token) this.failCrowd(); };
      layer.timeout = globalThis.setTimeout?.(() => { if (layer.token === token && layer.pending) this.failCrowd(); }, 4500);
      try {
        const playing = layer.media.play();
        if (playing?.then) playing.then(ready, failed);
        else ready();
      } catch (_) { failed(); }
    }
    stopCrowdLayer(layer, reset = false) {
      const wasLive = layer.pending || layer.active || layer.voiceId !== null || layer.target;
      layer.target = 0;
      if (wasLive) {
        globalThis.clearTimeout?.(layer.timeout);
        layer.token++; layer.pending = false; layer.active = false;
        try { layer.media.pause(); } catch (_) { /* Optional ambience cannot block the game. */ }
        if (this.crowdDirect) layer.media.volume = 0;
        this.voices.delete(layer.voiceId); layer.voiceId = null;
        const now = this.context.currentTime;
        layer.gain.gain.cancelScheduledValues?.(now); layer.gain.gain.setValueAtTime(0, now);
      }
      if (reset) this.seekCrowdLayer(layer, true);
    }
    stopCrowd(reset = false) {
      this.crowdWanted = false;
      this.crowdLayers.forEach(layer => this.stopCrowdLayer(layer, reset));
      if (this.crowdBus && this.crowdTarget) {
        const now = this.context.currentTime;
        this.crowdBus.gain.cancelScheduledValues?.(now); this.crowdBus.gain.setValueAtTime(0, now);
      }
      this.crowdTarget = 0;
    }
    failCrowd() {
      if (this.crowdFailed) return;
      this.crowdFailed = true; this.stopCrowd();
      this.crowdLayers.forEach(layer => { layer.source?.disconnect(); layer.gain.disconnect(); });
      this.crowdBus?.disconnect();
    }
    updateCrowd(state) {
      this.crowdState = state;
      const integrity = state?.structure, ratio = integrity?.maxHp > 0 ? integrity.hp / integrity.maxHp : 1;
      const playable = state && ['playing', 'boss'].includes(state.mode) && !state.bossDefeated && !state.paused && !globalThis.document?.hidden;
      if (!this.context || this.context.state !== 'running' || this.muted || this.volume === 0 || !playable || !Number.isFinite(ratio) || ratio <= 0 || ratio >= .85) {
        this.stopCrowd(ratio >= .85 || ['title', 'result'].includes(state?.mode)); return;
      }
      if (this.crowdFailed || !this.crowdLayers.length) return;
      this.crowdWanted = true;
      const bands = [[.85, .025], [.65, .055], [.4, .095], [.2, .135], [0, .17]];
      let volume = bands.at(-1)[1];
      for (let i = 1; i < bands.length; i++) {
        if (ratio < bands[i][0]) continue;
        const [high, from] = bands[i - 1], [low, to] = bands[i];
        volume = from + (to - from) * (high - ratio) / (high - low); break;
      }
      const target = volume * (this.ducked ? .45 : 1), now = this.context.currentTime;
      if (Math.abs(target - this.crowdTarget) > .0001) {
        this.crowdTarget = target; this.crowdBus.gain.setTargetAtTime(target, now, .24);
      }
      const count = ratio <= .2 ? 3 : ratio <= .4 ? 2 : 1;
      this.crowdLayers.forEach((layer, index) => {
        if (index >= count) { this.stopCrowdLayer(layer); return; }
        const gain = [1, .6, .45][index];
        if (layer.target !== gain) {
          layer.target = gain; layer.gain.gain.setTargetAtTime(gain, now, .35);
        }
        this.startCrowdLayer(layer);
        if (this.crowdDirect) layer.media.volume = Math.max(0, Math.min(1, this.volume * (this.ducked ? .24 : 1) * this.crowdTarget * layer.target));
      });
    }
    outlet(gain, pan) {
      const nodes = [];
      let endpoint = gain;
      if (this.context.createStereoPanner && pan) {
        const panner = this.context.createStereoPanner();
        panner.pan.value = Math.max(-0.85, Math.min(0.85, pan));
        gain.connect(panner); endpoint = panner; nodes.push(panner);
      }
      endpoint.connect(this.master);
      if (this.reverbSend) endpoint.connect(this.reverbSend);
      return nodes;
    }
    reserve(duration, delay) {
      const now = this.context.currentTime;
      for (const [id, end] of this.voices) if (end <= now) this.voices.delete(id);
      const limit = this.critical ? this.maxVoices : Math.max(1, this.maxVoices - this.criticalReserve);
      if (this.critical && this.voices.size >= limit) {
        // A warning or incoming bomb can replace the ordinary tail nearest its end.
        // Stop it before allocating the new source, so the physical cap stays unchanged.
        let victim=null,end=Infinity;
        for(const [id,voice] of this.voiceStops){
          const until=this.voices.get(id);
          if(!voice.critical&&until<end){victim=voice;end=until;}
        }
        victim?.stop();
      }
      if (this.voices.size >= limit) return null;
      const id = ++this.voiceSerial;
      this.voices.set(id, now + Math.max(0, delay) + duration + .04);
      return id;
    }
    trackVoice(id,source,nodes) {
      let released=false;
      const release=()=>{
        if(released)return;released=true;
        source.disconnect();nodes.forEach(node=>node.disconnect());
        this.voices.delete(id);this.voiceStops.delete(id);
      };
      this.voiceStops.set(id,{critical:this.critical,stop:()=>{try{source.stop(this.context.currentTime);}catch(_){/* It may have ended before its callback was delivered. */}finally{release();}}});
      source.onended=release;
    }
    criticalCue(recipe) {
      const prior=this.critical;this.critical=true;
      try{recipe();}finally{this.critical=prior;}
    }
    allow(key, interval) {
      const now = this.context.currentTime, last = this.eventTimes.get(key) ?? -Infinity;
      if (now - last < interval) return false;
      this.eventTimes.set(key, now);
      return true;
    }
    tone(freq, duration, type = 'square', volume = .08, endFreq = freq, delay = 0, pan = 0) {
      if (!this.context || this.muted || this.context.state !== 'running') return;
      const voiceId = this.reserve(duration, delay); if (voiceId === null) return;
      const c = this.context, t = c.currentTime + delay;
      const o = c.createOscillator(), g = c.createGain();
      o.type = type;
      o.frequency.setValueAtTime(Math.max(15, freq), t);
      o.frequency.exponentialRampToValueAtTime(Math.max(15, endFreq), t + duration);
      g.gain.setValueAtTime(0, t);
      g.gain.linearRampToValueAtTime(volume, t + .004);
      g.gain.exponentialRampToValueAtTime(.0001, t + duration);
      o.connect(g); const routes = this.outlet(g, pan);
      o.start(t); o.stop(t + duration + .015);
      this.trackVoice(voiceId,o,[g,...routes]);
    }
    noise(duration, volume = .15, freq = 2000, filter = 'lowpass', delay = 0, pan = 0) {
      if (!this.context || this.muted || this.context.state !== 'running') return;
      const voiceId = this.reserve(duration, delay); if (voiceId === null) return;
      const c = this.context, t = c.currentTime + delay;
      const n = c.createBufferSource(), f = c.createBiquadFilter(), g = c.createGain();
      n.buffer = this.noiseBuffer; n.loop = true; f.type = filter; f.frequency.value = freq;
      g.gain.setValueAtTime(.0001, t);
      g.gain.linearRampToValueAtTime(volume, t + .003);
      g.gain.exponentialRampToValueAtTime(.0001, t + duration);
      n.connect(f).connect(g); const routes = this.outlet(g, pan);
      n.start(t, Math.random()); n.stop(t + duration + .02);
      this.trackVoice(voiceId,n,[f,g,...routes]);
    }
    sweep(duration,volume,startFreq,endFreq,delay=0,pan=0,endPan=pan) {
      if(!this.context||this.muted||this.context.state!=='running')return;
      const voiceId=this.reserve(duration,delay);if(voiceId===null)return;
      const c=this.context,t=c.currentTime+delay,n=c.createBufferSource(),f=c.createBiquadFilter(),g=c.createGain();
      n.buffer=this.noiseBuffer;n.loop=true;f.type='bandpass';
      f.frequency.setValueAtTime(startFreq,t);f.frequency.exponentialRampToValueAtTime(endFreq,t+duration);
      g.gain.setValueAtTime(.0001,t);g.gain.linearRampToValueAtTime(volume,t+duration*.36);g.gain.exponentialRampToValueAtTime(.0001,t+duration);
      n.connect(f).connect(g);const routes=this.outlet(g,pan);
      if(routes[0]?.pan)routes[0].pan.linearRampToValueAtTime(Math.max(-.85,Math.min(.85,endPan)),t+duration);
      n.start(t,Math.random());n.stop(t+duration+.02);this.trackVoice(voiceId,n,[f,g,...routes]);
    }
    rumble(duration, volume = .09, freq = 220, pan = 0) {
      if (!this.context || this.muted || this.context.state !== 'running') return;
      const voiceId = this.reserve(duration, 0); if (voiceId === null) return;
      const c = this.context, t = c.currentTime;
      const n = c.createBufferSource(), f = c.createBiquadFilter(), g = c.createGain();
      n.buffer = this.noiseBuffer; n.loop = true; f.type = 'lowpass';
      f.frequency.setValueAtTime(freq, t);
      f.frequency.exponentialRampToValueAtTime(Math.max(65, freq * .54), t + duration);
      // Hold the low grinding bed through the floor impacts instead of letting
      // an ordinary impact envelope die away while the building is still falling.
      g.gain.setValueAtTime(.0001, t);
      g.gain.linearRampToValueAtTime(volume, t + .12);
      g.gain.linearRampToValueAtTime(volume * .82, t + duration * .67);
      g.gain.exponentialRampToValueAtTime(.0001, t + duration);
      n.connect(f).connect(g); const routes = this.outlet(g, pan);
      n.start(t, Math.random()); n.stop(t + duration + .02);
      this.trackVoice(voiceId,n,[f,g,...routes]);
    }
    glass(stage, pan, scale = 1) {
      if (stage === 'crack') {
        this.noise(.035, .115 * scale, 2800, 'bandpass', 0, pan);
        this.tone(1750, .055, 'triangle', .043 * scale, 620, .007, pan);
        this.tone(3600, .032, 'sine', .018 * scale, 2900, .02, pan);
        return;
      }
      if (stage === 'chip') {
        this.noise(.12, .19 * scale, 3100, 'bandpass', 0, pan);
        this.noise(.085, .075 * scale, 4700, 'highpass', .025, pan);
        this.tone(1460, .09, 'triangle', .05 * scale, 680, 0, pan);
        [2700, 3850, 1900].forEach((f, i) => this.tone(f, .1, 'sine', .036 * scale, f * .87, .055 + i * .065, pan + (i % 2 ? .1 : -.1)));
        return;
      }
      // Crack, breakup, airborne fragments, floor scatter, then the loose metal frame.
      this.tone(118,.38,'triangle',.075*scale,49,0,pan);
      this.noise(.16,.095*scale,620,'lowpass',.009,pan);
      this.noise(.22, .25 * scale, 2300, 'bandpass', 0, pan);
      this.noise(.36, .13 * scale, 4800, 'highpass', .024, pan);
      this.noise(.12, .105 * scale, 850, 'lowpass', .013, pan);
      this.tone(960, .085, 'triangle', .078 * scale, 210, 0, pan);
      [2340, 3450, 1760, 2870, 4070, 1330, 3150, 2080].forEach((f, i) => {
        const delay = .08 + i * .095 + Math.random() * .025;
        this.tone(f, .16 + i * .018, 'sine', (.044 - i * .003) * scale, f * .79, delay, pan + (i % 2 ? .24 : -.21));
      });
      [.33, .58, .86].forEach((delay, i) => {
        this.noise(.09, (.057 - i * .012) * scale, 2700 - i * 450, 'highpass', delay, pan);
        this.tone([260, 425, 680][i], .19, 'triangle', .037 * scale, [210, 310, 520][i], delay + .055, pan);
      });
    }
    metal(propType,pan,soft=false) {
      if(soft){
        const ac=propType==='ac',rail=propType==='rail';
        this.noise(.035,.06,ac?750:rail?2400:1400,'bandpass',0,pan);
        this.tone(ac?245:rail?970:570,.13,'triangle',.044,ac?155:rail?780:430,0,pan);
        return;
      }
      if(propType==='ac'){
        // Cabinet tears first; a decelerating fan and loose panels follow its weight.
        this.noise(.21,.17,1150,'bandpass',0,pan);
        this.tone(126,.34,'triangle',.1,52,0,pan);
        this.sweep(.34,.075,3200,520,.015,pan);
        this.tone(265,.86,'sawtooth',.037,38,.065,pan);
        this.tone(530,.75,'triangle',.025,68,.085,pan);
        [730,1150,430].forEach((f,i)=>this.tone(f,.2+i*.035,'sine',.027-i*.004,f*.63,.19+i*.14,pan+(i%2?.13:-.11)));
        this.noise(.15,.1,460,'lowpass',.54,pan);
        this.tone(76,.42,'sine',.087,27,.54,pan);
      }else if(propType==='rail'){
        // Thin bars ring higher than the AC casing and strike one another after tearing.
        this.noise(.18,.14,3000,'bandpass',0,pan);
        this.sweep(.27,.083,4400,1500,.009,pan);
        this.tone(640,.35,'triangle',.075,430,0,pan);
        this.tone(1180,.43,'sine',.039,790,.017,pan);
        this.tone(180,.28,'triangle',.045,85,.05,pan);
        [1420,780,1980,1050].forEach((f,i)=>this.tone(f,.26+i*.025,'triangle',.039-i*.005,f*.76,.14+i*.165,pan+(i%2?.16:-.14)));
        this.noise(.12,.055,900,'bandpass',.32,pan);
      }else if(propType==='pot'){
        this.noise(.12,.13,2100,'highpass',0,pan);
        this.tone(1240,.095,'sine',.035,560,0,pan);
        [.09,.19,.31].forEach((delay,i)=>this.noise(.06,.047-i*.01,1900-i*300,'bandpass',delay,pan));
      }else{
        this.noise(.1,.085,1600,'bandpass',0,pan);
        [420,670,1120].forEach((f,i)=>this.tone(f,.25,'triangle',.044,f*.85,i*.055,pan));
      }
    }
    landing(propType,pan) {
      const heavy=propType==='ac';
      this.noise(heavy?.3:.15,heavy?.15:.09,heavy?420:850,'lowpass',0,pan);
      this.tone(heavy?67:125,heavy?.47:.25,'sine',heavy?.12:.065,25,0,pan);
      if(heavy||propType==='rail'){
        [330,760,490].forEach((f,i)=>this.tone(f,.2,'triangle',.032-i*.006,f*.72,.04+i*.12,pan));
      }
    }
    bossAttack(stage,pan) {
      if(stage==='warning'){
        this.criticalCue(()=>{
          this.tone(185,.32,'sawtooth',.038,90,0,pan);
          this.tone(880,.065,'square',.06,880,0,pan);
          this.tone(1175,.11,'square',.055,1175,.105,pan);
        });
      }else if(stage==='throw'){
        this.criticalCue(()=>{
          this.sweep(.86,.11,380,3600,0,pan,0);
          this.tone(125,.31,'triangle',.062,42,.035,pan);
        });
        this.noise(.18,.027,4200,'highpass',.67,0);
      }else{
        // A close, low blast is distinct from the distant stick-bundle detonation.
        this.criticalCue(()=>{
          this.noise(1.28,.29,470,'lowpass',0,pan*.35);
          this.tone(58,1.12,'sine',.28,18,0,0);
          this.noise(.16,.145,2200,'bandpass',.008,pan);
          this.tone(34,.85,'sine',.09,18,.09,0);
          this.noise(.7,.085,110,'lowpass',.035,0);
        });
        [350,610,940].forEach((f,i)=>this.tone(f,.32,'triangle',.034-i*.006,f*.62,.24+i*.22,(i-1)*.28));
        this.noise(.38,.057,3700,'highpass',.19,-pan*.4);
        this.noise(.5,.06,370,'lowpass',.42,0);
      }
    }
    fire(pan, ignition = false, scale = 1) {
      this.noise(ignition ? .95 : .72, (ignition ? .17 : .066) * scale, ignition ? 1200 : 550, 'lowpass', 0, pan);
      if (ignition) this.tone(105, .4, 'sine', .065 * scale, 39, 0, pan);
      for (let i = 0; i < (ignition ? 5 : 3); i++) {
        const delay = .035 + i * .14 + Math.random() * .07;
        this.noise(.025 + Math.random() * .023, .034 * scale, 2600 + Math.random() * 1600, 'bandpass', delay, pan + (i % 2 ? .12 : -.12));
      }
    }
    blast(pan, large = false) {
      const previousPriority = this.critical;
      this.critical = previousPriority || large;
      try {
        // Six core voices may use reserved capacity; optional debris uses the ordinary budget.
        this.noise(large ? 1.25 : .6, large ? .3 : .23, 680, 'lowpass', 0, pan);
        this.tone(large ? 68 : 88, large ? .95 : .48, 'sine', large ? .3 : .2, 23, 0, pan);
        this.noise(.23, .17, 2300, 'bandpass', .018, pan);
        this.noise(.55, .08, 3300, 'highpass', .15, pan);
        this.noise(.55, large ? .09 : .05, 850, 'lowpass', .2, -pan * .4);
        if (large) this.tone(42, .8, 'sine', .105, 20, .18, 0);
      } finally { this.critical = previousPriority; }
      if (large) {
        this.glass('shatter', pan, .66);
        [195, 320, 510].forEach((f, i) => this.tone(f, .37, 'triangle', .053, f * .71, .36 + i * .16, pan * .6));
        this.noise(.18, .1, 1200, 'lowpass', .76, pan);
      }
    }
    collapse(stage, pan, index = 0) {
      const previousPriority=this.critical;
      this.critical=true;
      try {
        if(stage==='started'){
          // A sustained structural groan, without the rising pitch of a rocket.
          this.rumble(4.35,.105,280,0);
          this.tone(44,3.75,'sine',.078,22,0,pan*.3);
          this.tone(81,.95,'sawtooth',.03,31,.07,-pan*.3);
        }else if(stage==='impact'){
          this.noise(.85,.17,620-index*85,'lowpass',0,pan);
          this.tone(58-index*9,.9,'sine',.16-index*.018,19,0,pan*.45);
          this.noise(.14,.075,1800,'bandpass',.012,pan);
        }else{
          this.rumble(1.8,.055,155,0);
          this.tone(33,1.6,'sine',.046,16,0,0);
        }
      }finally{this.critical=previousPriority;}
      if(stage==='started'){
        this.noise(.4,.047,1350,'bandpass',.11,pan);
        this.tone(245,.52,'triangle',.032,101,.19,-pan);
      }else if(stage==='impact'){
        // Several dry stones, a loose rail and fine grit arrive after the low thud.
        [.065,.16,.29,.44].forEach((delay,i)=>{
          this.noise(.045+i*.015,.055-i*.008,1700+i*400,'highpass',delay,pan+(i%2?.16:-.16));
          if(i<2)this.tone(260+i*170,.24,'triangle',.031,100+i*90,delay+.035,-pan*.5);
        });
        this.noise(.35,.043,900,'lowpass',.36,pan*.6);
      }else{
        [0.13,.47,.9].forEach((delay,i)=>this.tone(520+i*470,.22,'sine',.018-i*.004,340+i*340,delay,(i-1)*.23));
        this.noise(.54,.018,1800,'highpass',.68,0);
      }
    }
    play(event) {
      if (event && (['start', 'case', 'rocket', 'result'].includes(event.type) || event.type === 'boss' && event.defeated || event.type === 'collapse' && event.started)) this.stopCrowd(event.type === 'start' || event.type === 'result');
      if (!this.context || this.muted || !event || this.context.state !== 'running') return;
      const pan = Number.isFinite(event.x) ? (event.x / 480 - 1) * .7 : 0;
      const type = event.type;
      if (type === 'shot') {
        if (!this.allow('shot', .045)) return;
        const weapon = event.weapon;
        if (weapon === 1 || weapon === 'shotgun') {
          this.noise(.32, .4, 1500, 'lowpass', 0, pan);
          this.tone(110, .19, 'triangle', .26, 26, 0, pan);
          this.noise(.09, .09, 5000, 'highpass', .18, pan);
        } else if (weapon === 2 || weapon === 'machine' || weapon === 'machinegun') {
          this.noise(.09, .23, 2300, 'lowpass', 0, pan);
          this.tone(160, .07, 'square', .085, 42, 0, pan);
        } else {
          this.noise(.12, .31, 2900, 'lowpass', 0, pan);
          this.tone(180, .1, 'triangle', .24, 38, 0, pan);
          this.tone(2400, .03, 'sine', .055, 700, 0, pan);
        }
      } else if (type === 'hit') {
        this.tone(270, .06, 'square', .09, 90, 0, pan);
      } else if (type === 'kill') {
        this.tone(470, .095, 'square', .06, 730, .015, pan);
        this.tone(850, .15, 'triangle', .11, 520, .065, pan);
      } else if (type === 'headshot') {
        if (!this.allow('headshot', .055)) return;
        this.tone(1580, .055, 'sine', .065, 2200, 0, pan);
        this.tone(2350, .11, 'triangle', .055, 1760, .032, pan);
        this.noise(.025, .036, 4200, 'bandpass', 0, pan);
      } else if (type === 'warningShot') {
        if (!this.allow('warningShot', .08)) return;
        this.tone(810, .04, 'triangle', .06, 430, 0, pan);
        this.tone(1220, .08, 'sine', .046, 1440, .045, pan);
      } else if (type === 'glass') {
        const stage = ['crack', 'chip', 'shatter'].includes(event.stage) ? event.stage : 'shatter';
        if (!this.allow('glass:' + stage, { crack: .048, chip: .07, shatter: .13 }[stage])) return;
        this.glass(stage, pan);
      } else if (type === 'metal') {
        const soft=!!event.soft;
        if(!this.allow('metal:'+(event.propType||'generic')+':'+(soft?'hit':'break'),soft?.065:.16))return;
        this.metal(event.propType,pan,soft);
      } else if (type === 'fall') {
        if(!this.allow('fall:'+(event.impact?'impact':'start'),.12))return;
        if(event.impact)this.landing(event.propType,pan);
        else{this.tone(370,.34,'triangle',.046,72,0,pan);this.sweep(.36,.035,1700,480,0,pan);}
      } else if (type === 'explosion') {
        if (event.dynamite || !this.allow('explosion', event.finale ? .8 : .13)) return;
        if (event.secondary) {
          this.noise(.46, .095, 720, 'lowpass', 0, pan);
          this.tone(64, .31, 'sine', .065, 27, 0, pan);
        } else this.blast(pan, !!event.finale);
      } else if (type === 'dynamite' && event.detonated) {
        if (!this.allow('dynamite', .8)) return;
        this.blast(pan, true);
      } else if(type==='bossRage'&&event.warning){
        if(this.allow('bossRage',1))this.bossAttack('warning',pan);
      } else if(type==='bossThrow'){
        if(this.allow('bossThrow',.9))this.bossAttack('throw',pan);
      } else if(type==='bossBomb'){
        if(this.allow('bossBomb',.9))this.bossAttack('impact',pan);
      } else if(type==='collapse'){
        const stage=event.started?'started':event.impact?'impact':event.settled?'settled':null;
        if(!stage)return;
        const index=Math.max(0,Math.min(2,Number.isFinite(event.index)?event.index:0));
        if(this.allow('collapse:'+stage+(stage==='impact'?index:''),stage==='started'?1.5:.45))this.collapse(stage,pan,index);
      } else if(type==='structureWarning'){
        if(!this.allow('structureWarning:'+event.level,.8))return;
        this.noise(.38,.044,620,'bandpass',0,pan);
        this.tone(142,.44,'triangle',.035,58,.04,pan);
      } else if (type === 'fire') {
        if (this.allow('fireStart', .25)) this.fire(pan, true, event.room ? 1 : .75);
      } else if (type === 'resident') {
        if (this.context.currentTime - this.lastVoice < .2) return;
        this.lastVoice = this.context.currentTime;
        this.tone(330, .11, 'sawtooth', .045, 510, 0, pan);
        this.tone(380, .2, 'triangle', .1, 150, .12, pan);
      } else if (type === 'combo') {
        this.tone(980, .08, 'square', .05, 980, 0, pan);
        this.tone(1308, .14, 'triangle', .07, 1308, .06, pan);
      } else if (type === 'unlock' || type === 'start') {
        [330, 440, 554, 660].forEach((f, i) => this.tone(f, .23, 'square', .055, f, i * .09));
      } else if (type === 'boss' && !event.defeated) {
        [82, 78, 73].forEach((f, i) => { this.tone(f, .65, 'sawtooth', .12, f * .7, i * .3); });
        this.noise(1.1, .2, 450);
      } else if (type === 'case') {
        this.noise(.1, .14, 2200, 'highpass');
        [660, 880, 1320].forEach((f, i) => this.tone(f, .26, 'sine', .1, f, i * .15));
      } else if (type === 'rocket') {
        // The separate finale event supplies the impact, so the launch cannot double it.
        this.noise(.72, .31, 1900);
        this.tone(80, .64, 'sawtooth', .11, 360);
      } else if (type === 'result') {
        [523, 659, 784, 1047, 988, 784].forEach((f, i) => this.tone(f, .28, 'triangle', .12, f, i * .14));
        this.noise(.12, .07, 4000, 'highpass', 1.1);
      } else if (type === 'steal' || type === 'theft' || type === 'penalty') {
        this.tone(270, .15, 'square', .05, 130);
        this.tone(130, .22, 'triangle', .09, 65, .13);
      } else if (type === 'select' || type === 'weapon') {
        this.noise(.04, .09, 3000, 'highpass');
        this.tone(560, .04, 'square', .025, 320);
      }
    }
    update(state) {
      this.updateCrowd(state);
      if (!this.context || this.muted || state.paused || !['playing', 'boss'].includes(state.mode)) return;
      const rooms = (state.cells || []).filter(cell => cell.roomFire?.active && cell.roomFire.stage !== 'out');
      if (rooms.length && this.context.currentTime - this.lastFire >= .65) {
        this.lastFire = this.context.currentTime;
        const cell = rooms[Math.floor(state.time * 1.53) % rooms.length];
        const pan = ((cell.x + (cell.w || 110) / 2) / 480 - 1) * .7;
        this.fire(pan, false, Math.min(1.35, .8 + rooms.length * .12));
      }
      // A quiet, lopsided afternoon groove. One step at a time; no scheduled backlog on resume.
      const beat = Math.floor(state.time / .25);
      if (beat === this.lastBeat) return;
      this.lastBeat = beat;
      const notes = state.mode === 'boss' ? [41.2, 41.2, 46.25, 43.65] : [65.41, 55, 58.27, 49];
      const root = notes[Math.floor(beat / 16) % 4];
      if (beat % 4 === 0) {
        this.tone(root, .22, 'triangle', .072, root);
        this.tone(105, .12, 'sine', .028, 40);
      }
      if (beat % 8 === 4) this.noise(.09, .017, 2800, 'highpass');
      if (beat % 2 === 1) this.noise(.023, .011, 6500, 'highpass');
      if (beat % 8 === 3) {
        [4, 5, 6].forEach((multiple, i) => this.tone(root * multiple, .15, 'sine', .015, root * multiple, i * .009));
      }
    }
  }
  D.Audio = Sound;
})(globalThis.Danchi = globalThis.Danchi || {});
