Files
AWCharacterCreator/frontend/src/Lobbies.jsx
T

1007 lines
65 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 PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
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) })
const msg = (d, fallback) => (typeof d?.detail === 'string' ? d.detail : fallback)
function useMine() {
const [mine, setMine] = useState([])
useEffect(() => { fetch('/api/characters').then((r) => r.json()).then((d) => setMine(d.mine || [])).catch(() => {}) }, [])
return mine
}
function CharSelect({ mine, value, onChange, label }) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} aria-label={label}>
<option value="">Choose a character …</option>
{mine.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
)
}
// ---- list of lobbies: create one (you become the DM) or join one with a code
function LobbyList() {
const mine = useMine()
const [data, setData] = useState(null)
const [name, setName] = useState('')
const [code, setCode] = useState('')
const [cid, setCid] = useState('')
const [err, setErr] = useState('')
const load = () => fetch('/api/lobbies').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your lobbies.'))
useEffect(() => { load() }, [])
const create = async (e) => {
e.preventDefault(); setErr('')
const r = await send('/api/lobbies', 'POST', { name })
const d = await r.json().catch(() => ({}))
if (!r.ok) return setErr(msg(d, 'Could not create the lobby.'))
window.location.hash = `lobbies/${d.id}`
}
const join = async (e) => {
e.preventDefault(); setErr('')
if (!cid) return setErr('Choose which of your characters joins.')
const r = await send('/api/lobbies/join', 'POST', { code, character_id: cid })
const d = await r.json().catch(() => ({}))
if (!r.ok) return setErr(msg(d, 'Could not join.'))
window.location.hash = `lobbies/${d.id}`
}
return (
<section className="page narrow">
<h1>Lobbies</h1>
<p className="muted">
A lobby is a group for one campaign. Whoever creates it is the DM and can look at every character in it.
Players only see a short card of each other (name, concept, class and profession).
</p>
{err && <p className="warn">{err}</p>}
<div className="card">
<h2>Join a lobby</h2>
<form className="row wrapform" onSubmit={join}>
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Join code from your DM" aria-label="Join code" autoComplete="off" />
<CharSelect mine={mine} value={cid} onChange={setCid} label="Character that joins" />
<button type="submit" disabled={!code.trim()}>Join</button>
</form>
{mine.length === 0 && <p className="small muted">You need a saved character first (Character → Summary → Save).</p>}
</div>
<h2>Lobbies you joined</h2>
{data && data.joined.length === 0 && <p className="muted small">None yet.</p>}
<ul className="charlist">
{(data?.joined || []).map((l) => (
<li key={l.id} className="card row">
<div>
<strong>{l.name}</strong>
<div className="muted small">DM {l.dm}{l.character ? ` · playing ${l.character}` : ' · no character selected'}</div>
</div>
<div className="actions"><a className="button" href={`#lobbies/${l.id}`}>Open</a></div>
</li>
))}
</ul>
<h2>Lobbies you run (as DM)</h2>
{data && data.mine.length === 0 && <p className="muted small">None yet.</p>}
<ul className="charlist">
{(data?.mine || []).map((l) => (
<li key={l.id} className="card row">
<div>
<strong>{l.name}</strong>
<div className="muted small">{l.members} player{l.members === 1 ? '' : 's'}</div>
</div>
<div className="actions"><a className="button" href={`#lobbies/${l.id}`}>Open</a></div>
</li>
))}
</ul>
<form className="row wrapform" onSubmit={create}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name of a new lobby" aria-label="Lobby name" maxLength={60} />
<button type="submit" disabled={!name.trim()}>+ Create lobby</button>
</form>
</section>
)
}
const clock = (t) => new Date(t * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
const DICE = [4, 6, 8, 10, 12, 20, 100]
const reduced = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
const sgn = (n) => (n > 0 ? `+${n}` : n < 0 ? `−${-n}` : '±0')
const store = (k, v) => { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v) } catch { /* storage may be blocked */ } return null }
const OUTCOME = { crit_success: 'Critical success!', success: 'Success', failure: 'Failure', crit_failure: 'Critical failure!' }
// One WebSocket per open lobby. It only says "something changed" ({t: 'messages' | 'lobby' | 'sheet'}), the data is loaded through
// the normal API. Without a working socket the page falls back to polling, so a proxy that blocks WebSockets does not break anything.
function useSocket(id, handlers) {
const [up, setUp] = useState(false)
const h = useRef(handlers)
h.current = handlers
useEffect(() => {
let ws, timer, ping, tries = 0, closed = false
const open = () => {
ws = new WebSocket(`${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/api/lobbies/${id}/ws`)
ws.onopen = () => {
tries = 0; setUp(true); h.current.open?.()
ping = setInterval(() => ws.readyState === 1 && ws.send('ping'), 25000)
}
ws.onmessage = (e) => {
if (e.data === 'pong') return
try { const n = JSON.parse(e.data); h.current[n.t]?.(n) } catch { /* ignore malformed notes */ }
}
ws.onclose = (e) => {
clearInterval(ping); setUp(false)
if (closed || (e.code >= 4400 && e.code < 4500)) return // refused or removed: the normal polling shows why
timer = setTimeout(open, Math.min(10000, 1000 * 2 ** tries++))
}
}
try { open() } catch { /* no WebSocket support: polling only */ }
const wake = () => { if (!document.hidden) h.current.open?.() } // catch up after the tab was in the background
document.addEventListener('visibilitychange', wake)
return () => { closed = true; clearTimeout(timer); clearInterval(ping); document.removeEventListener('visibilitychange', wake); ws?.close() }
}, [id])
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
}
// The ground this player has already seen (dimmed under the live view). Reloaded a moment after the picture changes.
function useFogImage(id, rev, as) {
const [src, setSrc] = useState(null)
useEffect(() => {
if (!rev) { setSrc(null); return }
const t = setTimeout(() => setSrc(`/api/lobbies/${id}/map/explored?v=${rev}${as ? `&as=${encodeURIComponent(as)}` : ''}`), 400)
return () => clearTimeout(t)
}, [id, rev, as])
return src
}
// 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([])
const last = useRef(0)
const live = useRef(false) // false until the history is loaded: old rolls are not animated again
const cb = useRef({})
cb.current = { onEvent, onRoll }
const busy = useRef(false) // two overlapping polls (timer + right after a roll) would both deliver the same entries
const poll = useCallback(async () => {
if (document.hidden || busy.current) return
busy.current = true
const r = await fetch(`/api/lobbies/${id}/messages?after=${last.current}`).catch(() => null)
busy.current = false
if (!r || !r.ok) return
const fresh = (await r.json()).filter((m) => m.id > last.current)
const wasLive = live.current
live.current = true
if (!fresh.length) return
last.current = fresh[fresh.length - 1].id
setMsgs((old) => [...old, ...fresh].slice(-300))
if (fresh.some((m) => m.kind === 'event')) cb.current.onEvent()
if (wasLive) fresh.filter((m) => m.kind === 'roll' || m.kind === 'check').forEach((m) => cb.current.onRoll(m))
}, [id])
useEffect(() => {
poll()
const t = setInterval(poll, socketUp ? 20000 : 2500) // the socket says when something is new, the timer is only a safety net
return () => clearInterval(t)
}, [poll, socketUp])
return { msgs, poll }
}
const rollText = (m) => {
const res = m.roll.results, n = m.roll.n
return { dice: `${n > 1 ? n : ''}d${m.roll.sides}`, list: res && res.join(' + '), sum: res && res.reduce((a, b) => a + b, 0), n }
}
// the text of a skill / attribute check; players do not see the numbers when the DM hides the difficulty
function checkLine(c) {
const out = OUTCOME[c.outcome]
if (c.target === undefined) return <>rolled <strong>{c.roll}</strong> → <span className={`out ${c.outcome}`}>{out}</span></>
const parts = `${c.base}${c.mod ? ` ${sgn(c.mod)} ${c.diff}` : ''}${c.cmod ? ` ${sgn(c.cmod)} bonus` : ''}`
return <>rolled <strong>{c.roll}</strong> vs {c.target}{parts !== String(c.target) && <span className="muted small"> ({parts})</span>} →{' '}
<span className={`out ${c.outcome}`}>{out}</span> <span className="muted small">{c.margin >= 0 ? `by ${c.margin}` : `by ${-c.margin}`}</span></>
}
function Chat({ me, people, msgs, poll, id, to, setTo }) {
const [text, setText] = useState('')
const [events, setEvents] = useState(true)
const [err, setErr] = useState('')
const box = useRef(null)
const stick = useRef(true)
useEffect(() => { // keep the newest line in view unless the user scrolled up
const el = box.current
if (el && stick.current) el.scrollTop = el.scrollHeight
}, [msgs, events])
const submit = async (e) => {
e.preventDefault(); setErr('')
if (!text.trim()) return
const r = await send(`/api/lobbies/${id}/messages`, 'POST', { text, to })
if (!r.ok) return setErr(msg(await r.json().catch(() => ({})), 'Could not send.'))
setText(''); stick.current = true; poll()
}
const shown = msgs.filter((m) => events || m.kind !== 'event')
const who = (m) => (
<>
<strong style={m.color ? { color: m.color } : undefined}>{m.mine ? 'You' : m.from || '?'}</strong>
{m.to && <em className="small whispertag"> {m.to === m.from ? '(private, only you)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>}
</>
)
return (
<div className="chatwrap">
<div className="chatbox" ref={box} onScroll={(e) => { const el = e.currentTarget; stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40 }}
role="log" aria-live="polite">
{shown.length === 0 && <p className="muted small">Nothing here yet.</p>}
{shown.map((m) => {
if (m.kind === 'event') return <div key={m.id} className="msg event"><span className="time">{clock(m.time)}</span> {m.text}</div>
if (m.kind === 'check') {
return (
<div key={m.id} className={`msg rollmsg ${m.mine ? 'mine' : ''}`}>
<span className="time">{clock(m.time)}</span> {who(m)} – <b>{m.check.what}</b>: {checkLine(m.check)}{m.check.manual && <span className="manualtag small"> own dice</span>}
</div>
)
}
if (m.kind === 'roll') {
const t = rollText(m)
return (
<div key={m.id} className={`msg rollmsg ${m.to ? 'whisper' : ''} ${m.mine ? 'mine' : ''}`}>
<span className="time">{clock(m.time)}</span> {who(m)} rolled <strong>{t.dice}</strong>:{' '}
{t.list === null ? <span className="rollres mystery">{Array(t.n).fill('?').join(' ')}</span>
: <span className="rollres">{t.n > 1 ? `${t.list} = ` : ''}<b>{t.sum}</b></span>}
{m.roll.manual && <span className="manualtag small"> own dice</span>}
</div>
)
}
return (
<div key={m.id} className={`msg ${m.kind} ${m.mine ? 'mine' : ''}`}>
<span className="time">{clock(m.time)}</span> {who(m)}<span>: {m.text}</span>
</div>
)
})}
</div>
<form className="row chatform" onSubmit={submit}>
<select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}>
<option value="">Everyone</option>
<option value={me}>Only me (private)</option>
{people.filter((p) => p !== me).map((p) => <option key={p} value={p}>Only {p} (whisper)</option>)}
</select>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder={to ? `Whisper to ${to} …` : 'Message …'} maxLength={500} aria-label="Message" autoComplete="off" />
<button type="submit" disabled={!text.trim()}>Send</button>
</form>
{err && <p className="warn small">{err}</p>}
<label className="small muted"><input type="checkbox" checked={events} onChange={(e) => setEvents(e.target.checked)} style={{ width: 'auto' }} /> Show events (joins, character switches …)</label>
<p className="small muted">Whispers and private rolls are only seen by you and the person you pick, not even the DM sees other people's.</p>
</div>
)
}
// ---- dice: hotbar below the table, results are thrown into the middle of the table and posted in the chat
const SHAPE = { 4: '50% 4%, 96% 92%, 4% 92%', 6: '6% 6%, 94% 6%, 94% 94%, 6% 94%', 8: '50% 0, 100% 50%, 50% 100%, 0 50%', 10: '50% 0, 98% 40%, 50% 100%, 2% 40%',
12: '50% 0, 98% 38%, 80% 96%, 20% 96%, 2% 38%', 20: '25% 4%, 75% 4%, 100% 50%, 75% 96%, 25% 96%, 0 50%', 100: '' }
function DieIcon({ sides }) {
const pts = SHAPE[sides] && SHAPE[sides].split(', ').map((p) => p.split(' ').map((v) => parseFloat(v) * .24).join(',')).join(' ')
return (
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
{pts ? <polygon points={pts} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" /> : <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="1.6" />}
</svg>
)
}
const MODES = [['mystery', 'Mystery', 'Players see grey dice, but not the result'], ['hidden', 'Hidden', 'Only you see the roll'], ['public', 'Public', 'Everybody sees the roll']]
function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy, sfx }) {
const [snd, setSnd] = useState(sound.enabled)
const [vol, setVol] = useState(sound.volume)
return (
<div className="dicebar" role="toolbar" aria-label="Dice">
<label className="small muted dicecount">Dice
<select value={count} onChange={(e) => setCount(+e.target.value)} aria-label="Number of dice">
{[1, 2, 3, 4, 5, 6, 8, 10].map((n) => <option key={n} value={n}>{n}×</option>)}
</select>
</label>
{DICE.map((d) => (
<button key={d} className="ghost diebtn" onClick={() => onRoll(d)} disabled={busy} title={`Roll ${count > 1 ? count : ''}d${d}`} aria-label={`Roll ${count} d${d}`}>
<DieIcon sides={d} /><span>d{d}</span>
</button>
))}
<label className="small ownswitch" title="You rolled real dice: type in the result instead of rolling here">
<input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} style={{ width: 'auto' }} /> Own dice
</label>
<span className="soundctl small" title="Dice sounds">
<button className="ghost" aria-pressed={snd} aria-label={snd ? 'Mute dice sounds' : 'Turn dice sounds on'} onClick={() => { sound.setEnabled(!snd); setSnd(!snd); if (!snd) playRoll(20, 1, undefined, sfx) }}>{snd ? '🔊' : '🔇'}</button>
{snd && <input type="range" min="0" max="1" step="0.05" value={vol} aria-label="Dice sound volume" onChange={(e) => { setVol(+e.target.value); sound.setVolume(+e.target.value) }} onPointerUp={() => playRoll(20, 1, undefined, sfx)} />}
</span>
{isDm && !to && (
<span className="seg small" role="group" aria-label="Who sees the DM's rolls">
{MODES.map(([k, label, tip]) => <button key={k} className={mode === k ? 'on' : 'ghost'} title={tip} onClick={() => setMode(k)}>{label}</button>)}
</span>
)}
{to && <span className="small dicedest priv">{to === me ? 'Private: only you' : `Private: ${to}`}</span>}
</div>
)
}
// shown instead of rolling when "Own dice" is on: the player types what the real dice showed
function ManualEntry({ pending, onSubmit, onCancel, err }) {
const n = pending.type === 'check' ? 1 : pending.count
const sides = pending.type === 'check' ? 100 : pending.sides
const [vals, setVals] = useState(Array(n).fill(''))
const ok = vals.every((v) => /^\d+$/.test(v) && +v >= 1 && +v <= sides)
return (
<form className="manual card" onSubmit={(e) => { e.preventDefault(); if (ok) onSubmit(vals.map(Number)) }}>
<div className="small">{pending.type === 'check' ? <>Your real d100 for <b>{pending.name}</b></> : <>Your real result{n > 1 ? 's' : ''} for <b>{n > 1 ? n : ''}d{sides}</b></>} (1–{sides})</div>
<div className="row">
{vals.map((v, i) => (
<input key={i} autoFocus={i === 0} inputMode="numeric" value={v} aria-label={`Result ${i + 1}`} style={{ width: '4.2rem' }}
onChange={(e) => setVals(vals.map((x, j) => (j === i ? e.target.value.replace(/\D/g, '').slice(0, 3) : x)))} />
))}
<button type="submit" disabled={!ok}>Send</button>
<button type="button" className="ghost" onClick={onCancel}>Cancel</button>
</div>
{err && <span className="warn small">{err}</span>}
</form>
)
}
function Die({ sides, value, delay, mystery }) {
const [shown, setShown] = useState(reduced() ? value : null)
useEffect(() => {
if (reduced()) return
const t0 = Date.now(), run = 650 + delay
const iv = setInterval(() => {
if (Date.now() - t0 >= run) { setShown(value); clearInterval(iv) } else setShown(1 + Math.floor(Math.random() * sides))
}, 70)
return () => clearInterval(iv)
}, [sides, value, delay])
const rolling = shown !== value
return (
<span className={`die d${sides} ${rolling ? 'rolling' : 'landed'}${mystery ? ' mystery' : ''}`} style={{ animationDelay: `${delay % 200}ms` }}>
<span>{mystery && !rolling ? '?' : shown ?? '?'}</span>
</span>
)
}
// dice are thrown onto the table from a random side and come to rest at a random spot, in the colour of whoever threw them
const placed = [] // spots of the dice still lying on the table, so new ones do not land on top of them
function spots(n) {
const out = []
for (let i = 0; i < n; i++) {
let p, tries = 0
do { p = { x: 12 + Math.random() * 76, y: 22 + Math.random() * 58 }; tries++ } while (tries < 40 && [...placed, ...out].some((q) => Math.hypot((q.x - p.x) * 1.6, q.y - p.y) < 14))
placed.push(p); if (placed.length > 12) placed.shift()
const a = Math.random() * Math.PI * 2
out.push({ ...p, sx: Math.round(Math.cos(a) * 700), sy: Math.round(Math.sin(a) * 450), sr: Math.round(Math.random() * 720 - 360), er: Math.round(Math.random() * 50 - 25) })
}
return out
}
function ThrownDice({ m }) {
const sec = m.kind === 'roll' && m.roll.mystery && !m.mine
const n = m.kind === 'check' ? 1 : m.roll.n
const [pos] = useState(() => spots(n))
return pos.map((p, i) => (
<span key={i} className="thrown" style={{ left: `${p.x}%`, top: `${p.y}%`, '--sx': `${p.sx}px`, '--sy': `${p.sy}px`, '--sr': `${p.sr}deg`, '--er': `${p.er}deg`,
'--dc': sec ? '#8b8f85' : m.color || 'var(--noxium)', animationDelay: `${i * 90}ms` }}>
{m.kind === 'check' ? <Die sides={100} value={m.check.roll} delay={0} />
: <Die sides={m.roll.sides} value={m.roll.results ? m.roll.results[i] : 0} delay={i * 120} mystery={!m.roll.results} />}
</span>
))
}
function DiceTray({ rolls }) {
if (!rolls.length) return null
return (
<div className="dicetray" aria-hidden="true">
{rolls.map((m) => <ThrownDice key={m.key} m={m} />)}
<div className="tablelog">
{rolls.map((m) => {
const who = <b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b>
if (m.kind === 'check') {
const c = m.check
return <div key={m.key} className="trayroll">{who} – {c.what}{c.manual ? ' · own dice' : ''}{m.to ? ' · only you' : ''}: <span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>{c.target !== undefined && <small> ({c.roll} vs {c.target})</small>}</div>
}
const t = rollText(m)
return <div key={m.key} className="trayroll">{who} rolled <strong>{t.dice}</strong>{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}
{t.sum !== null ? <> = <b>{t.sum}</b></> : ' = ?'}</div>
})}
</div>
</div>
)
}
// ---- character bar: one card per player, later also for initiative and enemies
function CharBar({ lobby, sel, onSel }) {
return (
<div className="charbar" role="list">
<div role="listitem" className={`pcard dmcard${lobby.dm_online ? '' : ' off'}`} title={`Dungeon master${lobby.dm_online ? ' (online)' : ' (offline)'}`}>
<span className="pavatar dm"><i className={`presence ${lobby.dm_online ? 'on' : ''}`} aria-label={lobby.dm_online ? 'online' : 'offline'} />DM</span>
<span className="pinfo"><b>{lobby.dm}{lobby.is_dm ? ' (you)' : ''}</b></span>
</div>
{lobby.members.map((m) => {
const c = m.character, can = !!m.character_id
return (
<div key={m.username} role="listitem" className={`pcard${sel === m.username ? ' sel' : ''}${can ? ' can' : ''}${m.online ? '' : ' off'}`} title={m.online ? `${m.username} is online` : `${m.username} is offline`} style={{ '--pc': m.color || 'var(--line)' }}
onClick={() => can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}>
<span className="pavatar"><i className={`presence ${m.online ? 'on' : ''}`} aria-label={m.online ? 'online' : 'offline'} />{c?.portrait ? <img src={`/api/lobbies/${lobby.id}/portrait/${encodeURIComponent(m.username)}?v=${c.portrait}`} alt="" /> : (c?.name || m.username).slice(0, 1).toUpperCase()}</span>
<span className="pinfo">
<b title={m.username}>{c ? c.name : 'No character'}</b>
</span>
{lobby.is_dm
? <input type="number" className="modin" key={`${m.username}:${m.mod}`} defaultValue={m.mod || 0} min={-100} max={100} title={`Bonus / malus for ${m.username}`} aria-label={`Modifier for ${m.username}`}
onClick={(e) => e.stopPropagation()} onBlur={(e) => lobby.setMod(m.username, e.target.value)} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} />
: m.mod ? <span className={`modchip ${m.mod > 0 ? 'up' : 'down'}`} title="Your personal bonus / malus">{sgn(m.mod)}</span> : null}
</div>
)
})}
{lobby.members.length === 0 && <span className="muted small">Nobody has joined yet – share the join code (⚙ Lobby).</span>}
</div>
)
}
// Drag handle that resizes a panel. Reports the new size through onChange, keeps it within min/max. dir = 1: dragging right grows, -1: shrinks.
function Splitter({ value, onChange, min, max, dir = 1, className = '', label }) {
const down = (e) => {
e.preventDefault()
const x0 = e.clientX, v0 = value
document.body.classList.add('resizing')
const mv = (ev) => onChange(Math.round(Math.min(max, Math.max(min, v0 + dir * (ev.clientX - x0)))))
const up = () => { document.body.classList.remove('resizing'); window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', up) }
window.addEventListener('pointermove', mv); window.addEventListener('pointerup', up)
}
return <div className={`splitter ${className}`} role="separator" aria-orientation="vertical" aria-label={label} title={`${label} (drag, double click to reset)`} onPointerDown={down} />
}
// ---- initiative tracker: compact cards (portrait + number). Players only see the name of whoever's turn it is.
function InitiativeCards({ lobby, tokens, onOp, onPick }) {
const c = lobby.combat, dm = lobby.is_dm
const cardPic = (e) => {
if (e.kind === 'player') { const m = lobby.members.find((x) => x.username === e.user); return m?.character?.portrait ? `/api/lobbies/${lobby.id}/portrait/${encodeURIComponent(e.user)}?v=${m.character.portrait}` : null }
const t = tokens.find((x) => x.id === e.token)
return t?.portrait ? `/api/lobbies/${lobby.id}/token-portrait/${t.id}?v=${t.portrait}` : null
}
const missing = dm ? lobby.members.filter((m) => m.character && !c.entries.some((e) => e.user === m.username)) : []
return (
<div className="initcards" role="list" aria-label="Initiative order">
<span className="initround" title="Round"><b>R{c.round}</b></span>
{c.entries.map((e) => {
const cur = c.current === e.id, pic = cardPic(e), showName = dm || cur
return (
<div key={e.id} role="listitem" className={`initcard ${e.kind}${cur ? ' cur' : ''}`} onClick={() => onPick(e)} title={showName ? `${e.name}: d20 ${e.roll} + ${e.base}` : 'Initiative'}>
<span className="initpic">{pic ? <img src={pic} alt="" /> : (e.name || '?').slice(0, 1).toUpperCase()}</span>
{dm
? <input type="number" className="modin" defaultValue={e.init} key={`${e.id}:${e.init}`} aria-label={`Initiative of ${e.name}`}
onClick={(ev) => ev.stopPropagation()} onBlur={(ev) => ev.target.value !== String(e.init) && onOp({ op: 'init', id: e.id, value: ev.target.value })} onKeyDown={(ev) => ev.key === 'Enter' && ev.currentTarget.blur()} />
: <span className="initnum">{e.init}</span>}
{showName && <b className="initname">{e.name}</b>}
{dm && <button className="linklike" onClick={(ev) => { ev.stopPropagation(); onOp({ op: 'reroll', id: e.id }) }} title="Roll again" aria-label={`Reroll ${e.name}`}>🎲</button>}
{dm && <button className="linklike" onClick={(ev) => { ev.stopPropagation(); onOp({ op: 'remove', id: e.id }) }} title="Remove from the fight" aria-label={`Remove ${e.name}`}>×</button>}
</div>
)
})}
{missing.map((m) => <button key={m.username} className="ghost small" onClick={() => onOp({ op: 'add', user: m.username })}>+ {m.character.name}</button>)}
</div>
)
}
// ---- the DM sets the difficulty for all checks, from the rules table or a custom number
function DifficultyBar({ lobby, onSet }) {
const d = lobby.difficulty
const idx = lobby.levels.findIndex((l) => l.name === d.label && l.mod === d.mod)
const [custom, setCustom] = useState('')
if (!lobby.is_dm) {
return <div className="diffbar small muted">{d.hidden ? 'The DM sets the difficulty' : <>Difficulty: <b>{d.label}</b> {sgn(d.mod)}</>}</div>
}
return (
<div className="diffbar">
<label className="small">Difficulty
<select value={idx >= 0 ? idx : 'custom'} aria-label="Difficulty"
onChange={(e) => { const l = lobby.levels[e.target.value]; if (l) onSet({ label: l.name, mod: l.mod, hidden: d.hidden }) }}>
{lobby.levels.map((l, i) => <option key={l.name} value={i}>{l.name} ({sgn(l.mod)})</option>)}
{idx < 0 && <option value="custom">{d.label} ({sgn(d.mod)})</option>}
</select>
</label>
<form className="row" onSubmit={(e) => { e.preventDefault(); if (/^[-+−]?\d+$/.test(custom.trim())) { onSet({ label: 'Custom', mod: parseInt(custom.replace('−', '-'), 10), hidden: d.hidden }); setCustom('') } }}>
<input value={custom} onChange={(e) => setCustom(e.target.value)} placeholder="custom ±N" aria-label="Custom modifier" style={{ width: '6rem' }} />
<button type="submit" className="ghost" disabled={!custom.trim()}>Set</button>
</form>
<label className="small"><input type="checkbox" checked={d.hidden} onChange={(e) => onSet({ label: d.label, mod: d.mod, hidden: e.target.checked })} style={{ width: 'auto' }} /> Hide numbers from players</label>
</div>
)
}
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
function CharTab({ cid, rules, onRoll, tick, editable }) {
const [data, setData] = useState(null)
const [err, setErr] = useState('')
const typing = useRef(0) // time of the last keystroke: refreshes must not overwrite what is being typed
const timer = useRef(null)
const saveNotes = (patch) => {
typing.current = Date.now()
setData((d) => ({ ...d, ...patch }))
clearTimeout(timer.current)
timer.current = setTimeout(() => { send(`/api/characters/${cid}/notes`, 'PUT', patch).catch(() => setErr('Could not save the notes.')) }, 700)
}
useEffect(() => () => clearTimeout(timer.current), [])
useEffect(() => {
setData(null); setErr('')
const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then((d) => { if (Date.now() - typing.current > 4000) setData(d) }).catch(() => setErr('Could not load this character.'))
load()
const t = setInterval(() => !document.hidden && load(), 15000)
return () => clearInterval(t)
}, [cid])
useEffect(() => { // pushed by the server when the player saved the character
if (tick) fetch(`/api/characters/${cid}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && Date.now() - typing.current > 4000 && setData(d)).catch(() => {})
}, [tick]) // eslint-disable-line
return (
<div>
{err && <p className="warn">{err}</p>}
{data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} onNotes={editable ? saveNotes : undefined} />}
</div>
)
}
// DM: players in the same sight group see what anyone in the group sees
function VisionGroups({ lobby, id, act }) {
const set = (b) => act(`/api/lobbies/${id}/vision-groups`, 'PUT', b)
return (
<div className="card visiongroups">
<h4>Sight groups</h4>
<p className="small muted">Players in the same group share what they see (with line of sight on). “Alone” means only their own view.</p>
<div className="row"><button className="ghost" onClick={() => set({ preset: 'shared' })}>Everyone shares sight</button><button className="ghost" onClick={() => set({ preset: 'separate' })}>Everyone alone</button></div>
{lobby.members.map((m) => (
<label key={m.username} className="small row vgrow">{m.character?.name || m.username}
<select value={m.vgroup || 0} onChange={(e) => set({ groups: { [m.username]: Number(e.target.value) } })} aria-label={`Sight group of ${m.username}`}>
<option value={0}>Alone</option>{[1, 2, 3, 4].map((g) => <option key={g} value={g}>Group {'ABCD'[g - 1]}</option>)}
</select>
</label>
))}
</div>
)
}
// lobby settings: join code, rename, colour, character switch, members, leave / delete
// DM: replace the dice sound, add sounds for critical results, switch cues on or off
function SoundSettings({ lobby, id, act, sfx }) {
const sn = lobby.sounds
const [err, setErr] = useState('')
const upload = async (slot, e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr('')
const r = await fetch(`/api/lobbies/${id}/sounds/${slot}`, { method: 'POST', headers: { 'Content-Type': 'audio/mpeg' }, body: f }).catch(() => null)
if (!r || !r.ok) return setErr(r ? ((await r.json().catch(() => ({}))).detail || 'Upload failed') : 'Upload failed')
act()
}
const flag = (k, v) => act(`/api/lobbies/${id}/sounds`, 'PUT', { [k]: v })
const row = (slot, label, defaultText, flagKey) => (
<div className="soundrow">
<div className="row">
{flagKey && <label className="small chk"><input type="checkbox" checked={!!sn[flagKey]} onChange={(e) => flag(flagKey, e.target.checked)} /> {label}</label>}
{!flagKey && <strong className="small">{label}</strong>}
</div>
<div className="row small">
<span className="muted">{sn.custom[slot] ? 'Your file' : defaultText}</span>
<span className="row">
<button className="ghost" onClick={() => playRoll(slot === 'roll' ? 20 : 100, 1, slot === 'roll' ? undefined : slot, { ...sfx, [`${slot}_on`]: true })}>▶ Test</button>
<label className="uploadbtn small">Upload MP3<input type="file" accept="audio/mpeg,.mp3" hidden onChange={(e) => upload(slot, e)} /></label>
{sn.custom[slot] && <button className="ghost" onClick={() => act(`/api/lobbies/${id}/sounds/${slot}`, 'DELETE')}>Reset</button>}
</span>
</div>
</div>
)
return (
<div className="soundsettings">
<h4>Sounds</h4>
<p className="small muted">For everyone in this lobby. MP3 up to 1.5 MB; players can still mute the sounds for themselves.</p>
{row('roll', 'Dice sound', 'Default sound', null)}
<label className="small chk"><input type="checkbox" checked={!!sn.result} onChange={(e) => flag('result', e.target.checked)} /> Short tones for success / failure of a check</label>
{row('crit_success', 'Critical success', 'Built-in tone', 'crit_success')}
{row('crit_failure', 'Critical failure', 'Built-in tone', 'crit_failure')}
{err && <p className="warn small">{err}</p>}
</div>
)
}
function Settings({ lobby, id, mine, act, setInfo, onClose, inline }) {
const [rename, setRename] = useState('')
const [pick, setPick] = useState('')
const me = lobby.members.find((m) => m.me)
const taken = new Set(lobby.members.filter((m) => !m.me).map((m) => m.color))
return (
<div className={inline ? 'settings inline' : 'settings card'}>
{!inline && <div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div>}
{lobby.is_dm && (
<>
<div className="row">
<div>
<div className="muted small">Join code – give it to your players</div>
<strong className="joincode">{lobby.code}</strong>
</div>
<div className="actions">
<button className="ghost" onClick={() => navigator.clipboard?.writeText(lobby.code).then(() => setInfo('Code copied.')).catch(() => {})}>Copy</button>
<button className="ghost" onClick={() => window.confirm('The old code stops working. Current players stay in the lobby.') && act(`/api/lobbies/${id}/code`, 'POST')}>New code</button>
</div>
</div>
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (rename.trim()) act(`/api/lobbies/${id}`, 'PUT', { name: rename }, () => { setRename(''); act() }) }}>
<input value={rename} onChange={(e) => setRename(e.target.value)} placeholder="Rename lobby" aria-label="New lobby name" maxLength={60} />
<button type="submit" className="ghost" disabled={!rename.trim()}>Rename</button>
</form>
<h3>Players</h3>
{lobby.members.length === 0 && <p className="muted small">Nobody yet.</p>}
{lobby.members.map((m) => (
<div key={m.username} className="row">
<span><span className="swatch" style={{ background: m.color }} /> {m.username} <span className="muted small">{m.character?.name}</span></span>
<button className="ghost danger" onClick={() => window.confirm(`Remove ${m.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(m.username)}`, 'DELETE')}>Remove</button>
</div>
))}
</>
)}
{!lobby.is_dm && me && (
<>
<h3>Your colour</h3>
<div className="swatches">
{lobby.palette.map((p) => (
<button key={p.id} className={`swatchbtn${me.color === p.id ? ' on' : ''}`} style={{ background: p.hex }} disabled={taken.has(p.id)} title={taken.has(p.id) ? `${p.id} (taken)` : p.id}
aria-label={p.id} onClick={() => act(`/api/lobbies/${id}/me`, 'PUT', { color: p.id })} />
))}
</div>
<h3>Your character</h3>
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (pick) act(`/api/lobbies/${id}/me`, 'PUT', { character_id: pick }, () => { setPick(''); act() }) }}>
<CharSelect mine={mine} value={pick} onChange={setPick} label="Switch character" />
<button type="submit" className="ghost" disabled={!pick}>Switch</button>
</form>
<p className="small muted">The DM always sees the current saved version of your character. Of the others you only see a short card.</p>
</>
)}
<div className="actions wrap">
{lobby.is_dm
? <button className="ghost danger" onClick={() => window.confirm(`Delete the lobby "${lobby.name}" for everyone? Characters are not deleted.`) && act(`/api/lobbies/${id}`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Delete lobby</button>
: <button className="ghost danger" onClick={() => window.confirm('Leave this lobby?') && act(`/api/lobbies/${id}/me`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Leave lobby</button>}
</div>
</div>
)
}
// ---- one lobby: sheet on the left, player area (character bar, difficulty, dice table) in the middle, chat on the right
function LobbyView({ id, rules, user }) {
const mine = useMine()
const [lobby, setLobby] = useState(null)
const [err, setErr] = useState('')
const [info, setInfo] = useState('')
const [sel, setSel] = useState('')
const [to, setTo] = useState('') // whisper recipient, used for chat messages and dice rolls
const [tray, setTray] = useState([])
const [count, setCount] = useState(1)
const [mode, setMode] = useState('mystery')
const [own, setOwnState] = useState(() => store('aw_owndice') === '1')
const [pending, setPending] = useState(null)
const [busy, setBusy] = useState(false)
const [mErr, setMErr] = useState('')
const [showSettings, setShowSettings] = useState(false)
const [leftTab, setLeftTab] = useState('sheet') // the DM's left column: character sheet or lobby actions
const [sheetOpen, setSheetOpen] = useState(() => store('aw_sheetopen') !== '0')
const setOwn = (v) => { setOwnState(v); store('aw_owndice', v ? '1' : '0') }
const load = useCallback(() => fetch(`/api/lobbies/${id}`).then(async (r) => {
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 [viewAs, setViewAs] = useState('') // the DM can look at the table through one player's eyes
const [showGrid, setShowGrid] = useState(false)
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 fogImg = useFogImage(id, mapState.vision?.fog, viewAs)
const sock = useRef({})
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
const up = useSocket(id, {
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 })); sock.current.load?.(); sock.current.loadMap?.() }, // a saved character can bring a new name or portrait
})
useEffect(() => {
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, loadMap, up])
const sfx = useRef({})
const sn = lobby?.sounds
sfx.current = sn ? { roll: sn.custom.roll ? `/api/lobbies/${id}/sounds/roll?v=${sn.v}` : DEFAULT_ROLL,
crit_success: sn.custom.crit_success ? `/api/lobbies/${id}/sounds/crit_success?v=${sn.v}` : null,
crit_failure: sn.custom.crit_failure ? `/api/lobbies/${id}/sounds/crit_failure?v=${sn.v}` : null,
result: sn.result, crit_success_on: sn.crit_success, crit_failure_on: sn.crit_failure } : { roll: DEFAULT_ROLL }
useEffect(() => { preload(sfx.current) }, [sn?.v]) // eslint-disable-line
const onRoll = useCallback((m) => {
const key = `${m.id}`
if (m.kind === 'check') playRoll(100, 1, m.check.outcome, sfx.current)
else if (m.kind === 'roll') playRoll(m.roll.sides, m.roll.n, undefined, sfx.current)
setTray((t) => [...t.slice(-2), { ...m, key }])
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
}, [])
const feed = useFeed(id, load, onRoll, up)
sock.current = { load, poll: feed.poll, loadMap }
const act = async (url, method, body, done) => {
if (!url) return load()
setErr(''); setInfo('')
const r = await send(url, method, body).catch(() => null)
const d = r ? await r.json().catch(() => ({})) : {}
if (!r || !r.ok) return setErr(msg(d, 'That did not work.'))
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 defSheet = () => Math.round(Math.min(800, Math.max(480, window.innerWidth * 0.42)))
const [sheetW, setSheetW] = useState(() => Number(store('aw_sheetw')) || defSheet())
const [chatW, setChatW] = useState(() => Number(store('aw_chatw')) || 380)
useEffect(() => { document.documentElement.style.setProperty('--chatw', `${chatW}px`); store('aw_chatw', String(chatW)) }, [chatW])
useEffect(() => () => document.documentElement.style.removeProperty('--chatw'), [])
useEffect(() => { store('aw_sheetw', String(sheetW)) }, [sheetW])
const [rv, setRv] = useState(null) // DM "show an area" tool: {origin, target, hover, fov, to}
const rvShape = (() => {
if (!rv?.origin) return null
const t = rv.target || rv.hover
if (!t) return { x: rv.origin.x, y: rv.origin.y, radius: 0.01, dir: 0, fov: rv.fov }
const dx = t.x - rv.origin.x, dy = t.y - rv.origin.y
return { x: rv.origin.x, y: rv.origin.y, radius: Math.max(0.5, Math.hypot(dx, dy) / (mapState.map?.grid || 50)), dir: (Math.atan2(dy, dx) * 180) / Math.PI, fov: rv.fov }
})()
const [menu, setMenu] = useState(null) // right click menu on a token
const [topOpen, setTopOpen] = useState(() => store('aw_topopen') === '1') // difficulty and player bar during a fight
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: true }).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
setNpcPick(false)
send(`/api/lobbies/${id}/tokens`, 'POST', { name: n.name, npc_id: n.id, x: m.w / 2, y: m.h / 2, size: n.size || 1, color: '#c0392b', hp_max: hp, hidden: true }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
}
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', hidden: true }).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>
const me = lobby.members.find((m) => m.me)
const people = [lobby.dm, ...lobby.members.map((m) => m.username)]
const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open
const cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0]
const npcTok = lobby.is_dm && !viewAs ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc' && t.npc_id) : null
const dest = people.includes(to) ? to : ''
const canCheck = !!(me && me.character_id)
lobby.setMod = (u, v) => act(`/api/lobbies/${id}/members/${encodeURIComponent(u)}/mod`, 'PUT', { mod: parseInt(v, 10) || 0 })
const post = async (url, body, after) => {
setBusy(true); setMErr('')
const r = await send(url, 'POST', body).catch(() => null)
if (!r || !r.ok) setMErr(msg(r ? await r.json().catch(() => ({})) : {}, 'That did not work.'))
else { after?.(); feed.poll() }
setTimeout(() => setBusy(false), 350)
}
const throwDice = (sides) => {
if (busy) return
if (own) return setPending({ type: 'dice', sides, count })
post(`/api/lobbies/${id}/rolls`, { sides, count, to: dest, ...(lobby.is_dm ? { mode } : {}) })
}
const check = (kind, cid, name, npcToken) => {
if (busy) return
if (own) return setPending({ type: 'check', kind, id: cid, name, npcToken })
post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '', ...(npcToken ? { npc_token: npcToken } : {}) })
}
const submitManual = (vals) => {
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0], to: dest === user ? user : '', ...(pending.npcToken ? { npc_token: pending.npcToken } : {}) }, () => setPending(null))
else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null))
}
return (
<section className="page lobbypage">
{err && <p className="warn">{err}</p>}
{info && <p className="small">{info}</p>}
<div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`} style={{ '--sheetw': `${sheetW}px` }}>
{sheetOpen && (
<div className="sheetcol">
<div className="sheetbar">
{lobby.is_dm ? (
<span className="seg" role="tablist">
<button role="tab" className={leftTab === 'sheet' ? 'on' : 'ghost'} aria-selected={leftTab === 'sheet'} onClick={() => setLeftTab('sheet')}>Sheet{npcTok ? `: ${npcTok.name}` : cur ? `: ${cur.character.name}` : ''}</button>
<button role="tab" className={leftTab === 'lobby' ? 'on' : 'ghost'} aria-selected={leftTab === 'lobby'} onClick={() => setLeftTab('lobby')}>Lobby actions</button>
</span>
) : <b>{cur ? cur.character.name : 'Character sheet'}</b>}
<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'
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><VisionGroups lobby={lobby} id={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
: npcTok ? <CharTab key={`npc${npcTok.npc_id}`} cid={npcTok.npc_id} rules={rules} tick={sheetTick.cid === npcTok.npc_id ? sheetTick.n : 0} onRoll={(k, i, n) => check(k, i, n, npcTok.id)} />
: 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} editable={cur.me} />
: <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>
)}
{sheetOpen && <Splitter value={sheetW} onChange={setSheetW} min={320} max={Math.max(400, window.innerWidth - chatW - 360)} label="Resize the sheet" className="sheetsplit" />}
<div className="playarea">
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
<div className="topbar">
<div className="topctl" role="group" aria-label="Fight controls">
{lobby.is_dm && !lobby.combat && <button className="ghost mini wide" onClick={() => act(`/api/lobbies/${id}/combat`, 'POST')} title="Start a fight">⚔</button>}
{lobby.is_dm && lobby.combat && <>
<button className="ghost mini" onClick={() => combatOp({ op: 'prev' })} title="Previous turn" aria-label="Previous turn">◀</button>
<button className="on mini" onClick={() => combatOp({ op: 'next' })} title="Next turn" aria-label="Next turn">▶</button>
<button className="ghost mini danger" onClick={() => window.confirm('End the fight?') && act(`/api/lobbies/${id}/combat`, 'DELETE')} title="End the fight" aria-label="End the fight">■</button>
</>}
<button className={`mini ${topOpen ? 'on' : 'ghost'}`} onClick={toggleTop} aria-expanded={topOpen} title="Players, difficulty and details">{topOpen ? '▴' : '▾'}</button>
</div>
<div className="topscroll">
{lobby.combat
? <InitiativeCards lobby={lobby} tokens={mapState.tokens} onOp={combatOp}
onPick={(e) => { if (e.kind === 'player') { setSel(e.user); setMapSel(null); setLeftTab('sheet') } else if (lobby.is_dm) { setMapSel(e.token); setLeftTab('sheet') } }} />
: <CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setMapSel(null); setLeftTab('sheet') }} />}
</div>
<button className="ghost lobbycfg" title="Lobby settings" onClick={() => (lobby.is_dm ? (setSheetOpen(true), setLeftTab('lobby')) : setShowSettings((v) => !v))} aria-expanded={lobby.is_dm ? sheetOpen && leftTab === 'lobby' : showSettings}>⚙ {lobby.name}</button>
</div>
{topOpen && (
<div className="topmore">
{lobby.combat && <CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setMapSel(null); setLeftTab('sheet') }} />}
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
</div>
)}
<div className="table">
{showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
{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} 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) }}
tool={rv ? 'reveal' : undefined}
onClick={rv ? (p) => setRv((s) => (s.origin && !s.target ? { ...s, target: p } : s.origin ? { ...s, target: p } : { ...s, origin: p })) : undefined}
onHover={rv && rv.origin && !rv.target ? (p) => setRv((s) => (s ? { ...s, hover: p } : s)) : undefined}
overlay={<>{walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}
{full && <RevealLayer map={mapState.map} zones={mapState.reveals} draft={rv && rvShape && rv.origin ? rvShape : 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 && (
<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.los_on && <>
<label className="small sightrange" title="How far players see, in squares (0 = unlimited). Resets to the map's value when you switch maps."><input type="number" min="0" max="1000" key={mapState.sight} defaultValue={mapState.sight} aria-label="Sight range in squares"
onBlur={(e) => e.target.value !== String(mapState.sight) && act(`/api/lobbies/${id}/sight`, 'PUT', { range: e.target.value }, () => loadMap())} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} /> sq.</label>
<button className={mapState.fog_on ? 'on' : 'ghost'} aria-pressed={!!mapState.fog_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { fog: !mapState.fog_on }, () => loadMap())} title="Fog of war: players keep a dim memory of what they have seen">Fog</button>
{mapState.fog_on && <button className="ghost" onClick={() => window.confirm('Forget everything the players have explored?') && act(`/api/lobbies/${id}/explored`, 'DELETE', undefined, () => loadMap())} title="Forget the explored ground">↺</button>}
</>}
{lobby.is_dm && mapState.los_on && <button disabled={!full} className={rv ? 'on' : 'ghost'} aria-pressed={!!rv} onClick={() => setRv(rv ? null : { origin: null, target: null, hover: null, fov: 90, to: null })} title={full ? 'Show players a far away area: click the spot, then click where it ends' : 'Switch to the full view to use this'}>👁 Reveal</button>}
{lobby.is_dm && <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="Players only see what their character can see: limited by walls and by the sight range (needs one of them)">Sight {mapState.los_on ? 'on' : 'off'}</button>}
{lobby.is_dm && mapState.wall_count > 0 && <button disabled={!full} 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}>+ Token</button>}
{lobby.is_dm && <button className={npcPick ? 'on' : 'ghost'} onClick={() => { setNpcPick(!npcPick); setPropPick(false) }}>+ NPC</button>}
{lobby.is_dm && <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">
<b className="small">Reveal an area</b>
{rv && <>
<p className="small muted">{!rv.origin ? '1. Click the spot the view starts from.' : !rv.target ? '2. Move and click where the view ends (distance = range, direction = where it looks).' : 'Ready. Click again to change the target.'}</p>
<label className="small">Cone width {rv.fov >= 359 ? '360° (all around)' : `${rv.fov}°`}
<input type="range" min="20" max="360" step="10" value={rv.fov} onChange={(e) => setRv({ ...rv, fov: Number(e.target.value) })} /></label>
<div className="small">Shown to:
<label className="chk"><input type="checkbox" checked={!rv.to} onChange={() => setRv({ ...rv, to: null })} /> everyone</label>
{lobby.members.map((mm) => <label key={mm.username} className="chk"><input type="checkbox" checked={!!rv.to?.includes(mm.username)}
onChange={(e) => setRv({ ...rv, to: e.target.checked ? [...(rv.to || []), mm.username] : (rv.to || []).filter((n) => n !== mm.username) })} /> {mm.character?.name || mm.username}</label>)}
</div>
<div className="row">
<button className="on" disabled={!rv.target || (rv.to && !rv.to.length)} onClick={() => act(`/api/lobbies/${id}/reveals`, 'POST', { x: rvShape.x, y: rvShape.y, radius: rvShape.radius, dir: rvShape.dir, fov: rv.fov, to: rv.to }, () => { setRv(null); loadMap() })}>Reveal</button>
<button className="ghost" onClick={() => setRv(null)}>Cancel</button>
</div>
</>}
{mapState.reveals?.length > 0 && <>
<b className="small">Shown now</b>
{mapState.reveals.map((z, i) => <div key={z.id} className="row small">Area {i + 1} · {z.to ? z.to.join(', ') : 'everyone'}
<button className="ghost mini" onClick={() => act(`/api/lobbies/${id}/reveals/${z.id}`, 'DELETE', undefined, () => loadMap())}>Hide</button></div>)}
</>}
</div>
)}
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>}
{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
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 }) },
{ icon: '📄', label: 'Sheet', disabled: t.kind === 'npc' && !t.npc_id, onClick: () => { if (t.kind === 'npc') setMapSel(t.id); else { setSel(t.username); setMapSel(null) } setLeftTab('sheet'); setSheetOpen(true) } },
{ icon: '❤', label: t.show_hp ? 'HP shown' : 'HP hidden', disabled: !(t.hp_max > 0), onClick: () => patchToken(t.id, { show_hp: !t.show_hp }) },
{ icon: '☣', label: t.show_cond ? 'Cond. shown' : 'Cond. hidden', onClick: () => patchToken(t.id, { show_cond: !t.show_cond }) },
...(t.kind === 'npc' ? [
{ 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) } }] : []),
]} />
})()}
<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} />}
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
</div>
<DiceBar to={dest} me={user} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} sfx={sfx.current} />
</div>
<aside className="lobbychat card" aria-label="Chat">
<Splitter value={chatW} onChange={setChatW} min={240} max={Math.max(300, Math.min(900, window.innerWidth - 700))} dir={-1} label="Resize the chat" className="chatsplit" />
<Chat id={id} me={user} people={people} msgs={feed.msgs} poll={feed.poll} to={dest} setTo={setTo} />
</aside>
</div>
</section>
)
}
export default function Lobbies({ section, rules, user, onOpen }) {
return section ? <LobbyView key={section} id={section} rules={rules} user={user} onOpen={onOpen} /> : <LobbyList />
}