Maps tab with sharing, walls/doors and strict line of sight

Maps page (list, upload, edit, share, copy), wall/room/door tools in the
map editor, server-side visibility polygon: the image is cut per player
and other tokens are filtered, DM can view as a player, open doors and
switch sight on/off.

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 14:23:17 +00:00
parent e8755582d5
commit 82e4ac9458
8 changed files with 632 additions and 58 deletions
+45 -8
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
import { MapView, MapLibrary, TokenForm } from './MapView.jsx'
import { MapView, MapLibrary, TokenForm, WallLayer } 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) })
@@ -142,6 +142,31 @@ function useSocket(id, handlers) {
return up
}
// The map picture for this viewer. With line of sight the server sends only the visible part (position in X-Crop), so the picture
// is fetched again whenever the server's `rev` for this viewer changes; the old one stays until the new one has arrived.
function useMapImage(id, vision, as) {
const [img, setImg] = useState(null)
const last = useRef({ t: 0, n: 0, timer: 0 })
const rev = vision?.rev
useEffect(() => {
if (!rev || vision.empty) { setImg(null); return }
const n = ++last.current.n
const go = () => fetch(`/api/lobbies/${id}/map/view${as ? `?as=${encodeURIComponent(as)}` : ''}`).then(async (r) => {
if (!r.ok) throw new Error(r.status)
const [x, y, w, h] = (r.headers.get('X-Crop') || '0,0,0,0').split(',').map(Number)
const url = URL.createObjectURL(await r.blob())
if (n !== last.current.n) { URL.revokeObjectURL(url); return } // a newer one was asked for meanwhile
setImg((old) => { if (old) URL.revokeObjectURL(old.src); return { src: url, x, y, w, h } })
}).catch(() => {})
const wait = Math.max(0, 200 - (Date.now() - last.current.t)) // at most ~5 pictures per second while a token is dragged
clearTimeout(last.current.timer)
last.current.timer = setTimeout(() => { last.current.t = Date.now(); go() }, wait)
return () => clearTimeout(last.current.timer)
}, [id, rev, as]) // eslint-disable-line
useEffect(() => () => setImg((old) => { old && URL.revokeObjectURL(old.src); return null }), [])
return img
}
// Chat, whispers, event log, dice rolls and checks arrive through one feed that is polled while the page is open.
function useFeed(id, onEvent, onRoll, socketUp) {
const [msgs, setMsgs] = useState([])
@@ -552,9 +577,11 @@ function LobbyView({ id, rules, user }) {
}).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 [viewAs, setViewAs] = useState('') // the DM can look at the table through one player's eyes
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 [showWalls, setShowWalls] = useState(true)
const loadMap = useCallback(() => fetch(`/api/lobbies/${id}/map${viewAs ? `?as=${encodeURIComponent(viewAs)}` : ''}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setMapState(d)).catch(() => {}), [id, viewAs])
const mapImg = useMapImage(id, mapState.vision, viewAs)
const sock = useRef({})
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
const up = useSocket(id, {
@@ -672,15 +699,25 @@ function LobbyView({ id, rules, user }) {
<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 full = lobby.is_dm && !viewAs
const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null
const walls = full && showWalls ? mapState.walls || [] : []
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}>
<MapView map={mapState.map} image={mapImg} tokens={mapState.tokens} grid={showGrid}
onMove={moveToken} selected={mapSel} onSelect={setMapSel}
overlay={walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}>
{mapState.vision?.empty && <div className="mapempty">{lobby.is_dm ? 'This player is not on the map yet. Switch to the full view and drag their token onto it.' : 'Waiting for the DM to put your token on the map.'}</div>}
<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>}
{lobby.is_dm && (
<select value={viewAs} onChange={(e) => { setViewAs(e.target.value); setMapSel(null) }} aria-label="View the table as" title="Look at the table through a player's eyes">
<option value="">Full view</option>
{lobby.members.map((m) => <option key={m.username} value={m.username}>View as {m.character?.name || m.username}</option>)}
</select>
)}
{lobby.is_dm && mapState.walls?.length > 0 && <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="Line of sight: players only see what their character can see">Sight {mapState.los_on ? 'on' : 'off'}</button>}
{full && mapState.walls?.length > 0 && <button 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>
{lobby.is_dm && <button className="ghost" onClick={addToken}>+ NPC</button>}
{full && <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>