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:
@@ -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} />}
|
||||
|
||||
Reference in New Issue
Block a user