Skip to content
Open
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
5 changes: 4 additions & 1 deletion site/beacon/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,14 @@
<div class="hud-actions">
<div class="hud-action" id="hud-new-contract">[NEW CONTRACT]</div>
<div class="hud-action" id="hud-bounties" style="color:#ffd700">[BOUNTIES]</div>
<button class="hud-action hud-sound" id="hud-sound" type="button" aria-label="Enable Beacon Atlas sound" aria-pressed="false">[SOUND OFF]</button>
</div>
</div>

<!-- Controls hint (bottom-left) -->
<div class="controls-hint">
DRAG rotate | SCROLL zoom | RIGHT-DRAG pan<br>
CLICK agent/city for details | ESC close
CLICK agent/city for details | SOUND toggle | ESC close
</div>

<!-- Info Panel (right side) -->
Expand Down Expand Up @@ -112,6 +113,7 @@
import { buildVehicles } from './vehicles.js';
import { buildBounties } from './bounties.js';
import { initUI, openContractForm, openBountiesPanel } from './ui.js';
import { initSoundControls } from './sound.js';
import { fetchAllAgents, replaceContracts, AGENTS, CITIES, CONTRACTS } from './data.js';

const fill = document.getElementById('loading-fill');
Expand Down Expand Up @@ -207,6 +209,7 @@
await tick();
setupInteraction(canvas);
initUI();
initSoundControls(document.getElementById('hud-sound'));

// Force HUD update with current counts
const hudEl = document.querySelector('.hud-stats');
Expand Down
218 changes: 218 additions & 0 deletions site/beacon/sound.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// SPDX-License-Identifier: MIT

const MASTER_LEVEL = 0.045;
const MAX_TRANSIENTS = 6;
const HOVER_COOLDOWN_MS = 120;

let audioContext = null;
let masterGain = null;
let ambientNodes = [];
let soundControl = null;
let soundEnabled = false;
let soundSupported = true;
let activeTransients = 0;
let lastHoverAt = 0;
let suspendTimer = null;
let delegatedEventsBound = false;

function audioContextConstructor() {
return globalThis.AudioContext || globalThis.webkitAudioContext || null;
}

function setParam(param, value, time) {
if (typeof param.setValueAtTime === 'function') {
param.setValueAtTime(value, time);
} else {
param.value = value;
}
}

function buildAudioGraph() {
if (audioContext && audioContext.state !== 'closed') return audioContext;

const AudioContextClass = audioContextConstructor();
if (!AudioContextClass) {
soundSupported = false;
updateControl();
return null;
}

audioContext = new AudioContextClass();
masterGain = audioContext.createGain();
setParam(masterGain.gain, 0, audioContext.currentTime);
masterGain.connect(audioContext.destination);

const lowPass = audioContext.createBiquadFilter();
lowPass.type = 'lowpass';
setParam(lowPass.frequency, 180, audioContext.currentTime);
setParam(lowPass.Q, 0.7, audioContext.currentTime);
lowPass.connect(masterGain);

ambientNodes = [
{ frequency: 55, level: 0.32, type: 'sine' },
{ frequency: 82.5, level: 0.12, type: 'triangle' },
].map(({ frequency, level, type }) => {
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
oscillator.type = type;
setParam(oscillator.frequency, frequency, audioContext.currentTime);
setParam(gain.gain, level, audioContext.currentTime);
oscillator.connect(gain);
gain.connect(lowPass);
oscillator.start();
return { oscillator, gain };
});

return audioContext;
}

function updateControl() {
if (!soundControl) return;

if (!soundSupported) {
soundControl.textContent = '[SOUND N/A]';
soundControl.disabled = true;
soundControl.setAttribute('aria-label', 'Sound is not supported by this browser');
soundControl.setAttribute('aria-pressed', 'false');
return;
}

soundControl.disabled = false;
soundControl.textContent = soundEnabled ? '[SOUND ON]' : '[SOUND OFF]';
soundControl.setAttribute('aria-label', soundEnabled ? 'Mute Beacon Atlas sound' : 'Enable Beacon Atlas sound');
soundControl.setAttribute('aria-pressed', String(soundEnabled));
soundControl.classList.toggle('is-active', soundEnabled);
}

function fadeMaster(target, seconds) {
if (!audioContext || !masterGain) return;
const now = audioContext.currentTime;
const gain = masterGain.gain;
gain.cancelScheduledValues?.(now);
setParam(gain, gain.value, now);
gain.linearRampToValueAtTime?.(target, now + seconds);
if (typeof gain.linearRampToValueAtTime !== 'function') gain.value = target;
}

export async function setSoundEnabled(enabled) {
if (!enabled) {
soundEnabled = false;
fadeMaster(0, 0.18);
updateControl();

clearTimeout(suspendTimer);
suspendTimer = setTimeout(() => {
if (!soundEnabled && audioContext?.state === 'running') audioContext.suspend();
}, 220);
return false;
}

const context = buildAudioGraph();
if (!context) return false;

clearTimeout(suspendTimer);
try {
if (context.state === 'suspended') await context.resume();
} catch (error) {
console.warn('[sound] Browser blocked audio activation:', error.message);
soundEnabled = false;
updateControl();
return false;
}

soundEnabled = context.state !== 'closed';
fadeMaster(MASTER_LEVEL, 0.25);
updateControl();
return soundEnabled;
}

