1/** 2 * Sound Manager for the game 3 * Implements the Observer pattern to respond to game events with sounds 4 * Enhanced to use simulation context filtering 5 */ 6class SoundManager { 7 constructor(events) { 8 this.events = events || window.gameEvents; 9 this.sounds = {}; 10 this.loadedSounds = 0; 11 this.totalSounds = 0; 12 this.isSoundEnabled = true; 13 this.isMuted = false; 14 this.isReady = false; 15 16 // Load sound preferences first 17 this.loadSoundPreferences(); 18 19 // Initialize and load sounds 20 this.initSounds(); 21 22 // Set up event listeners after a short delay to ensure game is initialized 23 setTimeout(() => this.setupEventListeners(), 100); 24 } 25 26 /** 27 * Initialize sound objects with error handling 28 */ 29 initSounds() { 30 const soundFiles = { 31 // Menu and UI sounds 32 buttonClick: 'assets/sounds/button-click.mp3', 33 menuButtonClick: 'assets/sounds/menu-button-click.mp3', 34 35 // Game sounds 36 tokenSelect: 'assets/sounds/token-select.mp3', 37 player1Move: 'assets/sounds/player1-move.mp3', 38 player2Move: 'assets/sounds/player2-move.mp3', 39 player1Capture: 'assets/sounds/player1-capture.mp3', 40 player2Capture: 'assets/sounds/player2-capture.mp3', 41 42 // Result sounds 43 matchWin: 'assets/sounds/match_win.mp3', 44 matchLose: 'assets/sounds/match-lose.mp3', 45 winTournament: 'assets/sounds/win-tournament.mp3' 46 }; 47 48 this.totalSounds = Object.keys(soundFiles).length; 49 50 // Create and load all sound objects 51 Object.entries(soundFiles).forEach(([name, path]) => { 52 try { 53 const audio = new Audio(); 54 55 // Add event listeners for loading and errors 56 audio.addEventListener('canplaythrough', () => { 57 this.loadedSounds++; 58 if (this.events && this.loadedSounds === this.totalSounds) { 59 this.isReady = true; 60 this.events.emit('sound:allLoaded', { 61 soundCount: this.totalSounds 62 }); 63 } 64 }, { once: true }); 65 66 audio.addEventListener('error', (e) => { 67 console.warn(`Failed to load sound ${name} from ${path}:`, e); 68 }); 69 70 // Set source and load 71 audio.src = path; 72 audio.volume = 0.5; 73 audio.load(); 74 75 this.sounds[name] = audio; 76 } catch (e) { 77 console.error(`Failed to create audio object for ${name}:`, e); 78 } 79 }); 80 } 81 82 /** 83 * Set up event listeners for game events 84 */ 85 setupEventListeners() { 86 if (!this.events) return; 87 88 // Define event to sound mappings for game events - using onReal to filter simulations 89 const eventSoundMap = { 90 // UI interactions - these are always real anyway 91 'ui:tokenSelected': (data) => { 92 // If the UIManager has already validated this token, use that information 93 if (data && data.isValidToken === true) { 94 return 'tokenSelect'; 95 } 96 97 // Otherwise, perform our own validation 98 if (data && data.position && window.game) { 99 const row = data.position.row; 100 const col = data.position.col; 101 const token = window.game.board.getTokenAt(row, col); 102 103 if (token && 104 token.color === window.game.currentPlayer && 105 token.isActive && 106 !token.isCaptured) { 107 return 'tokenSelect'; 108 } 109 } 110 return null; 111 }, 112 113 'ui:winModalShown': data => { 114 // In tournament mode, check if the winner is the player 115 if (window.game && window.game.isTournamentMode && window.game.tournamentManager) { 116 const playerColor = window.game.tournamentManager.playerColor; 117 return data.winner === playerColor ? 'matchWin' : 'matchLose'; 118 } 119 // In standard mode, use the default behavior (white = player) 120 return data.winner === 'white' ? 'matchWin' : 'matchLose'; 121 }, 122 123 // Move execution - now using onReal events 124 'move:executed': (data) => { 125 // Tournament mode - check who's moving based on player color 126 if (window.game && window.game.isTournamentMode && window.game.tournamentManager) { 127 const playerColor = window.game.tournamentManager.playerColor; 128 return data.player === playerColor ? 'player1Move' : 'player2Move'; 129 } 130 131 // Standard mode - use default logic (white = player1, black = player2) 132 return data.player === 'white' ? 'player1Move' : 'player2Move'; 133 }, 134 135 // Token events - capture sounds 136 'token:captured': (data) => { 137 // Play sound based on the color of the captured token 138 if (data && data.color) { 139 // In tournament mode, check if the captured token belongs to player or AI 140 if (window.game && window.game.isTournamentMode && window.game.tournamentManager) { 141 const playerColor = window.game.tournamentManager.playerColor; 142 // If the captured token belongs to the player, play player1Capture 143 // If it belongs to the AI, play player2Capture 144 return data.color === playerColor ? 'player1Capture' : 'player2Capture'; 145 } 146 147 // In standard mode, use the default logic 148 return data.color === 'white' ? 'player1Capture' : 'player2Capture'; 149 } 150 151 // Fallback 152 return 'player1Capture'; 153 }, 154 155 // Game state 156 'game:over': data => { 157 // In tournament mode, check if the winner is the player 158 if (window.game && window.game.isTournamentMode && window.game.tournamentManager) { 159 const playerColor = window.game.tournamentManager.playerColor; 160 return data.winner === playerColor ? 'matchWin' : 'matchLose'; 161 } 162 163 // In standard mode, use the default behavior (white = player) 164 return data.winner === 'white' ? 'matchWin' : 'matchLose'; 165 }, 166 167 // Tournament events 168 'tournament:completed': 'winTournament', 169 'tournament:gameEnded': data => { 170 // Check if the winner is the player 171 if (window.game && window.game.tournamentManager) { 172 const playerColor = window.game.tournamentManager.playerColor; 173 return data.winner === playerColor ? 'matchWin' : 'matchLose'; 174 } 175 // Fallback to default if tournament manager not available 176 return data.winner === 'white' ? 'matchWin' : 'matchLose'; 177 }, 178 }; 179 180 // Add tutorial events if they exist 181 if (typeof window.tutorialService !== 'undefined') { 182 eventSoundMap['tutorial:completed'] = 'winTournament'; 183 eventSoundMap['tutorial:stepCompleted'] = 'tokenSelect'; 184 } 185 186 // Register listeners based on mapping - always use onReal for gameplay events
187 Object.entries(eventSoundMap).forEach(([eventName, soundNameOrFn]) => { 188 // Gameplay events should use onReal to filter out simulations 189 if (eventName.startsWith('move:') || 190 eventName === 'token:captured' || 191 eventName === 'game:over') { 192 193 this.events.onReal(eventName, data => { 194 try { 195 const soundName = typeof soundNameOrFn === 'function' 196 ? soundNameOrFn(data) 197 : soundNameOrFn; 198 199 // Only play if soundName is not null 200 if (soundName) { 201 this.playSound(soundName); 202 } 203 } catch (e) { 204 console.warn(`Error playing sound for event ${eventName}:`, e); 205 } 206 }); 207 } else { 208 // UI events are already real (not simulated) so can use regular on 209 this.events.on(eventName, data => { 210 try { 211 const soundName = typeof soundNameOrFn === 'function' 212 ? soundNameOrFn(data) 213 : soundNameOrFn; 214 215 // Only play if soundName is not null 216 if (soundName) { 217 this.playSound(soundName); 218 } 219 } catch (e) { 220 console.warn(`Error playing sound for event ${eventName}:`, e); 221 } 222 }); 223 } 224 }); 225 226 // Add button click sounds 227 this.addButtonClickSounds(); 228 } 229 230 /** 231 * Add button click sounds to various UI elements 232 * Fixed version that prevents duplicates 233 */ 234 addButtonClickSounds() { 235 // Flag to track if document handler is already added 236 if (this._documentHandlerAdded) return; 237 this._documentHandlerAdded = true; 238 239 // Add direct click listeners to all buttons 240 const addButtonSounds = () => { 241 // Find all buttons in the document 242 const allButtons = document.querySelectorAll('button:not([data-sound-added])'); 243 244 allButtons.forEach(button => { 245 // Skip buttons that already have sound handlers 246 if (button.hasAttribute('data-sound-added')) return; 247 248 // Add the sound handler 249 button.addEventListener('click', (e) => { 250 // Skip sound toggle button to avoid double sounds 251 if (button.id === 'sound-toggle-btn' || button.closest('#sound-toggle')) return; 252 253 // Determine which sound to play based on context 254 if (button.closest('#main-menu') || 255 button.classList.contains('large-btn') || 256 button.closest('.tournament-controls')) { 257 this.playSound('menuButtonClick'); 258 } else { 259 this.playSound('buttonClick'); 260 } 261 262 // Mark event as having played a sound 263 e._soundPlayed = true; 264 }, true); // Use capture to ensure we get the event before stopPropagation 265 266 // Mark the button as having a sound handler 267 button.setAttribute('data-sound-added', 'true'); 268 }); 269 }; 270 271 // Initial application of sound handlers 272 addButtonSounds(); 273 274 // Add event delegation for dynamically added elements 275 document.addEventListener('click', e => { 276 // Skip if sound already played for this event or if clicked on/within element with sound handler 277 if (e._soundPlayed || e.target.closest('[data-sound-added]')) return; 278 279 // Handle cell clicks (but not token clicks) 280 const cell = e.target.closest('.cell'); 281 const token = e.target.closest('.token'); 282 283 if (cell && !token && !cell.hasAttribute('data-sound-added')) { 284 cell.setAttribute('data-sound-added', 'true'); 285 this.playSound('buttonClick'); 286 return; 287 } 288 289 // Handle ladder opponent clicks in tournament mode 290 const ladderOpponent = e.target.closest('.ladder-opponent'); 291 if (ladderOpponent && !ladderOpponent.hasAttribute('data-sound-added')) { 292 ladderOpponent.setAttribute('data-sound-added', 'true'); 293 this.playSound('menuButtonClick'); 294 return; 295 } 296 }); 297 298 // Use MutationObserver to detect new buttons 299 const observer = new MutationObserver((mutations) => { 300 let shouldAddSounds = false;
301 302 // Check if relevant nodes were added 303 for (const mutation of mutations) { 304 if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { 305 for (const node of mutation.addedNodes) { 306 if (node.nodeType === 1) { // Element node 307 shouldAddSounds = true; 308 break; 309 } 310 } 311 if (shouldAddSounds) break; 312 } 313 } 314 315 if (shouldAddSounds) { 316 addButtonSounds(); 317 } 318 }); 319 320 // Start observing document body 321 observer.observe(document.body, { 322 childList: true, 323 subtree: true 324 }); 325 } 326 327 /** 328 * Play a sound if sound is enabled 329 * @param {string} soundName - Name of the sound to play 330 */ 331 playSound(soundName) { 332 if (!this.isSoundEnabled || this.isMuted || !this.isReady) return; 333 334 const sound = this.sounds[soundName]; 335 if (sound) { 336 try { 337 // Try to use the original sound object (more efficient) 338 if (sound.paused || sound.ended) { 339 sound.currentTime = 0; 340 const playPromise = sound.play(); 341 342 if (playPromise !== undefined) { 343 playPromise.catch(e => { 344 console.warn(`Failed to play sound ${soundName} (original):`, e); 345 this.playClonedSound(sound, soundName); 346 }); 347 } 348 } else { 349 // Sound is already playing, use a clone 350 this.playClonedSound(sound, soundName); 351 } 352 } catch (e) { 353 console.warn(`Failed to play sound ${soundName}:`, e); 354 } 355 } 356 } 357 358 /** 359 * Play a cloned sound for overlapping sounds 360 * @param {HTMLAudioElement} sound - Original sound 361 * @param {string} soundName - Sound name for debug 362 */ 363 playClonedSound(sound, soundName) { 364 try { 365 const soundClone = sound.cloneNode(); 366 soundClone.volume = sound.volume; 367 368 const playPromise = soundClone.play(); 369 if (playPromise !== undefined) { 370 playPromise.catch(e => { 371 console.warn(`Failed to play sound ${soundName} (clone):`, e); 372 }); 373 } 374 } catch (e) { 375 console.warn(`Failed to clone sound ${soundName}:`, e); 376 } 377 } 378 379 /** 380 * Enable or disable all sounds 381 * @param {boolean} enabled - Whether sound should be enabled 382 */ 383 setSoundEnabled(enabled) { 384 this.isSoundEnabled = enabled; 385 386 // Save preference 387 this.saveSoundPreferences(); 388 389 // Emit event 390 if (this.events) { 391 this.events.emit('sound:enabledChanged', { 392 enabled: this.isSoundEnabled 393 }); 394 } 395 396 // Update UI toggle if it exists 397 this.updateSoundToggleUI(); 398 } 399 400 /** 401 * Toggle sound enabled state 402 * @returns {boolean} - New sound enabled state 403 */ 404 toggleSound() { 405 this.setSoundEnabled(!this.isSoundEnabled); 406 return this.isSoundEnabled; 407 } 408 409 /** 410 * Mute or unmute all sounds (temporary) 411 * @param {boolean} muted - Whether sound should be muted 412 */ 413 setMuted(muted) { 414 this.isMuted = muted; 415 416 // Emit event 417 if (this.events) { 418 this.events.emit('sound:mutedChanged', { 419 muted: this.isMuted 420 }); 421 } 422 } 423 424 /** 425 * Toggle mute state 426 * @returns {boolean} - New muted state 427 */ 428 toggleMute() { 429 this.setMuted(!this.isMuted); 430 return this.isMuted; 431 } 432 433 /** 434 * Set volume for all sounds 435 * @param {number} volume - Volume level (0 to 1) 436 */ 437 setVolume(volume) { 438 // Clamp volume between 0 and 1 439 const clampedVolume = Math.min(Math.max(volume, 0), 1); 440 441 // Set volume for all sounds 442 Object.values(this.sounds).forEach(sound => { 443 sound.volume = clampedVolume; 444 }); 445 446 // Emit event 447 if (this.events) { 448 this.events.emit('sound:volumeChanged', { 449 volume: clampedVolume 450 }); 451 } 452 } 453 454 /** 455 * Save sound preferences to localStorage 456 */ 457 saveSoundPreferences() { 458 try { 459 localStorage.setItem('pressure_sound_enabled', this.isSoundEnabled.toString()); 460 } catch (e) { 461 console.warn('Failed to save sound preferences:', e); 462 } 463 } 464 465 /** 466 * Load sound preferences from localStorage 467 */ 468 loadSoundPreferences() { 469 try { 470 const soundEnabled = localStorage.getItem('pressure_sound_enabled'); 471 if (soundEnabled !== null) { 472 this.isSoundEnabled = soundEnabled === 'true'; 473 } 474 } catch (e) { 475 console.warn('Failed to load sound preferences:', e); 476 } 477 } 478 479 /** 480 * Update the sound toggle UI elements 481 */ 482 updateSoundToggleUI() { 483 // Update floating sound toggle 484 const soundOn = document.getElementById('sound-on'); 485 const soundOff = document.getElementById('sound-off'); 486 487 if (soundOn && soundOff) { 488 soundOn.style.display = this.isSoundEnabled ? 'inline' : 'none'; 489 soundOff.style.display = this.isSoundEnabled ? 'none' : 'inline'; 490 } 491 492 // Update settings toggle button 493 const soundToggleBtn = document.getElementById('sound-toggle-btn'); 494 if (soundToggleBtn) { 495 soundToggleBtn.textContent = `Sound: ${this.isSoundEnabled ? 'ON' : 'OFF'}`; 496 } 497 } 498 499 /** 500 * Add floating sound toggle to the UI 501 */ 502 addSoundToggleToUI() { 503 // Don't add if it already exists 504 if (document.getElementById('sound-toggle')) return; 505 506 // Create sound toggle button 507 const soundToggle = document.createElement('div'); 508 soundToggle.id = 'sound-toggle'; 509 soundToggle.className = 'sound-toggle'; 510 soundToggle.innerHTML = `
511 <span id="sound-on" ${!this.isSoundEnabled ? 'style="display:none"' : ''}>🔊</span> 512 <span id="sound-off" ${this.isSoundEnabled ? 'style="display:none"' : ''}>🔇</span> 513 `; 514 515 // Add click event listener 516 soundToggle.addEventListener('click', () => { 517 const isEnabled = this.toggleSound(); 518 519 // Play sound when enabling 520 if (isEnabled) { 521 this.playSound('buttonClick'); 522 } 523 }); 524 525 // Add to document 526 document.body.appendChild(soundToggle); 527 } 528} 529 530// Create sound manager instance when DOM is loaded 531document.addEventListener('DOMContentLoaded', () => { 532 // Wait for event system to be available 533 const initSoundManager = () => { 534 if (window.gameEvents) { 535 window.soundManager = new SoundManager(window.gameEvents); 536 537 // Add sound toggle to UI after a short delay to ensure DOM is fully ready 538 setTimeout(() => { 539 if (window.soundManager) { 540 window.soundManager.addSoundToggleToUI(); 541 } 542 }, 500); 543 } else { 544 // Retry after a short delay 545 setTimeout(initSoundManager, 100); 546 } 547 }; 548 549 initSoundManager(); 550});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.