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:
@@ -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)
|
||||
}, [])
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user