feat(audio): add procedural BGM and SFX using Web Audio API

Add complete audio system with zero npm dependencies:
- Gameplay BGM (140 BPM workout beat, 6 layers, anti-repetition)
- Game Over BGM (70 BPM somber theme)
- 7 SFX: catch clank, miss thud, flex grunt, powerup chime,
  combo arpeggio, streak fanfare, entrance slam
- AudioBridge wires EventBus events to audio playback
- Mute button UI (bottom-right, M key shortcut, localStorage persist)
- AudioContext created on first user interaction (autoplay policy)
- All audio non-blocking with try/catch fallbacks

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
rshtirmer
2026-03-02 14:25:53 -05:00
parent f49458bcd2
commit 7f2ea28eab
9 changed files with 1172 additions and 2 deletions
+56 -1
View File
@@ -40,7 +40,7 @@ GigaChad Gym Simulator - endless gym workout simulator where GigaChad catches fa
- [x] Step 1.5: Replace primitives with Meshy AI GLB models
- [x] Step 2: Visual design — particles, transitions, screen effects, juice
- [ ] Step 4: Record promo video
- [ ] Step 5: Add BGM (gym beats) + SFX (catch clank, miss thud, powerup chime, flex grunt)
- [x] Step 3: Add BGM (gym beats) + SFX (catch clank, miss thud, powerup chime, flex grunt)
- [ ] Step 6: Deploy to here.now
- [ ] Step 7: Monetize with Play.fun
@@ -114,3 +114,58 @@ Three new effect systems under `src/effects/`, plus Constants and Game.js integr
- Hit freeze skips gameplay updates but continues rendering particles/effects for visual continuity
- Screen shake is managed entirely by ScreenEffects (old manual shake removed from Game.js)
- All magic numbers in Constants.js under the EFFECTS object
## Step 3: Audio (Complete)
### What was added
Full procedural audio system using Web Audio API — zero external audio files or npm packages. Background music (step sequencer) and 7 one-shot sound effects, all synthesized with OscillatorNode, GainNode, and BiquadFilterNode.
### New files
- **`src/audio/AudioManager.js`** — Singleton that owns the AudioContext and master GainNode. Creates/resumes AudioContext on first user interaction (browser autoplay policy). Provides `playMusic(patternFn)`, `stopMusic()`, and `setMuted(bool)`. All audio routes through a single master GainNode for instant global mute.
- **`src/audio/music.js`** — Two BGM patterns using a Web Audio API step sequencer:
- **Gameplay BGM** (140 BPM): Energetic gym/workout beat with 6 layers — 4-on-the-floor kick, offbeat hi-hats, deep sawtooth bass (E minor), power riff melody with 3 phrase variations (A/B/C cycled across 8 phrases = 128 steps before melody repeat), synth stab accents, and high arp texture. Anti-repetition via: different layer lengths (16/12/14/128/8/10 steps), random note omission (12%), and 3 melody phrase variations.
- **Game Over BGM** (70 BPM): Somber minor-key piece with descending triangle melody, sustained A minor pad (root + fifth), and deep bass drone. Chord changes between Am and Em.
- **`src/audio/sfx.js`** — 7 one-shot sound effects, all procedural:
- **catch**: Metallic clank — bandpass-filtered square wave with pitch drop (1200->400 Hz), sine overtone ring at 3200 Hz, and noise transient click
- **miss**: Heavy thud — sine with deep pitch drop (120->30 Hz), low-passed at 200 Hz, plus noise rumble and sub-bass impact at 50 Hz
- **flex**: Power grunt/growl — sawtooth with filter sweep (300->800->200 Hz), plus noise burst for breath/exertion
- **powerup**: Ascending chime — 5 sine notes (C5->E5->G5->C6->E6) spaced 50ms apart, plus high shimmer overtone
- **combo**: Quick ascending arpeggio (E4->G4->B4) with pitch scaling based on combo count (1.0x to 2.0x), filter opens wider with higher combos
- **streak**: Epic fanfare — 4-note sawtooth chord (G3+C4+E4+G4) with filter sweep up, plus rising square accent
- **entrance**: Dramatic slam — deep sine boom with pitch drop (200->25 Hz), sub rumble at 40 Hz, noise impact transient, and metallic ring at 1600 Hz
- **`src/audio/AudioBridge.js`** — Wires EventBus events to audio playback. Initializes AudioContext on first user interaction (click/touchstart/keydown). Handles BGM transitions (gameplay/game over/restart), all 7 SFX triggers, and mute toggle with localStorage persistence. Unmuting resumes appropriate BGM based on game state.
- **`src/ui/MuteButton.js`** — HTML/Canvas-based mute toggle button. Speaker icon drawn on a `<canvas>` element at 2x resolution for retina. Positioned bottom-right above Play.fun safe zone. Shows sound waves when unmuted, red X when muted. Click/touch handler + M key shortcut.
### Modified files
- **`src/core/EventBus.js`** — Added `AUDIO_TOGGLE_MUTE: 'audio:toggleMute'` event (now 20 events total)
- **`src/core/GameState.js`** — `isMuted` now initializes from `localStorage.getItem('muted')` for persistence across sessions
- **`src/main.js`** — Imports and initializes `initAudioBridge()` and `MuteButton` before game creation. Both wrapped in try/catch for non-blocking fallback.
### Event-to-audio mapping
| Event | Audio Response |
|-------|---------------|
| `GAME_START` | Ensure AudioContext initialized |
| `MUSIC_GAMEPLAY` | Play gameplay BGM (140 BPM workout beat) |
| `GAME_OVER` | Stop BGM, play game over BGM (70 BPM somber) |
| `GAME_RESTART` | Stop all music (GAME_START/MUSIC_GAMEPLAY restarts it) |
| `WEIGHT_CAUGHT` | Metallic clank SFX |
| `WEIGHT_MISSED` | Heavy thud SFX |
| `PLAYER_FLEX` | Power grunt/growl SFX |
| `POWERUP_COLLECTED` | Ascending chime SFX |
| `SPECTACLE_COMBO` | Ascending arpeggio SFX (pitch scales with combo) |
| `SPECTACLE_STREAK` | Epic fanfare blast SFX |
| `SPECTACLE_ENTRANCE` | Dramatic slam/impact SFX |
| `AUDIO_TOGGLE_MUTE` | Toggle mute, persist to localStorage, stop/resume BGM |
### Design decisions
- Zero npm packages — all Web Audio API (OscillatorNode, GainNode, BiquadFilterNode)
- Audio is completely non-blocking — if AudioContext fails to create, game works without sound
- All SFX and BGM functions wrapped in try/catch
- AudioContext created on first user interaction (click/touch/key) per browser autoplay policy
- Master GainNode controls global volume; `gain.value = 0` for instant mute of all audio
- Mute state persisted to localStorage and restored on page load
- BGM uses look-ahead step sequencer (schedules 100ms ahead, checks every 25ms) for sample-accurate timing
- Different layer lengths in gameplay BGM create polyrhythmic variation (LCM of 16/12/14/128/8/10 = very long before exact repeat)
- Random note omission (12% chance on quiet layers) adds organic feel
- Combo SFX pitch scales with combo count for escalating feedback
- M key shortcut for quick mute toggle during gameplay
@@ -0,0 +1,163 @@
// =============================================================================
// AudioBridge.js — Wires EventBus events to audio playback
// Listens for game events and triggers appropriate BGM/SFX.
// AudioContext is initialized on first user interaction (autoplay policy).
// All calls wrapped in try/catch — audio failures never break gameplay.
// =============================================================================
import { eventBus, Events } from '../core/EventBus.js';
import { gameState } from '../core/GameState.js';
import { audioManager } from './AudioManager.js';
import { gameplayBGM, gameOverBGM } from './music.js';
import {
catchSfx,
missSfx,
flexSfx,
powerupSfx,
comboSfx,
streakSfx,
entranceSfx,
} from './sfx.js';
/**
* Initialize the audio bridge. Call once from main.js.
* Sets up all EventBus listeners for audio playback.
*/
export function initAudioBridge() {
// --- AudioContext init on first user interaction ---
let audioInitialized = false;
function ensureAudioInit() {
if (audioInitialized) return;
audioInitialized = true;
try {
audioManager.init();
// Restore mute state from localStorage
try {
const savedMute = localStorage.getItem('muted');
if (savedMute === 'true') {
gameState.isMuted = true;
audioManager.setMuted(true);
}
} catch (_) {}
} catch (e) {
console.warn('[AudioBridge] Failed to init AudioContext:', e);
}
}
// Init on any user interaction (click, tap, key)
const interactionEvents = ['click', 'touchstart', 'keydown'];
function handleFirstInteraction() {
ensureAudioInit();
// Remove listeners after first interaction
interactionEvents.forEach(evt => {
window.removeEventListener(evt, handleFirstInteraction, { capture: true });
});
}
interactionEvents.forEach(evt => {
window.addEventListener(evt, handleFirstInteraction, { capture: true, once: true });
});
// Also init on explicit AUDIO_INIT event
eventBus.on(Events.AUDIO_INIT, () => ensureAudioInit());
// --- BGM transitions ---
// GAME_START ensures AudioContext is ready (MUSIC_GAMEPLAY handles actual BGM)
eventBus.on(Events.GAME_START, () => {
try { ensureAudioInit(); } catch (_) {}
});
eventBus.on(Events.MUSIC_GAMEPLAY, () => {
try {
ensureAudioInit();
if (!gameState.isMuted) {
audioManager.playMusic(gameplayBGM);
}
} catch (_) {}
});
eventBus.on(Events.GAME_OVER, () => {
try {
audioManager.stopMusic();
if (!gameState.isMuted) {
audioManager.playMusic(gameOverBGM);
}
} catch (_) {}
});
eventBus.on(Events.MUSIC_GAMEOVER, () => {
try {
if (!gameState.isMuted) {
audioManager.playMusic(gameOverBGM);
}
} catch (_) {}
});
eventBus.on(Events.GAME_RESTART, () => {
try {
audioManager.stopMusic();
// Gameplay BGM will start via GAME_START event from startGame()
} catch (_) {}
});
eventBus.on(Events.MUSIC_STOP, () => {
try {
audioManager.stopMusic();
} catch (_) {}
});
// --- SFX (one-shot) ---
eventBus.on(Events.WEIGHT_CAUGHT, () => {
try { catchSfx(); } catch (_) {}
});
eventBus.on(Events.WEIGHT_MISSED, () => {
try { missSfx(); } catch (_) {}
});
eventBus.on(Events.PLAYER_FLEX, () => {
try { flexSfx(); } catch (_) {}
});
eventBus.on(Events.POWERUP_COLLECTED, () => {
try { powerupSfx(); } catch (_) {}
});
eventBus.on(Events.SPECTACLE_COMBO, (data) => {
try {
const combo = data?.combo || 1;
comboSfx(combo);
} catch (_) {}
});
eventBus.on(Events.SPECTACLE_STREAK, () => {
try { streakSfx(); } catch (_) {}
});
eventBus.on(Events.SPECTACLE_ENTRANCE, () => {
try { entranceSfx(); } catch (_) {}
});
// --- Mute toggle ---
eventBus.on(Events.AUDIO_TOGGLE_MUTE, () => {
try {
ensureAudioInit();
gameState.isMuted = !gameState.isMuted;
try { localStorage.setItem('muted', gameState.isMuted); } catch (_) {}
audioManager.setMuted(gameState.isMuted);
if (gameState.isMuted) {
audioManager.stopMusic();
} else {
// Resume BGM if game is active
if (gameState.started && !gameState.gameOver) {
audioManager.playMusic(gameplayBGM);
} else if (gameState.gameOver) {
audioManager.playMusic(gameOverBGM);
}
}
} catch (_) {}
});
}
@@ -0,0 +1,92 @@
// =============================================================================
// AudioManager.js — Web Audio API singleton
// Owns AudioContext, master GainNode, and BGM step sequencer.
// AudioContext is created/resumed on first user interaction (autoplay policy).
// All audio routes through masterGain for global mute control.
// =============================================================================
class AudioManager {
constructor() {
this.ctx = null;
this.currentBgm = null; // { stop() }
this.masterGain = null;
}
/**
* Create or resume AudioContext. Safe to call multiple times.
* Must be triggered from a user interaction (click/tap/keypress).
*/
init() {
try {
if (this.ctx) {
// Resume if suspended (e.g. after tab switch)
if (this.ctx.state === 'suspended') {
this.ctx.resume().catch(() => {});
}
return;
}
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.ctx.createGain();
this.masterGain.connect(this.ctx.destination);
} catch (e) {
console.warn('[AudioManager] Failed to create AudioContext:', e);
}
}
/**
* Get AudioContext (creates if needed).
* @returns {AudioContext|null}
*/
getCtx() {
if (!this.ctx) this.init();
return this.ctx;
}
/**
* Get master GainNode (creates if needed).
* @returns {GainNode|null}
*/
getMaster() {
if (!this.masterGain) this.init();
return this.masterGain;
}
/**
* Play a BGM pattern function. Stops any currently playing BGM first.
* @param {Function} patternFn - (ctx, dest) => { stop() }
*/
playMusic(patternFn) {
this.stopMusic();
try {
const ctx = this.getCtx();
const master = this.getMaster();
if (!ctx || !master) return;
this.currentBgm = patternFn(ctx, master);
} catch (e) {
console.warn('[AudioManager] BGM error:', e);
}
}
/**
* Stop current BGM.
*/
stopMusic() {
if (this.currentBgm) {
try { this.currentBgm.stop(); } catch (_) {}
this.currentBgm = null;
}
}
/**
* Set muted state via master gain.
* @param {boolean} muted
*/
setMuted(muted) {
if (this.masterGain) {
this.masterGain.gain.value = muted ? 0 : 1;
}
}
}
export const audioManager = new AudioManager();
+242
View File
@@ -0,0 +1,242 @@
// =============================================================================
// music.js — BGM patterns using Web Audio API step sequencer
// Gameplay: Energetic gym/workout beat — 140 BPM, deep bass, driving rhythm
// Game Over: Slower, somber — 70 BPM
// Uses anti-repetition: varied layer lengths, multiple phrases, random omission
// =============================================================================
const NOTES = {
// Octave 2 (sub bass)
C2: 65.41, D2: 73.42, E2: 82.41, F2: 87.31, G2: 98.00, A2: 110.00, B2: 123.47,
// Octave 3
C3: 130.81, D3: 146.83, E3: 164.81, F3: 174.61, G3: 196.00, A3: 220.00, B3: 246.94,
// Octave 4
C4: 261.63, D4: 293.66, E4: 329.63, F4: 349.23, G4: 392.00, A4: 440.00, B4: 493.88,
// Octave 5
C5: 523.25, D5: 587.33, E5: 659.25, G5: 783.99, A5: 880.00,
R: 0, // rest
};
/**
* Step sequencer — schedules notes in a loop using Web Audio API.
* Returns { stop() } to cancel the loop.
*
* @param {AudioContext} ctx
* @param {GainNode} dest - destination node (master gain)
* @param {Array<Array<{freq, type, gain, duration, lpf, freqEnd}>>} layers
* @param {number} bpm - beats per minute
* @param {number} stepsPerBeat - subdivisions per beat (default 2 = eighth notes)
*/
function sequencer(ctx, dest, layers, bpm, stepsPerBeat = 2) {
const stepDuration = 60 / bpm / stepsPerBeat;
let nextStepTime = ctx.currentTime + 0.05;
let stepIndex = 0;
let stopped = false;
let timerId = null;
function scheduleStep() {
if (stopped) return;
while (nextStepTime < ctx.currentTime + 0.1) {
for (const layer of layers) {
const note = layer[stepIndex % layer.length];
if (note && note.freq > 0) {
// Random note omission for organic variation (skip ~12% of non-bass notes)
if (note.gain < 0.18 && Math.random() > 0.88) continue;
const osc = ctx.createOscillator();
osc.type = note.type || 'square';
osc.frequency.setValueAtTime(note.freq, nextStepTime);
if (note.freqEnd) {
osc.frequency.exponentialRampToValueAtTime(
note.freqEnd,
nextStepTime + (note.duration || stepDuration)
);
}
const g = ctx.createGain();
const noteGain = note.gain ?? 0.15;
g.gain.setValueAtTime(noteGain, nextStepTime);
g.gain.exponentialRampToValueAtTime(
0.001,
nextStepTime + (note.duration || stepDuration * 0.9)
);
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(note.lpf || 3000, nextStepTime);
osc.connect(f).connect(g).connect(dest);
osc.start(nextStepTime);
osc.stop(nextStepTime + (note.duration || stepDuration));
}
}
stepIndex++;
nextStepTime += stepDuration;
}
timerId = setTimeout(scheduleStep, 25);
}
scheduleStep();
return { stop() { stopped = true; clearTimeout(timerId); } };
}
/**
* Helper: convert a string pattern into note objects.
*/
function parsePattern(str, type = 'square', gain = 0.15, lpf = 3000, duration = null) {
return str.split(' ').map(n => {
if (n === 'R' || n === '~') return { freq: 0 };
return { freq: NOTES[n] || 0, type, gain, lpf, duration };
});
}
/**
* Helper: create a kick drum note (sine with pitch drop).
*/
function kick(gain = 0.25) {
return { freq: 150, freqEnd: 40, type: 'sine', gain, lpf: 300, duration: 0.12 };
}
/**
* Helper: create a hi-hat-like noise note (high-freq triangle).
*/
function hihat(gain = 0.06) {
return { freq: 8000, type: 'square', gain, lpf: 12000, duration: 0.03 };
}
/**
* Helper: rest note.
*/
function rest() {
return { freq: 0 };
}
// =============================================================================
// GAMEPLAY BGM — Energetic gym/workout beat, 140 BPM
// Deep bass, driving rhythm, pump-up workout energy
// Multiple phrase variations for anti-repetition
// =============================================================================
export function gameplayBGM(ctx, dest) {
// --- Kick pattern (4 on the floor with extra hits) ---
// 16 steps = 2 bars at 140 BPM (eighth notes)
const kickLayer = [
kick(0.28), rest(), rest(), rest(),
kick(0.28), rest(), rest(), kick(0.18),
kick(0.28), rest(), rest(), rest(),
kick(0.28), rest(), kick(0.15), rest(),
];
// --- Hi-hat pattern (offbeat emphasis) --- 12 steps for polyrhythm
const hihatLayer = [
rest(), hihat(0.05), hihat(0.07), hihat(0.05),
rest(), hihat(0.05), hihat(0.07), hihat(0.05),
rest(), hihat(0.05), hihat(0.07), hihat(0.05),
];
// --- Deep bass line (driving sub) --- 16 steps
const bassA = parsePattern(
'E2 R E2 R E2 R G2 R A2 R A2 R G2 R E2 R',
'sawtooth', 0.20, 250
);
// --- Bass variation B --- 14 steps (different length = polyrhythm)
const bassB = parsePattern(
'E2 R E2 R G2 R A2 R C3 R A2 R G2 R',
'sawtooth', 0.20, 250
);
// Pick bass variation based on phrase cycle
// Using different lengths (16 vs 14) means they realign after LCM = 112 steps
// That's ~24 seconds at 140 BPM/8th notes before exact repetition
// --- Melody phrase A (power riff) --- 16 steps
const melodyA = parsePattern(
'E4 R G4 A4 R A4 G4 R E4 R D4 E4 R R R R',
'square', 0.12, 2000
);
// --- Melody phrase B (ascending push) --- 16 steps
const melodyB = parsePattern(
'E4 G4 A4 R B4 R A4 G4 R E4 R R G4 A4 B4 R',
'square', 0.12, 2000
);
// --- Melody phrase C (call-and-response) --- 16 steps
const melodyC = parsePattern(
'R R E5 D5 R R R R R R A4 G4 E4 R R R',
'square', 0.10, 2200
);
// Cycle through melody phrases: A A B B C A B C (32 bars before full repeat)
const melodyPhases = [melodyA, melodyA, melodyB, melodyB, melodyC, melodyA, melodyB, melodyC];
const stepsPerPhrase = 16;
const melodyLayer = [];
for (const phrase of melodyPhases) {
melodyLayer.push(...phrase);
}
// --- Synth stab (power chord accent) --- 8 steps (very different length)
const stabLayer = [
{ freq: NOTES.E3, type: 'sawtooth', gain: 0.08, lpf: 1500, duration: 0.08 },
rest(), rest(), rest(),
{ freq: NOTES.E3, type: 'sawtooth', gain: 0.06, lpf: 1200, duration: 0.06 },
rest(), rest(), rest(),
];
// --- High arp texture --- 10 steps (yet another length for maximum variation)
const arpLayer = parsePattern(
'E5 R G5 R A5 R G5 R E5 R',
'square', 0.03, 1200
);
return sequencer(ctx, dest, [
kickLayer,
hihatLayer,
bassA, // 16-step bass
melodyLayer, // 128-step melody cycle (8 phrases x 16)
stabLayer, // 8-step stab
arpLayer, // 10-step arp
], 140, 2);
}
// =============================================================================
// GAME OVER BGM — Somber, slow, 70 BPM
// Minor key, descending patterns, reflective mood
// =============================================================================
export function gameOverBGM(ctx, dest) {
// --- Slow descending melody ---
const melody = parsePattern(
'B4 R R A4 R R G4 R R E4 R R D4 R R R R R C4 R R R R R R R R R R R R R',
'triangle', 0.16, 1800
);
// --- Sustained pad (minor chord wash) ---
const padRoot = parsePattern(
'A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 A3 E3 E3 E3 E3 E3 E3 E3 E3 E3 E3 E3 E3 E3 E3 E3 E3',
'sine', 0.09, 1000
);
const padFifth = parsePattern(
'E4 E4 E4 E4 E4 E4 E4 E4 E4 E4 E4 E4 E4 E4 E4 E4 B3 B3 B3 B3 B3 B3 B3 B3 B3 B3 B3 B3 B3 B3 B3 B3',
'sine', 0.06, 800
);
// --- Very deep bass drone ---
const bass = parsePattern(
'A2 A2 A2 A2 A2 A2 A2 A2 A2 A2 A2 A2 A2 A2 A2 A2 E2 E2 E2 E2 E2 E2 E2 E2 E2 E2 E2 E2 E2 E2 E2 E2',
'sine', 0.12, 200
);
return sequencer(ctx, dest, [
melody,
padRoot,
padFifth,
bass,
], 70, 2);
}
+429
View File
@@ -0,0 +1,429 @@
// =============================================================================
// sfx.js — One-shot sound effects using Web Audio API
// All SFX use OscillatorNode + GainNode + BiquadFilterNode, zero audio files.
// Each function creates nodes, plays immediately, and auto-cleans up.
// =============================================================================
import { audioManager } from './AudioManager.js';
// ---------------------------------------------------------------------------
// Utility helpers
// ---------------------------------------------------------------------------
/**
* Play a single tone with gain envelope and lowpass filter.
*/
function playTone(freq, type, duration, gain = 0.3, filterFreq = 4000) {
try {
const ctx = audioManager.getCtx();
if (!ctx) return;
const now = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq, now);
const g = ctx.createGain();
g.gain.setValueAtTime(gain, now);
g.gain.exponentialRampToValueAtTime(0.001, now + duration);
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(filterFreq, now);
osc.connect(f).connect(g).connect(audioManager.getMaster());
osc.start(now);
osc.stop(now + duration);
} catch (_) {}
}
/**
* Play a sequence of notes with fixed timing.
*/
function playNotes(notes, type, noteDuration, gap, gain = 0.3, filterFreq = 4000) {
try {
const ctx = audioManager.getCtx();
if (!ctx) return;
const now = ctx.currentTime;
notes.forEach((freq, i) => {
const start = now + i * gap;
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq, start);
const g = ctx.createGain();
g.gain.setValueAtTime(gain, start);
g.gain.exponentialRampToValueAtTime(0.001, start + noteDuration);
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(filterFreq, start);
osc.connect(f).connect(g).connect(audioManager.getMaster());
osc.start(start);
osc.stop(start + noteDuration);
});
} catch (_) {}
}
/**
* Play white noise burst with lowpass and optional highpass filter.
*/
function playNoise(duration, gain = 0.2, lpfFreq = 4000, hpfFreq = 0) {
try {
const ctx = audioManager.getCtx();
if (!ctx) return;
const now = ctx.currentTime;
const bufferSize = Math.floor(ctx.sampleRate * duration);
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 source = ctx.createBufferSource();
source.buffer = buffer;
const g = ctx.createGain();
g.gain.setValueAtTime(gain, now);
g.gain.exponentialRampToValueAtTime(0.001, now + duration);
const lpf = ctx.createBiquadFilter();
lpf.type = 'lowpass';
lpf.frequency.setValueAtTime(lpfFreq, now);
if (hpfFreq > 0) {
const hpf = ctx.createBiquadFilter();
hpf.type = 'highpass';
hpf.frequency.setValueAtTime(hpfFreq, now);
source.connect(hpf).connect(lpf).connect(g).connect(audioManager.getMaster());
} else {
source.connect(lpf).connect(g).connect(audioManager.getMaster());
}
source.start(now);
source.stop(now + duration);
} catch (_) {}
}
// ---------------------------------------------------------------------------
// Game SFX
// ---------------------------------------------------------------------------
/**
* Metallic clank — weight being caught.
* Short, punchy metallic impact with high harmonics.
*/
export function catchSfx() {
try {
const ctx = audioManager.getCtx();
if (!ctx) return;
const now = ctx.currentTime;
// Primary metallic hit — high sine with fast decay
const osc1 = ctx.createOscillator();
osc1.type = 'square';
osc1.frequency.setValueAtTime(1200, now);
osc1.frequency.exponentialRampToValueAtTime(400, now + 0.06);
const g1 = ctx.createGain();
g1.gain.setValueAtTime(0.25, now);
g1.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
const f1 = ctx.createBiquadFilter();
f1.type = 'bandpass';
f1.frequency.setValueAtTime(2000, now);
f1.Q.setValueAtTime(3, now);
osc1.connect(f1).connect(g1).connect(audioManager.getMaster());
osc1.start(now);
osc1.stop(now + 0.1);
// Secondary ring — metallic overtone
const osc2 = ctx.createOscillator();
osc2.type = 'sine';
osc2.frequency.setValueAtTime(3200, now);
const g2 = ctx.createGain();
g2.gain.setValueAtTime(0.08, now);
g2.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
osc2.connect(g2).connect(audioManager.getMaster());
osc2.start(now);
osc2.stop(now + 0.15);
// Noise transient (impact click)
playNoise(0.03, 0.15, 6000, 2000);
} catch (_) {}
}
/**
* Heavy thud — weight hitting the floor.
* Deep, impactful low-frequency thump.
*/
export function missSfx() {
try {
const ctx = audioManager.getCtx();
if (!ctx) return;
const now = ctx.currentTime;
// Deep thud — sine with pitch drop
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(120, now);
osc.frequency.exponentialRampToValueAtTime(30, now + 0.25);
const g = ctx.createGain();
g.gain.setValueAtTime(0.35, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(200, now);
osc.connect(f).connect(g).connect(audioManager.getMaster());
osc.start(now);
osc.stop(now + 0.35);
// Noise rumble (floor vibration)
playNoise(0.2, 0.12, 300, 20);
// Sub impact
const sub = ctx.createOscillator();
sub.type = 'sine';
sub.frequency.setValueAtTime(50, now);
const gs = ctx.createGain();
gs.gain.setValueAtTime(0.20, now);
gs.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
sub.connect(gs).connect(audioManager.getMaster());
sub.start(now);
sub.stop(now + 0.2);
} catch (_) {}
}
/**
* Power grunt/growl — short burst for flex.
* Low sawtooth with distortion-like filter sweep.
*/
export function flexSfx() {
try {
const ctx = audioManager.getCtx();
if (!ctx) return;
const now = ctx.currentTime;
// Growl — low sawtooth with filter sweep up
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(80, now);
osc.frequency.exponentialRampToValueAtTime(120, now + 0.1);
osc.frequency.exponentialRampToValueAtTime(60, now + 0.25);
const g = ctx.createGain();
g.gain.setValueAtTime(0.22, now);
g.gain.linearRampToValueAtTime(0.28, now + 0.08);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(300, now);
f.frequency.exponentialRampToValueAtTime(800, now + 0.1);
f.frequency.exponentialRampToValueAtTime(200, now + 0.3);
osc.connect(f).connect(g).connect(audioManager.getMaster());
osc.start(now);
osc.stop(now + 0.35);
// Noise burst (breath/exertion)
playNoise(0.12, 0.10, 1200, 200);
} catch (_) {}
}
/**
* Energetic chime/sparkle — ascending notes for powerup.
* Bright, ascending arpeggio with shimmer.
*/
export function powerupSfx() {
try {
const ctx = audioManager.getCtx();
if (!ctx) return;
const now = ctx.currentTime;
// Ascending bright notes
const notes = [523.25, 659.25, 783.99, 1046.5, 1318.5];
notes.forEach((freq, i) => {
const start = now + i * 0.05;
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, start);
const g = ctx.createGain();
g.gain.setValueAtTime(0.18, start);
g.gain.exponentialRampToValueAtTime(0.001, start + 0.2);
osc.connect(g).connect(audioManager.getMaster());
osc.start(start);
osc.stop(start + 0.25);
});
// Shimmer overtone
const shimmer = ctx.createOscillator();
shimmer.type = 'sine';
shimmer.frequency.setValueAtTime(2637, now + 0.15);
const gs = ctx.createGain();
gs.gain.setValueAtTime(0.06, now + 0.15);
gs.gain.exponentialRampToValueAtTime(0.001, now + 0.5);
shimmer.connect(gs).connect(audioManager.getMaster());
shimmer.start(now + 0.15);
shimmer.stop(now + 0.55);
} catch (_) {}
}
/**
* Quick ascending arpeggio for combos.
* Pitch scales higher with combo count.
* @param {number} comboCount - current combo (affects pitch)
*/
export function comboSfx(comboCount = 1) {
try {
const ctx = audioManager.getCtx();
if (!ctx) return;
const now = ctx.currentTime;
// Base pitch scales up with combo (capped at +2 octaves)
const pitchMultiplier = 1 + Math.min(comboCount, 20) * 0.05;
const baseNotes = [329.63, 392.00, 493.88]; // E4, G4, B4
baseNotes.forEach((freq, i) => {
const scaledFreq = freq * pitchMultiplier;
const start = now + i * 0.04;
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.setValueAtTime(scaledFreq, start);
const g = ctx.createGain();
g.gain.setValueAtTime(0.15, start);
g.gain.exponentialRampToValueAtTime(0.001, start + 0.1);
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(3000 + comboCount * 200, start);
osc.connect(f).connect(g).connect(audioManager.getMaster());
osc.start(start);
osc.stop(start + 0.12);
});
} catch (_) {}
}
/**
* Epic horn/fanfare blast for streaks.
* Bold sawtooth chord with rising power.
*/
export function streakSfx() {
try {
const ctx = audioManager.getCtx();
if (!ctx) return;
const now = ctx.currentTime;
// Fanfare chord — stacked sawtooth fifths
const chordFreqs = [196.00, 261.63, 329.63, 392.00]; // G3, C4, E4, G4
chordFreqs.forEach((freq, i) => {
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(freq, now);
const g = ctx.createGain();
g.gain.setValueAtTime(0, now);
g.gain.linearRampToValueAtTime(0.12, now + 0.05);
g.gain.setValueAtTime(0.12, now + 0.25);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.6);
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(800, now);
f.frequency.exponentialRampToValueAtTime(3000, now + 0.15);
f.frequency.exponentialRampToValueAtTime(1000, now + 0.6);
osc.connect(f).connect(g).connect(audioManager.getMaster());
osc.start(now);
osc.stop(now + 0.65);
});
// Rising pitch accent
const accent = ctx.createOscillator();
accent.type = 'square';
accent.frequency.setValueAtTime(392, now + 0.2);
accent.frequency.exponentialRampToValueAtTime(784, now + 0.45);
const ga = ctx.createGain();
ga.gain.setValueAtTime(0.08, now + 0.2);
ga.gain.exponentialRampToValueAtTime(0.001, now + 0.5);
accent.connect(ga).connect(audioManager.getMaster());
accent.start(now + 0.2);
accent.stop(now + 0.55);
} catch (_) {}
}
/**
* Dramatic slam/impact for GigaChad's entrance landing.
* Deep boom with reverb-like tail and metallic ring.
*/
export function entranceSfx() {
try {
const ctx = audioManager.getCtx();
if (!ctx) return;
const now = ctx.currentTime;
// Deep boom — sine with dramatic pitch drop
const boom = ctx.createOscillator();
boom.type = 'sine';
boom.frequency.setValueAtTime(200, now);
boom.frequency.exponentialRampToValueAtTime(25, now + 0.5);
const gb = ctx.createGain();
gb.gain.setValueAtTime(0.35, now);
gb.gain.exponentialRampToValueAtTime(0.001, now + 0.6);
const fb = ctx.createBiquadFilter();
fb.type = 'lowpass';
fb.frequency.setValueAtTime(400, now);
boom.connect(fb).connect(gb).connect(audioManager.getMaster());
boom.start(now);
boom.stop(now + 0.65);
// Sub rumble
const sub = ctx.createOscillator();
sub.type = 'sine';
sub.frequency.setValueAtTime(40, now);
const gs = ctx.createGain();
gs.gain.setValueAtTime(0.25, now);
gs.gain.exponentialRampToValueAtTime(0.001, now + 0.4);
sub.connect(gs).connect(audioManager.getMaster());
sub.start(now);
sub.stop(now + 0.45);
// Noise impact transient
playNoise(0.15, 0.20, 2000, 100);
// Metallic ring (dramatic)
const ring = ctx.createOscillator();
ring.type = 'sine';
ring.frequency.setValueAtTime(1600, now + 0.02);
const gr = ctx.createGain();
gr.gain.setValueAtTime(0.06, now + 0.02);
gr.gain.exponentialRampToValueAtTime(0.001, now + 0.4);
ring.connect(gr).connect(audioManager.getMaster());
ring.start(now + 0.02);
ring.stop(now + 0.45);
} catch (_) {}
}
@@ -36,6 +36,7 @@ export const Events = {
// Audio (used by /add-audio)
AUDIO_INIT: 'audio:init',
AUDIO_TOGGLE_MUTE: 'audio:toggleMute',
MUSIC_MENU: 'music:menu',
MUSIC_GAMEPLAY: 'music:gameplay',
MUSIC_GAMEOVER: 'music:gameover',
+6 -1
View File
@@ -10,7 +10,12 @@ class GameState {
constructor() {
this.bestScore = 0;
this.bestCombo = 0;
this.isMuted = false;
// Restore mute preference from localStorage
try {
this.isMuted = localStorage.getItem('muted') === 'true';
} catch (_) {
this.isMuted = false;
}
this.reset();
}
+17
View File
@@ -6,9 +6,26 @@
import { Game } from './core/Game.js';
import { eventBus, Events } from './core/EventBus.js';
import { gameState } from './core/GameState.js';
import { initAudioBridge } from './audio/AudioBridge.js';
import { MuteButton } from './ui/MuteButton.js';
// Initialize audio bridge (wires EventBus events to Web Audio API)
// Non-blocking — if audio fails, game still works
try {
initAudioBridge();
} catch (e) {
console.warn('[main] Audio bridge init failed (game will work without audio):', e);
}
const game = new Game();
// Create mute button UI (bottom-right, M key shortcut)
try {
new MuteButton();
} catch (e) {
console.warn('[main] Mute button creation failed:', e);
}
// Expose for Playwright testing
window.__GAME__ = game;
window.__GAME_STATE__ = gameState;
+166
View File
@@ -0,0 +1,166 @@
// =============================================================================
// MuteButton.js — Mute toggle button (HTML/Canvas-based)
// Positioned bottom-right above Play.fun safe zone.
// Speaker icon drawn on a <canvas> element — no external assets.
// M key shortcut for keyboard toggle.
// =============================================================================
import { eventBus, Events } from '../core/EventBus.js';
import { gameState } from '../core/GameState.js';
import { SAFE_ZONE } from '../core/Constants.js';
export class MuteButton {
constructor() {
this._iconSize = 18;
this._buttonSize = 40;
this._margin = 16;
this._createButton();
this._setupKeyboard();
this._drawIcon();
// Re-draw when mute state changes externally
eventBus.on(Events.AUDIO_TOGGLE_MUTE, () => {
// Defer to next tick so gameState.isMuted is already toggled
requestAnimationFrame(() => this._drawIcon());
});
}
_createButton() {
// Container button
this.container = document.createElement('div');
this.container.id = 'mute-button';
this.container.style.cssText = `
position: fixed;
bottom: max(${SAFE_ZONE.TOP_PX + this._margin}px, calc(${SAFE_ZONE.TOP_PERCENT}vh + ${this._margin}px));
right: ${this._margin}px;
width: ${this._buttonSize}px;
height: ${this._buttonSize}px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
z-index: 25;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
transition: background 0.2s;
`;
// Canvas for speaker icon
this.canvas = document.createElement('canvas');
this.canvas.width = this._iconSize * 2;
this.canvas.height = this._iconSize * 2;
this.canvas.style.cssText = `
width: ${this._iconSize}px;
height: ${this._iconSize}px;
pointer-events: none;
`;
this.container.appendChild(this.canvas);
// Hover effect
this.container.addEventListener('mouseenter', () => {
this.container.style.background = 'rgba(0, 0, 0, 0.6)';
});
this.container.addEventListener('mouseleave', () => {
this.container.style.background = 'rgba(0, 0, 0, 0.4)';
});
// Click handler
this.container.addEventListener('click', (e) => {
e.stopPropagation();
eventBus.emit(Events.AUDIO_TOGGLE_MUTE);
});
// Touch handler (prevent double-fire on mobile)
this.container.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
eventBus.emit(Events.AUDIO_TOGGLE_MUTE);
}, { passive: false });
document.body.appendChild(this.container);
}
_setupKeyboard() {
window.addEventListener('keydown', (e) => {
if (e.code === 'KeyM' && !e.repeat) {
eventBus.emit(Events.AUDIO_TOGGLE_MUTE);
}
});
}
_drawIcon() {
const ctx = this.canvas.getContext('2d');
const s = this._iconSize * 2; // canvas is 2x for retina
const cx = s / 2;
const cy = s / 2;
const unit = s * 0.2;
ctx.clearRect(0, 0, s, s);
ctx.save();
// Speaker body — rectangle + triangle
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Speaker cone (left-pointing trapezoid)
const bodyLeft = cx - unit * 1.6;
const bodyRight = cx - unit * 0.3;
const bodyTop = cy - unit * 0.5;
const bodyBottom = cy + unit * 0.5;
// Rectangular part
ctx.fillRect(bodyRight - unit * 0.4, bodyTop, unit * 0.4, unit * 1.0);
// Triangle cone
ctx.beginPath();
ctx.moveTo(bodyRight - unit * 0.4, bodyTop);
ctx.lineTo(bodyLeft, cy - unit * 1.1);
ctx.lineTo(bodyLeft, cy + unit * 1.1);
ctx.lineTo(bodyRight - unit * 0.4, bodyBottom);
ctx.closePath();
ctx.fill();
if (!gameState.isMuted) {
// Sound waves — two arcs on the right side
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
// Small wave
ctx.beginPath();
ctx.arc(cx + unit * 0.1, cy, unit * 0.8, -Math.PI / 3.5, Math.PI / 3.5);
ctx.stroke();
// Large wave
ctx.beginPath();
ctx.arc(cx + unit * 0.1, cy, unit * 1.4, -Math.PI / 3.5, Math.PI / 3.5);
ctx.stroke();
} else {
// X mark for muted
ctx.strokeStyle = '#ff4444';
ctx.lineWidth = 2.5;
const xCenter = cx + unit * 0.5;
const xSize = unit * 0.9;
ctx.beginPath();
ctx.moveTo(xCenter - xSize, cy - xSize);
ctx.lineTo(xCenter + xSize, cy + xSize);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(xCenter - xSize, cy + xSize);
ctx.lineTo(xCenter + xSize, cy - xSize);
ctx.stroke();
}
ctx.restore();
}
}