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} />}
|
||||
|
||||
@@ -6,7 +6,7 @@ const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
|
||||
|
||||
// Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels.
|
||||
// Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise.
|
||||
export function MapView({ map, image, portraitUrl, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
|
||||
export function MapView({ map, image, portraitUrl, onMenu, isTurn, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
|
||||
const box = useRef(null)
|
||||
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
|
||||
const [drag, setDrag] = useState(null)
|
||||
@@ -122,7 +122,8 @@ export function MapView({ map, image, portraitUrl, tokens, onMove, selected, onS
|
||||
const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y
|
||||
if (x == null || (dr && dr.out)) return null
|
||||
return (
|
||||
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`}
|
||||
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''} ${isTurn?.(t) ? 'turn' : ''}`}
|
||||
onContextMenu={(e) => { if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }}
|
||||
style={{ left: x - d(t) / 2, top: y - d(t) / 2, width: d(t), height: d(t), '--tc': t.color, fontSize: Math.max(8, d(t) * 0.45) }}
|
||||
onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}>
|
||||
{portraitUrl?.(t) ? <img className="tokpic" src={portraitUrl(t)} alt="" draggable={false} /> : <span>{(t.name || '?').trim().slice(0, 2)}</span>}
|
||||
@@ -149,6 +150,32 @@ export function MapView({ map, image, portraitUrl, tokens, onMove, selected, onS
|
||||
)
|
||||
}
|
||||
|
||||
// Small ring of round buttons around a point (right click on a token). items: [{icon, label, onClick, disabled}]
|
||||
export function RadialMenu({ x, y, title, items, onClose }) {
|
||||
useEffect(() => {
|
||||
const k = (e) => e.key === 'Escape' && onClose()
|
||||
window.addEventListener('keydown', k)
|
||||
return () => window.removeEventListener('keydown', k)
|
||||
}, [onClose])
|
||||
const R = items.length > 4 ? 74 : 62
|
||||
return createPortal(
|
||||
<div className="radialback" onPointerDown={onClose} onContextMenu={(e) => { e.preventDefault(); onClose() }}>
|
||||
<div className="radial" style={{ left: x, top: y }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<span className="radialtitle">{title}</span>
|
||||
{items.map((it, i) => {
|
||||
const a = -Math.PI / 2 + (i * 2 * Math.PI) / items.length
|
||||
return (
|
||||
<button key={it.label} className="radialbtn" disabled={it.disabled} title={it.label}
|
||||
style={{ transform: `translate(${Math.cos(a) * R}px, ${Math.sin(a) * R}px)` }}
|
||||
onClick={() => { it.onClick(); onClose() }}>
|
||||
<span className="ri">{it.icon}</span><span className="rl">{it.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>, document.body)
|
||||
}
|
||||
|
||||
// Name, size, colour and visibility of one token (used in the live table and in the map editor)
|
||||
export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
|
||||
return (
|
||||
|
||||
@@ -501,3 +501,29 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
|
||||
.presence.on { background: #3fb950; }
|
||||
.presence.inline { position: static; display: inline-block; width: 9px; height: 9px; border: 0; margin-right: .2rem; }
|
||||
.pcard.off { opacity: .6; }
|
||||
|
||||
/* lobby header removed: DM chip and settings button live in the player bar */
|
||||
.playarea { height: calc(100vh - var(--hdr) - 1.2rem); }
|
||||
.sheetcol { max-height: calc(100vh - var(--hdr) - 1.2rem); }
|
||||
.dmcard { min-width: 0; border-left-color: var(--accent, gold); }
|
||||
.pavatar.dm { font-size: .7rem; font-weight: 700; }
|
||||
.lobbycfg { margin-left: auto; align-self: center; max-width: 16rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fightrow { display: flex; justify-content: flex-end; margin-top: -.3rem; }
|
||||
.initbar { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; padding: .35rem .5rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
|
||||
.initround { padding-right: .4rem; border-right: 1px solid var(--line); }
|
||||
.initcard { display: flex; align-items: center; gap: .35rem; padding: .2rem .5rem; border: 2px solid var(--line); border-radius: var(--radius); cursor: pointer; background: var(--panel2); }
|
||||
.initcard.player { border-left: 5px solid #4f93e6; } .initcard.npc { border-left: 5px solid #c0392b; }
|
||||
.initcard.cur { border-color: var(--accent, gold); box-shadow: 0 0 0 1px var(--accent, gold); background: color-mix(in srgb, var(--accent, gold) 18%, var(--panel2)); }
|
||||
.initnum { font-weight: 700; min-width: 1.6rem; text-align: center; }
|
||||
.initcard .modin { width: 3.2rem; }
|
||||
.initctl { margin-left: auto; display: flex; gap: .3rem; }
|
||||
.maptoken.turn { animation: turnpulse 1.4s ease-in-out infinite; }
|
||||
@keyframes turnpulse { 0%, 100% { box-shadow: 0 0 0 3px var(--accent, gold), 0 0 6px 3px rgba(255,215,0,.5); } 50% { box-shadow: 0 0 0 3px var(--accent, gold), 0 0 16px 8px rgba(255,215,0,.8); } }
|
||||
.radialback { position: fixed; inset: 0; z-index: 60; }
|
||||
.radial { position: fixed; width: 0; height: 0; }
|
||||
.radialtitle { position: absolute; transform: translate(-50%, -50%); background: rgba(0,0,0,.75); color: #fff; padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; white-space: nowrap; max-width: 9rem; overflow: hidden; text-overflow: ellipsis; }
|
||||
.radialbtn { position: absolute; left: -26px; top: -26px; width: 52px; height: 52px; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 0; gap: 0; line-height: 1; box-shadow: 0 2px 8px rgba(0,0,0,.6); background: var(--panel); border: 2px solid var(--line); }
|
||||
.radialbtn:hover:not(:disabled) { border-color: var(--accent, gold); background: var(--panel2); }
|
||||
.radialbtn .ri { font-size: 1.2rem; } .radialbtn .rl { font-size: .55rem; margin-top: 2px; }
|
||||
.initcard .modin { width: 2.8rem; padding: .1rem .25rem; min-height: 0; }
|
||||
.initcard { padding: .1rem .4rem; }
|
||||
|
||||
Reference in New Issue
Block a user