diff --git a/demo/zemeroth-demo/audio.js b/demo/zemeroth-demo/audio.js new file mode 100644 index 0000000..e93efef --- /dev/null +++ b/demo/zemeroth-demo/audio.js @@ -0,0 +1,156 @@ +class AudioManager { + constructor() { + this.ctx = null; + this.masterGain = null; + this.musicGain = null; + this.sfxGain = null; + this.musicSource = null; + this.sounds = {}; + this.music = {}; + this.isMuted = false; + this.initialized = false; + + const saved = localStorage.getItem('hvb_audio_volumes'); + this.volumes = saved ? JSON.parse(saved) : { + master: 0.7, + music: 0.4, + sfx: 0.6 + }; + } + + async init() { + if (this.initialized) return; + + try { + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); + this.masterGain = this.ctx.createGain(); + this.musicGain = this.ctx.createGain(); + this.sfxGain = this.ctx.createGain(); + + this.masterGain.connect(this.ctx.destination); + this.musicGain.connect(this.masterGain); + this.sfxGain.connect(this.masterGain); + + this.updateVolumes(); + this.initialized = true; + + // Background load + this.loadAssets(); + console.log("Audio Manager Initialized"); + } catch (e) { + console.error("AudioContext not supported", e); + } + } + + updateVolumes() { + if (!this.ctx) return; + const muteFactor = this.isMuted ? 0 : 1; + // Use setTargetAtTime for smooth transitions + this.masterGain.gain.setTargetAtTime(this.volumes.master * muteFactor, this.ctx.currentTime, 0.05); + this.musicGain.gain.setTargetAtTime(this.volumes.music, this.ctx.currentTime, 0.05); + this.sfxGain.gain.setTargetAtTime(this.volumes.sfx, this.ctx.currentTime, 0.05); + localStorage.setItem('hvb_audio_volumes', JSON.stringify(this.volumes)); + } + + async loadAssets() { + // High quality royalty free assets from Mixkit/SoundHelix for demo feel + const sfxUrls = { + click: 'https://assets.mixkit.co/sfx/preview/mixkit-simple-click-interface-1111.mp3', + move: 'https://assets.mixkit.co/sfx/preview/mixkit-fast-small-sweep-transition-166.mp3', + attack: 'https://assets.mixkit.co/sfx/preview/mixkit-light-impact-with-metallic-reverb-2144.mp3', + produce: 'https://assets.mixkit.co/sfx/preview/mixkit-industrial-mechanical-click-2141.mp3', + endTurn: 'https://assets.mixkit.co/sfx/preview/mixkit-magic-click-soft-hit-1118.mp3', + victory: 'https://assets.mixkit.co/sfx/preview/mixkit-winning-chimes-2015.mp3', + defeat: 'https://assets.mixkit.co/sfx/preview/mixkit-game-over-dark-orchestra-633.mp3' + }; + + const musicUrls = { + menu: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-15.mp3', + game: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-10.mp3' + }; + + for (const [name, url] of Object.entries(sfxUrls)) { + this.loadBuffer(url).then(buffer => { if (buffer) this.sounds[name] = buffer; }); + } + for (const [name, url] of Object.entries(musicUrls)) { + this.loadBuffer(url).then(buffer => { + if (buffer) { + this.music[name] = buffer; + // If we were waiting for this music to start + if (this.pendingMusic === name) { + this.playMusic(name); + } + } + }); + } + } + + async loadBuffer(url) { + try { + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + return await this.ctx.decodeAudioData(arrayBuffer); + } catch (e) { + console.warn(`Failed to load audio: ${url}`, e); + return null; + } + } + + playSfx(name) { + if (!this.initialized || !this.sounds[name]) return; + if (this.ctx.state === 'suspended') this.ctx.resume(); + + const source = this.ctx.createBufferSource(); + source.buffer = this.sounds[name]; + source.connect(this.sfxGain); + source.start(0); + } + + playMusic(name) { + if (!this.initialized) { + this.pendingMusic = name; + return; + } + if (!this.music[name]) { + this.pendingMusic = name; + return; + } + + if (this.currentMusicName === name) return; + if (this.ctx.state === 'suspended') this.ctx.resume(); + + this.stopMusic(); + this.musicSource = this.ctx.createBufferSource(); + this.musicSource.buffer = this.music[name]; + this.musicSource.loop = true; + this.musicSource.connect(this.musicGain); + this.musicSource.start(0); + this.currentMusicName = name; + this.pendingMusic = null; + } + + stopMusic() { + if (this.musicSource) { + try { + this.musicSource.stop(); + } catch (e) {} + this.musicSource = null; + this.currentMusicName = null; + } + } + + setVolume(type, value) { + if (this.volumes[type] !== undefined) { + this.volumes[type] = parseFloat(value); + this.updateVolumes(); + } + } + + toggleMute() { + this.isMuted = !this.isMuted; + this.updateVolumes(); + return this.isMuted; + } +} + +export const audioManager = new AudioManager(); diff --git a/demo/zemeroth-demo/game.js b/demo/zemeroth-demo/game.js index 0a8799f..0bfd291 100644 --- a/demo/zemeroth-demo/game.js +++ b/demo/zemeroth-demo/game.js @@ -1,5 +1,7 @@ const canvas = document.getElementById('arena'); const ctx = canvas.getContext('2d'); +import { audioManager } from './audio.js'; + const HEX_SIZE = 34; const SQRT3 = Math.sqrt(3); @@ -39,6 +41,10 @@ const ui = { btnZoomIn: document.getElementById('btnZoomIn'), btnZoomOut: document.getElementById('btnZoomOut'), btnZoomReset: document.getElementById('btnZoomReset'), + volumeMaster: document.getElementById('volumeMaster'), + volumeMusic: document.getElementById('volumeMusic'), + volumeSFX: document.getElementById('volumeSFX'), + btnMute: document.getElementById('btnMute'), walletState: document.getElementById('walletState'), humansCount: document.getElementById('humansCount'), botsCount: document.getElementById('botsCount'), @@ -385,6 +391,7 @@ function moveUnit(unit, q, r) { clearUnitPos(unit); placeUnit(unit, q, r); unit.acted = true; + audioManager.playSfx('move'); } function getUnitById(id) { @@ -485,6 +492,7 @@ function attack(attacker, defender) { const dmg = Math.max(6, Math.round(attacker.atk / terrainDef)); defender.hp -= dmg; attacker.acted = true; + audioManager.playSfx('attack'); log(`${attacker.kind.toUpperCase()} ${attacker.unitType} hits ${defender.kind.toUpperCase()} ${defender.unitType} for ${dmg}`); if (defender.hp <= 0) { removeUnit(defender); @@ -670,6 +678,7 @@ function produceHumanUnit() { state.humans.push(unit); structure.acted = true; state.mapByKey[key(spawn.q, spawn.r)].owner = 'human'; + audioManager.playSfx('produce'); log(`${structure.type} produced ${unitType}`, 'ok'); syncUi(); } @@ -694,6 +703,7 @@ function produceHumanAiUnit() { state.humans.push(unit); attempt.structure.acted = true; state.mapByKey[key(spawn.q, spawn.r)].owner = 'human'; + audioManager.playSfx('produce'); log(`LLM A produced ${attempt.unitType}`); return; } @@ -716,6 +726,7 @@ function produceBotRobot(force = false) { state.bots.push(robot); core.acted = true; state.mapByKey[key(spawn.q, spawn.r)].owner = 'bot'; + audioManager.playSfx('produce'); log('Bot tech-core produced robot', 'warn'); } @@ -735,6 +746,7 @@ function handlePlayerCellClick(cell) { humanOnCell.selected = true; state.selectedUnitId = humanOnCell.id; state.captureMode = false; + audioManager.playSfx('click'); log(`Selected human ${humanOnCell.unitType}`); syncUi(); return; @@ -851,6 +863,7 @@ function endPlayerTurn() { state.phase = 'player'; state.turn += 1; + audioManager.playSfx('endTurn'); const proof = buildProofSnapshot('turn'); log(`Turn ${state.turn} closed. Proof: ${proof.proofInputHash.slice(0, 14)}...`, 'ok'); syncUi(); @@ -858,6 +871,9 @@ function endPlayerTurn() { function finishMatch(result) { if (!state.inMatch) return; + const isVictory = result.includes('Win'); + audioManager.playSfx(isVictory ? 'victory' : 'defeat'); + audioManager.playMusic('menu'); state.inMatch = false; state.captureMode = false; ui.resultBanner.textContent = result; @@ -1088,6 +1104,7 @@ function resetArena() { } canvas.addEventListener('click', event => { + audioManager.init(); const rect = canvas.getBoundingClientRect(); const sx = (event.clientX - rect.left) * (canvas.width / rect.width); const sy = (event.clientY - rect.top) * (canvas.height / rect.height); @@ -1133,6 +1150,7 @@ ui.buildingSelect.addEventListener('change', () => { }); ui.btnConnect.addEventListener('click', async () => { + audioManager.init(); const acc = await StellarGameService.connectWallet(); state.connected = true; syncUi(); @@ -1165,6 +1183,7 @@ ui.btnStart.addEventListener('click', async () => { Object.values(state.structures.bot).forEach(structure => { structure.acted = false; }); ui.resultBanner.classList.remove('show'); + audioManager.playMusic('game'); syncUi(); log(`start_game sent: ${tx.txHash} • ${state.matchMode === 'llm-vs-llm' ? `${formatModelLabel(state.selectedHumanModel)} vs ${formatModelLabel(state.selectedAI)}` : `Human vs ${formatModelLabel(state.selectedAI)}`}`, 'ok'); }); @@ -1236,6 +1255,8 @@ ui.btnEnd.addEventListener('click', async () => { ui.btnReset.addEventListener('click', resetArena); ui.btnTogglePanel.addEventListener('click', () => { + audioManager.init(); // Initialize audio context on first user interaction + audioManager.playMusic('menu'); ui.panelDrawer.classList.toggle('open'); ui.btnTogglePanel.textContent = ui.panelDrawer.classList.contains('open') ? '✕ Menu' : '☰ Menu'; }); @@ -1244,6 +1265,20 @@ ui.btnZoomIn.addEventListener('click', () => setZoom(state.camera.zoom + 0.1)); ui.btnZoomOut.addEventListener('click', () => setZoom(state.camera.zoom - 0.1)); ui.btnZoomReset.addEventListener('click', () => setZoom(1)); +ui.volumeMaster.addEventListener('input', (e) => audioManager.setVolume('master', e.target.value)); +ui.volumeMusic.addEventListener('input', (e) => audioManager.setVolume('music', e.target.value)); +ui.volumeSFX.addEventListener('input', (e) => audioManager.setVolume('sfx', e.target.value)); +ui.btnMute.addEventListener('click', () => { + const muted = audioManager.toggleMute(); + ui.btnMute.textContent = muted ? 'Unmute Audio' : 'Mute Audio'; + ui.btnMute.classList.toggle('active-turn', muted); +}); + +// Load saved volumes into UI +ui.volumeMaster.value = audioManager.volumes.master; +ui.volumeMusic.value = audioManager.volumes.music; +ui.volumeSFX.value = audioManager.volumes.sfx; + resetArena(); setZoom(1); requestAnimationFrame(gameLoop); diff --git a/demo/zemeroth-demo/index.html b/demo/zemeroth-demo/index.html index 2a2d517..589f8b1 100644 --- a/demo/zemeroth-demo/index.html +++ b/demo/zemeroth-demo/index.html @@ -61,6 +61,22 @@