Lobby: MP3 dice sound with pitch per die, DM sound settings
Default dice sound is public/dice.mp3 with a small random pitch and a base pitch per die size (d4 lighter ... d100 heavier). The DM can upload an own dice sound and sounds for critical success / failure per lobby, reset them, and switch result and critical sounds on or off. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
|
||||
import { sound, playRoll } from './sound.js'
|
||||
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
|
||||
import { MapView, MapLibrary, TokenForm, WallLayer } from './MapView.jsx'
|
||||
|
||||
const J = { 'Content-Type': 'application/json' }
|
||||
@@ -303,7 +303,7 @@ 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 }) {
|
||||
function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy, sfx }) {
|
||||
const [snd, setSnd] = useState(sound.enabled)
|
||||
const [vol, setVol] = useState(sound.volume)
|
||||
return (
|
||||
@@ -322,8 +322,8 @@ function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, on
|
||||
<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)} />}
|
||||
<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(20, 1, undefined, sfx) }}>{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(20, 1, undefined, sfx)} />}
|
||||
</span>
|
||||
{isDm && !to && (
|
||||
<span className="seg small" role="group" aria-label="Who sees the DM's rolls">
|
||||
@@ -498,6 +498,49 @@ function CharTab({ cid, rules, onRoll, tick }) {
|
||||
}
|
||||
|
||||
// lobby settings: join code, rename, colour, character switch, members, leave / delete
|
||||
// DM: replace the dice sound, add sounds for critical results, switch cues on or off
|
||||
function SoundSettings({ lobby, id, act, sfx }) {
|
||||
const sn = lobby.sounds
|
||||
const [err, setErr] = useState('')
|
||||
const upload = async (slot, e) => {
|
||||
const f = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!f) return
|
||||
setErr('')
|
||||
const r = await fetch(`/api/lobbies/${id}/sounds/${slot}`, { method: 'POST', headers: { 'Content-Type': 'audio/mpeg' }, body: f }).catch(() => null)
|
||||
if (!r || !r.ok) return setErr(r ? ((await r.json().catch(() => ({}))).detail || 'Upload failed') : 'Upload failed')
|
||||
act()
|
||||
}
|
||||
const flag = (k, v) => act(`/api/lobbies/${id}/sounds`, 'PUT', { [k]: v })
|
||||
const row = (slot, label, defaultText, flagKey) => (
|
||||
<div className="soundrow">
|
||||
<div className="row">
|
||||
{flagKey && <label className="small chk"><input type="checkbox" checked={!!sn[flagKey]} onChange={(e) => flag(flagKey, e.target.checked)} /> {label}</label>}
|
||||
{!flagKey && <strong className="small">{label}</strong>}
|
||||
</div>
|
||||
<div className="row small">
|
||||
<span className="muted">{sn.custom[slot] ? 'Your file' : defaultText}</span>
|
||||
<span className="row">
|
||||
<button className="ghost" onClick={() => playRoll(slot === 'roll' ? 20 : 100, 1, slot === 'roll' ? undefined : slot, { ...sfx, [`${slot}_on`]: true })}>▶ Test</button>
|
||||
<label className="uploadbtn small">Upload MP3<input type="file" accept="audio/mpeg,.mp3" hidden onChange={(e) => upload(slot, e)} /></label>
|
||||
{sn.custom[slot] && <button className="ghost" onClick={() => act(`/api/lobbies/${id}/sounds/${slot}`, 'DELETE')}>Reset</button>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="soundsettings">
|
||||
<h4>Sounds</h4>
|
||||
<p className="small muted">For everyone in this lobby. MP3 up to 1.5 MB; players can still mute the sounds for themselves.</p>
|
||||
{row('roll', 'Dice sound', 'Default sound', null)}
|
||||
<label className="small chk"><input type="checkbox" checked={!!sn.result} onChange={(e) => flag('result', e.target.checked)} /> Short tones for success / failure of a check</label>
|
||||
{row('crit_success', 'Critical success', 'Built-in tone', 'crit_success')}
|
||||
{row('crit_failure', 'Critical failure', 'Built-in tone', 'crit_failure')}
|
||||
{err && <p className="warn small">{err}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Settings({ lobby, id, mine, act, setInfo, onClose, inline }) {
|
||||
const [rename, setRename] = useState('')
|
||||
const [pick, setPick] = useState('')
|
||||
@@ -604,10 +647,17 @@ function LobbyView({ id, rules, user }) {
|
||||
return () => clearInterval(t)
|
||||
}, [load, loadMap, up])
|
||||
|
||||
const sfx = useRef({})
|
||||
const sn = lobby?.sounds
|
||||
sfx.current = sn ? { roll: sn.custom.roll ? `/api/lobbies/${id}/sounds/roll?v=${sn.v}` : DEFAULT_ROLL,
|
||||
crit_success: sn.custom.crit_success ? `/api/lobbies/${id}/sounds/crit_success?v=${sn.v}` : null,
|
||||
crit_failure: sn.custom.crit_failure ? `/api/lobbies/${id}/sounds/crit_failure?v=${sn.v}` : null,
|
||||
result: sn.result, crit_success_on: sn.crit_success, crit_failure_on: sn.crit_failure } : { roll: DEFAULT_ROLL }
|
||||
useEffect(() => { preload(sfx.current) }, [sn?.v]) // eslint-disable-line
|
||||
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)
|
||||
if (m.kind === 'check') playRoll(100, 1, m.check.outcome, sfx.current)
|
||||
else if (m.kind === 'roll') playRoll(m.roll.sides, m.roll.n, undefined, sfx.current)
|
||||
setTray((t) => [...t.slice(-2), { ...m, key }])
|
||||
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
|
||||
}, [])
|
||||
@@ -695,7 +745,7 @@ function LobbyView({ id, rules, user }) {
|
||||
<span className="tabright">{(!lobby.is_dm || leftTab === 'sheet') && <ThemeSwitch />} <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide this column">◀</button></span>
|
||||
</div>
|
||||
{lobby.is_dm && leftTab === 'lobby'
|
||||
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
|
||||
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
|
||||
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} />
|
||||
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
|
||||
</div>
|
||||
@@ -737,7 +787,7 @@ function LobbyView({ id, rules, user }) {
|
||||
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
|
||||
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
|
||||
</div>
|
||||
<DiceBar to={dest} me={user} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} />
|
||||
<DiceBar to={dest} me={user} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} sfx={sfx.current} />
|
||||
</div>
|
||||
|
||||
<aside className="lobbychat card" aria-label="Chat">
|
||||
|
||||
Reference in New Issue
Block a user