Lobby: 3-column layout with player area, skill checks, difficulty, colours, own dice, mystery dice
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
+268
-132
@@ -102,35 +102,14 @@ function LobbyList() {
|
||||
)
|
||||
}
|
||||
|
||||
function Card({ m, isDm, onOpen, onKick }) {
|
||||
const c = m.character
|
||||
return (
|
||||
<li className={`card lobbycard ${m.me ? 'me' : ''}`}>
|
||||
<div className="row">
|
||||
<div>
|
||||
<strong>{c ? c.name : 'No character'}</strong>
|
||||
<div className="muted small">{m.username}{m.me ? ' (you)' : ''}</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{m.character_id && <button onClick={() => onOpen(m)}>{m.me ? 'Sheet' : 'View sheet'}</button>}
|
||||
{isDm && <button className="ghost danger" onClick={() => onKick(m)}>Remove</button>}
|
||||
</div>
|
||||
</div>
|
||||
{c && (
|
||||
<div className="chips">
|
||||
{[c.class && (c.subclass ? `${c.class} · ${c.subclass}` : c.class), c.profession].filter(Boolean).map((t) => <span key={t} className="chip">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
{c?.concept && <p className="small muted">{c.concept}</p>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
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!' }
|
||||
|
||||
// Chat, whispers, event log and dice rolls arrive through one feed that is polled while the page is open.
|
||||
// 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) {
|
||||
const [msgs, setMsgs] = useState([])
|
||||
const last = useRef(0)
|
||||
@@ -148,7 +127,7 @@ function useFeed(id, onEvent, onRoll) {
|
||||
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').forEach((m) => cb.current.onRoll(m))
|
||||
if (wasLive) fresh.filter((m) => m.kind === 'roll' || m.kind === 'check').forEach((m) => cb.current.onRoll(m))
|
||||
}, [id])
|
||||
useEffect(() => {
|
||||
poll()
|
||||
@@ -159,8 +138,17 @@ function useFeed(id, onEvent, onRoll) {
|
||||
}
|
||||
|
||||
const rollText = (m) => {
|
||||
const n = m.roll.results.length, sum = m.roll.results.reduce((a, b) => a + b, 0)
|
||||
return { dice: `${n > 1 ? n : ''}d${m.roll.sides}`, list: m.roll.results.join(' + '), sum, n }
|
||||
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 }) {
|
||||
@@ -185,7 +173,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
|
||||
const shown = msgs.filter((m) => events || m.kind !== 'event')
|
||||
const who = (m) => (
|
||||
<>
|
||||
<strong>{m.mine ? 'You' : m.from || '?'}</strong>
|
||||
<strong style={m.color ? { color: m.color } : undefined}>{m.mine ? 'You' : m.from || '?'}</strong>
|
||||
{m.to && <em className="small whispertag"> {m.to === m.from ? '(only visible to you, the DM)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>}
|
||||
</>
|
||||
)
|
||||
@@ -197,12 +185,21 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
|
||||
{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>:{' '}
|
||||
<span className="rollres">{t.n > 1 ? `${t.list} = ` : ''}<b>{t.sum}</b></span>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -228,7 +225,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- dice: hotbar at the bottom, results fly into a tray above it and are posted in the chat
|
||||
// ---- 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: '' }
|
||||
|
||||
@@ -241,18 +238,9 @@ function DieIcon({ sides }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DiceBar({ id, to, poll, isDm }) {
|
||||
const [count, setCount] = useState(1)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const roll = async (sides) => {
|
||||
if (busy) return
|
||||
setBusy(true); setErr('')
|
||||
const r = await send(`/api/lobbies/${id}/rolls`, 'POST', { sides, count, to }).catch(() => null)
|
||||
if (!r || !r.ok) setErr(msg(r ? await r.json().catch(() => ({})) : {}, 'Roll failed.'))
|
||||
else poll()
|
||||
setTimeout(() => setBusy(false), 350)
|
||||
}
|
||||
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, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
|
||||
return (
|
||||
<div className="dicebar" role="toolbar" aria-label="Dice">
|
||||
<label className="small muted dicecount">Dice
|
||||
@@ -261,17 +249,46 @@ function DiceBar({ id, to, poll, isDm }) {
|
||||
</select>
|
||||
</label>
|
||||
{DICE.map((d) => (
|
||||
<button key={d} className="ghost diebtn" onClick={() => roll(d)} disabled={busy} title={`Roll ${count > 1 ? count : ''}d${d}`} aria-label={`Roll ${count} d${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>
|
||||
))}
|
||||
<span className={`small dicedest ${to || isDm ? 'priv' : 'muted'}`}>{to ? `Private: ${to}` : isDm ? 'Hidden (only you)' : 'Public'}</span>
|
||||
{err && <span className="warn small">{err}</span>}
|
||||
<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>
|
||||
{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">Private: {to}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Die({ sides, value, delay }) {
|
||||
// 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
|
||||
@@ -283,24 +300,39 @@ function Die({ sides, value, delay }) {
|
||||
}, [sides, value, delay])
|
||||
const rolling = shown !== value
|
||||
return (
|
||||
<span className={`die d${sides} ${rolling ? 'rolling' : 'landed'}`} style={{ animationDelay: `${delay % 200}ms` }}>
|
||||
<span>{shown ?? '?'}</span>
|
||||
<span className={`die d${sides} ${rolling ? 'rolling' : 'landed'}${mystery ? ' mystery' : ''}`} style={{ animationDelay: `${delay % 200}ms` }}>
|
||||
<span>{mystery && !rolling ? '?' : shown ?? '?'}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DiceTray({ rolls, me }) {
|
||||
// the tray is the middle of the table: dice in the colour of whoever threw them
|
||||
function DiceTray({ rolls }) {
|
||||
if (!rolls.length) return null
|
||||
return (
|
||||
<div className="dicetray" aria-hidden="true">
|
||||
{rolls.map((m) => {
|
||||
const style = { '--dc': m.color || 'var(--noxium)' }
|
||||
if (m.kind === 'check') {
|
||||
const c = m.check
|
||||
return (
|
||||
<div key={m.key} className="trayroll" style={style}>
|
||||
<div className="small"><b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b> – {c.what}{c.manual ? ' · own dice' : ''}</div>
|
||||
<div className="trayrow"><Die sides={100} value={c.roll} delay={0} />
|
||||
<span className="trayout"><span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>
|
||||
{c.target !== undefined && <small>{c.roll} vs {c.target}</small>}</span></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const t = rollText(m)
|
||||
const sec = m.roll.mystery && !m.mine
|
||||
return (
|
||||
<div key={m.key} className="trayroll">
|
||||
<div className="small">{m.mine ? 'You' : m.from} rolled <strong>{t.dice}</strong>{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}</div>
|
||||
<div key={m.key} className="trayroll" style={sec ? { '--dc': '#8b8f85' } : style}>
|
||||
<div className="small"><b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b> rolled <strong>{t.dice}</strong>
|
||||
{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}</div>
|
||||
<div className="trayrow">
|
||||
{m.roll.results.map((v, i) => <Die key={i} sides={m.roll.sides} value={v} delay={i * 120} />)}
|
||||
{t.n > 1 && <span className="traytotal">= {t.sum}</span>}
|
||||
{Array.from({ length: t.n }, (_, i) => <Die key={i} sides={m.roll.sides} value={m.roll.results ? m.roll.results[i] : 0} delay={i * 120} mystery={!m.roll.results} />)}
|
||||
{t.n > 1 && t.sum !== null && <span className="traytotal">= {t.sum}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -309,8 +341,60 @@ function DiceTray({ rolls, me }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- character bar: one card per player, later also for initiative and enemies
|
||||
function CharBar({ lobby, sel, onSel }) {
|
||||
return (
|
||||
<div className="charbar" role="list">
|
||||
{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' : ''}`} 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">{(c?.name || m.username).slice(0, 1).toUpperCase()}</span>
|
||||
<span className="pinfo">
|
||||
<b>{c ? c.name : 'No character'}</b>
|
||||
<span className="muted small">{m.username}{m.me ? ' (you)' : ''}{c?.class ? ` · ${c.class}` : ''}</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 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, owner, rules, onOpen, name }) {
|
||||
function CharTab({ cid, rules, onRoll }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
useEffect(() => {
|
||||
@@ -323,32 +407,100 @@ function CharTab({ cid, owner, rules, onOpen, name }) {
|
||||
return (
|
||||
<div>
|
||||
{err && <p className="warn">{err}</p>}
|
||||
{data && <PaperSheet data={data} baseRules={rules} bare />}
|
||||
{data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- one lobby: member cards and one sheet tab per visible character on the left, chat on the right, dice at the bottom
|
||||
function LobbyView({ id, rules, user, onOpen }) {
|
||||
// lobby settings: join code, rename, colour, character switch, members, leave / delete
|
||||
function Settings({ lobby, id, mine, act, setInfo, onClose }) {
|
||||
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="settings card">
|
||||
<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 [pick, setPick] = useState('')
|
||||
const [rename, setRename] = useState('')
|
||||
const [tab, setTab] = useState('party')
|
||||
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 [sheetOpen, setSheetOpen] = useState(() => store('aw_sheetopen') !== '0')
|
||||
const setOwn = (v) => { setOwnState(v); store('aw_owndice', v ? '1' : '0') }
|
||||
|
||||
const load = () => fetch(`/api/lobbies/${id}`).then(async (r) => {
|
||||
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.') })
|
||||
}).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') }), [id])
|
||||
useEffect(() => {
|
||||
load()
|
||||
const t = setInterval(() => !document.hidden && load(), 15000) // picks up joins and character changes of the others
|
||||
const t = setInterval(() => !document.hidden && load(), 5000) // picks up joins, colours, difficulty and character changes
|
||||
return () => clearInterval(t)
|
||||
}, [id]) // eslint-disable-line
|
||||
}, [load])
|
||||
|
||||
const onRoll = useCallback((m) => {
|
||||
const key = `${m.id}`
|
||||
@@ -358,6 +510,7 @@ function LobbyView({ id, rules, user, onOpen }) {
|
||||
const feed = useFeed(id, load, onRoll)
|
||||
|
||||
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(() => ({})) : {}
|
||||
@@ -369,11 +522,34 @@ function LobbyView({ id, rules, user, onOpen }) {
|
||||
if (!lobby) return <section className="page lobbypage"><p className="muted">Loading …</p></section>
|
||||
|
||||
const me = lobby.members.find((m) => m.me)
|
||||
const open = (m) => onOpen({ id: m.character_id, name: m.character?.name, ...(m.me ? {} : { owner: m.username }) })
|
||||
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 active = tab.startsWith('sheet:') && !sheets.find((m) => m.username === tab.slice(6)) ? 'party' : tab
|
||||
const cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0]
|
||||
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) => {
|
||||
if (busy) return
|
||||
if (own) return setPending({ type: 'check', kind, id: cid, name })
|
||||
post(`/api/lobbies/${id}/checks`, { kind, id: cid })
|
||||
}
|
||||
const submitManual = (vals) => {
|
||||
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0] }, () => 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">
|
||||
@@ -381,83 +557,43 @@ function LobbyView({ id, rules, user, onOpen }) {
|
||||
<a className="small" href="#lobbies">← Lobbies</a>
|
||||
<h1>{lobby.name}</h1>
|
||||
<span className="muted small">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
|
||||
<span className="tabright">
|
||||
<button className="ghost" onClick={() => setShowSettings((v) => !v)} aria-expanded={showSettings}>⚙ Lobby</button>
|
||||
</span>
|
||||
</div>
|
||||
{err && <p className="warn">{err}</p>}
|
||||
{info && <p className="small">{info}</p>}
|
||||
|
||||
<div className="lobbygrid">
|
||||
<div className="lobbymain">
|
||||
<div className="tabs" role="tablist">
|
||||
<button role="tab" aria-selected={active === 'party'} className={active === 'party' ? 'on' : ''} onClick={() => setTab('party')}>Party ({lobby.members.length})</button>
|
||||
{sheets.map((m) => (
|
||||
<button key={m.username} role="tab" aria-selected={active === `sheet:${m.username}`} className={active === `sheet:${m.username}` ? 'on' : ''}
|
||||
onClick={() => setTab(`sheet:${m.username}`)} title={m.username}>{m.me && !lobby.is_dm ? 'My character' : m.character.name}</button>
|
||||
))}
|
||||
{active.startsWith('sheet:') && <span className="tabright"><ThemeSwitch /></span>}
|
||||
<div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`}>
|
||||
{sheetOpen && (
|
||||
<div className="sheetcol">
|
||||
<div className="sheetbar">
|
||||
<b>{cur ? cur.character.name : 'Character sheet'}</b>
|
||||
<span className="tabright"><ThemeSwitch /> <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide the sheet">◀</button></span>
|
||||
</div>
|
||||
{cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} onRoll={cur.me && canCheck ? check : undefined} />
|
||||
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active.startsWith('sheet:') && (() => {
|
||||
const m = sheets.find((x) => x.username === active.slice(6))
|
||||
return <CharTab key={m.character_id} cid={m.character_id} owner={m.me ? '' : m.username} name={m.character.name} rules={rules} onOpen={() => open(m)} />
|
||||
})()}
|
||||
|
||||
{active === 'party' && (
|
||||
<>
|
||||
{lobby.is_dm && (
|
||||
<div className="card">
|
||||
<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', undefined, (d) => setLobby({ ...lobby, code: d.code }))}>New code</button>
|
||||
</div>
|
||||
</div>
|
||||
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (rename.trim()) act(`/api/lobbies/${id}`, 'PUT', { name: rename }, () => { setRename(''); load() }) }}>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lobby.members.length === 0 && <p className="card">Nobody has joined yet. Share the code above.</p>}
|
||||
<ul className="charlist partygrid">
|
||||
{lobby.members.map((m) => (
|
||||
<Card key={m.username} m={m} isDm={lobby.is_dm} onOpen={(x) => setTab(`sheet:${x.username}`)}
|
||||
onKick={(x) => window.confirm(`Remove ${x.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(x.username)}`, 'DELETE')} />
|
||||
))}
|
||||
</ul>
|
||||
{!lobby.is_dm && <p className="small muted">You see your own character in full. Of the others you only see this short card.</p>}
|
||||
|
||||
{!lobby.is_dm && me && (
|
||||
<div className="card">
|
||||
<h2>Your character</h2>
|
||||
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (pick) act(`/api/lobbies/${id}/me`, 'PUT', { character_id: pick }, () => { setPick(''); load() }) }}>
|
||||
<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.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 className="playarea">
|
||||
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
|
||||
<CharBar lobby={lobby} sel={cur?.username} onSel={setSel} />
|
||||
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
|
||||
<div className="table">
|
||||
{showSettings && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
|
||||
<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} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} />
|
||||
</div>
|
||||
|
||||
<aside className="lobbychat card" aria-label="Chat">
|
||||
<Chat id={id} me={user} people={people} msgs={feed.msgs} poll={feed.poll} to={dest} setTo={setTo} />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<DiceTray rolls={tray} me={user} />
|
||||
<DiceBar id={id} to={dest} poll={feed.poll} isDm={lobby.is_dm} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ const Pips = ({ n, locked }) => (
|
||||
)
|
||||
|
||||
// The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes).
|
||||
export default function PaperSheet({ data, baseRules, tools, bare }) {
|
||||
export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
|
||||
const ch = useMemo(() => normalize(data, baseRules), [data, baseRules])
|
||||
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
|
||||
const c = useMemo(() => compute(ch, rules), [ch, rules])
|
||||
@@ -86,6 +86,11 @@ export default function PaperSheet({ data, baseRules, tools, bare }) {
|
||||
const gear = kitItems(ch, rules)
|
||||
const name = ch.name || 'Nameless'
|
||||
const sign = (n) => (n > 0 ? `+${n}` : `${n}`)
|
||||
// With onRoll (own character in a lobby) attributes and usable skills can be clicked to roll a check
|
||||
const roll = (kind, id, label, ok = true) => (onRoll && ok ? {
|
||||
className: 'rollable', role: 'button', tabIndex: 0,
|
||||
onClick: () => onRoll(kind, id, label), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRoll(kind, id, label) } },
|
||||
} : {})
|
||||
|
||||
const Field = ({ label, children }) => <div className="pfield"><span>{label}</span><b>{children || '–'}</b></div>
|
||||
const Foot = ({ n }) => <div className="pfoot"><span>Arcane Wasteland</span><span>{name}</span><span>Page {n} / 2</span></div>
|
||||
@@ -117,7 +122,7 @@ export default function PaperSheet({ data, baseRules, tools, bare }) {
|
||||
|
||||
<div className="pattrs">
|
||||
{rules.attributes.map((a) => (
|
||||
<div key={a.id} className="pattr">
|
||||
<div key={a.id} {...roll('attr', a.id, a.name)} className={`pattr${onRoll ? ' rollable' : ''}`}>
|
||||
<Tip text={explainAttr(a, x)}><span className="pattr-n">{a.name}</span></Tip>
|
||||
<b className="pattr-v">{c.attrs[a.id]}</b>
|
||||
<span className="pattr-m">Mod {sign(c.mods[a.id])}</span>
|
||||
@@ -140,7 +145,7 @@ export default function PaperSheet({ data, baseRules, tools, bare }) {
|
||||
<div key={g.id} className="pgroup">
|
||||
<h4>{g.name}</h4>
|
||||
{c.skills.filter((s) => s.group === g.id).map((s) => (
|
||||
<div key={s.id} className={`prow ${s.usable ? '' : 'locked'}`}>
|
||||
<div key={s.id} {...roll('skill', s.id, s.name, s.usable)} className={`prow ${s.usable ? '' : 'locked'}${onRoll && s.usable ? ' rollable' : ''}`}>
|
||||
<Pips n={s.pips} locked={!s.usable} />
|
||||
<Tip text={explainSkill(s, x)}><span className="pn">{s.name}{s.type === 'advanced' && <em> adv</em>}</span></Tip>
|
||||
<span className="pa">{short(s)}</span>
|
||||
|
||||
+46
-6
@@ -208,7 +208,7 @@ h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
|
||||
/* lobby desktop layout: main area left, chat column fixed at the right edge of the window, dice bar bottom centre of the main area */
|
||||
:root { --chatw: 380px; --hdr: 3.7rem; }
|
||||
body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(--chatw) + 1.5rem); }
|
||||
.lobbypage { max-width: 1200px; margin: 0; padding-bottom: 8rem; }
|
||||
.lobbypage { max-width: none; margin: 0; padding: 0; }
|
||||
.lobbychat { position: fixed; right: 0; top: var(--hdr); bottom: 0; width: var(--chatw); z-index: 10; border-radius: 0; border-width: 0 0 0 1px; padding: .8rem; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.lobbychat .chatwrap { flex: 1; min-height: 0; grid-template-rows: minmax(0, 1fr) auto auto auto auto; }
|
||||
.lobbychat .chatbox { height: auto; min-height: 0; }
|
||||
@@ -220,19 +220,16 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
|
||||
.whispertag { color: #8a63c7; }
|
||||
|
||||
|
||||
.dicebar { position: fixed; bottom: 1rem; left: calc((100vw - var(--chatw)) / 2); transform: translateX(-50%); z-index: 30; display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; justify-content: center;
|
||||
max-width: calc(100vw - var(--chatw) - 1.5rem); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: .4rem .8rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .22); }
|
||||
.dicecount { display: flex; align-items: center; gap: .3rem; margin-right: .3rem; }
|
||||
.dicecount select { width: auto; padding: .25rem .4rem; }
|
||||
.diebtn { display: flex; flex-direction: column; align-items: center; gap: 0; padding: .2rem .5rem; border-radius: 12px; min-width: 3.1rem; font-size: .75rem; line-height: 1.1; }
|
||||
.diebtn:hover:not(:disabled) { border-color: var(--noxium); color: var(--noxium); }
|
||||
.dicedest { margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); }
|
||||
.dicedest.priv { color: #8a63c7; font-weight: 600; }
|
||||
.dicetray { position: fixed; bottom: 5.4rem; left: calc((100vw - var(--chatw)) / 2); transform: translateX(-50%); z-index: 29; display: grid; gap: .5rem; justify-items: center; pointer-events: none; }
|
||||
.trayroll { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: .5rem .9rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .25); animation: trayin .25s ease-out; }
|
||||
.trayrow { display: flex; align-items: center; gap: .5rem; margin-top: .3rem; flex-wrap: wrap; justify-content: center; max-width: 30rem; }
|
||||
.traytotal { font-weight: 700; font-size: 1.2rem; margin-left: .3rem; }
|
||||
.die { display: inline-flex; align-items: center; justify-content: center; width: 2.9rem; height: 2.9rem; background: var(--noxium); color: #12150f; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.die { display: inline-flex; align-items: center; justify-content: center; width: 2.9rem; height: 2.9rem; background: var(--dc, var(--noxium)); color: #12150f; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.die.d4 { clip-path: polygon(50% 4%, 96% 92%, 4% 92%); padding-top: .55rem; }
|
||||
.die.d6 { border-radius: 12px; }
|
||||
.die.d8 { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
|
||||
@@ -247,7 +244,6 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
|
||||
@keyframes trayin { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { .die.rolling, .die.landed, .trayroll { animation: none; } }
|
||||
|
||||
@media (max-width: 1050px) { body:has(.lobbychat) main { max-width: 1280px; margin: 0 auto; padding-right: 1rem; } .lobbychat { position: static; width: auto; height: 28rem; border-width: 1px; border-radius: var(--radius); margin-top: 1rem; } .dicebar, .dicetray { left: 50%; } .dicebar { max-width: calc(100vw - 1.5rem); } }
|
||||
.lobbycard { align-content: start; }
|
||||
.lobbycard .chips { align-items: flex-start; align-content: flex-start; }
|
||||
.lobbycard .chip { align-self: flex-start; }
|
||||
@@ -335,3 +331,47 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
|
||||
.tabs .tabright button.on { border-color: var(--noxium); }
|
||||
.tabs .tabright button:first-child { border-radius: var(--radius) 0 0 var(--radius); }
|
||||
.tabs .tabright button:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
|
||||
|
||||
/* lobby: three columns - sheet | player area | chat (fixed at the right edge) */
|
||||
body:has(.lobbychat) main { padding: .5rem calc(var(--chatw) + .5rem) 0 .5rem; }
|
||||
.lobbyhead { margin: 0 0 .4rem; } .lobbyhead .tabright { margin-left: auto; }
|
||||
.lobbygrid { display: grid; grid-template-columns: clamp(480px, 42vw, 800px) minmax(0, 1fr); gap: .8rem; align-items: start; }
|
||||
.lobbygrid.nosheet { grid-template-columns: minmax(0, 1fr); }
|
||||
.sheetcol { position: sticky; top: calc(var(--hdr) + .4rem); max-height: calc(100vh - var(--hdr) - 3.2rem); overflow-y: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); padding: .5rem; }
|
||||
.sheetbar { display: flex; align-items: center; gap: .5rem; margin-bottom: .4rem; } .sheetbar .tabright { margin-left: auto; display: flex; gap: .4rem; align-items: center; }
|
||||
.sheetbar .seg button { padding: .2rem .6rem; }
|
||||
.playarea { display: flex; flex-direction: column; gap: .6rem; min-width: 0; height: calc(100vh - var(--hdr) - 3.2rem); position: sticky; top: calc(var(--hdr) + .4rem); }
|
||||
.showsheet { align-self: flex-start; }
|
||||
.charbar { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||
.pcard { display: flex; align-items: center; gap: .55rem; padding: .35rem .6rem; border: 2px solid var(--line); border-left: 6px solid var(--pc); border-radius: var(--radius); background: var(--panel); min-width: 11rem; }
|
||||
.pcard.can { cursor: pointer; } .pcard.can:hover { background: var(--panel2); }
|
||||
.pcard.sel { border-color: var(--pc); background: var(--panel2); box-shadow: 0 0 0 1px var(--pc); }
|
||||
.pavatar { width: 2.2rem; height: 2.2rem; border-radius: 50%; background: var(--pc); color: #12150f; font-weight: 800; display: grid; place-items: center; flex: none; }
|
||||
.pinfo { display: grid; line-height: 1.2; min-width: 0; } .pinfo b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.modin { width: 4rem; padding: .2rem .3rem; margin-left: auto; text-align: center; }
|
||||
.modchip { margin-left: auto; padding: .05rem .5rem; border-radius: 999px; font-weight: 700; border: 1px solid var(--line); } .modchip.up { color: var(--noxium); } .modchip.down { color: var(--danger); }
|
||||
.diffbar { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; padding: .3rem .6rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
|
||||
.diffbar label { display: flex; align-items: center; gap: .4rem; } .diffbar select { width: auto; padding: .25rem .4rem; }
|
||||
.table { position: relative; flex: 1; min-height: 14rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto;
|
||||
background: radial-gradient(ellipse at center, color-mix(in srgb, var(--noxium) 10%, var(--panel)) 0%, var(--panel) 75%); }
|
||||
.tablehint { position: absolute; top: .6rem; left: 0; right: 0; text-align: center; margin: 0; }
|
||||
.tableerr { position: absolute; bottom: .5rem; left: 0; right: 0; text-align: center; }
|
||||
.dicetray { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 3; display: grid; gap: .6rem; justify-items: center; pointer-events: none; max-width: 95%; }
|
||||
.trayroll { border-top: 3px solid var(--dc, var(--noxium)); }
|
||||
.trayout { display: grid; line-height: 1.2; margin-left: .5rem; } .trayout .out { font-weight: 800; }
|
||||
.die.mystery { filter: grayscale(.4); }
|
||||
.dicebar { display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; justify-content: center; background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: .4rem .8rem; align-self: center; max-width: 100%; }
|
||||
.ownswitch { display: flex; align-items: center; gap: .3rem; margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); }
|
||||
.dicebar .seg button { padding: .2rem .6rem; }
|
||||
.manual { position: absolute; left: 50%; top: 30%; transform: translateX(-50%); z-index: 4; display: grid; gap: .4rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .35); }
|
||||
.settings { position: absolute; inset: .6rem; z-index: 6; overflow: auto; align-content: start; display: grid; gap: .5rem; max-width: 34rem; margin: 0 auto; height: fit-content; max-height: calc(100% - 1.2rem); }
|
||||
.swatches { display: flex; gap: .4rem; flex-wrap: wrap; } .swatchbtn { width: 2rem; height: 2rem; border-radius: 50%; padding: 0; border: 2px solid var(--line); } .swatchbtn.on { border-color: var(--text); box-shadow: 0 0 0 2px var(--panel), 0 0 0 4px var(--text); } .swatchbtn:disabled { opacity: .25; cursor: not-allowed; }
|
||||
.swatch { display: inline-block; width: .8rem; height: .8rem; border-radius: 50%; vertical-align: middle; }
|
||||
.out.crit_success { color: var(--noxium); font-weight: 800; } .out.success { color: var(--noxium); } .out.failure { color: var(--danger); } .out.crit_failure { color: var(--danger); font-weight: 800; }
|
||||
.manualtag { color: var(--diesium); } .rollres.mystery { letter-spacing: .15em; font-weight: 700; color: var(--muted); }
|
||||
.papers .rollable { cursor: pointer; } .papers .rollable:hover { outline: 1.5px solid var(--pg); outline-offset: 1px; background: color-mix(in srgb, var(--pg) 12%, transparent); }
|
||||
@media (max-width: 1100px) {
|
||||
body:has(.lobbychat) main { padding: .5rem; }
|
||||
.lobbychat { position: static; width: auto; height: 28rem; border-width: 1px; border-radius: var(--radius); margin-top: 1rem; }
|
||||
.lobbygrid { grid-template-columns: minmax(0, 1fr); } .playarea { order: -1; height: 32rem; position: static; } .sheetcol { position: static; max-height: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user