function playTone(frequency, duration, level, waveform = 'sine') {
if (!soundEnabled || audioContext?.state !== 'running' || !masterGain) return false;
if (activeTransients >= MAX_TRANSIENTS) return false;

const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
const now = audioContext.currentTime;
oscillator.type = waveform;
setParam(oscillator.frequency, frequency, now);
setParam(gain.gain, 0.0001, now);
gain.gain.exponentialRampToValueAtTime?.(level, now + 0.01);
gain.gain.exponentialRampToValueAtTime?.(0.0001, now + duration);
oscillator.connect(gain);
gain.connect(masterGain);

activeTransients += 1;
oscillator.addEventListener('ended', () => {
oscillator.disconnect();
gain.disconnect();
activeTransients = Math.max(0, activeTransients - 1);
}, { once: true });
oscillator.start(now);
oscillator.stop(now + duration + 0.02);
return true;
}

export function playHoverTone() {
const now = performance.now();
if (now - lastHoverAt < HOVER_COOLDOWN_MS) return false;
lastHoverAt = now;
return playTone(720, 0.045, 0.12, 'sine');
}

export function playClickTone(kind = 'default') {
const frequencies = {
agent: 520,
city: 390,
close: 220,
toggle: 660,
default: 460,
};
return playTone(frequencies[kind] || frequencies.default, 0.09, 0.2, 'triangle');
}

function bindDelegatedFeedback() {
if (delegatedEventsBound || !globalThis.document?.addEventListener) return;
delegatedEventsBound = true;
const selector = 'a, button, [role="button"], .panel-dot, .bounty-card, .contract-new-btn';

document.addEventListener('pointerover', (event) => {
const target = event.target.closest?.(selector);
if (!target || target.contains(event.relatedTarget) || target === soundControl) return;
playHoverTone();
});

document.addEventListener('click', (event) => {
const target = event.target.closest?.(selector);
if (target && target !== soundControl) playClickTone();
});
}

export function initSoundControls(control) {
soundControl = control;
updateControl();
bindDelegatedFeedback();

if (!control) return;
control.addEventListener('click', async () => {
const activated = await setSoundEnabled(!soundEnabled);
if (activated) playClickTone('toggle');
});
globalThis.window?.addEventListener('pagehide', disposeSound, { once: true });
}

export function disposeSound() {
clearTimeout(suspendTimer);
soundEnabled = false;
ambientNodes.forEach(({ oscillator, gain }) => {
try { oscillator.stop(); } catch {}
oscillator.disconnect();
gain.disconnect();
});
ambientNodes = [];
masterGain?.disconnect();
masterGain = null;
if (audioContext && audioContext.state !== 'closed') audioContext.close();
audioContext = null;
activeTransients = 0;
updateControl();
}
25 changes: 25 additions & 0 deletions site/beacon/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,14 @@ html, body {
transition: text-shadow 0.2s;
}

button.hud-action {
appearance: none;
border: 0;
padding: 0;
background: transparent;
line-height: inherit;
}

.hud-actions {
display: flex;
gap: 16px;
Expand All @@ -616,6 +624,23 @@ html, body {
color: #ffc833;
}

.hud-sound.is-active,
.hud-sound[aria-pressed="true"] {
color: var(--green);
text-shadow: 0 0 12px var(--green-glow);
}

.hud-sound:focus-visible {
outline: 1px dashed var(--amber);
outline-offset: 3px;
}

.hud-sound:disabled {
color: var(--text-dim);
cursor: not-allowed;
opacity: 0.7;
}

/* --- Bounty cards --- */
.bounty-card {
background: rgba(0, 20, 0, 0.6);
Expand Down
27 changes: 17 additions & 10 deletions site/beacon/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getAgentPosition, highlightAgent } from './agents.js';
import { getCityCenter } from './cities.js';
import { highlightAgentConnections, addContractLine } from './connections.js';
import { initChat, setCurrentAgent, getChatHTML, bindChatEvents } from './chat.js';
import { playClickTone, playHoverTone } from './sound.js';

const BEACON_API = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
? 'http://localhost:8071'
Expand All @@ -20,15 +21,7 @@ let panel, panelContent, panelPath, tooltip;
let selectedAgent = null;
let selectedCity = null;
let hoveredId = null;

function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
let hoveredObjectKey = null;

function safeNumber(value, fallback = 0, min = 0, max = Number.MAX_SAFE_INTEGER) {
const number = Number(value);
Expand Down Expand Up @@ -107,7 +100,10 @@ export function initUI() {
tooltip = document.querySelector('.tooltip');

// Close button
document.querySelector('.panel-dot').addEventListener('click', closePanel);
document.querySelector('.panel-dot').addEventListener('click', () => {
playClickTone('close');
closePanel();
});

// HUD stats
updateHUD();
Expand Down Expand Up @@ -166,6 +162,8 @@ function setPanelPath(path) {
function onObjectClick(mesh) {
const data = mesh.userData;

if (data.type === 'agent' || data.type === 'city') playClickTone(data.type);

if (data.type === 'agent') {
selectAgent(data.agentId);
} else if (data.type === 'city') {
Expand All @@ -179,6 +177,7 @@ function onObjectHover(hit, event) {
highlightAgent(hoveredId, false);
hoveredId = null;
}
hoveredObjectKey = null;
tooltip.classList.remove('visible');
document.body.style.cursor = 'default';
return;
Expand All @@ -187,6 +186,14 @@ function onObjectHover(hit, event) {
const data = hit.object.userData;
document.body.style.cursor = 'pointer';

const hoverKey = data.type === 'agent'
? `agent:${data.agentId}`
: data.type === 'city' ? `city:${data.cityId}` : null;
if (hoverKey && hoverKey !== hoveredObjectKey) {
hoveredObjectKey = hoverKey;
playHoverTone();
}

if (data.type === 'agent' && data.agentId !== hoveredId) {
if (hoveredId) highlightAgent(hoveredId, false);
hoveredId = data.agentId;
Expand Down
Loading