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:
Claude
2026-10-24 15:55:23 +00:00
parent f6448aff85
commit 72d8113ece
6 changed files with 295 additions and 14 deletions
+7
View File
@@ -126,6 +126,12 @@ The **NPCs** tab is a library of rules-based NPCs. It uses the same wizard as th
Players and the DM show a green dot in the lobby while they have it open (online presence over the WebSocket; without WebSockets everyone appears offline).
### Initiative and fights
The DM presses **⚔ Start fight** in the lobby. Every player with a character joins automatically and rolls initiative (1d20 + the character's Initiative value, as in the rules). **Right click** a token (DM, full view) for a radial menu: add it to the fight, open its sheet, hide/reveal or remove an NPC token. Players who join later can be added with the **+ name** buttons in the initiative bar.
The bar shows the order, the round and whose turn it is (the token pulses on the map). The DM can overwrite every number, reroll, remove entries, step **◀ / Next turn ▶** and **End fight**. Hidden NPCs are not shown to players. Fight start, joins and the end are logged in the chat feed.
### Maps, tokens and line of sight
- **Maps tab** (top navigation): all your maps with preview. Upload PNG, JPEG or WebP (up to 12 MB, 30 maps each; the server checks and re-encodes every image as WebP, max 4096 px per side, into `db/maps/`). **Edit** opens the map editor, **Share** gives other users access by username (like characters: they can put the map on their own table or **Copy** it, but not change it), **Duplicate** copies a map.
@@ -147,6 +153,7 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
| GET | `/api/rules` | Rule data including presets |
| GET / POST | `/api/characters[?npc=1]` | Own and shared-with-me characters (or the NPC library) / create one |
| GET | `/api/characters/{id}/portrait` | Portrait image (readers of the character) |
| POST / PUT / DELETE | `/api/lobbies/{id}/combat` | Start a fight / add, remove, set, reroll, next, prev / end (DM) |
| GET | `/api/lobbies/{id}/token-portrait/{token}` | Portrait of an NPC token (lobby members) |
| GET / PUT | `/api/admin/users[/{username}]` | List accounts / make or remove an admin (admins) |
| GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) |
+170
View File
@@ -0,0 +1,170 @@
"""Initiative tracker. One fight per lobby, stored as JSON on the lobby row.
{"round": 1, "current": "<entry id>", "entries": [{"id", "kind": "player"|"npc", "user": username, "token": token id,
"name", "init": number, "base": initiative modifier, "roll": d20}]}
The DM starts and ends the fight; players are added automatically at the start. Initiative is 1d20 + the character's
Initiative value (Agility Mod + Perception Mod), the DM can overwrite every number."""
import json
import secrets
import uuid
from fastapi import Body, Depends, HTTPException
from .derive import compute
MAX_ENTRIES = 60
def schema(con):
if "combat" not in [r["name"] for r in con.execute("PRAGMA table_info(lobbies)")]:
con.execute("ALTER TABLE lobbies ADD COLUMN combat TEXT")
def _sorted(c: dict):
c["entries"].sort(key=lambda e: (-(e["init"] if e["init"] is not None else -999), e.get("seq", 0)))
def install(api, db, current_user, lobby_for, dm_only, hub, event, load_rules):
def base_of(data, rules) -> int:
try:
return int(compute(json.loads(data), rules)["derived"].get("initiative", 0))
except Exception:
return 0
def new_entry(c, kind, name, base, **kw):
roll = secrets.randbelow(20) + 1
c["seq"] = c.get("seq", 0) + 1
return {"id": uuid.uuid4().hex[:10], "kind": kind, "name": name, "base": base, "roll": roll, "init": roll + base, "seq": c["seq"], **kw}
def load(lobby):
return json.loads(lobby["combat"]) if lobby["combat"] else None
def save(con, lid, c, log=None, uid=None):
con.execute("UPDATE lobbies SET combat=? WHERE id=?", (json.dumps(c) if c else None, lid))
if log:
event(con, lid, log, uid)
hub.notify(lid, "lobby")
def view(con, lobby, is_dm):
"""What the viewer gets: hidden NPCs are left out for players; NPCs whose token is gone disappear."""
c = load(lobby)
if not c:
return None
toks = {r["id"]: r["hidden"] for r in con.execute("SELECT id, hidden FROM lobby_tokens WHERE lobby_id=?", (lobby["id"],))}
out = []
for e in c["entries"]:
if e["kind"] == "npc":
if e["token"] not in toks:
continue
if toks[e["token"]] and not is_dm:
continue
out.append({k: e[k] for k in ("id", "kind", "name", "init", "base", "roll") if k in e} | {k: e[k] for k in ("user", "token") if k in e})
cur = c.get("current")
return {"round": c["round"], "current": cur if any(e["id"] == cur for e in out) else None, "entries": out}
@api.post("/lobbies/{lid}/combat")
def start(lid: str, user: dict = Depends(current_user)):
rules = load_rules()
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
c = {"round": 1, "current": None, "seq": 0, "entries": []}
for r in con.execute("""SELECT u.username, ch.data FROM lobby_members m JOIN users u ON u.id = m.user_id
JOIN characters ch ON ch.id = m.character_id WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)):
nm = json.loads(r["data"]).get("name") or r["username"]
c["entries"].append(new_entry(c, "player", nm, base_of(r["data"], rules), user=r["username"]))
_sorted(c)
c["current"] = c["entries"][0]["id"] if c["entries"] else None
save(con, lid, c, "⚔ A fight begins. Initiative: " + ", ".join(f"{e['name']} {e['init']}" for e in c["entries"]), user["id"])
return {"ok": True}
@api.put("/lobbies/{lid}/combat")
def change(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
rules = load_rules()
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
c = load(lobby)
if not c:
raise HTTPException(409, "There is no fight")
op, eid = body.get("op"), str(body.get("id") or "")
ents = c["entries"]
ent = next((e for e in ents if e["id"] == eid), None)
log = None
if op == "add":
if len(ents) >= MAX_ENTRIES:
raise HTTPException(409, "Too many entries")
if body.get("token"):
t = con.execute("""SELECT t.id, t.name, t.user_id, n.data FROM lobby_tokens t LEFT JOIN characters n ON n.id = t.npc_id
WHERE t.id=? AND t.lobby_id=?""", (str(body["token"]), lid)).fetchone()
if not t or t["user_id"]:
raise HTTPException(404, "No NPC token")
if any(e.get("token") == t["id"] for e in ents):
raise HTTPException(409, "Already in the fight")
e = new_entry(c, "npc", t["name"], base_of(t["data"], rules) if t["data"] else 0, token=t["id"])
elif body.get("user"):
r = con.execute("""SELECT u.username, ch.data FROM lobby_members m JOIN users u ON u.id = m.user_id
JOIN characters ch ON ch.id = m.character_id WHERE m.lobby_id=? AND u.username=?""", (lid, str(body["user"]))).fetchone()
if not r:
raise HTTPException(404, "No such player with a character")
if any(e.get("user") == r["username"] for e in ents):
raise HTTPException(409, "Already in the fight")
e = new_entry(c, "player", json.loads(r["data"]).get("name") or r["username"], base_of(r["data"], rules), user=r["username"])
else:
raise HTTPException(422, "Say which token or player to add")
ents.append(e)
log = f"{e['name']} joins the fight with initiative {e['init']}"
elif op == "remove":
if not ent:
raise HTTPException(404, "No such entry")
if c["current"] == eid:
i = ents.index(ent)
nxt = ents[(i + 1) % len(ents)] if len(ents) > 1 else None
c["current"] = nxt["id"] if nxt else None
ents.remove(ent)
elif op == "init":
if not ent:
raise HTTPException(404, "No such entry")
try:
ent["init"] = max(-99, min(999, int(body.get("value"))))
except (TypeError, ValueError):
raise HTTPException(422, "Initiative must be a whole number")
elif op == "reroll":
if not ent:
raise HTTPException(404, "No such entry")
ent["roll"] = secrets.randbelow(20) + 1
ent["init"] = ent["roll"] + ent["base"]
log = f"{ent['name']} rerolls initiative: {ent['init']}"
elif op in ("next", "prev"):
_sorted(c)
if ents:
i = next((k for k, e in enumerate(ents) if e["id"] == c.get("current")), -1)
if op == "next":
if i + 1 >= len(ents):
c["round"] += 1
i = (i + 1) % len(ents)
else:
if i <= 0:
c["round"] = max(1, c["round"] - 1)
i = (i - 1) % len(ents)
c["current"] = ents[i]["id"]
else:
raise HTTPException(422, "Unknown action")
cur = c.get("current")
_sorted(c)
if cur and not any(e["id"] == cur for e in c["entries"]):
c["current"] = c["entries"][0]["id"] if c["entries"] else None
save(con, lid, c, log, user["id"])
return {"ok": True}
@api.delete("/lobbies/{lid}/combat")
def end(lid: str, user: dict = Depends(current_user)):
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
c = load(lobby)
if c:
save(con, lid, None, f"The fight is over after {c['round']} round{'s' if c['round'] != 1 else ''}.", user["id"])
return {"ok": True}
return view
+5 -1
View File
@@ -18,6 +18,7 @@ from . import rules_edit
from . import maps as maps_mod
from . import sounds as sounds_mod
from . import portrait as portrait_mod
from . import combat as combat_mod
from .realtime import hub
from .export import to_docx, to_pdf
@@ -109,6 +110,7 @@ def db() -> sqlite3.Connection:
con.execute("ALTER TABLE characters ADD COLUMN npc INTEGER NOT NULL DEFAULT 0")
maps_mod.schema(con)
sounds_mod.schema(con)
combat_mod.schema(con)
for table in ("characters", "presets"): # older databases: add the owner column
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
@@ -658,6 +660,7 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
rows = con.execute("""SELECT m.user_id, m.character_id, m.color, m.char_mod, u.username, c.data FROM lobby_members m
JOIN users u ON u.id = m.user_id LEFT JOIN characters c ON c.id = m.character_id
WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
combat = combat_view(con, lobby, is_dm)
members = []
online = hub.online(lid)
for r in rows:
@@ -672,7 +675,7 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
if hidden and not is_dm:
diff = {"label": "", "mod": None, "hidden": True}
return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm,
"dm_online": lobby["owner_id"] in online, "code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff,
"dm_online": lobby["owner_id"] in online, "combat": combat, "code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff,
"sounds": sounds_mod.settings(lobby["sounds"]), "levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR}
@@ -1125,6 +1128,7 @@ def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin
maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps", _can_read_character)
combat_view = combat_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, load_rules)
purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds")
+58 -11
View File
@@ -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} />}
+29 -2
View File
@@ -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 (
+26
View File
@@ -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; }