Lobby: maps, map editor and tokens

DM map library (upload re-encoded with Pillow), map editor with prepared
enemies/NPCs, map on the lobby table with personal pan/zoom, draggable
tokens (players move their own, DM all), full/player view, live sync via
the WebSocket.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-23 13:59:21 +00:00
parent 162d8c9224
commit e8755582d5
8 changed files with 679 additions and 9 deletions
+41 -6
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
import { MapView, MapLibrary, TokenForm } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' }
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
@@ -549,19 +550,25 @@ function LobbyView({ id, rules, user }) {
if (!r.ok) throw new Error(r.status)
setLobby(await r.json())
}).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') }), [id])
const [mapState, setMapState] = useState({ map: null, tokens: [] })
const [mapSel, setMapSel] = useState(null)
const [playerView, setPlayerView] = useState(false) // the DM looks at the table the way the players see it
const [showGrid, setShowGrid] = useState(false)
const loadMap = useCallback(() => fetch(`/api/lobbies/${id}/map`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setMapState(d)).catch(() => {}), [id])
const sock = useRef({})
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
const up = useSocket(id, {
open: () => { sock.current.load?.(); sock.current.poll?.() },
open: () => { sock.current.load?.(); sock.current.poll?.(); sock.current.loadMap?.() },
map: () => sock.current.loadMap?.(),
messages: () => sock.current.poll?.(),
lobby: () => sock.current.load?.(),
sheet: (n) => setSheetTick((t) => ({ cid: n.cid, n: t.n + 1 })),
})
useEffect(() => {
load()
const t = setInterval(() => !document.hidden && load(), up ? 30000 : 5000) // joins, colours, difficulty: pushed, with a timer as safety net
load(); loadMap()
const t = setInterval(() => { if (!document.hidden) { load(); loadMap() } }, up ? 30000 : 5000) // joins, colours, difficulty: pushed, with a timer as safety net
return () => clearInterval(t)
}, [load, up])
}, [load, loadMap, up])
const onRoll = useCallback((m) => {
const key = `${m.id}`
@@ -569,7 +576,7 @@ 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 }
sock.current = { load, poll: feed.poll, loadMap }
const act = async (url, method, body, done) => {
if (!url) return load()
@@ -580,6 +587,19 @@ function LobbyView({ id, rules, user }) {
done ? done(d) : load()
}
const moveToken = (tid, x, y) => {
setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, x, y } : t)) }))
return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', { x, y }).catch(() => null)
}
const patchToken = (tid, p) => {
setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, ...p } : t)) }))
return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', p).catch(() => null)
}
const addToken = () => {
const m = mapState.map, n = mapState.tokens.filter((t) => t.kind === 'npc').length + 1
send(`/api/lobbies/${id}/tokens`, 'POST', { name: `Enemy ${n}`, x: m.w / 2, y: m.h / 2, size: 1, color: '#c0392b' }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
}
if (err && !lobby) return <section className="page lobbypage"><p className="warn">{err}</p><a href="#lobbies">Back to all lobbies</a></section>
if (!lobby) return <section className="page lobbypage"><p className="muted">Loading …</p></section>
@@ -639,7 +659,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'
? <Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} />
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><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>
@@ -651,6 +671,21 @@ function LobbyView({ id, rules, user }) {
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
<div className="table">
{showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
{mapState.map && (() => {
const shown = mapState.tokens.filter((t) => !(playerView && t.hidden))
const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null
return (
<MapView map={mapState.map} src={`/api/lobbies/${id}/map/image?m=${mapState.map.id}`} tokens={shown} grid={showGrid}
onMove={moveToken} selected={mapSel} onSelect={setMapSel}>
<div className="maptools mapui">
{lobby.is_dm && <button className={playerView ? 'on' : 'ghost'} aria-pressed={playerView} onClick={() => setPlayerView(!playerView)} title="Show the table the way your players see it">{playerView ? 'Player view' : 'Full view'}</button>}
<button className={showGrid ? 'on' : 'ghost'} aria-pressed={showGrid} onClick={() => setShowGrid(!showGrid)}>Grid</button>
{lobby.is_dm && <button className="ghost" onClick={addToken}>+ NPC</button>}
</div>
{selTok && <div className="tokenpanel mapui"><TokenForm t={selTok} onChange={(p) => patchToken(selTok.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selTok.id}`, 'DELETE'); setMapSel(null) }} /></div>}
</MapView>
)
})()}
<DiceTray rolls={tray} />
{canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>}
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}