import { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' const COLORS = ['#c0392b', '#8e44ad', '#16a085', '#7f8c8d', '#d35400', '#2c3e50', '#27ae60', '#b9770e'] 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, 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) const viewRef = useRef(view) viewRef.current = view const pointers = useRef(new Map()) const fitted = useRef('') const fit = useCallback(() => { const r = box.current?.getBoundingClientRect() if (!r || !r.width || !map) return const s = Math.min(r.width / map.w, r.height / map.h) * 0.96 setView({ s, tx: (r.width - map.w * s) / 2, ty: (r.height - map.h * s) / 2 }) }, [map?.id, map?.w, map?.h]) // eslint-disable-line useEffect(() => { // fit once per map, as soon as the box has a size if (!map || fitted.current === map.id) return const t = setInterval(() => { if (box.current?.getBoundingClientRect().width) { fit(); fitted.current = map.id; clearInterval(t) } }, 50) return () => clearInterval(t) }, [map?.id, fit]) // eslint-disable-line const zoomAt = (mx, my, f) => setView((v) => { const s = clamp(v.s * f, 0.05, 8) return { s, tx: mx - (mx - v.tx) * (s / v.s), ty: my - (my - v.ty) * (s / v.s) } }) useEffect(() => { const el = box.current if (!el) return const wheel = (e) => { e.preventDefault() const r = el.getBoundingClientRect() zoomAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015))) } el.addEventListener('wheel', wheel, { passive: false }) return () => el.removeEventListener('wheel', wheel) }, []) const down = (e) => { if (e.target.closest('.maptoken, .mapui, .mapdoor')) return box.current.setPointerCapture(e.pointerId) 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() if (pointers.current.size === 2) { // pinch 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, 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) if (before > 0) zoomAt((c.x + d.x) / 2 - r.left, (c.y + d.y) / 2 - r.top, after / before) return } const dx = e.clientX - p.x, dy = e.clientY - p.y 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) => { 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 return { x: (ev.clientX - r.left - v.tx) / v.s, y: (ev.clientY - r.top - v.ty) / v.s, inside: ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom } } const startToken = (e, t) => { e.stopPropagation() if (!t.can_move) { onSelect?.(t.id); return } e.preventDefault() const from = { x: e.clientX, y: e.clientY } let moved = false, last = 0 const clampPos = (p) => ({ x: clamp(p.x, 0, map.w), y: clamp(p.y, 0, map.h) }) const mv = (ev) => { if (!moved && Math.hypot(ev.clientX - from.x, ev.clientY - from.y) < 4) return moved = true const p = toWorld(ev) setDrag({ id: t.id, ...clampPos(p), out: !p.inside }) if (t.x != null && p.inside && Date.now() - last > 120) { last = Date.now(); const c = clampPos(p); onMove(t.id, c.x, c.y) } } const end = (ev) => { window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end) if (!moved) { setDrag(null); onSelect?.(t.id); return } const p = toWorld(ev) if (!p.inside) { setDrag(null); return } const c = clampPos(p) Promise.resolve(onMove(t.id, c.x, c.y)).finally(() => setDrag(null)) // keep the local position until the server has it } window.addEventListener('pointermove', mv); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end) } const unplaced = tokens.filter((t) => t.x == null && t.can_move) const d = (t) => t.size * map.grid return (
{image && } {showGrid &&
} {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 if (x == null || (dr && dr.out)) return null return (
startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}> {(t.name || '?').trim().slice(0, 2)} {t.name}
) })}
{unplaced.length > 0 && (
Drag onto the map: {unplaced.map((t) => ( ))}
)}
{children}
) } // Name, size, colour and visibility of one token (used in the live table and in the map editor) export function TokenForm({ t, onChange, onDelete, onDuplicate }) { return (
onChange({ name: e.target.value })} /> {COLORS.map((c) => }
) } const uid = () => Math.random().toString(36).slice(2, 12) // 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 = {}) => ( ) return ( {walls.filter((w) => !(onDoor && w.kind === 'door')).map((w) => line(w))} {walls.filter((w) => onDoor && w.kind === 'door').map((w) => ( onDoor(w)} style={{ pointerEvents: 'stroke', cursor: 'pointer' }}> {w.open ? 'Open door, click to close' : 'Closed door, click to open'} {line(w, { style: { pointerEvents: 'none' } })} ))} {draft && hover && } {draft && } ) } 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 }))); 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) * 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), 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 tools = [['select', 'Move tokens'], ['wall', 'Wall'], ['rect', 'Room'], ['door', 'Door'], ['erase', 'Erase']] return createPortal(
setHover(p) : undefined} overlay={} onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
, document.body) } // DM: the map library (in the "Lobby actions" tab) export function MapLibrary({ activeId, lobbyId, act }) { const [list, setList] = useState([]) const [err, setErr] = useState('') const [editing, setEditing] = useState(null) const [busy, setBusy] = useState(false) const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => {}), []) 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 }) setBusy(false) if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Upload failed'); return } load() } return (

Maps

{err &&

{err}

}
    {list.map((m) => (
  • {m.name} {m.w}×{m.h}, {m.tokens} token{m.tokens === 1 ? '' : 's'}, {m.walls} wall{m.walls === 1 ? '' : 's'}{!m.mine && ` · from ${m.author}`} {m.id === activeId ? : } {m.mine && } {m.mine && }
  • ))} {!list.length &&
  • No maps yet. Upload an image, then prepare enemies in the editor.
  • }
{editing && setEditing(null)} />}
) }