mirror of
https://github.com/PlayableIntelligence/game-creator.git
synced 2026-09-19 07:34:10 +08:00
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:
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user