feat: Implement sound system and toggle functionality
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 8s
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:
parent
4220e216e1
commit
e638ae4d6d
6 changed files with 720 additions and 5 deletions
23
index.html
23
index.html
|
|
@ -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">
|
||||||
|
|
|
||||||
21
src/components/SoundEvent.js
Normal file
21
src/components/SoundEvent.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
584
src/systems/SoundSystem.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue