Sprint 1: polish — sound, pause, tutorial, touch UI
- 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
This commit is contained in:
131
src/managers/SoundManager.js
Normal file
131
src/managers/SoundManager.js
Normal file
@@ -0,0 +1,131 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user