Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions demo/zemeroth-demo/audio.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
class AudioManager {
constructor() {
this.ctx = null;

Check warning on line 3 in demo/zemeroth-demo/audio.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer class field declaration over `this` assignment in constructor for static values.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Human-vs-bots&issues=AZ8GARhRaH3_uav0Qcgr&open=AZ8GARhRaH3_uav0Qcgr&pullRequest=30
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)();

Check warning on line 25 in demo/zemeroth-demo/audio.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Human-vs-bots&issues=AZ8GARhRaH3_uav0Qcgs&open=AZ8GARhRaH3_uav0Qcgs&pullRequest=30

Check warning on line 25 in demo/zemeroth-demo/audio.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Human-vs-bots&issues=AZ8GARhRaH3_uav0Qcgt&open=AZ8GARhRaH3_uav0Qcgt&pullRequest=30
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) {}

Check warning on line 136 in demo/zemeroth-demo/audio.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Human-vs-bots&issues=AZ8GARhRaH3_uav0Qcgu&open=AZ8GARhRaH3_uav0Qcgu&pullRequest=30
this.musicSource = null;
this.currentMusicName = null;
}
}

setVolume(type, value) {
if (this.volumes[type] !== undefined) {
this.volumes[type] = parseFloat(value);

Check warning on line 144 in demo/zemeroth-demo/audio.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseFloat` over `parseFloat`.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Human-vs-bots&issues=AZ8GARhRaH3_uav0Qcgv&open=AZ8GARhRaH3_uav0Qcgv&pullRequest=30
this.updateVolumes();
}
}

toggleMute() {
this.isMuted = !this.isMuted;
this.updateVolumes();
return this.isMuted;
}
}

export const audioManager = new AudioManager();
35 changes: 35 additions & 0 deletions demo/zemeroth-demo/game.js
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -385,6 +391,7 @@ function moveUnit(unit, q, r) {
clearUnitPos(unit);
placeUnit(unit, q, r);
unit.acted = true;
audioManager.playSfx('move');
}

function getUnitById(id) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
Expand All @@ -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;
}
Expand All @@ -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');
}

Expand All @@ -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;
Expand Down Expand Up @@ -851,13 +863,17 @@ 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();
}

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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1133,6 +1150,7 @@ ui.buildingSelect.addEventListener('change', () => {
});

ui.btnConnect.addEventListener('click', async () => {
audioManager.init();
const acc = await StellarGameService.connectWallet();
state.connected = true;
syncUi();
Expand Down Expand Up @@ -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');
});
Expand Down Expand Up @@ -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';
});
Expand All @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions demo/zemeroth-demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ <h1>Human vs Bots</h1>
</div>
</details>

<details class="menu-group">
<summary>Audio Settings</summary>
<div class="group-body">
<label class="field-label" for="volumeMaster">Master Volume</label>
<input type="range" id="volumeMaster" min="0" max="1" step="0.01" value="0.7">

<label class="field-label" for="volumeMusic">Music Volume</label>
<input type="range" id="volumeMusic" min="0" max="1" step="0.01" value="0.4">

<label class="field-label" for="volumeSFX">SFX Volume</label>
<input type="range" id="volumeSFX" min="0" max="1" step="0.01" value="0.6">

<button id="btnMute" class="action-mute">Mute Audio</button>
</div>
</details>

<details class="menu-group" open>
<summary>Match Control</summary>
<div class="group-body">
Expand Down
8 changes: 8 additions & 0 deletions demo/zemeroth-demo/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,13 @@ body {
letter-spacing: .2px;
}

input[type="range"] {
width: 100%;
margin-bottom: 12px;
cursor: pointer;
accent-color: var(--stone-hi);
}

select {
width: 100%;
border: 1px solid #776845;
Expand Down Expand Up @@ -294,6 +301,7 @@ button[class*="action-"]::before {
}

.action-wallet::before { content: '◉'; }
.action-mute::before { content: '🔊'; }
.action-start::before { content: '▶'; }
.action-end::before { content: '⏳'; }
.action-produce::before { content: '⚙'; }
Expand Down