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:
@@ -106,7 +106,8 @@ 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.
|
- **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.
|
- **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*.
|
- **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.
|
- **Sound**: rolling plays `frontend/public/dice.mp3` (the default dice sound) with a slight random pitch; every die has its own base pitch, from a little higher and lighter (d4) to a little lower and heavier (d100). Many dice layer the sound up to three times. Checks add a short cue after the dice: built-in tones for success and failure, and for a critical success or failure either a built-in tone or a sound the DM uploaded. The speaker button next to the dice mutes everything for yourself, the slider sets the volume (remembered per browser). Browsers only allow sound after the page was clicked once, and nothing plays while the tab is in the background.
|
||||||
|
- **DM sound settings** (*Lobby actions* -> *Sounds*): replace the dice sound with an own MP3 (up to 1.5 MB, per lobby) or reset it to the default, upload sounds for *Critical success* and *Critical failure* (reset = built-in tone), and switch the critical sounds and the short success / failure tones on or off for the whole lobby. *Test* plays a sound locally.
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **Lobby menu** (top right): join code, rename, remove players, colour, switch character, leave or delete the lobby.
|
||||||
@@ -148,6 +149,8 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
|
|||||||
| PUT | `/api/lobbies/{id}/doors/{wall}` , `/sight` | DM: open / close a door; line of sight on / off |
|
| PUT | `/api/lobbies/{id}/doors/{wall}` , `/sight` | DM: open / close a door; line of sight on / off |
|
||||||
| POST / PUT / DELETE | `/api/lobbies/{id}/tokens[/{tid}]` | Add NPC token (DM) / move (own token or DM) and edit (DM) / remove (DM) |
|
| POST / PUT / DELETE | `/api/lobbies/{id}/tokens[/{tid}]` | Add NPC token (DM) / move (own token or DM) and edit (DM) / remove (DM) |
|
||||||
| WebSocket | `/api/lobbies/{id}/ws` | Live notes `{t: messages / lobby / sheet / map}` for lobby members (cookie login, same origin) |
|
| WebSocket | `/api/lobbies/{id}/ws` | Live notes `{t: messages / lobby / sheet / map}` for lobby members (cookie login, same origin) |
|
||||||
|
| PUT | `/api/lobbies/{id}/sounds` | DM: switch result / critical sounds on or off |
|
||||||
|
| GET / POST / DELETE | `/api/lobbies/{id}/sounds/{roll\|crit_success\|crit_failure}` | Custom lobby sound (MP3): everyone gets it / DM uploads (raw body) / DM resets |
|
||||||
| GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper |
|
| GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper |
|
||||||
| POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?, manual?, mode?}` (d4-d20, d100; up to 10 dice). `manual` = results of real dice, `mode` (DM only) = `hidden`/`mystery`/`public` |
|
| POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?, manual?, mode?}` (d4-d20, d100; up to 10 dice). `manual` = results of real dice, `mode` (DM only) = `hidden`/`mystery`/`public` |
|
||||||
| POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character |
|
| POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character |
|
||||||
|
|||||||
+5
-1
@@ -16,6 +16,7 @@ from . import auth
|
|||||||
from .derive import compute
|
from .derive import compute
|
||||||
from . import rules_edit
|
from . import rules_edit
|
||||||
from . import maps as maps_mod
|
from . import maps as maps_mod
|
||||||
|
from . import sounds as sounds_mod
|
||||||
from .realtime import hub
|
from .realtime import hub
|
||||||
from .export import to_docx, to_pdf
|
from .export import to_docx, to_pdf
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ def db() -> sqlite3.Connection:
|
|||||||
if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
||||||
con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
||||||
maps_mod.schema(con)
|
maps_mod.schema(con)
|
||||||
|
sounds_mod.schema(con)
|
||||||
for table in ("characters", "presets"): # older databases: add the owner column
|
for table in ("characters", "presets"): # older databases: add the owner column
|
||||||
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
||||||
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
|
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
|
||||||
@@ -612,7 +614,7 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
|
|||||||
diff = {"label": "", "mod": None, "hidden": True}
|
diff = {"label": "", "mod": None, "hidden": True}
|
||||||
return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm,
|
return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm,
|
||||||
"code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff,
|
"code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff,
|
||||||
"levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR}
|
"sounds": sounds_mod.settings(lobby["sounds"]), "levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR}
|
||||||
|
|
||||||
|
|
||||||
@api.put("/lobbies/{lid}/difficulty")
|
@api.put("/lobbies/{lid}/difficulty")
|
||||||
@@ -841,6 +843,7 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)):
|
|||||||
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
|
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
|
||||||
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
|
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
|
||||||
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
|
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
|
||||||
|
purge_sounds(lid)
|
||||||
hub.close_lobby(lid)
|
hub.close_lobby(lid)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -1018,6 +1021,7 @@ def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin
|
|||||||
|
|
||||||
|
|
||||||
maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps")
|
maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps")
|
||||||
|
purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds")
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/api/lobbies/{lid}/ws")
|
@app.websocket("/api/lobbies/{lid}/ws")
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Lobby sounds: the DM can replace the dice sound and add sounds for critical successes / failures (MP3, per lobby)."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import Body, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
MAX_SOUND = 1_500_000 # bytes per file
|
||||||
|
SLOTS = ("roll", "crit_success", "crit_failure")
|
||||||
|
FLAGS = ("result", "crit_success", "crit_failure") # result = the short tones for success / failure of a check
|
||||||
|
|
||||||
|
|
||||||
|
def schema(con):
|
||||||
|
if "sounds" not in [r["name"] for r in con.execute("PRAGMA table_info(lobbies)")]:
|
||||||
|
con.execute("ALTER TABLE lobbies ADD COLUMN sounds TEXT NOT NULL DEFAULT '{}'")
|
||||||
|
|
||||||
|
|
||||||
|
def settings(raw) -> dict:
|
||||||
|
try:
|
||||||
|
s = json.loads(raw or "{}")
|
||||||
|
except ValueError:
|
||||||
|
s = {}
|
||||||
|
return {"result": s.get("result", True), "crit_success": s.get("crit_success", True), "crit_failure": s.get("crit_failure", True),
|
||||||
|
"custom": {k: bool(s.get("custom", {}).get(k)) for k in SLOTS}, "v": int(s.get("v", 0))}
|
||||||
|
|
||||||
|
|
||||||
|
def install(api, db, current_user, lobby_for, dm_only, hub, store_dir: Path):
|
||||||
|
store_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def path(lid: str, slot: str) -> Path:
|
||||||
|
if slot not in SLOTS:
|
||||||
|
raise HTTPException(404, "Unknown sound")
|
||||||
|
return store_dir / f"{lid}_{slot}.mp3"
|
||||||
|
|
||||||
|
def save(con, lid, s):
|
||||||
|
s["v"] += 1
|
||||||
|
con.execute("UPDATE lobbies SET sounds=? WHERE id=?", (json.dumps(s), lid))
|
||||||
|
|
||||||
|
@api.put("/lobbies/{lid}/sounds")
|
||||||
|
def set_flags(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
|
with db() as con:
|
||||||
|
lobby = lobby_for(con, lid, user)
|
||||||
|
dm_only(lobby, user)
|
||||||
|
s = settings(lobby["sounds"])
|
||||||
|
for k in FLAGS:
|
||||||
|
if k in body:
|
||||||
|
s[k] = bool(body[k])
|
||||||
|
save(con, lid, s)
|
||||||
|
hub.notify(lid, "lobby")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@api.post("/lobbies/{lid}/sounds/{slot}")
|
||||||
|
async def upload_sound(lid: str, slot: str, request: Request, user: dict = Depends(current_user)):
|
||||||
|
"""Raw MP3 bytes in the body."""
|
||||||
|
p = path(lid, slot)
|
||||||
|
with db() as con:
|
||||||
|
dm_only(lobby_for(con, lid, user), user)
|
||||||
|
if int(request.headers.get("content-length") or 0) > MAX_SOUND:
|
||||||
|
raise HTTPException(413, f"The file is too large (at most {MAX_SOUND // 1_000_000} MB)")
|
||||||
|
chunks, size = [], 0
|
||||||
|
async for c in request.stream():
|
||||||
|
size += len(c)
|
||||||
|
if size > MAX_SOUND:
|
||||||
|
raise HTTPException(413, f"The file is too large (at most {MAX_SOUND // 1_000_000} MB)")
|
||||||
|
chunks.append(c)
|
||||||
|
data = b"".join(chunks)
|
||||||
|
if not (data[:3] == b"ID3" or (len(data) > 4 and data[0] == 0xFF and data[1] & 0xE0 == 0xE0)):
|
||||||
|
raise HTTPException(422, "Please upload an MP3 file")
|
||||||
|
p.write_bytes(data)
|
||||||
|
with db() as con:
|
||||||
|
lobby = lobby_for(con, lid, user)
|
||||||
|
s = settings(lobby["sounds"])
|
||||||
|
s["custom"][slot] = True
|
||||||
|
save(con, lid, s)
|
||||||
|
hub.notify(lid, "lobby")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@api.delete("/lobbies/{lid}/sounds/{slot}")
|
||||||
|
def reset_sound(lid: str, slot: str, user: dict = Depends(current_user)):
|
||||||
|
p = path(lid, slot)
|
||||||
|
with db() as con:
|
||||||
|
lobby = lobby_for(con, lid, user)
|
||||||
|
dm_only(lobby, user)
|
||||||
|
s = settings(lobby["sounds"])
|
||||||
|
s["custom"][slot] = False
|
||||||
|
save(con, lid, s)
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
hub.notify(lid, "lobby")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@api.get("/lobbies/{lid}/sounds/{slot}")
|
||||||
|
def get_sound(lid: str, slot: str, v: int = 0, user: dict = Depends(current_user)):
|
||||||
|
p = path(lid, slot)
|
||||||
|
with db() as con:
|
||||||
|
lobby_for(con, lid, user)
|
||||||
|
if not p.exists():
|
||||||
|
raise HTTPException(404, "No custom sound")
|
||||||
|
return Response(p.read_bytes(), media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"})
|
||||||
|
|
||||||
|
def purge(lid: str):
|
||||||
|
for slot in SLOTS:
|
||||||
|
path(lid, slot).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
return purge
|
||||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
|
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'
|
import { MapView, MapLibrary, TokenForm, WallLayer } from './MapView.jsx'
|
||||||
|
|
||||||
const J = { 'Content-Type': 'application/json' }
|
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']]
|
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 [snd, setSnd] = useState(sound.enabled)
|
||||||
const [vol, setVol] = useState(sound.volume)
|
const [vol, setVol] = useState(sound.volume)
|
||||||
return (
|
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
|
<input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} style={{ width: 'auto' }} /> Own dice
|
||||||
</label>
|
</label>
|
||||||
<span className="soundctl small" title="Dice sounds">
|
<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>
|
<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(1)} />}
|
{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>
|
</span>
|
||||||
{isDm && !to && (
|
{isDm && !to && (
|
||||||
<span className="seg small" role="group" aria-label="Who sees the DM's rolls">
|
<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
|
// 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 }) {
|
function Settings({ lobby, id, mine, act, setInfo, onClose, inline }) {
|
||||||
const [rename, setRename] = useState('')
|
const [rename, setRename] = useState('')
|
||||||
const [pick, setPick] = useState('')
|
const [pick, setPick] = useState('')
|
||||||
@@ -604,10 +647,17 @@ function LobbyView({ id, rules, user }) {
|
|||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [load, loadMap, up])
|
}, [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 onRoll = useCallback((m) => {
|
||||||
const key = `${m.id}`
|
const key = `${m.id}`
|
||||||
if (m.kind === 'check') playRoll(1, m.check.outcome)
|
if (m.kind === 'check') playRoll(100, 1, m.check.outcome, sfx.current)
|
||||||
else if (m.kind === 'roll') playRoll(m.roll.n)
|
else if (m.kind === 'roll') playRoll(m.roll.sides, m.roll.n, undefined, sfx.current)
|
||||||
setTray((t) => [...t.slice(-2), { ...m, key }])
|
setTray((t) => [...t.slice(-2), { ...m, key }])
|
||||||
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
|
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>
|
<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>
|
</div>
|
||||||
{lobby.is_dm && leftTab === 'lobby'
|
{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} />
|
: 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>}
|
: <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>
|
</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 && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
|
||||||
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
|
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<aside className="lobbychat card" aria-label="Chat">
|
<aside className="lobbychat card" aria-label="Chat">
|
||||||
|
|||||||
+51
-52
@@ -1,4 +1,6 @@
|
|||||||
// 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.
|
// Dice sounds. The default is public/dice.mp3 (the DM can replace it per lobby), played with a slight random pitch and a pitch that
|
||||||
|
// depends on the die: d4 a little higher and lighter, d100 a little lower and heavier. Results of checks add a short cue; the cues for
|
||||||
|
// critical results can be custom MP3s from the DM, otherwise they are synthesized tones.
|
||||||
let ctx = null
|
let ctx = null
|
||||||
const get = (k) => { try { return localStorage.getItem(k) } catch { return 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 */ } }
|
const put = (k, v) => { try { localStorage.setItem(k, v) } catch { /* storage may be blocked */ } }
|
||||||
@@ -10,6 +12,9 @@ export const sound = {
|
|||||||
setVolume(v) { this.volume = v; put('aw_volume', String(v)) },
|
setVolume(v) { this.volume = v; put('aw_volume', String(v)) },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_ROLL = '/dice.mp3'
|
||||||
|
const PITCH = { 4: 1.08, 6: 1.05, 8: 1.025, 10: 1.0, 12: 0.98, 20: 0.955, 100: 0.92 } // playback speed per die (lighter ... heavier)
|
||||||
|
|
||||||
const audio = () => {
|
const audio = () => {
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
const C = window.AudioContext || window.webkitAudioContext
|
const C = window.AudioContext || window.webkitAudioContext
|
||||||
@@ -20,42 +25,28 @@ const audio = () => {
|
|||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
let noise = null
|
const buffers = new Map() // url -> Promise<AudioBuffer | null>
|
||||||
const noiseBuffer = (c) => {
|
const buffer = (url) => {
|
||||||
if (!noise) {
|
if (!url) return Promise.resolve(null)
|
||||||
noise = c.createBuffer(1, c.sampleRate * 0.25, c.sampleRate)
|
if (!buffers.has(url)) {
|
||||||
const d = noise.getChannelData(0)
|
const c = audio()
|
||||||
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1
|
buffers.set(url, !c ? Promise.resolve(null) : fetch(url).then((r) => (r.ok ? r.arrayBuffer() : null))
|
||||||
|
.then((b) => (b ? c.decodeAudioData(b) : null)).catch(() => { buffers.delete(url); return null }))
|
||||||
}
|
}
|
||||||
return noise
|
return buffers.get(url)
|
||||||
}
|
}
|
||||||
|
// load everything of a lobby up front, so the first roll does not wait for the download
|
||||||
|
export const preload = (cfg) => { if (cfg) [cfg.roll, cfg.crit_success, cfg.crit_failure].forEach(buffer) }
|
||||||
|
|
||||||
// one bounce of a heavy die on a wooden table: a low thump with a dull knock on top
|
const clip = (c, out, buf, t, rate, vol) => {
|
||||||
const tock = (c, out, t, strength) => {
|
const s = c.createBufferSource()
|
||||||
const src = c.createBufferSource()
|
s.buffer = buf
|
||||||
src.buffer = noiseBuffer(c)
|
s.playbackRate.value = rate
|
||||||
const f = c.createBiquadFilter()
|
|
||||||
f.type = 'bandpass'
|
|
||||||
f.frequency.value = 260 + Math.random() * 900
|
|
||||||
f.Q.value = 0.7 + Math.random()
|
|
||||||
const g = c.createGain()
|
const g = c.createGain()
|
||||||
g.gain.setValueAtTime(0.0001, t)
|
g.gain.value = vol
|
||||||
g.gain.exponentialRampToValueAtTime(strength, t + 0.004)
|
s.connect(g).connect(out)
|
||||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.07 + Math.random() * 0.06)
|
s.start(t)
|
||||||
src.connect(f).connect(g).connect(out)
|
return buf.duration / rate
|
||||||
src.start(t, Math.random() * 0.1)
|
|
||||||
src.stop(t + 0.2)
|
|
||||||
const o = c.createOscillator() // the weight: a sine that drops in pitch
|
|
||||||
o.type = 'sine'
|
|
||||||
o.frequency.setValueAtTime(130 + Math.random() * 40, t)
|
|
||||||
o.frequency.exponentialRampToValueAtTime(48, t + 0.14)
|
|
||||||
const og = c.createGain()
|
|
||||||
og.gain.setValueAtTime(0.0001, t)
|
|
||||||
og.gain.exponentialRampToValueAtTime(strength * 1.1, t + 0.006)
|
|
||||||
og.gain.exponentialRampToValueAtTime(0.0001, t + 0.16)
|
|
||||||
o.connect(og).connect(out)
|
|
||||||
o.start(t)
|
|
||||||
o.stop(t + 0.2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tone = (c, out, t, freq, len, type = 'triangle', vol = 0.25) => {
|
const tone = (c, out, t, freq, len, type = 'triangle', vol = 0.25) => {
|
||||||
@@ -71,31 +62,39 @@ const tone = (c, out, t, freq, len, type = 'triangle', vol = 0.25) => {
|
|||||||
o.stop(t + len + 0.05)
|
o.stop(t + len + 0.05)
|
||||||
}
|
}
|
||||||
|
|
||||||
// n dice thrown; outcome ('crit_success' | 'success' | 'failure' | 'crit_failure') adds a short cue after they settled
|
const cue = {
|
||||||
export function playRoll(n, outcome) {
|
crit_success: (c, out, t) => { tone(c, out, t, 196, 0.7, 'triangle', 0.3); tone(c, out, t + 0.16, 294, 0.8, 'triangle', 0.28); tone(c, out, t + 0.16, 392, 0.9, 'sine', 0.12) },
|
||||||
|
success: (c, out, t) => { tone(c, out, t, 147, 0.5, 'triangle', 0.3); tone(c, out, t, 220, 0.4, 'sine', 0.1) },
|
||||||
|
failure: (c, out, t) => tone(c, out, t, 82, 0.55, 'triangle', 0.35),
|
||||||
|
crit_failure: (c, out, t) => { tone(c, out, t, 98, 0.5, 'sawtooth', 0.14); tone(c, out, t + 0.22, 62, 1.0, 'sawtooth', 0.18) },
|
||||||
|
}
|
||||||
|
|
||||||
|
// sides: kind of die (100 for checks), n: number of dice, outcome: result of a check (optional)
|
||||||
|
// cfg: { roll: url, crit_success: url | null, crit_failure: url | null, result: bool, crit_success_on: bool, crit_failure_on: bool }
|
||||||
|
export async function playRoll(sides, n, outcome, cfg = {}) {
|
||||||
if (!sound.enabled || sound.volume <= 0 || document.hidden) return
|
if (!sound.enabled || sound.volume <= 0 || document.hidden) return
|
||||||
const c = audio()
|
const c = audio()
|
||||||
if (!c) return
|
if (!c) return
|
||||||
|
const [buf, custom] = await Promise.all([buffer(cfg.roll || DEFAULT_ROLL), outcome ? buffer(cfg[outcome]) : null])
|
||||||
const out = c.createGain()
|
const out = c.createGain()
|
||||||
out.gain.value = sound.volume
|
out.gain.value = sound.volume
|
||||||
const dull = c.createBiquadFilter() // takes the brightness off everything
|
out.connect(c.destination)
|
||||||
dull.type = 'lowpass'
|
|
||||||
dull.frequency.value = 2200
|
|
||||||
const comp = c.createDynamicsCompressor()
|
|
||||||
out.connect(dull).connect(comp).connect(c.destination)
|
|
||||||
const t0 = c.currentTime + 0.02
|
const t0 = c.currentTime + 0.02
|
||||||
let end = 0
|
let length = 0.9
|
||||||
for (let i = 0; i < Math.min(n, 10); i++) {
|
if (buf) {
|
||||||
let t = t0 + i * 0.11
|
const base = PITCH[sides] || 1
|
||||||
for (let k = 0; k < 5; k++) { // fewer, heavier bounces that die away quickly
|
const layers = Math.min(Math.max(n, 1), 3) // many dice: a few layers, each with its own pitch
|
||||||
tock(c, out, t, 0.85 * Math.pow(0.55, k) * (0.75 + Math.random() * 0.25))
|
for (let i = 0; i < layers; i++) {
|
||||||
t += 0.07 + k * 0.05 + Math.random() * 0.03
|
const rate = base * (1 + (Math.random() - 0.5) * 0.05)
|
||||||
|
length = Math.max(length, i * 0.08 + clip(c, out, buf, t0 + i * 0.08, rate, 1 / Math.sqrt(layers)))
|
||||||
}
|
}
|
||||||
end = Math.max(end, t)
|
|
||||||
}
|
}
|
||||||
const t1 = end + 0.1
|
if (!outcome) return
|
||||||
if (outcome === 'crit_success') { tone(c, out, t1, 196, 0.7, 'triangle', 0.3); tone(c, out, t1 + 0.16, 294, 0.8, 'triangle', 0.28); tone(c, out, t1 + 0.16, 392, 0.9, 'sine', 0.12) }
|
const t1 = t0 + Math.min(1.2, length * 0.75)
|
||||||
else if (outcome === 'success') { tone(c, out, t1, 147, 0.5, 'triangle', 0.3); tone(c, out, t1, 220, 0.4, 'sine', 0.1) }
|
const crit = outcome === 'crit_success' || outcome === 'crit_failure'
|
||||||
else if (outcome === 'failure') tone(c, out, t1, 82, 0.55, 'triangle', 0.35)
|
if (crit) {
|
||||||
else if (outcome === 'crit_failure') { tone(c, out, t1, 98, 0.5, 'sawtooth', 0.14); tone(c, out, t1 + 0.22, 62, 1.0, 'sawtooth', 0.18) }
|
if (cfg[`${outcome}_on`] === false) return
|
||||||
|
if (custom) clip(c, out, custom, t1, 1, 1)
|
||||||
|
else cue[outcome](c, out, t1)
|
||||||
|
} else if (cfg.result !== false) cue[outcome](c, out, t1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -462,3 +462,7 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
|
|||||||
.traytoken { font-size: .85rem; }
|
.traytoken { font-size: .85rem; }
|
||||||
.soundctl { display: inline-flex; align-items: center; gap: .3rem; }
|
.soundctl { display: inline-flex; align-items: center; gap: .3rem; }
|
||||||
.soundctl input[type=range] { width: 5rem; padding: 0; }
|
.soundctl input[type=range] { width: 5rem; padding: 0; }
|
||||||
|
.soundsettings { display: grid; gap: .5rem; margin-bottom: 1rem; }
|
||||||
|
.soundrow { display: grid; gap: .25rem; padding: .4rem; border: 1px solid var(--line); border-radius: 8px; }
|
||||||
|
.soundrow .row { justify-content: space-between; align-items: center; flex-wrap: wrap; }
|
||||||
|
.uploadbtn.small { padding: .15rem .6rem; font-size: .85rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user