Files
ai-qpid-pvz/game/js/systems/audio.js
T

591 lines
18 KiB
JavaScript

// Web Audio API Audio System with Voice Polyphony Limiting & SFX Throttling
(function() {
'use strict';
// Musical note frequency lookup
const N = {
D2: 73.42, E2: 82.41, F2: 87.31, G2: 98.00, A2: 110.00, Bb2: 116.54, B2: 123.47,
C3: 130.81, D3: 146.83, Eb3: 155.56, E3: 164.81, F3: 174.61, G3: 196.00, A3: 220.00, Bb3: 233.08, B3: 246.94,
C4: 261.63, Csharp4: 277.18, D4: 293.66, Eb4: 311.13, E4: 329.63, F4: 349.23, Fsharp4: 369.99, G4: 392.00, Gsharp4: 415.30, A4: 440.00, Bb4: 466.16, B4: 493.88,
C5: 523.25, Csharp5: 554.37, D5: 587.33, Eb5: 622.25, E5: 659.25, F5: 698.46, Fsharp5: 739.99, G5: 783.99, A5: 880.00, Bb5: 932.33, B5: 987.77,
C6: 1046.50
};
const THEMES = {
day: {
tempo: 230,
bassType: 'triangle',
leadType: 'sine',
bassVol: 0.10,
leadVol: 0.08,
bass: [
N.A2, null, N.E3, null, N.A2, null, N.E3, null,
N.D3, null, N.A3, null, N.E3, null, N.G3, null
],
lead: [
N.A4, null, N.C5, N.E5, null, N.D5, N.C5, N.B4,
N.A4, null, N.C5, N.A4, null, N.B4, N.C5, N.B4
]
},
night: {
tempo: 280,
bassType: 'sine',
leadType: 'triangle',
bassVol: 0.12,
leadVol: 0.07,
bass: [
N.C3, null, N.G3, null, N.A2, null, N.E3, null,
N.F3, null, N.C4, null, N.G3, null, N.B3, null
],
lead: [
N.C5, null, N.Eb5, N.G5, null, N.F5, null, N.D5,
N.Eb5, null, N.C5, null, N.G4, null, N.Bb4, null
]
},
pool: {
tempo: 210,
bassType: 'triangle',
leadType: 'sine',
bassVol: 0.11,
leadVol: 0.09,
bass: [
N.G2, null, N.D3, N.G3, N.C3, null, N.G3, N.C4,
N.D3, null, N.A3, N.D4, N.G3, null, N.B3, null
],
lead: [
N.B4, N.D5, null, N.G5, N.Fsharp5, N.D5, null, N.B4,
N.C5, N.E5, null, N.A5, N.G5, N.E5, N.D5, null
]
},
fog: {
tempo: 310,
bassType: 'sawtooth',
leadType: 'triangle',
bassVol: 0.07,
leadVol: 0.07,
bass: [
N.D2, null, N.A2, null, N.D2, null, N.Bb2, null,
N.A2, null, N.F2, null, N.G2, null, N.A2, null
],
lead: [
N.D5, null, null, N.F5, null, N.E5, null, N.Csharp5,
N.D5, null, N.A4, null, null, N.F5, N.E5, null
]
},
roof: {
tempo: 190,
bassType: 'triangle',
leadType: 'sawtooth',
bassVol: 0.11,
leadVol: 0.06,
bass: [
N.F3, N.F3, N.C4, N.F3, N.Bb3, N.Bb3, N.F4, N.Bb3,
N.C3, N.C3, N.G3, N.C4, N.F3, null, N.C4, null
],
lead: [
N.F4, N.A4, N.C5, null, N.D5, N.F5, null, N.C5,
N.Bb4, N.D5, N.F5, null, N.A5, N.G5, N.F5, null
]
},
boss: {
tempo: 165,
bassType: 'sawtooth',
leadType: 'square',
bassVol: 0.09,
leadVol: 0.06,
bass: [
N.E2, N.E2, N.G2, N.E2, N.Bb2, N.A2, N.G2, N.E2,
N.E2, N.E2, N.B2, N.Bb2, N.A2, N.G2, N.E2, N.D3
],
lead: [
N.E5, N.E5, N.G5, null, N.Bb5, N.A5, N.G5, N.E5,
null, N.B5, N.Bb5, N.A5, null, N.G5, N.F5, N.E5
]
}
};
class AudioSystem {
constructor() {
this.ctx = null;
this.muted = false;
// Music State
this.musicInterval = null;
this.musicPlaying = false;
this.musicStep = 0;
this.currentThemeName = 'day';
// Polyphony & Voice Limiting State
this.maxGlobalVoices = 24;
this.activeVoiceCount = 0;
this.activeVoicesPerKey = new Map();
this.lastPlayTime = new Map();
// Per-sound configuration (polyphony cap & throttle cooldown ms)
this.sfxLimits = {
shoot: { maxVoices: 4, minIntervalMs: 40 },
hit: { maxVoices: 4, minIntervalMs: 35 },
splat: { maxVoices: 4, minIntervalMs: 35 },
chomp: { maxVoices: 3, minIntervalMs: 65 },
coin: { maxVoices: 4, minIntervalMs: 35 },
sun: { maxVoices: 3, minIntervalMs: 50 },
plant: { maxVoices: 3, minIntervalMs: 40 },
explosion: { maxVoices: 3, minIntervalMs: 80 },
sizzle: { maxVoices: 2, minIntervalMs: 100 },
mower: { maxVoices: 2, minIntervalMs: 150 },
strike: { maxVoices: 3, minIntervalMs: 50 },
btn: { maxVoices: 2, minIntervalMs: 30 }
};
}
init() {
if (!this.ctx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx) {
this.ctx = new AudioCtx();
}
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume().catch(() => {});
}
}
toggleMute() {
this.muted = !this.muted;
return this.muted;
}
isMuted() {
return this.muted;
}
// Check voice limits and throttling
canPlayVoice(key) {
if (this.muted) return false;
this.init();
if (!this.ctx) return false;
const now = performance.now();
const cfg = this.sfxLimits[key] || { maxVoices: 4, minIntervalMs: 40 };
// 1. Throttle check
const last = this.lastPlayTime.get(key) || 0;
if (now - last < cfg.minIntervalMs) {
return false;
}
// 2. Global polyphony limit
if (this.activeVoiceCount >= this.maxGlobalVoices) {
return false;
}
// 3. Per-sound polyphony limit
const currentActive = this.activeVoicesPerKey.get(key) || 0;
if (currentActive >= cfg.maxVoices) {
return false;
}
// Register timestamp
this.lastPlayTime.set(key, now);
return true;
}
registerVoice(key, durationSec) {
this.activeVoiceCount++;
const current = this.activeVoicesPerKey.get(key) || 0;
this.activeVoicesPerKey.set(key, current + 1);
const release = () => {
this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1);
const cur = this.activeVoicesPerKey.get(key) || 1;
this.activeVoicesPerKey.set(key, Math.max(0, cur - 1));
};
setTimeout(release, Math.ceil(durationSec * 1000) + 20);
}
playTone(freq, type, duration, startVol = 0.2, endVol = 0.001) {
if (this.muted) return;
this.init();
if (!this.ctx) return;
try {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(startVol, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(endVol, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + duration);
} catch (e) {}
}
playShoot() {
if (!this.canPlayVoice('shoot')) return;
try {
const ctx = this.ctx;
const dur = 0.11;
this.registerVoice('shoot', dur);
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(620, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(120, ctx.currentTime + dur);
gain.gain.setValueAtTime(0.24, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + dur);
} catch (e) {}
}
playHit() {
if (!this.canPlayVoice('hit')) return;
try {
const ctx = this.ctx;
const dur = 0.08;
this.registerVoice('hit', dur);
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(180, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(60, ctx.currentTime + dur);
gain.gain.setValueAtTime(0.2, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + dur);
} catch (e) {}
}
playSplat() {
if (!this.canPlayVoice('splat')) return;
try {
const ctx = this.ctx;
const dur = 0.09;
this.registerVoice('splat', dur);
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(280, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(70, ctx.currentTime + dur);
gain.gain.setValueAtTime(0.22, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + dur);
} catch (e) {}
}
playSun() {
if (!this.canPlayVoice('sun')) return;
try {
const ctx = this.ctx;
this.registerVoice('sun', 0.35);
const notes = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6
notes.forEach((freq, idx) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, ctx.currentTime + idx * 0.05);
gain.gain.setValueAtTime(0.18, ctx.currentTime + idx * 0.05);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + idx * 0.05 + 0.2);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime + idx * 0.05);
osc.stop(ctx.currentTime + idx * 0.05 + 0.2);
});
} catch (e) {}
}
playPlant() {
if (!this.canPlayVoice('plant')) return;
try {
const ctx = this.ctx;
const dur = 0.15;
this.registerVoice('plant', dur);
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(220, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.1);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + dur);
} catch (e) {}
}
playChomp() {
if (!this.canPlayVoice('chomp')) return;
try {
const ctx = this.ctx;
const dur = 0.1;
this.registerVoice('chomp', dur);
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(140, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(80, ctx.currentTime + dur);
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + dur);
} catch (e) {}
}
playExplosion() {
if (!this.canPlayVoice('explosion')) return;
try {
const ctx = this.ctx;
const dur = 0.5;
this.registerVoice('explosion', dur);
const bufferSize = Math.floor(ctx.sampleRate * dur);
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(800, ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(50, ctx.currentTime + dur);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.5, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
noise.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
noise.start();
} catch (e) {}
}
playMower() {
if (!this.canPlayVoice('mower')) return;
try {
const ctx = this.ctx;
const dur = 1.2;
this.registerVoice('mower', dur);
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(90, ctx.currentTime);
osc.frequency.linearRampToValueAtTime(240, ctx.currentTime + dur);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + dur);
} catch (e) {}
}
playCoin() {
if (!this.canPlayVoice('coin')) return;
try {
const ctx = this.ctx;
const dur = 0.3;
this.registerVoice('coin', dur);
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(987.77, ctx.currentTime); // B5
osc.frequency.setValueAtTime(1318.51, ctx.currentTime + 0.06); // E6
gain.gain.setValueAtTime(0.2, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + dur);
} catch (e) {}
}
playSizzle() {
if (!this.canPlayVoice('sizzle')) return;
try {
const ctx = this.ctx;
const dur = 0.8;
this.registerVoice('sizzle', dur);
const bufferSize = Math.floor(ctx.sampleRate * dur);
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1400, ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + dur);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.4, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
noise.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
noise.start();
} catch (e) {}
}
playMine() {
this.playExplosion();
this.playTone(550, 'sine', 0.15, 0.3, 0.01);
}
playVault() {
if (this.muted) return;
this.init();
if (!this.ctx) return;
try {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(180, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(620, this.ctx.currentTime + 0.3);
gain.gain.setValueAtTime(0.25, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.3);
} catch (e) {}
}
playGulp() {
this.playTone(180, 'sine', 0.18, 0.35, 0.01);
setTimeout(() => this.playTone(120, 'sine', 0.22, 0.3, 0.01), 90);
}
playStrike() {
if (!this.canPlayVoice('strike')) return;
this.registerVoice('strike', 0.2);
this.playTone(240, 'square', 0.1, 0.3, 0.01);
this.playTone(120, 'sawtooth', 0.2, 0.35, 0.01);
}
playHugeWave() {
this.playTone(180, 'sawtooth', 0.6, 0.35, 0.01);
}
playButtonClick() {
if (!this.canPlayVoice('btn')) return;
this.registerVoice('btn', 0.06);
this.playTone(480, 'sine', 0.05, 0.18, 0.01);
}
playWin() {
if (this.muted) return;
this.init();
if (!this.ctx) return;
const fanfare = [392, 523, 659, 784, 1046];
fanfare.forEach((f, i) => {
setTimeout(() => this.playTone(f, 'triangle', 0.35, 0.3, 0.01), i * 160);
});
}
playLose() {
if (this.muted) return;
this.init();
if (!this.ctx) return;
const doom = [400, 360, 320, 280, 220];
doom.forEach((f, i) => {
setTimeout(() => this.playTone(f, 'sawtooth', 0.4, 0.3, 0.01), i * 200);
});
}
// Background Music (Multi-Biome Procedural Chiptune Synthesizer)
startMusic(themeName = 'day') {
const targetTheme = THEMES[themeName] ? themeName : 'day';
if (this.musicPlaying && this.currentThemeName === targetTheme && this.musicInterval) {
return;
}
this.currentThemeName = targetTheme;
this.musicPlaying = true;
this.init();
if (this.musicInterval) {
clearInterval(this.musicInterval);
this.musicInterval = null;
}
this.musicStep = 0;
const theme = THEMES[this.currentThemeName];
this.musicInterval = setInterval(() => {
if (!this.musicPlaying || this.muted) return;
const b = theme.bass[this.musicStep % theme.bass.length];
const l = theme.lead[this.musicStep % theme.lead.length];
if (b) {
this.playTone(b, theme.bassType, (theme.tempo / 1000) * 1.05, theme.bassVol, 0.001);
}
if (l) {
this.playTone(l, theme.leadType, (theme.tempo / 1000) * 0.95, theme.leadVol, 0.001);
}
this.musicStep++;
}, theme.tempo);
}
setTheme(themeName) {
if (this.musicPlaying) {
this.startMusic(themeName);
} else {
this.currentThemeName = THEMES[themeName] ? themeName : 'day';
}
}
getTheme() {
return this.currentThemeName;
}
stopMusic() {
this.musicPlaying = false;
if (this.musicInterval) {
clearInterval(this.musicInterval);
this.musicInterval = null;
}
}
toggleMusic() {
if (this.musicPlaying) {
this.stopMusic();
return false;
} else {
this.startMusic(this.currentThemeName);
return true;
}
}
isMusicPlaying() {
return this.musicPlaying;
}
}
// Instantiate singleton instance
const audioInstance = new AudioSystem();
window.AudioSystem = AudioSystem;
window.PvZAudio = audioInstance;
})();