feat: Implement sound system and toggle functionality
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 8s

- Introduced SoundSystem to manage sound events using the Web Audio API.
- Added SoundEvent component to represent different sound types (e.g., coin, damage, powerup).
- Integrated sound event emissions in the Game class for various actions (e.g., collecting coins, taking damage).
- Updated UI to include a sound status indicator with toggle functionality using the 'M' key.
- Enhanced game experience with background music and sound effects for actions, improving player engagement.

This update enriches the gameplay by adding audio feedback, enhancing the overall user experience.
This commit is contained in:
Juan Sebastián Montoya 2025-11-26 17:52:59 -05:00
parent 4220e216e1
commit e638ae4d6d
6 changed files with 720 additions and 5 deletions

View file

@ -96,6 +96,20 @@
z-index: 100;
font-family: 'Courier New', monospace;
}
#soundStatus {
position: absolute;
top: 20px;
right: 100px;
font-size: 20px;
cursor: pointer;
z-index: 100;
user-select: none;
opacity: 0.8;
transition: opacity 0.2s;
}
#soundStatus:hover {
opacity: 1;
}
#perfMonitor {
position: absolute;
top: 60px;
@ -125,6 +139,11 @@
top: 10px;
right: 10px;
}
#soundStatus {
top: 10px;
right: 60px;
font-size: 18px;
}
#perfMonitor {
top: 40px;
right: 10px;
@ -144,6 +163,8 @@
<div id="version">v<span id="versionNumber">-</span></div>
<div id="soundStatus" title="Sound ON (Press M to mute)">🔊</div>
<div id="perfMonitor">
<div><span class="label">FPS:</span> <span id="fps">60</span></div>
<div><span class="label">Frame:</span> <span id="frameTime">16.7</span>ms</div>
@ -159,7 +180,7 @@
<div id="instructions">
<p><strong>Controls:</strong> WASD or Arrow Keys to move | Touch and drag to move (mobile) | Collect yellow coins | Avoid red obstacles!</p>
<p style="margin-top: 5px; font-size: 11px; opacity: 0.7;">Press "T" or shake device to toggle performance monitor</p>
<p style="margin-top: 5px; font-size: 11px; opacity: 0.7;">Press "T" to toggle performance monitor | Press "M" to toggle sound</p>
</div>
<script type="module">

View file

@ -0,0 +1,21 @@
/**
* SoundEvent component - represents a sound that should be played
* Entities can add this component to trigger sounds, which SoundSystem will process
*/
export class SoundEvent {
/**
* @param {string} type - Sound type: 'coin', 'damage', 'powerup', 'combo', 'health', 'gameover', 'highscore'
* @param {number} [value] - Optional value (e.g., combo multiplier)
*/
constructor(type, value = null) {
/** @type {string} Sound event type */
this.type = type;
/** @type {number|null} Optional value for the sound (e.g., combo multiplier) */
this.value = value;
/** @type {boolean} Whether this event has been processed */
this.processed = false;
}
}

View file

