Initiative tracker, radial token menu, slimmer lobby header
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
+58
-11
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
|
||||
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
|
||||
import { MapView, MapLibrary, NpcPicker, TokenForm, WallLayer } from './MapView.jsx'
|
||||
import { MapView, MapLibrary, NpcPicker, RadialMenu, 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) })
|
||||
@@ -424,9 +424,13 @@ function DiceTray({ rolls }) {
|
||||
}
|
||||
|
||||
// ---- character bar: one card per player, later also for initiative and enemies
|
||||
function CharBar({ lobby, sel, onSel }) {
|
||||
function CharBar({ lobby, sel, onSel, settings }) {
|
||||
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 (
|
||||
@@ -444,6 +448,38 @@ function CharBar({ lobby, sel, onSel }) {
|
||||
)
|
||||
})}
|
||||
{lobby.members.length === 0 && <span className="muted small">Nobody has joined yet – share the join code (⚙ Lobby).</span>}
|
||||
{settings}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- initiative tracker: the DM starts a fight, players join automatically, NPCs are added from the token menu
|
||||
function InitiativeBar({ lobby, onOp, onStart, onEnd, onPick }) {
|
||||
const c = lobby.combat, dm = lobby.is_dm
|
||||
if (!c) return dm ? <div className="fightrow"><button className="ghost" onClick={onStart}>⚔ Start fight</button></div> : null
|
||||
const missing = dm ? lobby.members.filter((m) => m.character && !c.entries.some((e) => e.user === m.username)) : []
|
||||
return (
|
||||
<div className="initbar" role="list" aria-label="Initiative order">
|
||||
<span className="initround"><b>Round {c.round}</b></span>
|
||||
{c.entries.map((e) => (
|
||||
<div key={e.id} role="listitem" className={`initcard ${e.kind}${c.current === e.id ? ' cur' : ''}`} onClick={() => onPick(e)}>
|
||||
{dm
|
||||
? <input type="number" className="modin" defaultValue={e.init} key={`${e.id}:${e.init}`} aria-label={`Initiative of ${e.name}`} title={`d20 ${e.roll} + ${e.base}. Click to overwrite.`}
|
||||
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>}
|
||||
<b title={e.name}>{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>)}
|
||||
{dm && (
|
||||
<span className="initctl">
|
||||
<button className="ghost" onClick={() => onOp({ op: 'prev' })} aria-label="Previous turn">◀</button>
|
||||
<button className="on" onClick={() => onOp({ op: 'next' })}>Next turn ▶</button>
|
||||
<button className="ghost danger" onClick={onEnd}>End fight</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -680,6 +716,8 @@ function LobbyView({ id, rules, user }) {
|
||||
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 [menu, setMenu] = useState(null) // right click menu on a token
|
||||
const combatOp = (b) => act(`/api/lobbies/${id}/combat`, 'PUT', b)
|
||||
const [npcPick, setNpcPick] = useState(false)
|
||||
const addNpcToken = (n) => {
|
||||
const m = mapState.map
|
||||
@@ -727,14 +765,6 @@ function LobbyView({ id, rules, user }) {
|
||||
|
||||
return (
|
||||
<section className="page lobbypage">
|
||||
<div className="lobbyhead">
|
||||
<a className="small" href="#lobbies">← Lobbies</a>
|
||||
<h1>{lobby.name}</h1>
|
||||
<span className="muted small"><i className={`presence inline ${lobby.dm_online ? 'on' : ''}`} aria-label={lobby.dm_online ? 'online' : 'offline'} /> DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
|
||||
<span className="tabright">
|
||||
<button className="ghost" onClick={() => (lobby.is_dm ? (setSheetOpen(true), setLeftTab('lobby')) : setShowSettings((v) => !v))} aria-expanded={lobby.is_dm ? sheetOpen && leftTab === 'lobby' : showSettings}>⚙ Lobby</button>
|
||||
</span>
|
||||
</div>
|
||||
{err && <p className="warn">{err}</p>}
|
||||
{info && <p className="small">{info}</p>}
|
||||
|
||||
@@ -760,7 +790,11 @@ function LobbyView({ id, rules, user }) {
|
||||
|
||||
<div className="playarea">
|
||||
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
|
||||
<CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setMapSel(null); setLeftTab('sheet') }} />
|
||||
<CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setMapSel(null); setLeftTab('sheet') }}
|
||||
settings={<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>} />
|
||||
<InitiativeBar lobby={lobby} onOp={combatOp} onStart={() => act(`/api/lobbies/${id}/combat`, 'POST')}
|
||||
onEnd={() => window.confirm('End the fight?') && act(`/api/lobbies/${id}/combat`, 'DELETE')}
|
||||
onPick={(e) => { if (e.kind === 'player') { setSel(e.user); setMapSel(null); setLeftTab('sheet') } else if (lobby.is_dm) { setMapSel(e.token); setLeftTab('sheet') } }} />
|
||||
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
|
||||
<div className="table">
|
||||
{showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
|
||||
@@ -771,6 +805,8 @@ function LobbyView({ id, rules, user }) {
|
||||
return (
|
||||
<MapView map={mapState.map} image={mapImg} 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) }}
|
||||
overlay={walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : 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">
|
||||
@@ -791,6 +827,17 @@ function LobbyView({ id, rules, user }) {
|
||||
</MapView>
|
||||
)
|
||||
})()}
|
||||
{menu && (() => {
|
||||
const t = menu.t, 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) } },
|
||||
...(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} />}
|
||||
|
||||
Reference in New Issue
Block a user