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:
@@ -168,6 +168,8 @@ Players only see a short card of the other players, never their sheets. Guessing
|
||||
- **DM notes**: NPC tokens and props have a "Notes (only you see these)" field (map editor and table; e.g. the contents of a chest). On the table click the token to edit; a 📝 marks tokens with notes and the text shows as tooltip. Players never receive notes.
|
||||
- **Fog without walls**: sight range and fog also work on maps without walls (circle around the token). Sight needs walls or a range above 0; the explored memory is now stored at up to 1024 px.
|
||||
|
||||
- **Map ping**: everybody (players and DM) can point at a spot: double-click the map, or press *📍 Ping* and click. All see a ring in the pinger's colour with their name, and hear a short ping (rate limited to one per second and person). The DM can switch the sound off or upload an MP3 for it under *Lobby actions → Sounds → Map ping*; players can still mute sounds for themselves.
|
||||
|
||||
## API
|
||||
|
||||
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
|
||||
@@ -199,6 +201,7 @@ 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 |
|
||||
| GET / POST | `/api/lobbies/{id}/scenes` | DM: saved scenes / save a copy of the table (`name`) |
|
||||
| PUT / DELETE | `/api/lobbies/{id}/scenes/{sid}` | DM: rename (`name`), save now (`save`) / delete |
|
||||
| POST | `/api/lobbies/{id}/ping` | Ping a spot (`x`, `y`); everybody gets a push |
|
||||
| PUT | `/api/lobbies/{id}/scene` | DM: switch to a scene (`scene_id`); the current one is saved first |
|
||||
| GET / POST / DELETE | `/api/props[/{id}]` | Your own prop images / upload (raw body, `?name=`) / delete |
|
||||
| GET | `/api/props/{id}/image`, `/api/lobbies/{id}/prop-image/{pid}` | Prop image (owner / lobby members, DM's props) |
|
||||
|
||||
@@ -774,6 +774,29 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
||||
hub.notify(lid, "map")
|
||||
return {"ok": True}
|
||||
|
||||
pinged: dict = {}
|
||||
|
||||
@api.post("/lobbies/{lid}/ping")
|
||||
def ping(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Anybody at the table can point at a spot of the map; everybody sees a ring there (and hears a ping)."""
|
||||
with db() as con:
|
||||
lobby = lobby_for(con, lid, user)
|
||||
m = need_map(con, lobby)
|
||||
try:
|
||||
x, y = float(body.get("x")), float(body.get("y"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(422, "Invalid position")
|
||||
if not (0 <= x <= m["w"] and 0 <= y <= m["h"]):
|
||||
raise HTTPException(422, "Outside of the map")
|
||||
now = time.time()
|
||||
if now - pinged.get((lid, user["id"]), 0) < 0.8:
|
||||
raise HTTPException(429, "Slow down")
|
||||
pinged[(lid, user["id"])] = now
|
||||
if len(pinged) > 2000:
|
||||
pinged.clear()
|
||||
hub.notify(lid, "ping", delay=0, x=round(x, 1), y=round(y, 1), user=user["username"], dm=lobby["owner_id"] == user["id"])
|
||||
return {"ok": True}
|
||||
|
||||
@api.put("/lobbies/{lid}/sight")
|
||||
def set_sight(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""DM: switch line of sight on or off for the table (off: players see the whole map)."""
|
||||
|
||||
@@ -6,8 +6,8 @@ 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
|
||||
SLOTS = ("roll", "crit_success", "crit_failure", "ping")
|
||||
FLAGS = ("result", "crit_success", "crit_failure", "ping") # result = the short tones for success / failure of a check
|
||||
|
||||
|
||||
def schema(con):
|
||||
@@ -20,7 +20,7 @@ def settings(raw) -> dict:
|
||||
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),
|
||||
return {"result": s.get("result", True), "crit_success": s.get("crit_success", True), "crit_failure": s.get("crit_failure", True), "ping": s.get("ping", True),
|
||||
"custom": {k: bool(s.get("custom", {}).get(k)) for k in SLOTS}, "v": int(s.get("v", 0))}
|
||||
|
||||
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
Reference in New Issue
Block a user