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:
2026-05-23 16:55:05 +07:00
parent d3f880d917
commit fd93da0a71
4 changed files with 383 additions and 55 deletions

View 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();