Feature/Gameloop enhancements #17

Merged
jusemon merged 4 commits from feature/gameloop-enhancements into main 2025-11-26 17:53:41 -05:00
6 changed files with 720 additions and 5 deletions
Showing only changes of commit e638ae4d6d - Show all commits

View file

@ -96,6 +96,20 @@
z-index: 100; z-index: 100;
font-family: 'Courier New', monospace; 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 { #perfMonitor {
position: absolute; position: absolute;
top: 60px; top: 60px;
@ -125,6 +139,11 @@
top: 10px; top: 10px;
right: 10px; right: 10px;
} }
#soundStatus {
top: 10px;
right: 60px;
font-size: 18px;
}
#perfMonitor { #perfMonitor {
top: 40px; top: 40px;
right: 10px; right: 10px;
@ -144,6 +163,8 @@
<div id="version">v<span id="versionNumber">-</span></div> <div id="version">v<span id="versionNumber">-</span></div>
<div id="soundStatus" title="Sound ON (Press M to mute)">🔊</div>
<div id="perfMonitor"> <div id="perfMonitor">
<div><span class="label">FPS:</span> <span id="fps">60</span></div> <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> <div><span class="label">Frame:</span> <span id="frameTime">16.7</span>ms</div>
@ -159,7 +180,7 @@
<div id="instructions"> <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><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> </div>
<script type="module"> <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 { InvincibilitySystem } from '../systems/InvincibilitySystem.js';
import { ParticleSystem } from '../systems/ParticleSystem.js'; import { ParticleSystem } from '../systems/ParticleSystem.js';
import { PowerUpSystem } from '../systems/PowerUpSystem.js'; import { PowerUpSystem } from '../systems/PowerUpSystem.js';
import { SoundSystem } from '../systems/SoundSystem.js';
import { RenderSystem } from '../systems/RenderSystem.js'; import { RenderSystem } from '../systems/RenderSystem.js';
// Components // Components
@ -21,6 +22,7 @@ import { Health } from '../components/Health.js';
import { Invincibility } from '../components/Invincibility.js'; import { Invincibility } from '../components/Invincibility.js';
import { CoinType } from '../components/CoinType.js'; import { CoinType } from '../components/CoinType.js';
import { PowerUp } from '../components/PowerUp.js'; import { PowerUp } from '../components/PowerUp.js';
import { SoundEvent } from '../components/SoundEvent.js';
/** /**
* Main Game class - manages the game loop and coordinates all systems. * Main Game class - manages the game loop and coordinates all systems.
@ -248,6 +250,10 @@ export class Game {
}); });
this.world.addSystem(this.collisionSystem); 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) // Rendering (must be last to sync transforms)
this.world.addSystem(new RenderSystem(this.scene)); this.world.addSystem(new RenderSystem(this.scene));
} }
@ -306,6 +312,14 @@ export class Game {
const coinType = this.world.getComponent(coinEntity, CoinType); const coinType = this.world.getComponent(coinEntity, CoinType);
const coinPosition = coinTransform ? coinTransform.position.clone() : null; 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 // Remove coin
this.entityFactory.destroyEntity(coinEntity); this.entityFactory.destroyEntity(coinEntity);
const index = this.coins.indexOf(coinEntity); const index = this.coins.indexOf(coinEntity);
@ -354,11 +368,19 @@ export class Game {
if (timeSinceLastCoin <= GameConfig.COMBO_TIME_WINDOW && this.lastCoinTime > 0) { if (timeSinceLastCoin <= GameConfig.COMBO_TIME_WINDOW && this.lastCoinTime > 0) {
// Maintain combo // Maintain combo
const oldMultiplier = this.comboMultiplier;
this.comboMultiplier = Math.min( this.comboMultiplier = Math.min(
this.comboMultiplier + 1, this.comboMultiplier + 1,
GameConfig.COMBO_MULTIPLIER_MAX GameConfig.COMBO_MULTIPLIER_MAX
); );
this.comboTimer = GameConfig.COMBO_TIME_WINDOW; 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 { } else {
// Reset combo // Reset combo
this.comboMultiplier = 1; this.comboMultiplier = 1;
@ -377,6 +399,9 @@ export class Game {
if (this.score > this.highScore) { if (this.score > this.highScore) {
this.highScore = this.score; this.highScore = this.score;
this.saveHighScore(this.highScore); this.saveHighScore(this.highScore);
// Emit high score sound event
const highScoreEntity = this.world.createEntity();
this.world.addComponent(highScoreEntity, new SoundEvent('highscore'));
} }
this.updateUI(); this.updateUI();
@ -415,6 +440,10 @@ export class Game {
invincibility.activate(powerUp.duration); invincibility.activate(powerUp.duration);
} }
} }
// Emit power-up sound event
const powerUpSoundEntity = this.world.createEntity();
this.world.addComponent(powerUpSoundEntity, new SoundEvent('powerup'));
} }
// Emit particles // Emit particles
@ -502,6 +531,10 @@ export class Game {
this.comboMultiplier = 1; this.comboMultiplier = 1;
this.comboTimer = 0; this.comboTimer = 0;
// Emit damage sound event
const damageSoundEntity = this.world.createEntity();
this.world.addComponent(damageSoundEntity, new SoundEvent('damage'));
this.updateUI(); this.updateUI();
if (isDead) { if (isDead) {
@ -570,6 +603,10 @@ export class Game {
newHighScoreEl.style.display = 'none'; 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'; document.getElementById('gameOver').style.display = 'block';
} }
@ -624,6 +661,10 @@ export class Game {
if (e.key.toLowerCase() === 't') { if (e.key.toLowerCase() === 't') {
this.togglePerformanceMonitor(); this.togglePerformanceMonitor();
} }
// Toggle sound with 'M' key
if (e.key.toLowerCase() === 'm') {
this.toggleSound();
}
}); });
// Shake detection for mobile // Shake detection for mobile
@ -658,6 +699,28 @@ export class Game {
// Load version // Load version
this.loadVersion(); 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 * Handle device motion for shake detection
* @param {DeviceMotionEvent} event * @param {DeviceMotionEvent} event
@ -851,6 +939,9 @@ export class Game {
const health = this.world.getComponent(this.playerEntity, Health); const health = this.world.getComponent(this.playerEntity, Health);
if (health && health.currentHealth < health.maxHealth) { if (health && health.currentHealth < health.maxHealth) {
health.heal(GameConfig.HEALTH_REGEN_AMOUNT); 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.updateUI();
} }
this.healthRegenTimer = 0; this.healthRegenTimer = 0;

View file

@ -4,7 +4,7 @@
*/ */
export const GameConfig = { export const GameConfig = {
// Gameplay // 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, COIN_SCORE: 10,
HEALTH_REGEN_INTERVAL: 10, // Heal 1 HP every 10 seconds HEALTH_REGEN_INTERVAL: 10, // Heal 1 HP every 10 seconds
HEALTH_REGEN_AMOUNT: 1, HEALTH_REGEN_AMOUNT: 1,
@ -15,7 +15,7 @@ export const GameConfig = {
COMBO_BASE_SCORE: 10, // Base score per coin COMBO_BASE_SCORE: 10, // Base score per coin
// Invincibility // 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 INVINCIBILITY_FLASH_RATE: 0.1, // Seconds between flash toggles
// Screen Shake // Screen Shake

View file

@ -1,8 +1,6 @@
import { System } from '../ecs/System.js'; import { System } from '../ecs/System.js';
import { PowerUp } from '../components/PowerUp.js'; import { PowerUp } from '../components/PowerUp.js';
import { Transform } from '../components/Transform.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'; 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;
}
}
}