From 0f97129243131cfc8c8b44ebe50eb2e7c1f2e2fc Mon Sep 17 00:00:00 2001 From: Otto Date: Sun, 25 Oct 2026 09:17:30 +0000 Subject: [PATCH] Map ping with sound (custom MP3 per lobby) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K --- README.md | 3 +++ backend/app/maps.py | 23 +++++++++++++++++++++++ backend/app/sounds.py | 6 +++--- frontend/src/Lobbies.jsx | 27 +++++++++++++++++++++------ frontend/src/MapView.jsx | 10 ++++++++-- frontend/src/sound.js | 16 +++++++++++++++- frontend/src/styles.css | 8 ++++++++ 7 files changed, 81 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7bf9b32..83aca88 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/backend/app/maps.py b/backend/app/maps.py index 610c7cc..90f116a 100644 --- a/backend/app/maps.py +++ b/backend/app/maps.py @@ -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).""" diff --git a/backend/app/sounds.py b/backend/app/sounds.py index 0dfa870..c5907c2 100644 --- a/backend/app/sounds.py +++ b/backend/app/sounds.py @@ -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))} diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index 4e7f3dc..3df639b 100644 --- a/frontend/src/Lobbies.jsx +++ b/frontend/src/Lobbies.jsx @@ -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 }) {
{sn.custom[slot] ? 'Your file' : defaultText} - + {sn.custom[slot] && } @@ -623,6 +623,7 @@ function SoundSettings({ lobby, id, act, sfx }) { {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 &&

{err}

}
) @@ -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 ( - `/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} + 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 ? act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null} {full && }}> @@ -926,6 +940,7 @@ function LobbyView({ id, rules, user }) { {lobby.is_dm && } {lobby.is_dm && mapState.wall_count > 0 && } + {lobby.is_dm && } {lobby.is_dm && } {lobby.is_dm && } diff --git a/frontend/src/MapView.jsx b/frontend/src/MapView.jsx index 211908c..12b462f 100644 --- a/frontend/src/MapView.jsx +++ b/frontend/src/MapView.jsx @@ -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 ( -
+
{ if (onPing && !e.target.closest('.maptoken, .mapui, .mapdoor')) { const p = toWorld(e); if (p.inside) onPing(p.x, p.y) } }}>
{fog && } {image && } {showGrid &&
} {overlay} + {pings?.map((p) => ( +
+ {p.name} +
+ ))} {[...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 diff --git a/frontend/src/sound.js b/frontend/src/sound.js index aa0c13e..2eb808e 100644 --- a/frontend/src/sound.js +++ b/frontend/src/sound.js @@ -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) } +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9139164..5843c30 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } }