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} />}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -396,3 +396,48 @@ body:has(.lobbychat) main { padding: .5rem calc(var(--chatw) + .5rem) 0 .5rem; }
|
||||
.editor .tabs { margin-top: .6rem; }
|
||||
|
||||
.settings.inline { position: static; max-width: none; margin: 0; border: 0; background: transparent; padding: 0; }
|
||||
|
||||
/* ---- maps */
|
||||
.table { background: color-mix(in srgb, var(--panel) 70%, #000); }
|
||||
.mapbox { position: absolute; inset: 0; overflow: hidden; touch-action: none; cursor: grab; background: #111; z-index: 1; }
|
||||
.mapbox:active { cursor: grabbing; }
|
||||
.mapworld { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; }
|
||||
.mapworld img { display: block; max-width: none; user-select: none; -webkit-user-drag: none; }
|
||||
.mapgrid { position: absolute; inset: 0; pointer-events: none; background-image: linear-gradient(to right, rgba(255,255,255,.28) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,.28) 1px, transparent 1px); }
|
||||
.maptoken { position: absolute; border-radius: 50%; background: var(--tc); border: 3px solid #fff; box-shadow: 0 0 0 2px rgba(0,0,0,.6), 0 2px 6px rgba(0,0,0,.6); display: grid; place-items: center; color: #fff; font-weight: 700; text-transform: uppercase; user-select: none; touch-action: none; cursor: default; }
|
||||
.maptoken.mov { cursor: move; }
|
||||
.maptoken.player { border-color: var(--tc); background: color-mix(in srgb, var(--tc) 55%, #000); box-shadow: 0 0 0 2px #fff, 0 2px 6px rgba(0,0,0,.6); }
|
||||
.maptoken.hid { opacity: .55; border-style: dashed; }
|
||||
.maptoken.sel { outline: 3px solid var(--accent, gold); outline-offset: 3px; }
|
||||
.maptoken span { pointer-events: none; text-shadow: 0 1px 2px #000; }
|
||||
.maptoken em { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); margin-top: 2px; font-style: normal; font-weight: 600; text-transform: none; white-space: nowrap; color: #fff; text-shadow: 0 0 3px #000, 0 0 3px #000; pointer-events: none; }
|
||||
.maptray { position: absolute; top: .5rem; left: .5rem; display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; padding: .3rem .5rem; border-radius: 8px; background: color-mix(in srgb, var(--panel) 90%, transparent); border: 1px solid var(--line); z-index: 4; max-width: 70%; }
|
||||
.traytoken { border-radius: 999px; border: 2px solid var(--tc); background: color-mix(in srgb, var(--tc) 40%, var(--panel)); color: var(--text); padding: .15rem .7rem; cursor: grab; touch-action: none; }
|
||||
.mapzoom { position: absolute; right: .5rem; bottom: .5rem; display: grid; gap: .25rem; z-index: 4; }
|
||||
.mapzoom button, .maptools button { background: color-mix(in srgb, var(--panel) 90%, transparent); }
|
||||
.maptools { position: absolute; top: .5rem; right: .5rem; display: flex; gap: .3rem; z-index: 4; }
|
||||
.tokenpanel { position: absolute; right: 3rem; bottom: .5rem; z-index: 5; width: 17rem; max-width: 60%; padding: .6rem; border-radius: 10px; background: var(--panel); border: 1px solid var(--line); cursor: default; }
|
||||
.tokenform { display: grid; gap: .45rem; }
|
||||
.tokenform input[type=number] { width: 4.5rem; }
|
||||
.swatches { display: flex; gap: .3rem; flex-wrap: wrap; }
|
||||
.swatch { width: 1.4rem; height: 1.4rem; padding: 0; border-radius: 50%; border: 2px solid transparent; }
|
||||
.swatch.on { border-color: var(--text); }
|
||||
.maplib { display: grid; gap: .5rem; margin-bottom: 1rem; }
|
||||
.uploadbtn { display: inline-block; padding: .4rem .8rem; border: 1px dashed var(--line); border-radius: 8px; cursor: pointer; text-align: center; }
|
||||
.maplist { list-style: none; padding: 0; margin: 0; display: grid; gap: .4rem; }
|
||||
.maplist li { padding: .5rem; border: 1px solid var(--line); border-radius: 8px; display: grid; gap: .3rem; }
|
||||
.maplist li.active { border-color: var(--accent, gold); }
|
||||
.mapeditor { position: fixed; inset: 0; z-index: 100; display: grid; grid-template-columns: 20rem 1fr; background: var(--bg); }
|
||||
.mapeditor aside { overflow-y: auto; border-radius: 0; display: grid; gap: .6rem; align-content: start; }
|
||||
.mapstage { position: relative; }
|
||||
.tokenlist { list-style: none; padding: 0; margin: 0; display: grid; gap: .25rem; }
|
||||
.tokenlist button { width: 100%; text-align: left; }
|
||||
.tokenlist i { display: inline-block; width: .8rem; height: .8rem; border-radius: 50%; vertical-align: middle; }
|
||||
@media (max-width: 800px) { .mapeditor { grid-template-columns: 1fr; grid-template-rows: 45% 1fr; } }
|
||||
.mapeditor, .mapeditor * { box-sizing: border-box; }
|
||||
.mapeditor { z-index: 1000; }
|
||||
.mapeditor .mapstage { min-width: 0; }
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user