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:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Creator from './Creator.jsx'
|
||||
import Rules from './Rules.jsx'
|
||||
import Maps from './Maps.jsx'
|
||||
import Characters from './Characters.jsx'
|
||||
import Presets from './Presets.jsx'
|
||||
import Lobbies from './Lobbies.jsx'
|
||||
@@ -54,7 +55,7 @@ export default function App() {
|
||||
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
|
||||
|
||||
const page = hash.split('/')[0]
|
||||
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])]
|
||||
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['maps', 'Maps'], ['characters', 'My characters'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])]
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -75,6 +76,7 @@ export default function App() {
|
||||
{rules && page === 'creator' && <Creator rules={rules} user={username} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} onName={setCharName} />}
|
||||
{rules && page === 'rules' && <Rules rules={rules} section={hash.split('/')[1]} />}
|
||||
{rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />}
|
||||
{page === 'maps' && <Maps />}
|
||||
{rules && page === 'characters' && (
|
||||
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
|
||||
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
+117
-17
@@ -6,7 +6,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, src, tokens, onMove, selected, onSelect, grid: showGrid, children }) {
|
||||
export function MapView({ map, image, 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)
|
||||
@@ -46,12 +46,14 @@ export function MapView({ map, src, tokens, onMove, selected, onSelect, grid: sh
|
||||
}, [])
|
||||
|
||||
const down = (e) => {
|
||||
if (e.target.closest('.maptoken, .mapui')) return
|
||||
if (e.target.closest('.maptoken, .mapui, .mapdoor')) return
|
||||
box.current.setPointerCapture(e.pointerId)
|
||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY })
|
||||
if (pointers.current.size === 1) onSelect?.(null)
|
||||
else pointers.current.forEach((p, k) => pointers.current.set(k, { ...p, sx: -1e9 })) // two fingers: pinch, not a click
|
||||
}
|
||||
const move = (e) => {
|
||||
if (onHover) onHover(toWorld(e), viewRef.current.s)
|
||||
const p = pointers.current.get(e.pointerId)
|
||||
if (!p) return
|
||||
const r = box.current.getBoundingClientRect()
|
||||
@@ -59,7 +61,7 @@ export function MapView({ map, src, tokens, onMove, selected, onSelect, grid: sh
|
||||
const [a, b] = [...pointers.current.values()]
|
||||
const before = Math.hypot(a.x - b.x, a.y - b.y)
|
||||
const o = pointers.current.get(e.pointerId)
|
||||
const now = { ...o, x: e.clientX, y: e.clientY }
|
||||
const now = { ...o, x: e.clientX, y: e.clientY, sx: -1e9 }
|
||||
pointers.current.set(e.pointerId, now)
|
||||
const [c, d] = [...pointers.current.values()]
|
||||
const after = Math.hypot(c.x - d.x, c.y - d.y)
|
||||
@@ -67,10 +69,15 @@ export function MapView({ map, src, tokens, onMove, selected, onSelect, grid: sh
|
||||
return
|
||||
}
|
||||
const dx = e.clientX - p.x, dy = e.clientY - p.y
|
||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
pointers.current.set(e.pointerId, { ...p, x: e.clientX, y: e.clientY })
|
||||
setView((v) => ({ ...v, tx: v.tx + dx, ty: v.ty + dy }))
|
||||
}
|
||||
const up = (e) => pointers.current.delete(e.pointerId)
|
||||
const up = (e) => {
|
||||
const p = pointers.current.get(e.pointerId)
|
||||
pointers.current.delete(e.pointerId)
|
||||
// a press that barely moved is a click on the map (used by the drawing tools)
|
||||
if (p && onClick && e.type === 'pointerup' && Math.hypot(e.clientX - p.sx, e.clientY - p.sy) < 5) onClick(toWorld(e), viewRef.current.s, e)
|
||||
}
|
||||
|
||||
const toWorld = (ev) => {
|
||||
const r = box.current.getBoundingClientRect(), v = viewRef.current
|
||||
@@ -105,10 +112,11 @@ export function MapView({ map, src, tokens, onMove, selected, onSelect, grid: sh
|
||||
const unplaced = tokens.filter((t) => t.x == null && t.can_move)
|
||||
const d = (t) => t.size * map.grid
|
||||
return (
|
||||
<div className="mapbox" 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}>
|
||||
<div className="mapworld" style={{ width: map.w, height: map.h, transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.s})` }}>
|
||||
<img src={src} 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}
|
||||
{tokens.map((t) => {
|
||||
const dr = drag?.id === t.id ? drag : null
|
||||
const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y
|
||||
@@ -159,36 +167,108 @@ export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
|
||||
|
||||
const uid = () => Math.random().toString(36).slice(2, 12)
|
||||
|
||||
// Full-screen editor for a map in the DM's library: set the grid, place enemies and NPCs in advance.
|
||||
// Walls and doors as a line layer on top of the map (DM only). In the live view doors can be clicked to open or close them.
|
||||
export function WallLayer({ map, walls, draft, hover, onDoor }) {
|
||||
const line = (w, extra = {}) => (
|
||||
<line key={w.id} x1={w.x1} y1={w.y1} x2={w.x2} y2={w.y2} vectorEffect="non-scaling-stroke"
|
||||
className={`wl ${w.kind} ${w.open ? 'open' : ''}`} {...extra} />
|
||||
)
|
||||
return (
|
||||
<svg className="walllayer" width={map.w} height={map.h} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', pointerEvents: 'none' }}>
|
||||
{walls.filter((w) => !(onDoor && w.kind === 'door')).map((w) => line(w))}
|
||||
{walls.filter((w) => onDoor && w.kind === 'door').map((w) => (
|
||||
<g key={w.id} className="mapdoor" onClick={() => onDoor(w)} style={{ pointerEvents: 'stroke', cursor: 'pointer' }}>
|
||||
<title>{w.open ? 'Open door, click to close' : 'Closed door, click to open'}</title>
|
||||
<line x1={w.x1} y1={w.y1} x2={w.x2} y2={w.y2} stroke="transparent" strokeWidth="16" vectorEffect="non-scaling-stroke" style={{ pointerEvents: 'stroke' }} />
|
||||
{line(w, { style: { pointerEvents: 'none' } })}
|
||||
</g>
|
||||
))}
|
||||
{draft && hover && <line x1={draft.x} y1={draft.y} x2={hover.x} y2={hover.y} vectorEffect="non-scaling-stroke" className="wl draft" />}
|
||||
{draft && <circle cx={draft.x} cy={draft.y} r="4" vectorEffect="non-scaling-stroke" className="wldot" />}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const distToSeg = (p, w) => {
|
||||
const dx = w.x2 - w.x1, dy = w.y2 - w.y1, L = dx * dx + dy * dy
|
||||
const t = L === 0 ? 0 : clamp(((p.x - w.x1) * dx + (p.y - w.y1) * dy) / L, 0, 1)
|
||||
return Math.hypot(w.x1 + t * dx - p.x, w.y1 + t * dy - p.y)
|
||||
}
|
||||
|
||||
// Full-screen editor for a map in the DM's library: grid, enemies and NPCs, walls and doors for line of sight.
|
||||
export function MapEditor({ id, onClose }) {
|
||||
const [m, setM] = useState(null)
|
||||
const [tokens, setTokens] = useState([])
|
||||
const [walls, setWalls] = useState([])
|
||||
const [name, setName] = useState('')
|
||||
const [grid, setGrid] = useState(40)
|
||||
const [sight, setSight] = useState(0)
|
||||
const [showGrid, setShowGrid] = useState(true)
|
||||
const [snap, setSnap] = useState(false)
|
||||
const [tool, setTool] = useState('select')
|
||||
const [draft, setDraft] = useState(null)
|
||||
const [hover, setHover] = useState(null)
|
||||
const [sel, setSel] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
useEffect(() => {
|
||||
fetch(`/api/maps/${id}`).then((r) => r.json()).then((d) => { setM(d); setTokens(d.tokens.map((t) => ({ ...t, kind: 'npc', can_move: true }))); setName(d.name); setGrid(d.grid) })
|
||||
fetch(`/api/maps/${id}`).then((r) => r.json()).then((d) => {
|
||||
setM(d); setTokens(d.tokens.map((t) => ({ ...t, kind: 'npc', can_move: true }))); setWalls(d.walls); setName(d.name); setGrid(d.grid); setSight(d.sight || 0)
|
||||
})
|
||||
}, [id])
|
||||
useEffect(() => {
|
||||
const k = (e) => { if (e.key === 'Escape') { setDraft(null); if (tool !== 'select') setTool('select') } }
|
||||
window.addEventListener('keydown', k)
|
||||
return () => window.removeEventListener('keydown', k)
|
||||
}, [tool])
|
||||
// the full-size image, loaded once for the editor
|
||||
const [image, setImage] = useState(null)
|
||||
useEffect(() => { if (m) setImage({ src: `/api/maps/${id}/image`, x: 0, y: 0, w: m.w, h: m.h }) }, [m?.id]) // eslint-disable-line
|
||||
if (!m) return null
|
||||
const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid
|
||||
const cur = tokens.find((t) => t.id === sel)
|
||||
const patch = (tid, p) => { setTokens((ts) => ts.map((t) => (t.id === tid ? { ...t, ...p } : t))); setDirty(true) }
|
||||
const addWalls = (list) => { setWalls((w) => [...w, ...list.map((x) => ({ id: uid(), ...x }))]); setDirty(true) }
|
||||
const add = () => {
|
||||
const n = tokens.length + 1
|
||||
const t = { id: uid(), kind: 'npc', can_move: true, name: `Enemy ${n}`, x: m.w / 2 + (n % 5) * m.grid, y: m.h / 2 + (n % 3) * m.grid, size: 1, color: COLORS[0], hidden: false }
|
||||
const t = { id: uid(), kind: 'npc', can_move: true, name: `Enemy ${n}`, x: m.w / 2 + (n % 5) * gridNum, y: m.h / 2 + (n % 3) * gridNum, size: 1, color: COLORS[0], hidden: false }
|
||||
setTokens([...tokens, t]); setSel(t.id); setDirty(true)
|
||||
}
|
||||
const snapPt = (p, s) => { // join onto existing corners first, then the grid
|
||||
let best = null, bd = 12 / s
|
||||
for (const w of walls) for (const [x, y] of [[w.x1, w.y1], [w.x2, w.y2]]) { const d = Math.hypot(x - p.x, y - p.y); if (d < bd) { bd = d; best = { x, y } } }
|
||||
if (best) return best
|
||||
if (snap) return { x: clamp(Math.round(p.x / gridNum) * gridNum, 0, m.w), y: clamp(Math.round(p.y / gridNum) * gridNum, 0, m.h) }
|
||||
return { x: clamp(p.x, 0, m.w), y: clamp(p.y, 0, m.h) }
|
||||
}
|
||||
const click = (world, s) => {
|
||||
if (tool === 'select') return
|
||||
if (tool === 'erase') {
|
||||
let best = null, bd = 10 / s
|
||||
for (const w of walls) { const d = distToSeg(world, w); if (d < bd) { bd = d; best = w } }
|
||||
if (best) { setWalls(walls.filter((w) => w.id !== best.id)); setDirty(true) }
|
||||
return
|
||||
}
|
||||
const p = snapPt(world, s)
|
||||
if (!draft) { setDraft(p); return }
|
||||
if (Math.hypot(p.x - draft.x, p.y - draft.y) < 1) { setDraft(null); return } // click the same point again: finish
|
||||
if (tool === 'wall') { addWalls([{ x1: draft.x, y1: draft.y, x2: p.x, y2: p.y, kind: 'wall' }]); setDraft(p) }
|
||||
else if (tool === 'door') { addWalls([{ x1: draft.x, y1: draft.y, x2: p.x, y2: p.y, kind: 'door' }]); setDraft(null) }
|
||||
else if (tool === 'rect') {
|
||||
addWalls([[draft.x, draft.y, p.x, draft.y], [p.x, draft.y, p.x, p.y], [p.x, p.y, draft.x, p.y], [draft.x, p.y, draft.x, draft.y]].map(([x1, y1, x2, y2]) => ({ x1, y1, x2, y2, kind: 'wall' })))
|
||||
setDraft(null)
|
||||
}
|
||||
}
|
||||
const save = async () => {
|
||||
setErr('')
|
||||
const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, grid: Number(grid), tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden }) => ({ id: i, name: n, x, y, size, color, hidden })) }) })
|
||||
body: JSON.stringify({ name, grid: Number(grid), sight: Number(sight) || 0, walls,
|
||||
tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden }) => ({ id: i, name: n, x, y, size, color, hidden })) }) })
|
||||
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false }
|
||||
setDirty(false); return true
|
||||
}
|
||||
const close = async () => { if (!dirty || window.confirm('Close without saving your changes?')) onClose() }
|
||||
const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid
|
||||
const tools = [['select', 'Move tokens'], ['wall', 'Wall'], ['rect', 'Room'], ['door', 'Door'], ['erase', 'Erase']]
|
||||
return createPortal(
|
||||
<div className="mapeditor" role="dialog" aria-label="Map editor">
|
||||
<aside className="card">
|
||||
@@ -197,6 +277,24 @@ export function MapEditor({ id, onClose }) {
|
||||
<label className="small">Grid: pixels per square <input type="number" min="4" value={grid} onChange={(e) => { setGrid(e.target.value); setDirty(true) }} /></label>
|
||||
<label className="small chk"><input type="checkbox" checked={showGrid} onChange={(e) => setShowGrid(e.target.checked)} /> Show grid</label>
|
||||
<p className="small muted">Match the grid to the squares on your image. Token sizes are counted in squares.</p>
|
||||
<h4>Walls and doors</h4>
|
||||
<div className="toolrow" role="group" aria-label="Tool">
|
||||
{tools.map(([k, label]) => <button key={k} className={tool === k ? 'on' : 'ghost'} aria-pressed={tool === k} onClick={() => { setTool(k); setDraft(null) }}>{label}</button>)}
|
||||
</div>
|
||||
<label className="small chk"><input type="checkbox" checked={snap} onChange={(e) => setSnap(e.target.checked)} /> Snap to grid corners</label>
|
||||
<p className="small muted">
|
||||
{tool === 'wall' && 'Click to set points, the line continues from the last one. Click the same point twice or press Esc to stop.'}
|
||||
{tool === 'rect' && 'Click two opposite corners of a room.'}
|
||||
{tool === 'door' && 'Click both ends of the door. The DM opens and closes it during play.'}
|
||||
{tool === 'erase' && 'Click a wall or door to remove it.'}
|
||||
{tool === 'select' && `${walls.length} wall segment${walls.length === 1 ? '' : 's'}. Walls end at existing corners automatically. Players only see what their token can see past them.`}
|
||||
</p>
|
||||
<div className="row">
|
||||
<button className="ghost" disabled={!walls.length} onClick={() => { setWalls(walls.slice(0, -1)); setDirty(true) }}>Undo last</button>
|
||||
<button className="ghost danger" disabled={!walls.length} onClick={() => window.confirm('Remove all walls and doors of this map?') && (setWalls([]), setDirty(true))}>Remove all</button>
|
||||
</div>
|
||||
<label className="small">Sight range in squares (0 = unlimited)
|
||||
<input type="number" min="0" max="1000" value={sight} onChange={(e) => { setSight(e.target.value); setDirty(true) }} /></label>
|
||||
<h4>Enemies and NPCs</h4>
|
||||
<button className="on" onClick={add}>+ Add token</button>
|
||||
<ul className="tokenlist">
|
||||
@@ -210,7 +308,9 @@ export function MapEditor({ id, onClose }) {
|
||||
<div className="row editactions"><button className="on" disabled={!dirty} onClick={save}>Save</button><button className="ghost" onClick={close}>Close</button></div>
|
||||
</aside>
|
||||
<div className="mapstage">
|
||||
<MapView map={{ ...m, grid: gridNum }} src={`/api/maps/${id}/image`} tokens={tokens} grid={showGrid} selected={sel} onSelect={setSel}
|
||||
<MapView map={{ ...m, grid: gridNum }} image={image} tokens={tokens} grid={showGrid} selected={sel} onSelect={setSel} tool={tool}
|
||||
onClick={click} onHover={draft ? (p, s) => setHover(p) : undefined}
|
||||
overlay={<WallLayer map={m} walls={walls} draft={draft} hover={draft ? hover : null} />}
|
||||
onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
|
||||
</div>
|
||||
</div>, document.body)
|
||||
@@ -242,13 +342,13 @@ export function MapLibrary({ activeId, lobbyId, act }) {
|
||||
<ul className="maplist">
|
||||
{list.map((m) => (
|
||||
<li key={m.id} className={m.id === activeId ? 'active' : ''}>
|
||||
<strong>{m.name}</strong> <span className="muted small">{m.w}×{m.h}, {m.tokens} token{m.tokens === 1 ? '' : 's'}</span>
|
||||
<strong>{m.name}</strong> <span className="muted small">{m.w}×{m.h}, {m.tokens} token{m.tokens === 1 ? '' : 's'}, {m.walls} wall{m.walls === 1 ? '' : 's'}{!m.mine && ` · from ${m.author}`}</span>
|
||||
<span className="row">
|
||||
{m.id === activeId
|
||||
? <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null })}>Clear table</button>
|
||||
: <button className="on" onClick={() => (!activeId || window.confirm('Replace the current map? Tokens on it are reset to the prepared ones.')) && act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: m.id })}>Show on table</button>}
|
||||
<button className="ghost" onClick={() => setEditing(m.id)}>Edit</button>
|
||||
<button className="ghost danger" onClick={() => window.confirm(`Delete the map "${m.name}"?`) && fetch(`/api/maps/${m.id}`, { method: 'DELETE' }).then(load)}>Delete</button>
|
||||
{m.mine && <button className="ghost" onClick={() => setEditing(m.id)}>Edit</button>}
|
||||
{m.mine && <button className="ghost danger" onClick={() => window.confirm(`Delete the map "${m.name}"?`) && fetch(`/api/maps/${m.id}`, { method: 'DELETE' }).then(load)}>Delete</button>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { MapEditor } from './MapView.jsx'
|
||||
|
||||
const J = { 'Content-Type': 'application/json' }
|
||||
const errText = async (r, fallback) => (await r.json().catch(() => ({}))).detail || fallback
|
||||
|
||||
function SharePanel({ m, reload }) {
|
||||
const [name, setName] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const add = async (e) => {
|
||||
e.preventDefault()
|
||||
setErr('')
|
||||
const r = await fetch(`/api/maps/${m.id}/shares`, { method: 'POST', headers: J, body: JSON.stringify({ username: name }) })
|
||||
if (!r.ok) return setErr(await errText(r, 'Could not share.'))
|
||||
setName(''); reload()
|
||||
}
|
||||
const remove = async (u) => { await fetch(`/api/maps/${m.id}/shares/${encodeURIComponent(u)}`, { method: 'DELETE' }).catch(() => {}); reload() }
|
||||
return (
|
||||
<div className="sharepanel">
|
||||
<div className="small muted">Shared with</div>
|
||||
<div className="chips">
|
||||
{m.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
|
||||
{m.shared_with.map((u) => <span key={u} className="chip">{u} <button className="ghost" aria-label={`Stop sharing with ${u}`} onClick={() => remove(u)}>×</button></span>)}
|
||||
</div>
|
||||
<form className="row" onSubmit={add}>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Username" aria-label="Username to share with" />
|
||||
<button type="submit" disabled={!name.trim()}>Share</button>
|
||||
</form>
|
||||
{err && <p className="warn small">{err}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// All maps of the user (prepare, share, copy) plus the maps other users shared with them
|
||||
export default function Maps() {
|
||||
const [list, setList] = useState(null)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [open, setOpen] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => setList([])), [])
|
||||
useEffect(() => { load() }, [load, editing])
|
||||
const upload = async (e) => {
|
||||
const f = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!f) return
|
||||
setErr(''); setBusy(true)
|
||||
const r = await fetch(`/api/maps?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f }).catch(() => null)
|
||||
setBusy(false)
|
||||
if (!r || !r.ok) return setErr(r ? await errText(r, 'Upload failed') : 'Upload failed')
|
||||
load()
|
||||
}
|
||||
const copy = async (m) => {
|
||||
const r = await fetch(`/api/maps/${m.id}/copy`, { method: 'POST' })
|
||||
if (!r.ok) return setErr(await errText(r, 'Could not copy'))
|
||||
load()
|
||||
}
|
||||
const del = async (m) => {
|
||||
if (!window.confirm(`Delete the map "${m.name}"? Lobbies that show it get an empty table, shared users lose it.`)) return
|
||||
await fetch(`/api/maps/${m.id}`, { method: 'DELETE' }); load()
|
||||
}
|
||||
const mine = (list || []).filter((m) => m.mine), shared = (list || []).filter((m) => !m.mine)
|
||||
const card = (m) => (
|
||||
<li key={m.id} className="mapcard card">
|
||||
<img src={`/api/maps/${m.id}/thumb`} alt="" loading="lazy" />
|
||||
<div className="mapcardbody">
|
||||
<strong>{m.name}</strong>
|
||||
<span className="small muted">{m.w}×{m.h} · {m.tokens} token{m.tokens === 1 ? '' : 's'} · {m.walls} wall{m.walls === 1 ? '' : 's'}{!m.mine && ` · from ${m.author}`}{m.mine && m.shared_with.length > 0 && ` · shared with ${m.shared_with.join(', ')}`}</span>
|
||||
<span className="row">
|
||||
{m.mine && <button className="on" onClick={() => setEditing(m.id)}>Edit</button>}
|
||||
{m.mine && <button className="ghost" onClick={() => setOpen(open === m.id ? null : m.id)}>Share</button>}
|
||||
<button className="ghost" onClick={() => copy(m)} title="Make your own editable copy">{m.mine ? 'Duplicate' : 'Copy to my maps'}</button>
|
||||
{m.mine && <button className="ghost danger" onClick={() => del(m)}>Delete</button>}
|
||||
</span>
|
||||
{open === m.id && m.mine && <SharePanel m={m} reload={load} />}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
return (
|
||||
<section className="page">
|
||||
<h1>Maps</h1>
|
||||
<p className="muted">Prepare maps with grid, enemies, NPCs, walls and doors. The DM puts one on the table from a lobby (<em>Lobby actions</em>). Shared maps can be used on your table or copied, but not changed.</p>
|
||||
<label className="uploadbtn">{busy ? 'Uploading …' : '+ Upload map (PNG, JPEG, WebP, up to 12 MB)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
|
||||
{err && <p className="warn">{err}</p>}
|
||||
{list === null && <p className="muted">Loading …</p>}
|
||||
{list && !mine.length && <p className="muted">You have no maps yet.</p>}
|
||||
<ul className="mapcards">{mine.map(card)}</ul>
|
||||
{shared.length > 0 && <><h2>Shared with me</h2><ul className="mapcards">{shared.map(card)}</ul></>}
|
||||
{editing && <MapEditor id={editing} onClose={() => setEditing(null)} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -441,3 +441,22 @@ label.chk { display: flex; align-items: center; gap: .45rem; }
|
||||
label.chk input[type=checkbox] { width: auto; margin: 0; }
|
||||
.mapeditor label.small:not(.chk) { display: grid; gap: .2rem; }
|
||||
.editactions { position: sticky; bottom: 0; background: var(--panel); padding: .5rem 0; }
|
||||
.mapbox.drawing { cursor: crosshair; }
|
||||
.mapworld { background: #000; }
|
||||
.wl { stroke: #ff4d4d; stroke-width: 3; stroke-linecap: round; }
|
||||
.wl.door { stroke: #ff9f1a; stroke-width: 5; }
|
||||
.wl.door.open { stroke: #4cd964; stroke-dasharray: 6 6; stroke-width: 4; }
|
||||
.wl.draft { stroke: #fff; stroke-dasharray: 5 5; stroke-width: 2; }
|
||||
.wldot { fill: #fff; }
|
||||
.toolrow { display: flex; flex-wrap: wrap; gap: .3rem; }
|
||||
.mapeditor input[type=number] { width: 100%; }
|
||||
.mapempty { position: absolute; inset: 0; display: grid; place-items: center; color: #ddd; z-index: 2; pointer-events: none; text-align: center; padding: 1rem; }
|
||||
.mapcards { list-style: none; padding: 0; margin: 1rem 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr)); gap: 1rem; }
|
||||
.mapcard { display: grid; grid-template-columns: 8rem 1fr; gap: .8rem; padding: .8rem; align-items: start; }
|
||||
.mapcard img { width: 8rem; height: 6rem; object-fit: cover; border-radius: 6px; background: #111; }
|
||||
.mapcardbody { display: grid; gap: .4rem; min-width: 0; }
|
||||
.mapcardbody .row { flex-wrap: wrap; }
|
||||
.maptools { flex-wrap: wrap; justify-content: flex-end; max-width: 60%; }
|
||||
.maptools button, .maptools select { padding: .2rem .55rem; font-size: .8rem; min-height: 0; white-space: nowrap; width: auto; }
|
||||
.maptray { max-width: 38%; }
|
||||
.traytoken { font-size: .85rem; }
|
||||
|
||||
Reference in New Issue
Block a user