Map ping with sound (custom MP3 per lobby)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
2026-10-25 09:18:46 +00:00
co-authored by Claude Sonnet 5.5
parent 2087ce9960
commit 0f97129243
7 changed files with 81 additions and 12 deletions
+21 -6
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
import { sound, playRoll, playPing, preload, DEFAULT_ROLL } from './sound.js'
import { MapView, MapLibrary, NpcPicker, PropForm, PropPicker, RadialMenu, RevealLayer, StatusForm, TokenForm, WallLayer } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' }
@@ -608,7 +608,7 @@ function SoundSettings({ lobby, id, act, sfx }) {
<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>
<button className="ghost" onClick={() => (slot === 'ping' ? playPing({ ...sfx, ping_on: true }) : 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>
@@ -623,6 +623,7 @@ function SoundSettings({ lobby, id, act, sfx }) {
<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')}
{row('ping', 'Map ping', 'Built-in tone', 'ping')}
{err && <p className="warn small">{err}</p>}
</div>
)
@@ -722,7 +723,10 @@ function LobbyView({ id, rules, user }) {
const fogImg = useFogImage(id, mapState.vision?.fog, viewAs)
const sock = useRef({})
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
const [pings, setPings] = useState([])
const [pingMode, setPingMode] = useState(false)
const up = useSocket(id, {
ping: (n) => sock.current.ping?.(n),
open: () => { sock.current.load?.(); sock.current.poll?.(); sock.current.loadMap?.() },
map: () => sock.current.loadMap?.(),
messages: () => sock.current.poll?.(),
@@ -740,6 +744,7 @@ function LobbyView({ id, rules, user }) {
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,
ping: sn.custom.ping ? `/api/lobbies/${id}/sounds/ping?v=${sn.v}` : null, ping_on: sn.ping,
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) => {
@@ -750,7 +755,16 @@ function LobbyView({ id, rules, user }) {
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
}, [])
const feed = useFeed(id, load, onRoll, up)
sock.current = { load, poll: feed.poll, loadMap }
const showPing = (n) => {
if (!lobby) return
const mem = lobby.members.find((m) => m.username === n.user)
const color = n.dm ? lobby.dm_color : lobby.palette.find((p) => p.id === mem?.color)?.hex || '#ffffff'
const key = `${Date.now()}${Math.random()}`
setPings((l) => [...l.slice(-5), { key, x: n.x, y: n.y, color, name: n.dm ? 'DM' : mem?.character?.name || n.user }])
setTimeout(() => setPings((l) => l.filter((p) => p.key !== key)), 3500)
playPing(sfx.current)
}
sock.current = { load, poll: feed.poll, loadMap, ping: showPing }
const act = async (url, method, body, done) => {
if (!url) return load()
@@ -899,12 +913,12 @@ function LobbyView({ id, rules, user }) {
const selProp = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'prop') : null
const walls = full && showWalls ? mapState.walls || [] : []
return (
<MapView map={mapState.map} image={mapImg} fog={fogImg} propSrc={(pid) => `/api/lobbies/${id}/prop-image/${pid}`} portraitUrl={(t) => t.portrait ? (t.kind === 'npc' ? `/api/lobbies/${id}/token-portrait/${t.id}?v=${t.portrait}` : `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}`) : null} tokens={mapState.tokens} grid={showGrid}
<MapView map={mapState.map} image={mapImg} fog={fogImg} pings={pings} onPing={(x, y) => send(`/api/lobbies/${id}/ping`, 'POST', { x, y }).catch(() => null)} propSrc={(pid) => `/api/lobbies/${id}/prop-image/${pid}`} portraitUrl={(t) => t.portrait ? (t.kind === 'npc' ? `/api/lobbies/${id}/token-portrait/${t.id}?v=${t.portrait}` : `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}`) : null} tokens={mapState.tokens} grid={showGrid}
onMove={moveToken} selected={mapSel} onSelect={setMapSel}
onMenu={lobby.is_dm && !viewAs ? (t, x, y) => setMenu({ t, x, y }) : undefined}
isTurn={(t) => { const e = lobby.combat?.entries.find((x) => x.id === lobby.combat.current); return !!e && (e.kind === 'npc' ? e.token === t.id : e.user === t.username) }}
tool={rv ? 'reveal' : undefined}
onClick={rv ? (p) => setRv((s) => (s.origin && !s.target ? { ...s, target: p } : s.origin ? { ...s, target: p } : { ...s, origin: p })) : undefined}
tool={rv ? 'reveal' : pingMode ? 'ping' : undefined}
onClick={rv ? (p) => setRv((s) => (s.origin && !s.target ? { ...s, target: p } : s.origin ? { ...s, target: p } : { ...s, origin: p })) : pingMode ? (p) => { setPingMode(false); send(`/api/lobbies/${id}/ping`, 'POST', { x: p.x, y: p.y }).catch(() => null) } : undefined}
onHover={rv && rv.origin && !rv.target ? (p) => setRv((s) => (s ? { ...s, hover: p } : s)) : undefined}
overlay={<>{walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}
{full && <RevealLayer map={mapState.map} zones={mapState.reveals} draft={rv && rvShape && rv.origin ? rvShape : null} />}</>}>
@@ -926,6 +940,7 @@ function LobbyView({ id, rules, user }) {
{lobby.is_dm && <button className={mapState.los_on ? 'on' : 'ghost'} aria-pressed={!!mapState.los_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { on: !mapState.los_on }, () => loadMap())} title="Players only see what their character can see: limited by walls and by the sight range (needs one of them)">Sight {mapState.los_on ? 'on' : 'off'}</button>}
{lobby.is_dm && mapState.wall_count > 0 && <button disabled={!full} className={showWalls ? 'on' : 'ghost'} aria-pressed={showWalls} onClick={() => setShowWalls(!showWalls)} title="Show walls and doors (click a door to open or close it)">Walls</button>}
<button className={showGrid ? 'on' : 'ghost'} aria-pressed={showGrid} onClick={() => setShowGrid(!showGrid)}>Grid</button>
<button className={pingMode ? 'on' : 'ghost'} aria-pressed={pingMode} onClick={() => { setPingMode(!pingMode); setRv(null) }} title="Point at a spot for everybody: click here, then the map (or just double-click the map)">📍 Ping</button>
{lobby.is_dm && <button className="ghost" onClick={addToken}>+ Token</button>}
{lobby.is_dm && <button className={npcPick ? 'on' : 'ghost'} onClick={() => { setNpcPick(!npcPick); setPropPick(false) }}>+ NPC</button>}
{lobby.is_dm && <button className={propPick ? 'on' : 'ghost'} onClick={() => { setPropPick(!propPick); setNpcPick(false) }} title="Decor such as chests, tables or doors">+ Prop</button>}
+8 -2
View File
@@ -7,7 +7,7 @@ const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
// Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels.
// Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise.
export function MapView({ map, image, fog, portraitUrl, propSrc, onMenu, isTurn, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
export function MapView({ map, image, fog, portraitUrl, propSrc, pings, onPing, onMenu, isTurn, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
const box = useRef(null)
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
const [drag, setDrag] = useState(null)
@@ -113,12 +113,18 @@ export function MapView({ map, image, fog, portraitUrl, propSrc, onMenu, isTurn,
const unplaced = tokens.filter((t) => t.x == null && t.can_move && t.kind !== 'prop')
const d = (t) => t.size * map.grid
return (
<div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
<div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}
onDoubleClick={(e) => { if (onPing && !e.target.closest('.maptoken, .mapui, .mapdoor')) { const p = toWorld(e); if (p.inside) onPing(p.x, p.y) } }}>
<div className="mapworld" style={{ width: map.w, height: map.h, transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.s})` }}>
{fog && <img className="mapfog" src={fog} width={map.w} height={map.h} alt="" draggable={false} />}
{image && <img src={image.src} style={{ position: 'absolute', left: image.x, top: image.y }} width={image.w} height={image.h} alt="" draggable={false} />}
{showGrid && <div className="mapgrid" style={{ backgroundSize: `${map.grid}px ${map.grid}px` }} />}
{overlay}
{pings?.map((p) => (
<div key={p.key} className="mapping" style={{ left: p.x, top: p.y, '--pc': p.color, '--ps': `${map.grid * 1.6}px` }}>
<i /><i /><b style={{ fontSize: Math.max(11, map.grid * 0.3) }}>{p.name}</b>
</div>
))}
{[...tokens].sort((a, b) => (b.kind === 'prop') - (a.kind === 'prop')).map((t) => {
const dr = drag?.id === t.id ? drag : null
const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y
+15 -1
View File
@@ -36,7 +36,7 @@ const buffer = (url) => {
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) }
export const preload = (cfg) => { if (cfg) [cfg.roll, cfg.crit_success, cfg.crit_failure, cfg.ping].forEach(buffer) }
const clip = (c, out, buf, t, rate, vol) => {
const s = c.createBufferSource()
@@ -98,3 +98,17 @@ export async function playRoll(sides, n, outcome, cfg = {}) {
else cue[outcome](c, out, t1)
} else if (cfg.result !== false) cue[outcome](c, out, t1)
}
// A ping on the map: the DM's MP3 if there is one, otherwise a short two-tone "blip". cfg.ping_on === false switches it off.
export async function playPing(cfg = {}) {
if (!sound.enabled || sound.volume <= 0 || document.hidden || cfg.ping_on === false) return
const c = audio()
if (!c) return
const custom = await buffer(cfg.ping)
const out = c.createGain()
out.gain.value = sound.volume
out.connect(c.destination)
const t = c.currentTime + 0.02
if (custom) clip(c, out, custom, t, 1, 1)
else { tone(c, out, t, 880, 0.18, 'sine', 0.3); tone(c, out, t + 0.11, 1320, 0.35, 'sine', 0.25) }
}
+8
View File
@@ -644,3 +644,11 @@ textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
.propmgr li { display: grid; justify-items: center; gap: .3rem; padding: .5rem; border: 1px solid var(--line); border-radius: 8px; }
.propmgr svg, .propmgr img { width: 64px; height: 64px; object-fit: contain; }
.propmgr .row { display: flex; gap: .25rem; }
/* map pings */
.mapping { position: absolute; width: 0; height: 0; pointer-events: none; z-index: 6; }
.mapping i { position: absolute; left: calc(var(--ps) / -2); top: calc(var(--ps) / -2); width: var(--ps); height: var(--ps); border-radius: 50%; border: 4px solid var(--pc); box-shadow: 0 0 10px var(--pc), inset 0 0 10px var(--pc); opacity: 0; animation: pingring 1.6s ease-out 2; }
.mapping i + i { animation-delay: .35s; }
.mapping b { position: absolute; left: 0; top: calc(var(--ps) / 2 + 2px); transform: translateX(-50%); white-space: nowrap; color: #fff; text-shadow: 0 0 3px #000, 0 0 3px #000; animation: pingname 3.4s forwards; }
@keyframes pingring { 0% { transform: scale(.1); opacity: .95; } 100% { transform: scale(1.4); opacity: 0; } }
@keyframes pingname { 0%, 80% { opacity: 1; } 100% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .mapping i { animation-duration: 3s; animation-iteration-count: 1; } }