Lobby: dice sounds

Clatter per die and a result cue for checks, synthesized with the Web
Audio API; mute button and volume slider next to the dice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-23 18:42:46 +00:00
parent 82e4ac9458
commit 2195ecce94
4 changed files with 99 additions and 0 deletions
+9
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
import { sound, playRoll } from './sound.js'
import { MapView, MapLibrary, TokenForm, WallLayer } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' }
@@ -303,6 +304,8 @@ function DieIcon({ sides }) {
const MODES = [['mystery', 'Mystery', 'Players see grey dice, but not the result'], ['hidden', 'Hidden', 'Only you see the roll'], ['public', 'Public', 'Everybody sees the roll']]
function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
const [snd, setSnd] = useState(sound.enabled)
const [vol, setVol] = useState(sound.volume)
return (
<div className="dicebar" role="toolbar" aria-label="Dice">
<label className="small muted dicecount">Dice
@@ -318,6 +321,10 @@ function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, on
<label className="small ownswitch" title="You rolled real dice: type in the result instead of rolling here">
<input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} style={{ width: 'auto' }} /> Own dice
</label>
<span className="soundctl small" title="Dice sounds">
<button className="ghost" aria-pressed={snd} aria-label={snd ? 'Mute dice sounds' : 'Turn dice sounds on'} onClick={() => { sound.setEnabled(!snd); setSnd(!snd); if (!snd) playRoll(1, 'success') }}>{snd ? '🔊' : '🔇'}</button>
{snd && <input type="range" min="0" max="1" step="0.05" value={vol} aria-label="Dice sound volume" onChange={(e) => { setVol(+e.target.value); sound.setVolume(+e.target.value) }} onPointerUp={() => playRoll(1)} />}
</span>
{isDm && !to && (
<span className="seg small" role="group" aria-label="Who sees the DM's rolls">
{MODES.map(([k, label, tip]) => <button key={k} className={mode === k ? 'on' : 'ghost'} title={tip} onClick={() => setMode(k)}>{label}</button>)}
@@ -599,6 +606,8 @@ function LobbyView({ id, rules, user }) {
const onRoll = useCallback((m) => {
const key = `${m.id}`
if (m.kind === 'check') playRoll(1, m.check.outcome)
else if (m.kind === 'roll') playRoll(m.roll.n)
setTray((t) => [...t.slice(-2), { ...m, key }])
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
}, [])
+87
View File
@@ -0,0 +1,87 @@
// Dice sounds, made with the Web Audio API (no sound files): a clatter per die and a short cue for the result of a check.
let ctx = null
const get = (k) => { try { return localStorage.getItem(k) } catch { return null } }
const put = (k, v) => { try { localStorage.setItem(k, v) } catch { /* storage may be blocked */ } }
export const sound = {
enabled: get('aw_sound') !== '0',
volume: Number(get('aw_volume') ?? 0.6),
setEnabled(v) { this.enabled = v; put('aw_sound', v ? '1' : '0') },
setVolume(v) { this.volume = v; put('aw_volume', String(v)) },
}
const audio = () => {
if (!ctx) {
const C = window.AudioContext || window.webkitAudioContext
if (!C) return null
ctx = new C()
}
if (ctx.state === 'suspended') ctx.resume().catch(() => {}) // browsers allow sound only after the page was clicked once
return ctx
}
let noise = null
const noiseBuffer = (c) => {
if (!noise) {
noise = c.createBuffer(1, c.sampleRate * 0.25, c.sampleRate)
const d = noise.getChannelData(0)
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1
}
return noise
}
// one "tock" of a die hitting the table
const tock = (c, out, t, strength) => {
const src = c.createBufferSource()
src.buffer = noiseBuffer(c)
const f = c.createBiquadFilter()
f.type = 'bandpass'
f.frequency.value = 900 + Math.random() * 3200
f.Q.value = 1.2 + Math.random() * 2
const g = c.createGain()
g.gain.setValueAtTime(0.0001, t)
g.gain.exponentialRampToValueAtTime(strength, t + 0.003)
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.04 + Math.random() * 0.04)
src.connect(f).connect(g).connect(out)
src.start(t, Math.random() * 0.1)
src.stop(t + 0.12)
}
const tone = (c, out, t, freq, len, type = 'sine', vol = 0.25) => {
const o = c.createOscillator()
o.type = type
o.frequency.value = freq
const g = c.createGain()
g.gain.setValueAtTime(0.0001, t)
g.gain.exponentialRampToValueAtTime(vol, t + 0.015)
g.gain.exponentialRampToValueAtTime(0.0001, t + len)
o.connect(g).connect(out)
o.start(t)
o.stop(t + len + 0.05)
}
// n dice thrown; outcome ('crit_success' | 'success' | 'failure' | 'crit_failure') adds a short cue after they settled
export function playRoll(n, outcome) {
if (!sound.enabled || sound.volume <= 0 || document.hidden) return
const c = audio()
if (!c) return
const out = c.createGain()
out.gain.value = sound.volume
out.connect(c.destination)
const t0 = c.currentTime + 0.02
let end = 0
for (let i = 0; i < Math.min(n, 10); i++) {
const start = t0 + i * 0.09
let t = start
for (let k = 0; k < 6; k++) { // bounces get softer and further apart
tock(c, out, t, 0.9 * Math.pow(0.62, k) * (0.7 + Math.random() * 0.3))
t += 0.045 + k * 0.035 + Math.random() * 0.03
}
end = Math.max(end, t)
}
const t1 = end + 0.12
if (outcome === 'crit_success') { tone(c, out, t1, 784, 0.35, 'sine', 0.22); tone(c, out, t1 + 0.12, 1175, 0.5, 'sine', 0.22); tone(c, out, t1 + 0.12, 1568, 0.5, 'sine', 0.08) }
else if (outcome === 'success') tone(c, out, t1, 698, 0.3, 'sine', 0.2)
else if (outcome === 'failure') tone(c, out, t1, 196, 0.28, 'triangle', 0.22)
else if (outcome === 'crit_failure') { tone(c, out, t1, 220, 0.3, 'sawtooth', 0.12); tone(c, out, t1 + 0.18, 147, 0.6, 'sawtooth', 0.14) }
}
+2
View File
@@ -460,3 +460,5 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
.maptools button, .maptools select { padding: .2rem .55rem; font-size: .8rem; min-height: 0; white-space: nowrap; width: auto; }
.maptray { max-width: 38%; }
.traytoken { font-size: .85rem; }
.soundctl { display: inline-flex; align-items: center; gap: .3rem; }
.soundctl input[type=range] { width: 5rem; padding: 0; }