Scenes (switch maps without losing state), decor props with rotation, editor visibility toggle
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
|
||||
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
|
||||
import { MapView, MapLibrary, NpcPicker, RadialMenu, RevealLayer, StatusForm, TokenForm, WallLayer } from './MapView.jsx'
|
||||
import { MapView, MapLibrary, NpcPicker, PropForm, PropPicker, RadialMenu, RevealLayer, StatusForm, 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) })
|
||||
@@ -788,6 +788,12 @@ function LobbyView({ id, rules, user }) {
|
||||
const toggleTop = () => { setTopOpen((v) => { store('aw_topopen', v ? '0' : '1'); return !v }) }
|
||||
const combatOp = (b) => act(`/api/lobbies/${id}/combat`, 'PUT', b)
|
||||
const [npcPick, setNpcPick] = useState(false)
|
||||
const [propPick, setPropPick] = useState(false)
|
||||
const addPropToken = (p) => {
|
||||
const m = mapState.map
|
||||
setPropPick(false)
|
||||
send(`/api/lobbies/${id}/tokens`, 'POST', { name: p.name, prop: p.prop, x: m.w / 2, y: m.h / 2, size: p.w, size_h: p.h, hidden: false }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
|
||||
}
|
||||
const addNpcToken = (n) => {
|
||||
const hp = n.hp > 0 ? n.hp : undefined
|
||||
const m = mapState.map
|
||||
@@ -890,9 +896,10 @@ function LobbyView({ id, rules, user }) {
|
||||
{mapState.map && (() => {
|
||||
const full = lobby.is_dm && !viewAs
|
||||
const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null
|
||||
const selProp = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'prop') : null
|
||||
const walls = full && showWalls ? mapState.walls || [] : []
|
||||
return (
|
||||
<MapView map={mapState.map} image={mapImg} fog={fogImg} portraitUrl={(t) => t.portrait ? (t.kind === 'npc' ? `/api/lobbies/${id}/token-portrait/${t.id}?v=${t.portrait}` : `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}`) : null} tokens={mapState.tokens} grid={showGrid}
|
||||
<MapView map={mapState.map} image={mapImg} fog={fogImg} propSrc={(pid) => `/api/lobbies/${id}/prop-image/${pid}`} portraitUrl={(t) => t.portrait ? (t.kind === 'npc' ? `/api/lobbies/${id}/token-portrait/${t.id}?v=${t.portrait}` : `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}`) : null} tokens={mapState.tokens} grid={showGrid}
|
||||
onMove={moveToken} selected={mapSel} onSelect={setMapSel}
|
||||
onMenu={lobby.is_dm && !viewAs ? (t, x, y) => setMenu({ t, x, y }) : undefined}
|
||||
isTurn={(t) => { const e = lobby.combat?.entries.find((x) => x.id === lobby.combat.current); return !!e && (e.kind === 'npc' ? e.token === t.id : e.user === t.username) }}
|
||||
@@ -920,7 +927,8 @@ function LobbyView({ id, rules, user }) {
|
||||
{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>
|
||||
{full && <button className="ghost" onClick={addToken}>+ Token</button>}
|
||||
{full && <button className={npcPick ? 'on' : 'ghost'} onClick={() => setNpcPick(!npcPick)}>+ NPC</button>}
|
||||
{full && <button className={npcPick ? 'on' : 'ghost'} onClick={() => { setNpcPick(!npcPick); setPropPick(false) }}>+ NPC</button>}
|
||||
{full && <button className={propPick ? 'on' : 'ghost'} onClick={() => { setPropPick(!propPick); setNpcPick(false) }} title="Decor such as chests, tables or doors">+ Prop</button>}
|
||||
</div>
|
||||
{full && (rv || mapState.reveals?.length > 0) && (
|
||||
<div className="tokenpanel mapui revealpanel">
|
||||
@@ -947,13 +955,24 @@ function LobbyView({ id, rules, user }) {
|
||||
</div>
|
||||
)}
|
||||
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>}
|
||||
{full && mapSel && (() => { const st = mapState.tokens.find((t) => t.id === mapSel); return st && !selTok ? <div className="tokenpanel mapui"><b className="small">{st.name || 'Player'}</b><StatusForm t={st} conditions={rules.conditions || []} onChange={(p) => patchToken(st.id, p)} /></div> : null })()}
|
||||
{propPick && <div className="mapui npcpickwrap"><PropPicker onPick={addPropToken} onClose={() => setPropPick(false)} /></div>}
|
||||
{selProp && <div className="tokenpanel mapui"><PropForm t={selProp} onChange={(p) => patchToken(selProp.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selProp.id}`, 'DELETE'); setMapSel(null) }} /></div>}
|
||||
{full && mapSel && (() => { const st = mapState.tokens.find((t) => t.id === mapSel); return st && !selTok && !selProp ? <div className="tokenpanel mapui"><b className="small">{st.name || 'Player'}</b><StatusForm t={st} conditions={rules.conditions || []} onChange={(p) => patchToken(st.id, p)} /></div> : null })()}
|
||||
{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) }} /><StatusForm t={selTok} conditions={rules.conditions || []} onChange={(p) => patchToken(selTok.id, p)} /></div>}
|
||||
</MapView>
|
||||
)
|
||||
})()}
|
||||
{menu && (() => {
|
||||
const t = menu.t, e = lobby.combat?.entries.find((x) => (t.kind === 'npc' ? x.token === t.id : x.user === t.username))
|
||||
const t = menu.t
|
||||
if (t.kind === 'prop') {
|
||||
const rot = t.rot || 0
|
||||
return <RadialMenu x={menu.x} y={menu.y} title={t.name} onClose={() => setMenu(null)} items={[
|
||||
{ icon: '⟲', label: 'Turn left', onClick: () => patchToken(t.id, { rot: (rot + 315) % 360 }) },
|
||||
{ icon: '⟳', label: 'Turn right', onClick: () => patchToken(t.id, { rot: (rot + 45) % 360 }) },
|
||||
{ icon: t.hidden ? '👁' : '🚫', label: t.hidden ? 'Reveal' : 'Hide', onClick: () => patchToken(t.id, { hidden: !t.hidden }) },
|
||||
{ icon: '🗑', label: 'Remove', onClick: () => { send(`/api/lobbies/${id}/tokens/${t.id}`, 'DELETE'); setMapSel(null) } }]} />
|
||||
}
|
||||
const e = lobby.combat?.entries.find((x) => (t.kind === 'npc' ? x.token === t.id : x.user === t.username))
|
||||
const fight = !!lobby.combat
|
||||
return <RadialMenu x={menu.x} y={menu.y} title={t.name} onClose={() => setMenu(null)} items={[
|
||||
{ icon: '⚔', label: e ? 'In fight' : fight ? 'Add to fight' : 'No fight', disabled: !fight || !!e, onClick: () => combatOp(t.kind === 'npc' ? { op: 'add', token: t.id } : { op: 'add', user: t.username }) },
|
||||
|
||||
+136
-19
@@ -1,12 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { BUILTIN, PropArt } from './props.jsx'
|
||||
|
||||
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, fog, portraitUrl, onMenu, isTurn, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
|
||||
export function MapView({ map, image, fog, portraitUrl, propSrc, onMenu, isTurn, 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)
|
||||
@@ -109,7 +110,7 @@ export function MapView({ map, image, fog, portraitUrl, onMenu, isTurn, tokens,
|
||||
window.addEventListener('pointermove', mv); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end)
|
||||
}
|
||||
|
||||
const unplaced = tokens.filter((t) => t.x == null && t.can_move)
|
||||
const unplaced = tokens.filter((t) => t.x == null && t.can_move && t.kind !== 'prop')
|
||||
const d = (t) => t.size * map.grid
|
||||
return (
|
||||
<div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
|
||||
@@ -118,10 +119,21 @@ export function MapView({ map, image, fog, portraitUrl, onMenu, isTurn, tokens,
|
||||
{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) => {
|
||||
{[...tokens].sort((a, b) => (b.kind === 'prop') - (a.kind === 'prop')).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
|
||||
if (t.kind === 'prop') { // decor: below the tokens, no label, rotated
|
||||
const w = t.size * map.grid, h = (t.size_h || t.size) * map.grid
|
||||
return (
|
||||
<div key={t.id} className={`maptoken prop ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`}
|
||||
onContextMenu={(e) => { if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }}
|
||||
style={{ left: x - w / 2, top: y - h / 2, width: w, height: h, transform: `rotate(${t.rot || 0}deg)` }}
|
||||
onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}>
|
||||
<PropArt prop={t.prop} customSrc={propSrc} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''} ${isTurn?.(t) ? 'turn' : ''}`}
|
||||
onContextMenu={(e) => { if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }}
|
||||
@@ -252,6 +264,71 @@ export function NpcPicker({ onPick, onClose }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Library of decor tokens: built-in drawings and the DM's own uploaded images. onPick({prop, name, w, h}).
|
||||
export function PropPicker({ onPick, onClose }) {
|
||||
const [mine, setMine] = useState([])
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const load = useCallback(() => fetch('/api/props').then((r) => (r.ok ? r.json() : [])).then(setMine).catch(() => {}), [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const upload = async (e) => {
|
||||
const f = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!f) return
|
||||
setErr(''); setBusy(true)
|
||||
const r = await fetch(`/api/props?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f })
|
||||
setBusy(false)
|
||||
if (!r.ok) { setErr(r.status === 413 ? 'The image is too large (at most 3 MB).' : (await r.json().catch(() => ({}))).detail || 'Upload failed'); return }
|
||||
load()
|
||||
}
|
||||
return (
|
||||
<div className="npcpicker card proppicker" role="dialog" aria-label="Prop library">
|
||||
<div className="row"><strong>Props</strong><button className="ghost" onClick={onClose} aria-label="Close">×</button></div>
|
||||
<div className="propgrid">
|
||||
{BUILTIN.map((p) => (
|
||||
<button key={p.id} className="ghost propbtn" title={p.name} onClick={() => onPick({ prop: `b:${p.id}`, name: p.name, w: p.w, h: p.h })}>
|
||||
<svg viewBox="0 0 100 100" aria-hidden="true">{p.art}</svg><span>{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{mine.map((p) => (
|
||||
<span key={p.id} className="propbtn own">
|
||||
<button className="ghost" title={p.name} onClick={() => onPick({ prop: `c:${p.id}`, name: p.name, w: 1, h: 1 })}><img src={`/api/props/${p.id}/image`} alt="" /><span>{p.name}</span></button>
|
||||
<button className="ghost mini danger" aria-label={`Delete ${p.name}`} onClick={() => window.confirm(`Delete the prop "${p.name}"? It disappears from all maps.`) && fetch(`/api/props/${p.id}`, { method: 'DELETE' }).then(load)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<label className="uploadbtn">{busy ? 'Uploading…' : '+ Own image (PNG with transparency)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
|
||||
{err && <p className="warn small">{err}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Name, size, rotation and visibility of a decor token
|
||||
export function PropForm({ t, onChange, onDelete, onDuplicate }) {
|
||||
const rot = Math.round(t.rot || 0)
|
||||
return (
|
||||
<div className="tokenform">
|
||||
<input value={t.name} maxLength={40} aria-label="Prop name" onChange={(e) => onChange({ name: e.target.value })} />
|
||||
<span className="row">
|
||||
<label className="small chk">Width <input type="number" min="0.25" max="8" step="0.25" value={t.size} onChange={(e) => onChange({ size: Number(e.target.value) || 1 })} /></label>
|
||||
<label className="small chk">Height <input type="number" min="0.25" max="8" step="0.25" value={t.size_h || t.size} onChange={(e) => onChange({ size_h: Number(e.target.value) || 1 })} /></label>
|
||||
</span>
|
||||
<label className="small">Rotation {rot}°
|
||||
<input type="range" min="0" max="359" step="5" value={rot} onChange={(e) => onChange({ rot: Number(e.target.value) })} /></label>
|
||||
<span className="row">
|
||||
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 315) % 360 })} title="Rotate left 45°">⟲ 45°</button>
|
||||
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 45) % 360 })} title="Rotate right 45°">⟳ 45°</button>
|
||||
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 90) % 360 })} title="Rotate right 90°">⟳ 90°</button>
|
||||
</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)
|
||||
|
||||
// Areas the DM shows to players: a circle or cone. zones = revealed ones, draft = being placed. Units: pixels / degrees.
|
||||
@@ -301,7 +378,7 @@ const distToSeg = (p, w) => {
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
export function MapEditor({ id, onClose, live, onSaved }) {
|
||||
const [m, setM] = useState(null)
|
||||
const [tokens, setTokens] = useState([])
|
||||
const [walls, setWalls] = useState([])
|
||||
@@ -318,7 +395,7 @@ export function MapEditor({ id, onClose }) {
|
||||
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)
|
||||
setM(d); setTokens(d.tokens.map((t) => ({ ...t, kind: t.prop ? 'prop' : 'npc', can_move: true }))); setWalls(d.walls); setName(d.name); setGrid(d.grid); setSight(d.sight || 0)
|
||||
})
|
||||
}, [id])
|
||||
useEffect(() => {
|
||||
@@ -330,6 +407,7 @@ export function MapEditor({ id, onClose }) {
|
||||
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
|
||||
const [picker, setPicker] = useState(false)
|
||||
const [propPick, setPropPick] = useState(false)
|
||||
if (!m) return null
|
||||
const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid
|
||||
const cur = tokens.find((t) => t.id === sel)
|
||||
@@ -340,6 +418,11 @@ export function MapEditor({ id, onClose }) {
|
||||
const t = { id: uid(), kind: 'npc', can_move: true, name: n.name, npc_id: n.id, portrait: n.portrait || '', x: m.w / 2 + (k % 5) * gridNum, y: m.h / 2 + (k % 3) * gridNum, size: n.size || 1, color: COLORS[0], hidden: false, hp_max: n.hp > 0 ? n.hp : null, hp: n.hp > 0 ? n.hp : null }
|
||||
setTokens([...tokens, t]); setSel(t.id); setDirty(true); setPicker(false)
|
||||
}
|
||||
const addProp = (p) => {
|
||||
const k = tokens.length + 1
|
||||
const t = { id: uid(), kind: 'prop', can_move: true, name: p.name, prop: p.prop, rot: 0, x: m.w / 2 + (k % 5) * gridNum, y: m.h / 2 + (k % 3) * gridNum, size: p.w, size_h: p.h, color: COLORS[0], hidden: false }
|
||||
setTokens([...tokens, t]); setSel(t.id); setDirty(true); setPropPick(false)
|
||||
}
|
||||
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 }
|
||||
@@ -374,9 +457,9 @@ export function MapEditor({ id, onClose }) {
|
||||
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, npc_id, hp_max }) => ({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max })) }) })
|
||||
tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h }) => ({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h })) }) })
|
||||
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false }
|
||||
setDirty(false); return true
|
||||
setDirty(false); onSaved?.(); 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']]
|
||||
@@ -406,21 +489,31 @@ export function MapEditor({ id, onClose }) {
|
||||
</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>
|
||||
<div className="row"><button className="on" onClick={add}>+ Add token</button><button className="ghost" onClick={() => setPicker(!picker)}>+ From NPC library</button></div>
|
||||
<h4>{live ? 'Prepared tokens (used when the map is put on the table again)' : 'Enemies, NPCs and props'}</h4>
|
||||
<div className="row"><button className="on" onClick={add}>+ Add token</button><button className="ghost" onClick={() => { setPicker(!picker); setPropPick(false) }}>+ From NPC library</button><button className="ghost" onClick={() => { setPropPick(!propPick); setPicker(false) }}>+ Prop</button></div>
|
||||
{picker && <NpcPicker onPick={addNpc} onClose={() => setPicker(false)} />}
|
||||
{propPick && <PropPicker onPick={addProp} onClose={() => setPropPick(false)} />}
|
||||
<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.npc_id ? ' ★' : ''}{t.hidden ? ' (hidden)' : ''}</button></li>)}
|
||||
{!tokens.length && <li className="muted small">None yet. Add a token, then drag it onto the map.</li>}
|
||||
{tokens.map((t) => (
|
||||
<li key={t.id} className="tokrow">
|
||||
<button className={sel === t.id ? 'on' : 'ghost'} onClick={() => setSel(t.id)}>{t.kind === 'prop' ? <i className="propdot" /> : <i style={{ background: t.color }} />} {t.name}{t.npc_id ? ' ★' : ''}{t.hidden ? ' (hidden)' : ''}</button>
|
||||
<button className="ghost mini" aria-pressed={!t.hidden} aria-label={t.hidden ? `Show ${t.name} to players` : `Hide ${t.name} from players`} title={t.hidden ? 'Hidden from players - click to show' : 'Visible to players - click to hide'}
|
||||
onClick={() => patch(t.id, { hidden: !t.hidden })}>{t.hidden ? '🚫' : '👁'}</button>
|
||||
</li>
|
||||
))}
|
||||
{!tokens.length && <li className="muted small">None yet. Add a token or a prop, 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) }} />}
|
||||
{cur && (() => {
|
||||
const props = { 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) } }
|
||||
return cur.kind === 'prop' ? <PropForm {...props} /> : <TokenForm {...props} />
|
||||
})()}
|
||||
{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 }} image={image} tokens={tokens} portraitUrl={(t) => (t.npc_id && t.portrait ? `/api/characters/${t.npc_id}/portrait?v=${t.portrait}` : null)} grid={showGrid} selected={sel} onSelect={setSel} tool={tool}
|
||||
<MapView map={{ ...m, grid: gridNum }} image={image} tokens={tokens} propSrc={(pid) => `/api/props/${pid}/image`} portraitUrl={(t) => (t.npc_id && t.portrait ? `/api/characters/${t.npc_id}/portrait?v=${t.portrait}` : null)} 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() }} />
|
||||
@@ -434,6 +527,9 @@ export function MapLibrary({ activeId, lobbyId, act }) {
|
||||
const [err, setErr] = useState('')
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [scenes, setScenes] = useState([])
|
||||
const loadScenes = useCallback(() => (lobbyId ? fetch(`/api/lobbies/${lobbyId}/scenes`).then((r) => (r.ok ? r.json() : [])).then(setScenes).catch(() => {}) : null), [lobbyId])
|
||||
useEffect(() => { loadScenes() }, [loadScenes, activeId])
|
||||
const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => {}), [])
|
||||
useEffect(() => { load() }, [load, editing])
|
||||
const upload = async (e) => {
|
||||
@@ -457,16 +553,37 @@ export function MapLibrary({ activeId, lobbyId, act }) {
|
||||
<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>}
|
||||
{m.mine && <button className="ghost" onClick={() => setEditing(m.id)}>Edit</button>}
|
||||
? <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null }, () => { loadScenes() })}>Clear table</button>
|
||||
: <button className="on" onClick={() => (!activeId || window.confirm('Put this map on the table? The current table is saved as a scene first - you can switch back to it below.')) && act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: m.id }, () => { loadScenes() })}>Show on table</button>}
|
||||
{m.mine && <button className="ghost" onClick={() => setEditing(m.id)} title={m.id === activeId ? 'Walls, grid and sight apply to the running game at once' : ''}>{m.id === activeId ? 'Edit live' : '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>
|
||||
))}
|
||||
{!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)} />}
|
||||
{lobbyId && (
|
||||
<>
|
||||
<h4>Scenes</h4>
|
||||
<p className="small muted">Every map you put on the table is a scene. Switching saves the current one (tokens, doors, explored ground, revealed areas, fight) and loads the other exactly as you left it.</p>
|
||||
<ul className="maplist">
|
||||
{scenes.map((sc) => (
|
||||
<li key={sc.id} className={sc.current ? 'active' : ''}>
|
||||
<strong>{sc.name}</strong> <span className="muted small">{sc.map_name ? `Map: ${sc.map_name}` : 'map deleted'} · saved {new Date(sc.updated * 1000).toLocaleString()}{sc.current ? ' · running' : ''}</span>
|
||||
<span className="row">
|
||||
{!sc.current && <button className="on" disabled={!sc.map_name} onClick={() => act(`/api/lobbies/${lobbyId}/scene`, 'PUT', { scene_id: sc.id }, () => { loadScenes(); load() })}>Switch to</button>}
|
||||
{sc.current && <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'PUT', { save: true }, () => loadScenes())}>Save now</button>}
|
||||
<button className="ghost" onClick={() => { const n = window.prompt('New name', sc.name); if (n) act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'PUT', { name: n }, () => loadScenes()) }}>Rename</button>
|
||||
<button className="ghost danger" onClick={() => window.confirm(`Delete the scene "${sc.name}"?${sc.current ? ' The table keeps running, but this state is no longer stored.' : ''}`) && act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'DELETE', undefined, () => loadScenes())}>Delete</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{!scenes.length && <li className="muted small">No scenes yet. Put a map on the table.</li>}
|
||||
</ul>
|
||||
{activeId && <button className="ghost" onClick={() => { const n = window.prompt('Name for the checkpoint', ''); if (n !== null) act(`/api/lobbies/${lobbyId}/scenes`, 'POST', { name: n }, () => loadScenes()) }}>Save a copy of the current table</button>}
|
||||
</>
|
||||
)}
|
||||
{editing && <MapEditor id={editing} live={editing === activeId} onClose={() => setEditing(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Decor tokens ("props"): drawn from above, purely visual. Built-in ones are SVG (id "b:<name>"), own uploads are images (id "c:<id>").
|
||||
// w / h are the default size in grid squares.
|
||||
const wood = '#8a5a2b', dark = '#5b3a18', light = '#b98545', metal = '#8d949b', stone = '#7d7f84', green = '#3f7d3a'
|
||||
|
||||
export const BUILTIN = [
|
||||
{ id: 'chest', name: 'Chest', w: 1, h: 0.7, art: <><rect x="4" y="12" width="92" height="76" rx="8" fill={wood} stroke={dark} strokeWidth="5" /><rect x="4" y="40" width="92" height="8" fill={dark} /><rect x="42" y="34" width="16" height="20" rx="3" fill="#e0b84a" stroke={dark} strokeWidth="3" /><path d="M24 12v76M76 12v76" stroke={dark} strokeWidth="5" /></> },
|
||||
{ id: 'barrel', name: 'Barrel', w: 1, h: 1, art: <><circle cx="50" cy="50" r="45" fill={wood} stroke={dark} strokeWidth="5" /><circle cx="50" cy="50" r="33" fill="none" stroke={dark} strokeWidth="4" /><circle cx="50" cy="50" r="20" fill={light} stroke={dark} strokeWidth="3" /><path d="M30 50h40" stroke={dark} strokeWidth="3" /></> },
|
||||
{ id: 'crate', name: 'Crate', w: 1, h: 1, art: <><rect x="5" y="5" width="90" height="90" fill={light} stroke={dark} strokeWidth="5" /><path d="M5 5l90 90M95 5L5 95" stroke={dark} strokeWidth="5" /><rect x="5" y="5" width="90" height="90" fill="none" stroke={dark} strokeWidth="10" opacity=".4" /></> },
|
||||
{ id: 'table', name: 'Table', w: 2, h: 1, art: <><rect x="3" y="12" width="94" height="76" rx="6" fill={light} stroke={dark} strokeWidth="5" /><path d="M12 30h76M12 50h76M12 70h76" stroke={wood} strokeWidth="2" opacity=".7" /></> },
|
||||
{ id: 'roundtable', name: 'Round table', w: 2, h: 2, art: <><circle cx="50" cy="50" r="46" fill={light} stroke={dark} strokeWidth="5" /><circle cx="50" cy="50" r="30" fill="none" stroke={wood} strokeWidth="2" opacity=".7" /></> },
|
||||
{ id: 'chair', name: 'Chair', w: 0.6, h: 0.6, art: <><rect x="12" y="25" width="76" height="68" rx="8" fill={wood} stroke={dark} strokeWidth="6" /><rect x="8" y="6" width="84" height="22" rx="6" fill={dark} /></> },
|
||||
{ id: 'bed', name: 'Bed', w: 1, h: 2, art: <><rect x="4" y="3" width="92" height="94" rx="8" fill={dark} /><rect x="9" y="8" width="82" height="84" rx="6" fill="#a8423b" /><rect x="14" y="12" width="72" height="22" rx="8" fill="#eee" stroke="#bbb" strokeWidth="2" /><path d="M9 46h82" stroke="#7b2d28" strokeWidth="4" /></> },
|
||||
{ id: 'door', name: 'Door (decor)', w: 1, h: 0.25, art: <><rect x="2" y="15" width="96" height="70" rx="6" fill={wood} stroke={dark} strokeWidth="8" /><circle cx="80" cy="50" r="7" fill="#e0b84a" /></> },
|
||||
{ id: 'campfire', name: 'Campfire', w: 1, h: 1, art: <><circle cx="50" cy="50" r="46" fill="#3b2f2a" /><path d="M20 70L80 30M20 30L80 70" stroke={dark} strokeWidth="9" strokeLinecap="round" /><path d="M50 18c14 16 22 26 14 42-5 9-23 9-28 0-8-16 6-26 14-42z" fill="#f08a24" /><path d="M50 40c8 9 10 14 6 21-3 5-10 5-12 0-3-7 2-12 6-21z" fill="#ffd24a" /></> },
|
||||
{ id: 'torch', name: 'Torch', w: 0.5, h: 0.5, art: <><circle cx="50" cy="50" r="46" fill="#ffcf5a" opacity=".35" /><circle cx="50" cy="50" r="22" fill="#f08a24" /><circle cx="50" cy="50" r="11" fill="#ffd24a" /></> },
|
||||
{ id: 'tree', name: 'Tree', w: 3, h: 3, art: <><circle cx="50" cy="50" r="48" fill="#2f6a2c" /><circle cx="36" cy="40" r="26" fill={green} /><circle cx="62" cy="56" r="28" fill="#47903f" /><circle cx="50" cy="50" r="10" fill="#6b4a25" opacity=".8" /></> },
|
||||
{ id: 'bush', name: 'Bush', w: 1.2, h: 1.2, art: <><circle cx="35" cy="55" r="28" fill="#2f6a2c" /><circle cx="62" cy="42" r="28" fill={green} /><circle cx="64" cy="66" r="24" fill="#47903f" /></> },
|
||||
{ id: 'rock', name: 'Rock', w: 1.2, h: 1, art: <path d="M10 62L22 24l32-14 30 12 8 34-18 22-34 4z" fill={stone} stroke="#4c4e52" strokeWidth="5" strokeLinejoin="round" /> },
|
||||
{ id: 'rug', name: 'Rug', w: 3, h: 2, art: <><rect x="2" y="6" width="96" height="88" rx="4" fill="#8e2f3a" stroke="#d9b45a" strokeWidth="5" /><rect x="12" y="16" width="76" height="68" fill="none" stroke="#d9b45a" strokeWidth="3" /><circle cx="50" cy="50" r="14" fill="#d9b45a" opacity=".8" /></> },
|
||||
{ id: 'stairs', name: 'Stairs', w: 1, h: 2, art: <><rect x="3" y="3" width="94" height="94" fill="#9b9ea3" stroke="#555" strokeWidth="4" />{[18, 33, 48, 63, 78].map((y) => <path key={y} d={`M3 ${y}h94`} stroke="#555" strokeWidth="4" />)}</> },
|
||||
{ id: 'pillar', name: 'Pillar', w: 1, h: 1, art: <><circle cx="50" cy="50" r="44" fill="#a7a9ae" stroke="#5c5e63" strokeWidth="6" /><circle cx="50" cy="50" r="28" fill="#c4c6cb" stroke="#5c5e63" strokeWidth="3" /></> },
|
||||
{ id: 'altar', name: 'Altar', w: 2, h: 1, art: <><rect x="3" y="12" width="94" height="76" rx="4" fill="#6c6f77" stroke="#3d3f45" strokeWidth="5" /><path d="M50 26v48M30 44h40" stroke="#d9b45a" strokeWidth="7" strokeLinecap="round" /></> },
|
||||
{ id: 'bones', name: 'Bones', w: 1, h: 1, art: <><g stroke="#4a4338" strokeWidth="3" fill="#e8e1cf"><circle cx="40" cy="42" r="17" /><rect x="30" y="52" width="20" height="12" rx="3" /><path d="M58 70l30-28" strokeWidth="9" stroke="#e8e1cf" strokeLinecap="round" /><path d="M12 82l34-14" strokeWidth="9" stroke="#e8e1cf" strokeLinecap="round" /></g><circle cx="34" cy="40" r="4" fill="#222" /><circle cx="47" cy="40" r="4" fill="#222" /></> },
|
||||
{ id: 'cart', name: 'Wreck / cart', w: 2, h: 1, art: <><rect x="6" y="14" width="88" height="72" rx="5" fill="#6d5a45" stroke="#3b2f22" strokeWidth="5" /><path d="M6 36h88M6 62h88" stroke="#3b2f22" strokeWidth="3" /><rect x="14" y="2" width="14" height="12" fill="#222" /><rect x="72" y="86" width="14" height="12" fill="#222" /></> },
|
||||
]
|
||||
|
||||
const byId = Object.fromEntries(BUILTIN.map((p) => [p.id, p]))
|
||||
|
||||
export const isCustom = (id) => typeof id === 'string' && id.startsWith('c:')
|
||||
export const builtin = (id) => byId[String(id).replace(/^b:/, '')]
|
||||
|
||||
// Picture of a prop filling its box. customSrc(id) gives the image URL for own uploads.
|
||||
export function PropArt({ prop, customSrc }) {
|
||||
if (isCustom(prop)) return <img src={customSrc?.(prop.slice(2))} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }} />
|
||||
const b = builtin(prop)
|
||||
return <svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: '100%', display: 'block', pointerEvents: 'none' }} aria-hidden="true">{b ? b.art : <rect width="100" height="100" fill="#888" />}</svg>
|
||||
}
|
||||
@@ -605,3 +605,27 @@ textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
|
||||
.radialbtn { width: 58px; height: 58px; left: -29px; top: -29px; }
|
||||
.radialtitle { background: #000; color: #fff; font-weight: 700; font-size: .8rem; border: 1px solid #8fd44c; }
|
||||
.radialbtn:disabled { opacity: .78; background: #262b22 !important; border-color: #5a634f !important; cursor: not-allowed; }
|
||||
|
||||
/* decor tokens (props) */
|
||||
.maptoken.prop { border: 0; border-radius: 0; background: none; box-shadow: none; display: block; transform-origin: center; }
|
||||
.maptoken.prop:not(.mov) { pointer-events: none; }
|
||||
.maptoken.prop.hid { opacity: .5; outline: 2px dashed rgba(255,255,255,.5); border-style: none; }
|
||||
.maptoken.prop.sel { outline: 3px solid var(--accent, gold); outline-offset: 2px; }
|
||||
.maptoken.prop em { display: none !important; }
|
||||
.proppicker { max-width: 360px; }
|
||||
.propgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(78px, 1fr)); gap: .3rem; max-height: 300px; overflow: auto; }
|
||||
.propbtn { display: grid; justify-items: center; gap: .15rem; padding: .3rem; font-size: .72rem; position: relative; }
|
||||
.propbtn svg, .propbtn img { width: 46px; height: 46px; object-fit: contain; }
|
||||
.propbtn.own { padding: 0; }
|
||||
.propbtn.own > .ghost:first-child { display: grid; justify-items: center; gap: .15rem; width: 100%; }
|
||||
.propbtn.own .mini { position: absolute; top: 0; right: 0; padding: 0 .3rem; min-height: 0; }
|
||||
.tokrow { display: flex; gap: .25rem; }
|
||||
.tokrow > button:first-child { flex: 1; min-width: 0; }
|
||||
.propdot { display: inline-block; width: .8rem; height: .8rem; border-radius: 2px; background: #b98545; vertical-align: middle; }
|
||||
.tokenform input[type=range] { width: 100%; }
|
||||
.tokenform .row { display: flex; flex-wrap: wrap; gap: .35rem; justify-content: flex-start; align-items: center; }
|
||||
.tokenform label.small { display: flex; align-items: center; gap: .3rem; flex-wrap: wrap; }
|
||||
.tokenform label.small input[type=number] { width: 4rem; }
|
||||
.tokenform .mini { min-height: 0; padding: .15rem .5rem; }
|
||||
.tokenlist .tokrow > button:first-child { width: auto; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tokenlist .tokrow > button.mini { width: auto; flex: none; }
|
||||
|
||||
Reference in New Issue
Block a user