@ -13,6 +13,7 @@ import { CollisionSystem } from '../systems/CollisionSystem.js';
import { InvincibilitySystem } from '../systems/InvincibilitySystem.js';
import { ParticleSystem } from '../systems/ParticleSystem.js';
import { PowerUpSystem } from '../systems/PowerUpSystem.js';
import { SoundSystem } from '../systems/SoundSystem.js';
import { RenderSystem } from '../systems/RenderSystem.js';
// Components
@ -21,6 +22,7 @@ import { Health } from '../components/Health.js';
import { Invincibility } from '../components/Invincibility.js';
import { CoinType } from '../components/CoinType.js';
import { PowerUp } from '../components/PowerUp.js';
import { SoundEvent } from '../components/SoundEvent.js';
/**
* Main Game class - manages the game loop and coordinates all systems.
@ -248,6 +250,10 @@ export class Game {
});
this.world.addSystem(this.collisionSystem);
// Sound system (processes SoundEvent components)
this.soundSystem = new SoundSystem();
this.world.addSystem(this.soundSystem);
// Rendering (must be last to sync transforms)
this.world.addSystem(new RenderSystem(this.scene));
}
@ -306,6 +312,14 @@ export class Game {
const coinType = this.world.getComponent(coinEntity, CoinType);
const coinPosition = coinTransform ? coinTransform.position.clone() : null;
// Emit sound event BEFORE destroying the entity
const soundEntity = this.world.createEntity();
if (coinType && coinType.type === 'health') {
this.world.addComponent(soundEntity, new SoundEvent('health'));
} else {
this.world.addComponent(soundEntity, new SoundEvent('coin'));
}
// Remove coin
this.entityFactory.destroyEntity(coinEntity);
const index = this.coins.indexOf(coinEntity);
@ -354,11 +368,19 @@ export class Game {
if (timeSinceLastCoin <= GameConfig.COMBO_TIME_WINDOW && this.lastCoinTime > 0) {
// Maintain combo
const oldMultiplier = this.comboMultiplier;
this.comboMultiplier = Math.min(
this.comboMultiplier + 1,
GameConfig.COMBO_MULTIPLIER_MAX
);
this.comboTimer = GameConfig.COMBO_TIME_WINDOW;
// Emit combo sound event if multiplier increased
if (this.comboMultiplier > oldMultiplier) {
// Create temporary entity for combo sound
const comboEntity = this.world.createEntity();
this.world.addComponent(comboEntity, new SoundEvent('combo', this.comboMultiplier));
}
} else {
// Reset combo
this.comboMultiplier = 1;
@ -377,6 +399,9 @@ export class Game {
if (this.score > this.highScore) {
this.highScore = this.score;
this.saveHighScore(this.highScore);
// Emit high score sound event
const highScoreEntity = this.world.createEntity();
this.world.addComponent(highScoreEntity, new SoundEvent('highscore'));
}
this.updateUI();
@ -415,6 +440,10 @@ export class Game {
invincibility.activate(powerUp.duration);
}
}
// Emit power-up sound event
const powerUpSoundEntity = this.world.createEntity();
this.world.addComponent(powerUpSoundEntity, new SoundEvent('powerup'));
}
// Emit particles
@ -502,6 +531,10 @@ export class Game {
this.comboMultiplier = 1;
this.comboTimer = 0;
// Emit damage sound event
const damageSoundEntity = this.world.createEntity();
this.world.addComponent(damageSoundEntity, new SoundEvent('damage'));
this.updateUI();
if (isDead) {
@ -570,6 +603,10 @@ export class Game {
newHighScoreEl.style.display = 'none';
}
// Emit game over sound event
const gameOverEntity = this.world.createEntity();
this.world.addComponent(gameOverEntity, new SoundEvent('gameover'));
document.getElementById('gameOver').style.display = 'block';
}
@ -624,6 +661,10 @@ export class Game {
if (e.key.toLowerCase() === 't') {
this.togglePerformanceMonitor();
}
// Toggle sound with 'M' key
if (e.key.toLowerCase() === 'm') {
this.toggleSound();
}
});
// Shake detection for mobile
@ -658,6 +699,28 @@ export class Game {
// Load version
this.loadVersion();
// Setup sound status click handler
const soundStatusEl = document.getElementById('soundStatus');
if (soundStatusEl) {
soundStatusEl.addEventListener('click', async () => {
this.toggleSound();
});
}
// Initialize sound system on first click anywhere
document.addEventListener('click', () => {
if (this.soundSystem && this.soundSystem.isEnabled()) {
console.log('Sound system ready, state:', this.soundSystem.getState());
// Start background music after initialization
if (this.soundSystem.isEnabled()) {
this.soundSystem.startBackgroundMusic().catch(err => {
console.warn('Failed to start background music:', err);
});
}
}
}, { once: true });
}
/**
@ -675,6 +738,31 @@ export class Game {
}
}
/**
* Toggle sound on/off
*/
toggleSound() {
if (this.soundSystem) {
const enabled = this.soundSystem.toggle();
const soundStatusEl = document.getElementById('soundStatus');
if (soundStatusEl) {
soundStatusEl.textContent = enabled ? '🔊' : '🔇';
soundStatusEl.title = enabled ? 'Sound ON (Press M to mute)' : 'Sound OFF (Press M to unmute)';
}
// Start/stop background music based on sound state
if (enabled) {
this.soundSystem.startBackgroundMusic().catch(err => {
console.warn('Failed to start background music:', err);
});
} else {
this.soundSystem.stopBackgroundMusic();
}
console.log('Sound', enabled ? 'enabled' : 'disabled');
}
}
/**
* Handle device motion for shake detection
* @param {DeviceMotionEvent} event
@ -851,6 +939,9 @@ export class Game {
const health = this.world.getComponent(this.playerEntity, Health);
if (health && health.currentHealth < health.maxHealth) {
health.heal(GameConfig.HEALTH_REGEN_AMOUNT);
// Emit health regen sound event
const healthRegenEntity = this.world.createEntity();
this.world.addComponent(healthRegenEntity, new SoundEvent('health'));
this.updateUI();
}
this.healthRegenTimer = 0;

View file

@ -4,7 +4,7 @@
*/
export const GameConfig = {
// Gameplay
OBSTACLE_DAMAGE: 3, // Increased from 1 to balance invincibility frames and health regen
OBSTACLE_DAMAGE: 5, // Increased from 3 - obstacles should be more dangerous
COIN_SCORE: 10,
HEALTH_REGEN_INTERVAL: 10, // Heal 1 HP every 10 seconds
HEALTH_REGEN_AMOUNT: 1,
@ -15,7 +15,7 @@ export const GameConfig = {
COMBO_BASE_SCORE: 10, // Base score per coin
// Invincibility
INVINCIBILITY_DURATION: 1.5, // Seconds of invincibility after damage
INVINCIBILITY_DURATION: 0.8, // Reduced from 1.5 to 0.8 seconds - shorter invincibility window
INVINCIBILITY_FLASH_RATE: 0.1, // Seconds between flash toggles
// Screen Shake

View file

@ -1,8 +1,6 @@
import { System } from '../ecs/System.js';
import { PowerUp } from '../components/PowerUp.js';
import { Transform } from '../components/Transform.js';
import { Velocity } from '../components/Velocity.js';
import { PlayerTag } from '../components/Tags.js';
import { GameConfig } from '../game/GameConfig.js';
/**

584
src/systems/SoundSystem.js Normal file
View file

@ -0,0 +1,584 @@
import { System } from '../ecs/System.js';
import { SoundEvent } from '../components/SoundEvent.js';
/**
* SoundSystem - Lightweight sound system using Web Audio API (no external dependencies)
* Generates simple procedural sound effects
* Follows ECS pattern: processes SoundEvent components from entities
*/
export class SoundSystem extends System {
constructor() {
super();
/** @type {AudioContext|null} Web Audio API context */
this.audioContext = null;
/** @type {boolean} Whether sound is enabled */
this.enabled = true;
/** @type {boolean} Whether audio context is initialized */
this.initialized = false;
/** @type {OscillatorNode[]} Active background music oscillators */
this.bgMusicOscillators = [];
/** @type {GainNode|null} Background music gain node */
this.bgMusicGain = null;
/** @type {boolean} Whether background music is playing */
this.bgMusicPlaying = false;
/** @type {number} Background music volume (0-1) */
this.bgMusicVolume = 0.25; // Increased from 0.15 for better audibility
// Initialize on first user interaction (browser requirement)
this.initOnInteraction();
}
/**
* Called when system is added to world
*/
init() {
if (this.enabled && !this.bgMusicPlaying) {
this.startBackgroundMusic().catch(err => {
console.warn('Failed to auto-start background music:', err);
});
}
}
/**
* Update - processes SoundEvent components
* @param {number} _deltaTime - Time since last frame (not used for sound events)
*/
update(_deltaTime) {
// Process all SoundEvent components
const soundEvents = this.getEntities(SoundEvent);
for (const entityId of soundEvents) {
const soundEvent = this.getComponent(entityId, SoundEvent);
if (soundEvent && !soundEvent.processed) {
// Play the appropriate sound based on type
this.playSoundEvent(soundEvent);
// Mark as processed and remove component
soundEvent.processed = true;
this.world.removeComponent(entityId, SoundEvent);
}
}
}
/**
* Play a sound event
* @param {SoundEvent} soundEvent - The sound event to play
*/
playSoundEvent(soundEvent) {
if (!this.enabled) return;
// All sound methods are async, handle them properly
const playSound = async (soundMethod) => {
try {
await soundMethod();
} catch (error) {
// Silently ignore sound errors
}
};
switch (soundEvent.type) {
case 'coin':
playSound(() => this.playCoinCollect());
break;
case 'damage':
playSound(() => this.playDamage());
break;
case 'powerup':
playSound(() => this.playPowerUp());
break;
case 'combo':
playSound(() => this.playCombo(soundEvent.value || 1));
break;
case 'health':
playSound(() => this.playHealthRegen());
break;
case 'gameover':
playSound(() => this.playGameOver());
break;
case 'highscore':
playSound(() => this.playHighScore());
break;
default:
console.warn('Unknown sound event type:', soundEvent.type);
}
}
/**
* Initialize audio context on first user interaction
*/
initOnInteraction() {
const initAudio = async () => {
if (!this.initialized) {
try {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.initialized = true;
// Resume if suspended
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume();
}
} catch (error) {
console.warn('Web Audio API not supported:', error);
this.enabled = false;
}
}
};
// Initialize on any user interaction
['click', 'touchstart', 'keydown', 'mousedown'].forEach(event => {
document.addEventListener(event, initAudio, { once: true });
});
}
/**
* Ensure audio context is initialized and resumed
* @returns {boolean} True if audio is ready (synchronous check)
*/
ensureInitialized() {
if (!this.enabled) return false;
// Create audio context if it doesn't exist
if (!this.audioContext) {
try {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.initialized = true;
} catch (error) {
console.warn('Failed to create audio context:', error);
return false;
}
}
// Try to resume if suspended (non-blocking - will resume on next interaction if needed)
if (this.audioContext.state === 'suspended') {
this.audioContext.resume().then(() => {
console.log('Audio context resumed');
}).catch(error => {
console.warn('Failed to resume audio context:', error);
});
}
return this.initialized && this.enabled;
}
/**
* Play a tone
* @param {number} frequency - Frequency in Hz
* @param {number} duration - Duration in seconds
* @param {string} type - Waveform type ('sine', 'square', 'sawtooth', 'triangle')
* @param {number} [volume=0.3] - Volume (0-1)
*/
async playTone(frequency, duration, type = 'sine', volume = 0.3) {
const ready = this.ensureInitialized();
if (!ready || !this.audioContext) return;
try {
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
oscillator.frequency.value = frequency;
oscillator.type = type;
// Envelope: quick attack, sustain, quick release
const now = this.audioContext.currentTime;
gainNode.gain.setValueAtTime(0, now);
gainNode.gain.linearRampToValueAtTime(volume, now + 0.01);
gainNode.gain.setValueAtTime(volume, now + duration * 0.7);
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
oscillator.start(now);
oscillator.stop(now + duration);
} catch (error) {
console.warn('Failed to play tone:', error);
}
}
/**
* Play a coin collection sound - metallic clinking like real coins
*/
async playCoinCollect() {
// Metallic clink using multiple harmonics via playTone
// Main coin "clink" - high frequency
await this.playTone(800, 0.15, 'sine', 0.3);
await this.playTone(1200, 0.15, 'sine', 0.2);
await this.playTone(1600, 0.15, 'sine', 0.1);
// Second coin clink slightly delayed for "clinking" effect
setTimeout(() => {
if (this.enabled) {
this.playTone(1000, 0.1, 'sine', 0.25).catch(() => { });
}
}, 30);
}
/**
* Play a damage sound - "ouch" like human exclamation
*/
async playDamage() {
await this.playTone(300, 0.2, 'sine', 0.4);
await this.playTone(600, 0.2, 'sine', 0.3);
await this.playTone(800, 0.2, 'sawtooth', 0.2);
// Add a quick "uh" sound at the end
setTimeout(() => {
if (this.enabled) {
this.playTone(250, 0.1, 'sine', 0.2).catch(() => { });
}
}, 100);
}
/**
* Play a power-up sound
*/
playPowerUp() {
// Ascending arpeggio
const notes = [440, 554.37, 659.25, 783.99]; // A, C#, E, G
notes.forEach((freq, i) => {
setTimeout(() => {
if (this.enabled) {
this.playTone(freq, 0.15, 'sine', 0.25).catch(() => { });
}
}, i * 80);
});
}
/**
* Play a combo sound (higher multiplier = more notes)
* @param {number} multiplier - Combo multiplier
*/
playCombo(multiplier) {
const baseFreq = 440;
const notes = Math.min(multiplier, 5);
for (let i = 0; i < notes; i++) {
setTimeout(() => {
if (this.enabled) {
const freq = baseFreq * (1 + i * 0.2);
this.playTone(freq, 0.1, 'sine', 0.15).catch(() => { });
}
}, i * 50);
}
}
/**
* Play a health regeneration sound
*/
playHealthRegen() {
// Gentle ascending tone
this.playTone(330, 0.2, 'sine', 0.15).catch(() => { });
setTimeout(() => {
if (this.enabled) {
this.playTone(392, 0.2, 'sine', 0.15).catch(() => { });
}
}, 100);
}
/**
* Play a game over sound
*/
playGameOver() {
// Descending sad tone
const notes = [440, 392, 349, 294]; // A, G, F, D
notes.forEach((freq, i) => {
setTimeout(() => {
if (this.enabled) {
this.playTone(freq, 0.3, 'sine', 0.3).catch(() => { });
}
}, i * 150);
});
}
/**
* Play a new high score sound
*/
playHighScore() {
// Triumphant ascending scale
const notes = [523.25, 659.25, 783.99, 987.77]; // C, E, G, B
notes.forEach((freq, i) => {
setTimeout(() => {
if (this.enabled) {
this.playTone(freq, 0.2, 'sine', 0.3).catch(() => { });
}
}, i * 100);
});
}
/**
* Toggle sound on/off
* @returns {boolean} New enabled state
*/
toggle() {
this.enabled = !this.enabled;
return this.enabled;
}
/**
* Set sound enabled state
* @param {boolean} enabled
*/
setEnabled(enabled) {
this.enabled = enabled;
}
/**
* Check if sound is enabled
* @returns {boolean}
*/
isEnabled() {
return this.enabled;
}
/**
* Get audio context state for debugging
* @returns {string} Audio context state
*/
getState() {
if (!this.audioContext) return 'not initialized';
return this.audioContext.state;
}
/**
* Test sound - plays a simple tone to verify audio works
*/
async testSound() {
console.log('Testing sound system...');
console.log('Audio context state:', this.getState());
await this.playTone(440, 0.2, 'sine', 0.5);
console.log('Test sound played');
}
/**
* Start background music - animated looping OST
*/
async startBackgroundMusic() {
if (this.bgMusicPlaying) {
console.log('Background music already playing');
return;
}
const ready = this.ensureInitialized();
if (!ready || !this.audioContext) {
console.warn('Cannot start background music: audio context not ready, state:', this.getState());
return;
}
try {
this.bgMusicPlaying = true;
console.log('Starting background music, audio context state:', this.audioContext.state);
// Create gain node for background music volume control
this.bgMusicGain = this.audioContext.createGain();
this.bgMusicGain.gain.value = this.bgMusicVolume * 0.5;
this.bgMusicGain.connect(this.audioContext.destination);
// Electronic/EDM style music
// Simple chord progression: C - Am - F - G (I - vi - IV - V - classic electronic progression)
const chordProgression = [
[130.81, 164.81, 196], // C major (C, E, G)
[220, 261.63, 329.63], // Am (A, C, E)
[174.61, 220, 261.63], // F major (F, A, C)
[196, 246.94, 293.66] // G major (G, B, D)
];
// Electronic melody notes (higher octave, pentatonic for catchy hook)
const melodyNotes = [523.25, 587.33, 659.25, 698.46, 783.99, 880, 987.77, 1046.5]; // C5 to C6 - bright electronic range
// Bass line for electronic feel (lower octave)
const bassNotes = [65.41, 73.42, 82.41, 87.31, 98, 110, 123.47]; // C2 to B2
const playChord = (chord, startTime, duration) => {
chord.forEach((freq, index) => {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
// Use sawtooth for electronic/analog synth feel
osc.type = 'sawtooth';
osc.frequency.value = freq;
osc.connect(gain);
gain.connect(this.bgMusicGain);
// Very bright, energetic attack for happy feel
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.5, startTime + 0.1); // Very fast, bright attack
gain.gain.setValueAtTime(0.5, startTime + duration - 0.2);
gain.gain.linearRampToValueAtTime(0, startTime + duration);
osc.start(startTime);
osc.stop(startTime + duration);
this.bgMusicOscillators.push(osc);
});
};
const playMelody = (note, startTime, duration) => {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = 'square'; // Square wave for electronic lead
osc.frequency.value = note;
osc.connect(gain);
gain.connect(this.bgMusicGain);
// Very energetic, bouncy attack for happy melody
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.4, startTime + 0.05); // Very quick, bright attack
gain.gain.setValueAtTime(0.4, startTime + duration - 0.05);
gain.gain.linearRampToValueAtTime(0, startTime + duration);
osc.start(startTime);
osc.stop(startTime + duration);
this.bgMusicOscillators.push(osc);
};
// Electronic bass line
const playBass = (note, startTime, duration) => {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = 'square'; // Square wave for electronic bass
osc.frequency.value = note;
osc.connect(gain);
gain.connect(this.bgMusicGain);
// Punchy bass envelope
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.4, startTime + 0.02);
gain.gain.setValueAtTime(0.4, startTime + duration - 0.05);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc.start(startTime);
osc.stop(startTime + duration);
this.bgMusicOscillators.push(osc);
};
// Loop function - creates 8 second loop (fast electronic tempo)
const loopDuration = 8; // seconds - fast electronic tempo
let nextLoopTime = this.audioContext.currentTime;
const scheduleLoop = () => {
if (!this.bgMusicPlaying || !this.audioContext) return;
const startTime = nextLoopTime;
// Play chord progression (4 chords, 2 seconds each - fast electronic tempo)
chordProgression.forEach((chord, chordIndex) => {
const chordStart = startTime + (chordIndex * 2);
playChord(chord, chordStart, 1.9);
});
// Electronic bass line (pulsing rhythm)
const bassPattern = [
{ note: bassNotes[0], time: 0, duration: 0.3 }, // C - kick
{ note: bassNotes[0], time: 0.5, duration: 0.2 }, // C
{ note: bassNotes[2], time: 1.0, duration: 0.3 }, // E
{ note: bassNotes[0], time: 1.5, duration: 0.2 }, // C
{ note: bassNotes[3], time: 2.0, duration: 0.3 }, // F
{ note: bassNotes[0], time: 2.5, duration: 0.2 }, // C
{ note: bassNotes[4], time: 3.0, duration: 0.3 }, // G
{ note: bassNotes[0], time: 3.5, duration: 0.2 }, // C
{ note: bassNotes[2], time: 4.0, duration: 0.3 }, // E
{ note: bassNotes[0], time: 4.5, duration: 0.2 }, // C
{ note: bassNotes[3], time: 5.0, duration: 0.3 }, // F
{ note: bassNotes[0], time: 5.5, duration: 0.2 }, // C
{ note: bassNotes[4], time: 6.0, duration: 0.3 }, // G
{ note: bassNotes[0], time: 6.5, duration: 0.2 }, // C
{ note: bassNotes[2], time: 7.0, duration: 0.3 } // E
];
bassPattern.forEach(({ note, time, duration }) => {
playBass(note, startTime + time, duration);
});
// Play melody over chords (simpler pattern)
const melodyPattern = [
{ note: melodyNotes[0], time: 0.5, duration: 1 },
{ note: melodyNotes[2], time: 2, duration: 1 },
{ note: melodyNotes[4], time: 4.5, duration: 1 },
{ note: melodyNotes[3], time: 6, duration: 1 },
{ note: melodyNotes[5], time: 8.5, duration: 1.5 },
{ note: melodyNotes[4], time: 10.5, duration: 1 },
{ note: melodyNotes[2], time: 12, duration: 1.5 },
{ note: melodyNotes[0], time: 14, duration: 1.5 }
];
melodyPattern.forEach(({ note, time, duration }) => {
playMelody(note, startTime + time, duration);
});
// Schedule next loop
nextLoopTime += loopDuration;
// Schedule next loop before current one ends (schedule 2 seconds before end)
const timeUntilNext = (nextLoopTime - this.audioContext.currentTime) * 1000 - 2000;
if (timeUntilNext > 0) {
setTimeout(() => {
if (this.bgMusicPlaying && this.audioContext) {
scheduleLoop();
}
}, timeUntilNext);
} else {
// If we're behind, schedule immediately
scheduleLoop();
}
};
// Start first loop immediately
scheduleLoop();
console.log('Background music started, audio context state:', this.audioContext.state);
console.log('Background music volume:', this.bgMusicVolume);
} catch (error) {
console.warn('Failed to start background music:', error);
this.bgMusicPlaying = false;
}
}
/**
* Stop background music
*/
stopBackgroundMusic() {
this.bgMusicPlaying = false;
// Stop all oscillators
this.bgMusicOscillators.forEach(osc => {
try {
osc.stop();
} catch (e) {
// Oscillator might already be stopped
}
});
this.bgMusicOscillators = [];
if (this.bgMusicGain) {
this.bgMusicGain.disconnect();
this.bgMusicGain = null;
}
console.log('Background music stopped');
}
/**
* Set background music volume
* @param {number} volume - Volume (0-1)
*/
setBackgroundMusicVolume(volume) {
this.bgMusicVolume = Math.max(0, Math.min(1, volume));
if (this.bgMusicGain) {
this.bgMusicGain.gain.value = this.bgMusicVolume;
}
}
}