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
+1
View File
@@ -106,6 +106,7 @@ The lobby page is built for desktop with three columns: the **character sheet**
- **Skill and attribute checks**: players click an attribute or an unlocked skill on their own sheet. The server rolls a d100 (roll under): target = attribute or skill total + the DM's difficulty + the character's personal modifier. 01-05 is always a critical success, 96-100 always a critical failure. The chat and the table show the roll, the target and by how much it succeeded or failed. Combat is not handled specially: roll the normal skill and let the DM adjust with the modifiers.
- **Difficulty** (bar under the characters): the DM picks a level from the rules table (Trivial +60 ... Hellish -60) or a custom number. It applies to all checks until changed. With **Hide numbers from players** the players only see their roll and success / failure, not the target, difficulty or margin.
- **Dice** (below the table): d4 ... d100 with a count (1 to 10 dice). The server rolls, the dice tumble into the middle of the table in the colour of whoever threw them, and the result goes into the chat. With a person selected as recipient in the chat dropdown it is a private roll. **DM rolls** have three modes: *Mystery* (default: players see grey dice with "?" but not the result), *Hidden* (only the DM sees it) and *Public*.
- **Sound**: dice clatter when anything is rolled (yours, other players' and DM rolls, mystery rolls too), plus a short cue for the result of a check (rising chime for a critical success, soft ping for success, low tone for failure, dark double tone for a critical failure). The sounds are generated in the browser, there are no sound files. The speaker button next to the dice mutes them, the slider sets the volume; both are remembered per browser. Browsers only allow sound after the page was clicked once, and nothing plays while the tab is in the background.
- **Own dice**: with this switch on, a click on a die or a skill asks for the result of your real dice instead of rolling. The server only checks the range and marks the roll as "own dice" in the chat.
- **Colours**: every player has a different colour (lobby menu -> Your colour); the DM is gold.
- **Lobby menu** (top right): join code, rename, remove players, colour, switch character, leave or delete the lobby.
+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; }