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:
@@ -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).
|
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, 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.
|
- **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 | `/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 / 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) |
|
| 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 | `/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 / 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) |
|
| GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) |
|
||||||
|
|||||||
@@ -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
@@ -18,6 +18,7 @@ from . import rules_edit
|
|||||||
from . import maps as maps_mod
|
from . import maps as maps_mod
|
||||||
from . import sounds as sounds_mod
|
from . import sounds as sounds_mod
|
||||||
from . import portrait as portrait_mod
|
from . import portrait as portrait_mod
|
||||||
|
from . import combat as combat_mod
|
||||||
from .realtime import hub
|
from .realtime import hub
|
||||||
from .export import to_docx, to_pdf
|
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")
|
con.execute("ALTER TABLE characters ADD COLUMN npc INTEGER NOT NULL DEFAULT 0")
|
||||||
maps_mod.schema(con)
|
maps_mod.schema(con)
|
||||||
sounds_mod.schema(con)
|
sounds_mod.schema(con)
|
||||||
|
combat_mod.schema(con)
|
||||||
for table in ("characters", "presets"): # older databases: add the owner column
|
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})")]:
|
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")
|
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
|
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
|
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()
|
WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
|
||||||
|
combat = combat_view(con, lobby, is_dm)
|
||||||
members = []
|
members = []
|
||||||
online = hub.online(lid)
|
online = hub.online(lid)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -672,7 +675,7 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
|
|||||||
if hidden and not is_dm:
|
if hidden and not is_dm:
|
||||||
diff = {"label": "", "mod": None, "hidden": True}
|
diff = {"label": "", "mod": None, "hidden": True}
|
||||||
return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm,
|
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}
|
"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)
|
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")
|
purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+58
-11
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
|
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
|
||||||
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
|
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 J = { 'Content-Type': 'application/json' }
|
||||||
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
|
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
|
// ---- character bar: one card per player, later also for initiative and enemies
|
||||||
function CharBar({ lobby, sel, onSel }) {
|
function CharBar({ lobby, sel, onSel, settings }) {
|
||||||
return (
|
return (
|
||||||
<div className="charbar" role="list">
|
<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) => {
|
{lobby.members.map((m) => {
|
||||||
const c = m.character, can = !!m.character_id
|
const c = m.character, can = !!m.character_id
|
||||||
return (
|
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>}
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -680,6 +716,8 @@ function LobbyView({ id, rules, user }) {
|
|||||||
setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, ...p } : t)) }))
|
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)
|
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 [npcPick, setNpcPick] = useState(false)
|
||||||
const addNpcToken = (n) => {
|
const addNpcToken = (n) => {
|
||||||
const m = mapState.map
|
const m = mapState.map
|
||||||
@@ -727,14 +765,6 @@ function LobbyView({ id, rules, user }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page lobbypage">
|
<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>}
|
{err && <p className="warn">{err}</p>}
|
||||||
{info && <p className="small">{info}</p>}
|
{info && <p className="small">{info}</p>}
|
||||||
|
|
||||||
@@ -760,7 +790,11 @@ function LobbyView({ id, rules, user }) {
|
|||||||
|
|
||||||
<div className="playarea">
|
<div className="playarea">
|
||||||
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
|
{!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)} />
|
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
|
||||||
<div className="table">
|
<div className="table">
|
||||||
{showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
|
{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 (
|
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}
|
<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}
|
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}>
|
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>}
|
{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">
|
<div className="maptools mapui">
|
||||||
@@ -791,6 +827,17 @@ function LobbyView({ id, rules, user }) {
|
|||||||
</MapView>
|
</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} />
|
<DiceTray rolls={tray} />
|
||||||
{canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>}
|
{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 && <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.
|
// 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.
|
// 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 box = useRef(null)
|
||||||
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
|
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
|
||||||
const [drag, setDrag] = useState(null)
|
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
|
const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y
|
||||||
if (x == null || (dr && dr.out)) return null
|
if (x == null || (dr && dr.out)) return null
|
||||||
return (
|
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) }}
|
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)' : '')}>
|
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>}
|
{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)
|
// 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 }) {
|
export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -501,3 +501,29 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
|
|||||||
.presence.on { background: #3fb950; }
|
.presence.on { background: #3fb950; }
|
||||||
.presence.inline { position: static; display: inline-block; width: 9px; height: 9px; border: 0; margin-right: .2rem; }
|
.presence.inline { position: static; display: inline-block; width: 9px; height: 9px; border: 0; margin-right: .2rem; }
|
||||||
.pcard.off { opacity: .6; }
|
.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