Files
zombie-game/resources/js/engine/SoundSystem.js
T
2026-09-07 20:37:40 +02:00

372 lines
12 KiB
JavaScript

export class SoundSystem {
constructor({ muted = false } = {}) {
this.muted = muted;
this.context = null;
this.master = null;
this.delaySend = null;
this.musicOn = false;
this.musicTimer = null;
this._musicStep = 0;
this._nextNoteTime = 0;
this._baseStepDur = 60 / 132 / 4;
this._stepDur = this._baseStepDur;
this._intensity = 0;
this._bossMode = false;
this._stepsPerBar = 16;
this._progression = [
{ root: 73.42, chord: [146.83, 174.61, 220] },
{ root: 58.27, chord: [116.54, 146.83, 174.61] },
{ root: 87.31, chord: [174.61, 220, 261.63] },
{ root: 65.41, chord: [130.81, 164.81, 196] },
];
this._bossProgression = [
{ root: 41.2, chord: [82.41, 98, 123.47] },
{ root: 65.41, chord: [130.81, 164.81, 196] },
{ root: 55, chord: [110, 130.81, 164.81] },
{ root: 73.42, chord: [146.83, 174.61, 220] },
];
this._arpPattern = [0, 1, 2, 1, 0, 2, 1, 2, 0, 1, 2, 1, 0, 2, 1, 2];
this._leadPattern = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[587.33, 0, 698.46, 0, 880, 0, 783.99, 0, 698.46, 0, 659.26, 0, 587.33, 0, 523.25, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 440, 0, 523.25, 0, 587.33, 0, 0, 0],
];
this._leadPatternB = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[659.26, 0, 880, 0, 987.77, 0, 880, 0, 783.99, 0, 698.46, 0, 659.26, 0, 0, 0],
[0, 0, 0, 0, 587.33, 0, 659.26, 0, 698.46, 0, 659.26, 0, 587.33, 0, 0, 0],
];
this._bassPattern = [1, 0, 1, 0, 1, 0, 1, 0];
this._barPlan = { fill: false, breakdown: false, lift: false, leadVariant: 0, bar: -1 };
const unlock = () => {
if (!this.context) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
this.context = new AudioContext();
this.master = this.context.createGain();
this.master.gain.value = this.muted ? 0 : 0.5;
this.master.connect(this.context.destination);
const delay = this.context.createDelay(0.6);
delay.delayTime.value = 0.31;
const feedback = this.context.createGain();
feedback.gain.value = 0.35;
const wet = this.context.createGain();
wet.gain.value = 0.22;
delay.connect(feedback);
feedback.connect(delay);
delay.connect(wet);
wet.connect(this.master);
this.delaySend = delay;
}
if (this.context.state === 'suspended') {
this.context.resume().catch(() => {});
}
};
window.addEventListener('keydown', unlock, { once: false });
window.addEventListener('pointerdown', unlock, { once: false });
}
startMusic() {
if (this.musicOn) return;
this.musicOn = true;
this._musicStep = 0;
this._nextNoteTime = this.context ? this.context.currentTime + 0.1 : 0;
if (this.musicTimer) clearInterval(this.musicTimer);
this.musicTimer = setInterval(() => this._scheduleMusic(), 100);
}
stopMusic() {
this.musicOn = false;
if (this.musicTimer) {
clearInterval(this.musicTimer);
this.musicTimer = null;
}
}
setIntensity(intensity) {
const clamped = Math.max(0, Math.min(1, intensity));
this._intensity = clamped;
this._stepDur = this._baseStepDur * (1 - 0.35 * clamped);
}
setBossMode(on) {
this._bossMode = on;
}
_scheduleMusic() {
if (!this.context || !this.musicOn) return;
const aheadTime = this.context.currentTime + 0.5;
const totalSteps = this._stepsPerBar * this._progression.length;
while (this._nextNoteTime < aheadTime) {
try {
this._scheduleStep(this._musicStep, this._nextNoteTime);
} catch (error) {
console.error('Music schedule error', error);
}
this._nextNoteTime += this._stepDur;
this._musicStep = (this._musicStep + 1) % totalSteps;
}
}
_rollBarPlan(bar) {
const plan = { fill: false, breakdown: false, lift: false, leadVariant: 0, bar };
if (bar === 1) {
const roll = Math.random();
if (roll < 0.2) {
plan.fill = true;
} else if (roll < 0.32) {
plan.breakdown = true;
}
} else if (bar === 2 || bar === 3) {
plan.leadVariant = Math.random() < 0.35 ? 1 : 0;
plan.lift = Math.random() < 0.25;
}
return plan;
}
_scheduleStep(step, time) {
const delay = time - this.context.currentTime;
const bar = Math.floor(step / this._stepsPerBar) % this._progression.length;
const beat = step % this._stepsPerBar;
const barDur = this._stepDur * this._stepsPerBar;
const intensity = this._intensity;
const drumGain = 1 + intensity * 0.6;
if (beat === 0 || this._barPlan.bar !== bar) {
this._barPlan = this._rollBarPlan(bar);
}
const plan = this._barPlan;
const fill = plan.fill;
const breakdown = plan.breakdown;
const boss = this._bossMode;
let { root, chord } = boss
? this._bossProgression[bar]
: this._progression[bar];
if (plan.lift && bar === 2 && !boss) {
chord = [174.61, 207.65, 261.63];
}
if (boss && beat === 0) {
this.tone(27.5, 'sine', barDur, 0.06, 27.5, delay);
}
if (beat === 0) {
for (const note of chord) {
this.tone(note, 'sawtooth', barDur, 0.02 + intensity * 0.01, note, delay);
}
}
const kickFreq = boss ? 44 : 55;
const snareFreq = boss ? 700 : 1800;
if (!fill && !breakdown) {
if (beat % 4 === 2 && this._bassPattern[Math.floor(beat / 4)]) {
this.tone(root, 'square', this._stepDur * 1.5, 0.055, root, delay);
}
if (intensity > 0.4 && beat % 2 === 0) {
this.tone(root * 2, 'square', this._stepDur * 0.9, 0.02, root * 2, delay);
}
if (beat % 4 === 0) {
this.tone(kickFreq, 'sine', 0.14, 0.1 * drumGain, Math.max(28, kickFreq - 18), delay);
}
if (beat === 4 || beat === 12) {
this.noise(0.1, 0.07 * drumGain, snareFreq, delay);
}
if (beat % 4 === 2) {
this.noise(0.03, 0.02 * drumGain, 9000, delay);
}
if (beat % 4 === 3 || beat % 4 === 1) {
this.noise(0.02, 0.012 * drumGain, 10000, delay);
}
if (beat % 4 === 1 || beat % 4 === 3) {
const arp = chord[this._arpPattern[beat]];
this.tone(arp, 'triangle', 0.1, 0.018, arp, delay, this.delaySend);
this.tone(arp * 2, 'triangle', 0.12, 0.012, arp * 2, delay);
}
} else if (fill) {
if (beat % 2 === 0) {
this.tone(55, 'sine', 0.1, 0.06, 34, delay);
}
if (beat >= 12) {
this.noise(0.06, 0.045, 1500, delay);
}
if (beat % 2 === 1) {
this.noise(0.03, 0.018, 8500, delay);
}
if (beat % 3 === 0) {
this.noise(0.05, 0.012, 400 + beat * 160, delay);
}
const index = beat >= 12 ? (beat - 12) % 3 : Math.floor(beat / 2) % 3;
const note = chord[index] * (beat >= 12 ? 4 : 2);
this.tone(note, 'triangle', 0.09, 0.02, note, delay);
} else if (breakdown) {
if (beat === 0 || beat === 8) {
this.tone(55, 'sine', 0.18, 0.08, 32, delay);
}
}
if (!fill && !breakdown) {
const lead = (plan.leadVariant === 1 ? this._leadPatternB : this._leadPattern)[bar][beat];
if (lead > 0) {
this.tone(lead, 'sawtooth', 0.22, 0.03, lead, delay, this.delaySend);
}
}
}
setMuted(muted) {
this.muted = muted;
if (this.master && this.context) {
this.master.gain.setTargetAtTime(muted ? 0 : 0.5, this.context.currentTime, 0.01);
}
}
toggleMuted() {
this.setMuted(!this.muted);
return this.muted;
}
tone(freq, type, duration, gain, glideTo = freq, delay = 0, bus = this.master) {
if (!this.context || this.muted) return;
if (!Number.isFinite(freq) || !Number.isFinite(glideTo)) return;
const { context } = this;
const start = context.currentTime + delay;
const oscillator = context.createOscillator();
const shape = context.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(Math.max(1, freq), start);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(1, glideTo), start + duration);
shape.gain.setValueAtTime(0.0001, start);
shape.gain.exponentialRampToValueAtTime(gain, start + 0.008);
shape.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(shape);
shape.connect(bus || this.master);
oscillator.start(start);
oscillator.stop(start + duration + 0.02);
}
noise(duration, gain, filterFreq, delay = 0, bus = this.master) {
if (!this.context || this.muted) return;
const { context } = this;
const start = context.currentTime + delay;
const bufferSize = Math.max(1, Math.ceil(context.sampleRate * duration));
const buffer = context.createBuffer(1, bufferSize, context.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i += 1) {
data[i] = Math.random() * 2 - 1;
}
const source = context.createBufferSource();
source.buffer = buffer;
const filter = context.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = filterFreq;
filter.Q.value = 1.2;
const shape = context.createGain();
shape.gain.setValueAtTime(0.0001, start);
shape.gain.exponentialRampToValueAtTime(gain, start + 0.005);
shape.gain.exponentialRampToValueAtTime(0.0001, start + duration);
source.connect(filter);
filter.connect(shape);
shape.connect(bus || this.master);
source.start(start);
source.stop(start + duration + 0.02);
}
shoot() {
this.tone(240, 'square', 0.1, 0.075, 85);
this.noise(0.05, 0.028, 3200);
}
hit() {
this.noise(0.12, 0.16, 1500);
this.tone(180, 'triangle', 0.1, 0.12, 120);
}
death(big = false) {
if (big) {
this.noise(0.35, 0.22, 700);
this.tone(120, 'sawtooth', 0.3, 0.2, 45);
this.tone(90, 'sine', 0.4, 0.24, 30);
return;
}
this.noise(0.2, 0.14, 500);
this.tone(140, 'triangle', 0.22, 0.16, 55);
}
damage() {
this.tone(160, 'sawtooth', 0.2, 0.18, 80);
this.noise(0.15, 0.1, 900);
}
explosion() {
this.noise(0.7, 0.3, 400);
this.tone(200, 'sawtooth', 0.6, 0.22, 30);
this.tone(60, 'sine', 0.8, 0.3, 20);
}
pickup() {
this.tone(660, 'sine', 0.12, 0.14, 660);
this.tone(990, 'sine', 0.18, 0.14, 990, 0.09);
}
stingerStart() {
this.tone(392, 'triangle', 0.12, 0.1);
this.tone(523, 'triangle', 0.12, 0.1, undefined, 0.1);
this.tone(659, 'triangle', 0.3, 0.12, undefined, 0.2);
}
bossEntrance() {
this.tone(27.5, 'sine', 1.6, 0.22, 27.5);
this.tone(110, 'sawtooth', 0.5, 0.1, 55);
this.noise(0.5, 0.12, 250);
this.tone(82.41, 'square', 0.25, 0.08, 82.41, 0.7);
this.tone(61.74, 'square', 0.4, 0.08, 61.74, 0.95);
}
playGameOverMusic() {
this.stopMusic();
this._playGameOverSeq();
}
_playGameOverSeq() {
if (!this.context) return;
const now = this.context.currentTime;
const t = (offset) => Math.max(0, offset - now);
this.tone(55, 'sine', 8, 0.05, 51, t(0));
this.tone(55, 'sine', 8, 0.03, 55, t(4));
const chords = [
[110, 164.81, 220],
[98, 146.83, 196],
[87.31, 130.81, 174.61],
[82.41, 123.47, 164.81],
];
chords.forEach((chord, i) => {
const start = 0.2 + i * 1.5;
for (const note of chord) {
this.tone(note, 'sawtooth', 1.5, 0.018, note, t(start));
}
if (i === 0 || i === 2) {
this.tone(55, 'sine', 0.14, 0.09, 32, t(start));
}
});
const melody = [880, 783.99, 698.46, 659.26, 587.33, 523.25, 493.88, 440];
melody.forEach((note, i) => {
this.tone(note, 'triangle', 0.7, 0.032, note, t(0.5 + i * 0.55), this.delaySend);
});
}
}