- SoundManager via Web Audio API (no asset files needed) with procedural SFX for jump, spring, powerup, stomp, break, death, milestone, new-best - Sound persists mute state in localStorage; mute button on Menu and Game - Pause system: ESC key or onscreen pause button, modal overlay with Resume and Main Menu options, physics correctly paused/resumed - First-run tutorial overlay explaining controls and platform types, dismissed and remembered via localStorage flag - Touch indicator hints fade after 3.5s on touch devices only - Menu start triggers AudioContext initialization (browser autoplay rules) - GameOverScene supports ENTER/SPACE shortcut for retry, NEW BEST text now pulses, sounds fire on each transition
132 lines
3.6 KiB
JavaScript
132 lines
3.6 KiB
JavaScript
class SoundManager {
|
|
constructor() {
|
|
this.ctx = null;
|
|
this.master = null;
|
|
this.muted = false;
|
|
this.musicNodes = null;
|
|
|
|
const raw = localStorage.getItem('naddie_muted');
|
|
this.muted = raw === '1';
|
|
}
|
|
|
|
init() {
|
|
if (this.ctx) return;
|
|
const AC = window.AudioContext || window.webkitAudioContext;
|
|
if (!AC) return;
|
|
this.ctx = new AC();
|
|
this.master = this.ctx.createGain();
|
|
this.master.gain.value = 0.4;
|
|
this.master.connect(this.ctx.destination);
|
|
}
|
|
|
|
resume() {
|
|
if (this.ctx && this.ctx.state === 'suspended') {
|
|
this.ctx.resume();
|
|
}
|
|
}
|
|
|
|
setMuted(value) {
|
|
this.muted = value;
|
|
localStorage.setItem('naddie_muted', value ? '1' : '0');
|
|
if (this.master) {
|
|
this.master.gain.value = value ? 0 : 0.4;
|
|
}
|
|
}
|
|
|
|
isMuted() {
|
|
return this.muted;
|
|
}
|
|
|
|
_beep(freq, duration, type = 'square', volume = 0.3) {
|
|
if (!this.ctx || this.muted) return;
|
|
const osc = this.ctx.createOscillator();
|
|
const gain = this.ctx.createGain();
|
|
osc.type = type;
|
|
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
|
|
gain.gain.setValueAtTime(volume, this.ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
|
|
osc.connect(gain);
|
|
gain.connect(this.master);
|
|
osc.start();
|
|
osc.stop(this.ctx.currentTime + duration);
|
|
}
|
|
|
|
_sweep(freqStart, freqEnd, duration, type = 'square', volume = 0.3) {
|
|
if (!this.ctx || this.muted) return;
|
|
const osc = this.ctx.createOscillator();
|
|
const gain = this.ctx.createGain();
|
|
osc.type = type;
|
|
osc.frequency.setValueAtTime(freqStart, this.ctx.currentTime);
|
|
osc.frequency.exponentialRampToValueAtTime(Math.max(1, freqEnd), this.ctx.currentTime + duration);
|
|
gain.gain.setValueAtTime(volume, this.ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
|
|
osc.connect(gain);
|
|
gain.connect(this.master);
|
|
osc.start();
|
|
osc.stop(this.ctx.currentTime + duration);
|
|
}
|
|
|
|
_noise(duration, volume = 0.15) {
|
|
if (!this.ctx || this.muted) return;
|
|
const bufferSize = this.ctx.sampleRate * duration;
|
|
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
|
const data = buffer.getChannelData(0);
|
|
for (let i = 0; i < bufferSize; i++) {
|
|
data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize);
|
|
}
|
|
const src = this.ctx.createBufferSource();
|
|
src.buffer = buffer;
|
|
const gain = this.ctx.createGain();
|
|
gain.gain.value = volume;
|
|
src.connect(gain);
|
|
gain.connect(this.master);
|
|
src.start();
|
|
}
|
|
|
|
jump() {
|
|
this._sweep(320, 540, 0.1, 'square', 0.14);
|
|
}
|
|
|
|
land() {
|
|
this._beep(180, 0.06, 'triangle', 0.10);
|
|
}
|
|
|
|
spring() {
|
|
this._sweep(280, 1000, 0.18, 'square', 0.22);
|
|
setTimeout(() => this._sweep(800, 1300, 0.12, 'triangle', 0.15), 50);
|
|
}
|
|
|
|
powerup() {
|
|
[440, 554, 659, 880].forEach((f, i) => setTimeout(() => this._beep(f, 0.13, 'triangle', 0.20), i * 55));
|
|
}
|
|
|
|
stomp() {
|
|
this._sweep(420, 100, 0.16, 'sawtooth', 0.22);
|
|
this._noise(0.08, 0.10);
|
|
}
|
|
|
|
break() {
|
|
this._noise(0.15, 0.12);
|
|
this._sweep(200, 60, 0.2, 'sawtooth', 0.10);
|
|
}
|
|
|
|
death() {
|
|
this._sweep(280, 50, 0.5, 'sawtooth', 0.28);
|
|
setTimeout(() => this._noise(0.3, 0.15), 100);
|
|
}
|
|
|
|
milestone() {
|
|
[523, 659, 783, 1046].forEach((f, i) => setTimeout(() => this._beep(f, 0.22, 'triangle', 0.25), i * 90));
|
|
}
|
|
|
|
click() {
|
|
this._beep(660, 0.05, 'square', 0.12);
|
|
}
|
|
|
|
newBest() {
|
|
[523, 659, 783, 1046, 1318].forEach((f, i) => setTimeout(() => this._beep(f, 0.18, 'triangle', 0.25), i * 80));
|
|
}
|
|
}
|
|
|
|
export const sound = new SoundManager();
|