Files
AWCharacterCreator/frontend/src/MapView.jsx
T
Claude e8755582d5 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
2026-10-23 13:59:21 +00:00

261 lines
14 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, src, tokens, onMove, selected, onSelect, grid: showGrid, 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')) return
box.current.setPointerCapture(e.pointerId)
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (pointers.current.size === 1) onSelect?.(null)
}
const move = (e) => {
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 }
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, { 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 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 (
<div className="mapbox" 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} />
{showGrid && <div className="mapgrid" style={{ backgroundSize: `${map.grid}px ${map.grid}px` }} />}
{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 (
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`}
style={{ left: x - d(t) / 2, top: y - d(t) / 2, width: d(t), height: d(t), '--tc': t.color, fontSize: Math.max(8, d(t) * 0.45) }}
onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}>
<span>{(t.name || '?').trim().slice(0, 2)}</span>
<em style={{ fontSize: Math.max(9, map.grid * 0.28) }}>{t.name}</em>
</div>
)
})}
</div>
{unplaced.length > 0 && (
<div className="maptray mapui" role="group" aria-label="Tokens not on the map yet">
<span className="small muted">Drag onto the map:</span>
{unplaced.map((t) => (
<button key={t.id} className="traytoken" style={{ '--tc': t.color }} onPointerDown={(e) => startToken(e, t)} title="Drag onto the map">{t.name}</button>
))}
</div>
)}
<div className="mapzoom mapui">
<button className="ghost" onClick={() => { const r = box.current.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1.3) }} aria-label="Zoom in">+</button>
<button className="ghost" onClick={() => { const r = box.current.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1 / 1.3) }} aria-label="Zoom out">-</button>
<button className="ghost" onClick={fit} aria-label="Fit the map to the window" title="Fit">⤢</button>
</div>
{children}
</div>
)
}
// 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 (
<div className="tokenform">
<input value={t.name} maxLength={40} aria-label="Token name" onChange={(e) => onChange({ name: e.target.value })} />
<label className="small chk">Size <input type="number" min="0.25" max="8" step="0.25" value={t.size} onChange={(e) => onChange({ size: Number(e.target.value) || 1 })} /> sq.</label>
<span className="swatches">{COLORS.map((c) => <button key={c} className={`swatch ${t.color === c ? 'on' : ''}`} style={{ background: c }} aria-label={`Colour ${c}`} onClick={() => onChange({ color: c })} />)}</span>
<label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label>
<span className="row">
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
<button className="ghost danger" onClick={onDelete}>Remove</button>
</span>
</div>
)
}
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.
export function MapEditor({ id, onClose }) {
const [m, setM] = useState(null)
const [tokens, setTokens] = useState([])
const [name, setName] = useState('')
const [grid, setGrid] = useState(40)
const [showGrid, setShowGrid] = useState(true)
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) })
}, [id])
if (!m) return null
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 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 }
setTokens([...tokens, t]); setSel(t.id); setDirty(true)
}
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 })) }) })
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
return createPortal(
<div className="mapeditor" role="dialog" aria-label="Map editor">
<aside className="card">
<h3>Map editor</h3>
<label className="small">Name <input value={name} maxLength={60} onChange={(e) => { setName(e.target.value); setDirty(true) }} /></label>
<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>Enemies and NPCs</h4>
<button className="on" onClick={add}>+ Add token</button>
<ul className="tokenlist">
{tokens.map((t) => <li key={t.id}><button className={sel === t.id ? 'on' : 'ghost'} onClick={() => setSel(t.id)}><i style={{ background: t.color }} /> {t.name}{t.hidden ? ' (hidden)' : ''}</button></li>)}
{!tokens.length && <li className="muted small">None yet. Add a token, then drag it onto the map.</li>}
</ul>
{cur && <TokenForm t={cur} onChange={(p) => patch(cur.id, p)}
onDelete={() => { setTokens(tokens.filter((t) => t.id !== cur.id)); setSel(null); setDirty(true) }}
onDuplicate={() => { const c = { ...cur, id: uid(), name: cur.name.replace(/\s*\d*$/, '') + ' ' + (tokens.length + 1), x: cur.x + gridNum, y: cur.y + gridNum }; setTokens([...tokens, c]); setSel(c.id); setDirty(true) }} />}
{err && <p className="warn small">{err}</p>}
<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}
onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
</div>
</div>, 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 (
<div className="maplib">
<h4>Maps</h4>
<label className="uploadbtn">{busy ? 'Uploading…' : '+ Upload map (PNG, JPEG, WebP)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
{err && <p className="warn small">{err}</p>}
<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>
<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>
</span>
</li>
))}
{!list.length && <li className="muted small">No maps yet. Upload an image, then prepare enemies in the editor.</li>}
</ul>
{editing && <MapEditor id={editing} onClose={() => setEditing(null)} />}
</div>
)
}