Compare commits

...
5 Commits
11 changed files with 561 additions and 40 deletions
+12
View File
@@ -164,6 +164,14 @@ Players only see a short card of the other players, never their sheets. Guessing
- **Visibility in the editor**: the 👁 button beside each prepared token toggles "hidden from players". - **Visibility in the editor**: the 👁 button beside each prepared token toggles "hidden from players".
- **Props**: decor tokens (chest, barrel, table, chair, bed, door, campfire, tree, rug, stairs, ... 19 built-in drawings, plus your own PNG/WebP uploads up to 3 MB, 40 per DM). Add them in the map editor (*+ Prop*) or on the table (*+ Prop*); drag like tokens, size width/height in squares, rotate by slider, 45° or 90° steps (also in the right click menu), hide from players. They are drawn below tokens; players cannot move them. - **Props**: decor tokens (chest, barrel, table, chair, bed, door, campfire, tree, rug, stairs, ... 19 built-in drawings, plus your own PNG/WebP uploads up to 3 MB, 40 per DM). Add them in the map editor (*+ Prop*) or on the table (*+ Prop*); drag like tokens, size width/height in squares, rotate by slider, 45° or 90° steps (also in the right click menu), hide from players. They are drawn below tokens; players cannot move them.
- **Prop library**: the *Maps* page lists all props (built-in and your own) and lets you upload, rename and remove your own ones.
- **DM notes**: NPC tokens and props have a "Notes (only you see these)" field (map editor and table; e.g. the contents of a chest). On the table click the token to edit; a 📝 marks tokens with notes and the text shows as tooltip. Players never receive notes.
- **Fog without walls**: sight range and fog also work on maps without walls (circle around the token). Sight needs walls or a range above 0; the explored memory is now stored at up to 1024 px.
- **Map ping**: everybody (players and DM) can point at a spot: double-click the map, or press *📍 Ping* and click. All see a ring in the pinger's colour with their name, and hear a short ping (rate limited to one per second and person). The DM can switch the sound off or upload an MP3 for it under *Lobby actions → Sounds → Map ping*; players can still mute sounds for themselves.
- **Measure, areas, markers** (for everybody, buttons above the map): *📏 Measure* - click start and end, the distance in squares is shown to the whole table for a few seconds. *⬡ Area* - spell templates (circle, cone with adjustable angle, line with width, square) in the colour of whoever placed them, visible to all until their author or the DM removes them (list top left, DM can clear all); they disappear when the map changes. *📌 Marker* - pins with icon, title and note on the map. Markers are private to their author until "Show to everybody at the table" is ticked; the DM can remove shared markers. They belong to the map, so they are back when you return to that map.
## API ## API
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie. All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
@@ -195,6 +203,10 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
| PUT | `/api/lobbies/{id}/doors/{wall}` , `/sight` | DM: open / close a door; line of sight on / off | | PUT | `/api/lobbies/{id}/doors/{wall}` , `/sight` | DM: open / close a door; line of sight on / off |
| GET / POST | `/api/lobbies/{id}/scenes` | DM: saved scenes / save a copy of the table (`name`) | | GET / POST | `/api/lobbies/{id}/scenes` | DM: saved scenes / save a copy of the table (`name`) |
| PUT / DELETE | `/api/lobbies/{id}/scenes/{sid}` | DM: rename (`name`), save now (`save`) / delete | | PUT / DELETE | `/api/lobbies/{id}/scenes/{sid}` | DM: rename (`name`), save now (`save`) / delete |
| POST | `/api/lobbies/{id}/measure` | Share a measured distance (`x1,y1,x2,y2`) |
| POST / DELETE | `/api/lobbies/{id}/templates[/{tid}]` | Place an area (`kind, x, y, dir, size, width`) / remove (author or DM; DELETE without id: DM clears all) |
| POST / PUT / DELETE | `/api/lobbies/{id}/marks[/{kid}]` | Marker with note (`x, y, icon, title, note, shared`) / edit (author) / remove |
| POST | `/api/lobbies/{id}/ping` | Ping a spot (`x`, `y`); everybody gets a push |
| PUT | `/api/lobbies/{id}/scene` | DM: switch to a scene (`scene_id`); the current one is saved first | | PUT | `/api/lobbies/{id}/scene` | DM: switch to a scene (`scene_id`); the current one is saved first |
| GET / POST / DELETE | `/api/props[/{id}]` | Your own prop images / upload (raw body, `?name=`) / delete | | GET / POST / DELETE | `/api/props[/{id}]` | Your own prop images / upload (raw body, `?name=`) / delete |
| GET | `/api/props/{id}/image`, `/api/lobbies/{id}/prop-image/{pid}` | Prop image (owner / lobby members, DM's props) | | GET | `/api/props/{id}/image`, `/api/lobbies/{id}/prop-image/{pid}` | Prop image (owner / lobby members, DM's props) |
+1 -1
View File
@@ -928,7 +928,7 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)):
_dm_only(_lobby_for(con, lid, user), user) _dm_only(_lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_scenes"): for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_scenes", "lobby_templates", "lobby_marks"):
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,)) con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobbies WHERE id=?", (lid,)) con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
purge_sounds(lid) purge_sounds(lid)
+192 -15
View File
@@ -29,6 +29,10 @@ MAX_TOKENS = 120 # per map / lobby
MAX_WALLS = 400 # wall and door segments per map MAX_WALLS = 400 # wall and door segments per map
MAX_SHARES = 30 MAX_SHARES = 30
MAX_SCENES = 12 # saved scenes per lobby MAX_SCENES = 12 # saved scenes per lobby
MAX_TEMPLATES = 24 # per lobby
MAX_MARKS = 60 # per player and lobby
TEMPLATE_KINDS = ("circle", "cone", "line", "square")
MARK_ICONS = ("📌", "⚠️", "💰", "🔒", "🚪", "❓", "⭐", "☠️", "🏕️", "👁️")
MAX_PROPS = 40 # custom prop images per user MAX_PROPS = 40 # custom prop images per user
COLORS = ("#c0392b", "#8e44ad", "#16a085", "#7f8c8d", "#d35400", "#2c3e50", "#27ae60", "#b9770e") COLORS = ("#c0392b", "#8e44ad", "#16a085", "#7f8c8d", "#d35400", "#2c3e50", "#27ae60", "#b9770e")
@@ -51,6 +55,12 @@ def schema(con):
fov REAL NOT NULL, targets TEXT, created REAL NOT NULL)""") fov REAL NOT NULL, targets TEXT, created REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS lobby_scenes ( con.execute("""CREATE TABLE IF NOT EXISTS lobby_scenes (
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, name TEXT NOT NULL, map_id TEXT, state TEXT NOT NULL DEFAULT '{}', updated REAL NOT NULL)""") id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, name TEXT NOT NULL, map_id TEXT, state TEXT NOT NULL DEFAULT '{}', updated REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS lobby_templates (
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, user_id INTEGER NOT NULL, kind TEXT NOT NULL, x REAL NOT NULL, y REAL NOT NULL,
dir REAL NOT NULL, size REAL NOT NULL, width REAL NOT NULL, created REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS lobby_marks (
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, map_id TEXT NOT NULL, user_id INTEGER NOT NULL, x REAL NOT NULL, y REAL NOT NULL,
icon TEXT NOT NULL, title TEXT NOT NULL, note TEXT NOT NULL, shared INTEGER NOT NULL DEFAULT 0, created REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS user_props ( con.execute("""CREATE TABLE IF NOT EXISTS user_props (
id TEXT PRIMARY KEY, owner_id INTEGER NOT NULL, name TEXT NOT NULL, created REAL NOT NULL)""") id TEXT PRIMARY KEY, owner_id INTEGER NOT NULL, name TEXT NOT NULL, created REAL NOT NULL)""")
con.execute("CREATE TABLE IF NOT EXISTS map_shares (map_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (map_id, user_id))") con.execute("CREATE TABLE IF NOT EXISTS map_shares (map_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (map_id, user_id))")
@@ -62,7 +72,7 @@ def schema(con):
("lobby_tokens", "show_cond", "INTEGER NOT NULL DEFAULT 0"), ("lobby_tokens", "show_cond", "INTEGER NOT NULL DEFAULT 0"),
("lobby_members", "vgroup", "INTEGER NOT NULL DEFAULT 0"), ("lobby_members", "vgroup", "INTEGER NOT NULL DEFAULT 0"),
("lobby_tokens", "prop", "TEXT"), ("lobby_tokens", "rot", "REAL NOT NULL DEFAULT 0"), ("lobby_tokens", "size_h", "REAL"), ("lobby_tokens", "prop", "TEXT"), ("lobby_tokens", "rot", "REAL NOT NULL DEFAULT 0"), ("lobby_tokens", "size_h", "REAL"),
("lobbies", "scene_id", "TEXT")): ("lobbies", "scene_id", "TEXT"), ("lobby_tokens", "note", "TEXT")):
if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]: if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}") con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
@@ -107,6 +117,7 @@ def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict:
tid = str(t.get("id", "")) if keep_id else "" tid = str(t.get("id", "")) if keep_id else ""
npc = str(t.get("npc_id") or "") npc = str(t.get("npc_id") or "")
hp_max, hp = clean_int(t.get("hp_max"), 1, 9999), clean_int(t.get("hp"), -999, 9999) hp_max, hp = clean_int(t.get("hp_max"), 1, 9999), clean_int(t.get("hp"), -999, 9999)
note = str(t.get("note") or "")[:2000]
prop = str(t.get("prop") or "") prop = str(t.get("prop") or "")
prop = prop if re.fullmatch(r"[bc]:[a-z0-9_]{1,24}", prop) else "" prop = prop if re.fullmatch(r"[bc]:[a-z0-9_]{1,24}", prop) else ""
try: try:
@@ -117,7 +128,7 @@ def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict:
return {"id": tid if tid.isalnum() and len(tid) <= 16 else uuid.uuid4().hex[:12], "name": name, "x": x, "y": y, return {"id": tid if tid.isalnum() and len(tid) <= 16 else uuid.uuid4().hex[:12], "name": name, "x": x, "y": y,
"size": size, "color": clean_color(t.get("color")), "hidden": bool(t.get("hidden")), "size": size, "color": clean_color(t.get("color")), "hidden": bool(t.get("hidden")),
"npc_id": npc if npc.isalnum() and len(npc) <= 16 else "", "hp": hp, "hp_max": hp_max, "npc_id": npc if npc.isalnum() and len(npc) <= 16 else "", "hp": hp, "hp_max": hp_max,
"prop": prop, "rot": rot, "size_h": size_h if prop else None} "prop": prop, "rot": rot, "size_h": size_h if prop else None, "note": note}
def clean_int(v, lo, hi): def clean_int(v, lo, hi):
@@ -289,6 +300,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
for lid in lobbies: for lid in lobbies:
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"): for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"):
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,)) con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_marks WHERE map_id=?", (mid,))
con.execute("UPDATE lobbies SET scene_id=NULL WHERE scene_id IN (SELECT id FROM lobby_scenes WHERE map_id=?)", (mid,)) con.execute("UPDATE lobbies SET scene_id=NULL WHERE scene_id IN (SELECT id FROM lobby_scenes WHERE map_id=?)", (mid,))
con.execute("DELETE FROM lobby_scenes WHERE map_id=?", (mid,)) con.execute("DELETE FROM lobby_scenes WHERE map_id=?", (mid,))
con.execute("DELETE FROM maps WHERE id=?", (mid,)) con.execute("DELETE FROM maps WHERE id=?", (mid,))
@@ -402,6 +414,15 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
con.execute("INSERT INTO user_props (id, owner_id, name, created) VALUES (?,?,?,?)", (pid, user["id"], name.strip()[:30] or "Prop", time.time())) con.execute("INSERT INTO user_props (id, owner_id, name, created) VALUES (?,?,?,?)", (pid, user["id"], name.strip()[:30] or "Prop", time.time()))
return {"id": pid} return {"id": pid}
@api.put("/props/{pid}")
def rename_prop(pid: str, body: dict = Body(...), user: dict = Depends(current_user)):
name = str(body.get("name", "")).strip()[:30]
with db() as con:
if not name or not con.execute("SELECT 1 FROM user_props WHERE id=? AND owner_id=?", (pid, user["id"])).fetchone():
raise HTTPException(404, "Prop not found")
con.execute("UPDATE user_props SET name=? WHERE id=?", (name, pid))
return {"ok": True}
@api.delete("/props/{pid}") @api.delete("/props/{pid}")
def delete_prop(pid: str, user: dict = Depends(current_user)): def delete_prop(pid: str, user: dict = Depends(current_user)):
with db() as con: with db() as con:
@@ -443,13 +464,13 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
def put_token(con, lid, t, npc=None, tid=None): def put_token(con, lid, t, npc=None, tid=None):
"""Inserts a (cleaned or snapshot) token row for the lobby.""" """Inserts a (cleaned or snapshot) token row for the lobby."""
con.execute("""INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id, hp, hp_max, prop, rot, size_h) con.execute("""INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id, hp, hp_max, prop, rot, size_h, note)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(tid or uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(bool(t["hidden"])), npc, (tid or uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(bool(t["hidden"])), npc,
t.get("hp") if t.get("hp") is not None else t.get("hp_max"), t.get("hp_max"), t.get("prop") or None, t.get("rot") or 0, t.get("size_h"))) t.get("hp") if t.get("hp") is not None else t.get("hp_max"), t.get("hp_max"), t.get("prop") or None, t.get("rot") or 0, t.get("size_h"), t.get("note") or None))
def mask_dims(m): def mask_dims(m):
f = max(1, -(-max(m["w"], m["h"]) // 512)) f = max(1, -(-max(m["w"], m["h"]) // 1024))
return f, -(-m["w"] // f), -(-m["h"] // f) return f, -(-m["w"] // f), -(-m["h"] // f)
def explore(con, lobby, viewer, m, polys, key): def explore(con, lobby, viewer, m, polys, key):
@@ -466,7 +487,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
old = Image.open(io.BytesIO(row["data"])) old = Image.open(io.BytesIO(row["data"]))
old.load() old.load()
if old.size != (mw, mh): if old.size != (mw, mh):
old = None old = old.resize((mw, mh), Image.BILINEAR) # memory made at another resolution is kept
base = old or Image.new("L", (mw, mh), 0) base = old or Image.new("L", (mw, mh), 0)
cur = Image.new("L", (mw, mh), 0) cur = Image.new("L", (mw, mh), 0)
for poly in polys: for poly in polys:
@@ -487,6 +508,11 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
def lobby_map(con, lobby): def lobby_map(con, lobby):
return con.execute("SELECT * FROM maps WHERE id=?", (lobby["map_id"],)).fetchone() if lobby["map_id"] else None return con.execute("SELECT * FROM maps WHERE id=?", (lobby["map_id"],)).fetchone() if lobby["map_id"] else None
def vision_on(lobby, m) -> bool:
"""Players only see what their token sees: needs walls to block the view or a sight range to limit it."""
sight = lobby["sight"] if lobby["sight"] is not None else m["sight"]
return bool(lobby["los"] and (json.loads(m["walls"]) or sight > 0))
def view_ctx(con, lobby, m, user, as_name=None): def view_ctx(con, lobby, m, user, as_name=None):
"""Whose eyes the table is shown through. Players: their own token. DM: everything, or (as=name) one player's view.""" """Whose eyes the table is shown through. Players: their own token. DM: everything, or (as=name) one player's view."""
is_dm = lobby["owner_id"] == user["id"] is_dm = lobby["owner_id"] == user["id"]
@@ -506,7 +532,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
if viewer is None: if viewer is None:
return ctx return ctx
ctx["rev"] = "open:" + m["id"] ctx["rev"] = "open:" + m["id"]
if not (lobby["los"] and walls): if not vision_on(lobby, m):
return ctx return ctx
ctx["los"] = True ctx["los"] = True
blocking = [(w["x1"], w["y1"], w["x2"], w["y2"]) for w in walls if w["kind"] == "wall" or not doors.get(w["id"])] blocking = [(w["x1"], w["y1"], w["x2"], w["y2"]) for w in walls if w["kind"] == "wall" or not doors.get(w["id"])]
@@ -598,6 +624,8 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
status.update(show_hp=bool(r["show_hp"]), show_cond=bool(r["show_cond"]), hp_hint=health(src) if src else None) status.update(show_hp=bool(r["show_hp"]), show_cond=bool(r["show_cond"]), hp_hint=health(src) if src else None)
if r["prop"]: if r["prop"]:
status = {} status = {}
if full and not player:
status["note"] = r["note"] or ""
out.append({"id": r["id"], "kind": "player" if player else "prop" if r["prop"] else "npc", "prop": r["prop"], "rot": r["rot"], "size_h": r["size_h"], "username": r["username"], "name": name, "portrait": pt, **status, out.append({"id": r["id"], "kind": "player" if player else "prop" if r["prop"] else "npc", "prop": r["prop"], "rot": r["rot"], "size_h": r["size_h"], "username": r["username"], "name": name, "portrait": pt, **status,
"x": r["x"], "y": r["y"], "size": r["size"], "hidden": bool(r["hidden"]), "x": r["x"], "y": r["y"], "size": r["size"], "hidden": bool(r["hidden"]),
"color": (r["mcolor"] or "#999999") if player else r["color"], "color": (r["mcolor"] or "#999999") if player else r["color"],
@@ -616,6 +644,14 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
ctx = view_ctx(con, lobby, m, user, as_) ctx = view_ctx(con, lobby, m, user, as_)
res = {"map": meta(m), "tokens": tokens_for(con, lobby, user, ctx), res = {"map": meta(m), "tokens": tokens_for(con, lobby, user, ctx),
"vision": {"los": ctx["los"], "empty": ctx["empty"], "rev": ctx["rev"], "fog": ctx.get("fog")}} "vision": {"los": ctx["los"], "empty": ctx["empty"], "rev": ctx["rev"], "fog": ctx.get("fog")}}
res["templates"] = [{"id": t["id"], "kind": t["kind"], "x": t["x"], "y": t["y"], "dir": t["dir"], "size": t["size"], "width": t["width"],
"user": t["username"], "dm": t["user_id"] == lobby["owner_id"], "mine": t["user_id"] == user["id"]}
for t in con.execute("""SELECT t.*, u.username FROM lobby_templates t JOIN users u ON u.id = t.user_id
WHERE t.lobby_id=? ORDER BY t.created""", (lid,))]
res["marks"] = [{"id": k["id"], "x": k["x"], "y": k["y"], "icon": k["icon"], "title": k["title"], "note": k["note"], "shared": bool(k["shared"]),
"user": k["username"], "mine": k["user_id"] == user["id"]}
for k in con.execute("""SELECT k.*, u.username FROM lobby_marks k JOIN users u ON u.id = k.user_id
WHERE k.lobby_id=? AND k.map_id=? AND (k.user_id=? OR k.shared=1) ORDER BY k.created""", (lid, m["id"], user["id"]))]
if ctx["is_dm"]: if ctx["is_dm"]:
res["los_on"] = bool(lobby["los"]) res["los_on"] = bool(lobby["los"])
res["wall_count"] = len(ctx["walls"]) res["wall_count"] = len(ctx["walls"])
@@ -757,6 +793,144 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
hub.notify(lid, "map") hub.notify(lid, "map")
return {"ok": True} return {"ok": True}
pinged: dict = {}
@api.post("/lobbies/{lid}/ping")
def ping(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""Anybody at the table can point at a spot of the map; everybody sees a ring there (and hears a ping)."""
with db() as con:
lobby = lobby_for(con, lid, user)
m = need_map(con, lobby)
try:
x, y = float(body.get("x")), float(body.get("y"))
except (TypeError, ValueError):
raise HTTPException(422, "Invalid position")
if not (0 <= x <= m["w"] and 0 <= y <= m["h"]):
raise HTTPException(422, "Outside of the map")
now = time.time()
if now - pinged.get((lid, user["id"]), 0) < 0.8:
raise HTTPException(429, "Slow down")
pinged[(lid, user["id"])] = now
if len(pinged) > 2000:
pinged.clear()
hub.notify(lid, "ping", delay=0, x=round(x, 1), y=round(y, 1), user=user["username"], dm=lobby["owner_id"] == user["id"])
return {"ok": True}
def throttle(lid, uid, gap=0.8):
now = time.time()
if now - pinged.get((lid, uid), 0) < gap:
raise HTTPException(429, "Slow down")
pinged[(lid, uid)] = now
def num(body, key, lo, hi, default=None):
try:
v = float(body.get(key, default))
except (TypeError, ValueError):
raise HTTPException(422, f"Invalid {key}")
if not lo <= v <= hi:
raise HTTPException(422, f"{key} is out of range")
return v
@api.post("/lobbies/{lid}/measure")
def share_measure(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""A measured distance, shown to everybody for a few seconds."""
with db() as con:
lobby = lobby_for(con, lid, user)
m = need_map(con, lobby)
pts = [num(body, k, 0, m["w"] if k[0] == "x" else m["h"]) for k in ("x1", "y1", "x2", "y2")]
throttle(lid, user["id"], 0.4)
hub.notify(lid, "measure", delay=0, x1=pts[0], y1=pts[1], x2=pts[2], y2=pts[3], user=user["username"], dm=lobby["owner_id"] == user["id"])
return {"ok": True}
@api.post("/lobbies/{lid}/templates")
def add_template(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""An area (spell template) on the map, visible to everybody until it is removed."""
kind = body.get("kind")
if kind not in TEMPLATE_KINDS:
raise HTTPException(422, "Unknown template")
with db() as con:
lobby = lobby_for(con, lid, user)
m = need_map(con, lobby)
x, y = num(body, "x", 0, m["w"]), num(body, "y", 0, m["h"])
d, size = num(body, "dir", -720, 720, 0) % 360, num(body, "size", 0.25, 60)
width = num(body, "width", 1, 180, 1)
if kind == "line":
width = min(width, 20)
if kind == "cone":
width = min(max(width, 20), 180)
if con.execute("SELECT COUNT(*) FROM lobby_templates WHERE lobby_id=?", (lid,)).fetchone()[0] >= MAX_TEMPLATES:
raise HTTPException(409, "Too many templates on the map - remove some first")
tid = uuid.uuid4().hex[:12]
con.execute("INSERT INTO lobby_templates (id, lobby_id, user_id, kind, x, y, dir, size, width, created) VALUES (?,?,?,?,?,?,?,?,?,?)",
(tid, lid, user["id"], kind, x, y, d, size, width, time.time()))
hub.notify(lid, "map")
return {"id": tid}
@api.delete("/lobbies/{lid}/templates/{tid}")
def remove_template(lid: str, tid: str, user: dict = Depends(current_user)):
with db() as con:
lobby = lobby_for(con, lid, user)
if lobby["owner_id"] == user["id"]:
con.execute("DELETE FROM lobby_templates WHERE id=? AND lobby_id=?", (tid, lid))
else:
con.execute("DELETE FROM lobby_templates WHERE id=? AND lobby_id=? AND user_id=?", (tid, lid, user["id"]))
hub.notify(lid, "map")
return {"ok": True}
@api.delete("/lobbies/{lid}/templates")
def clear_templates(lid: str, user: dict = Depends(current_user)):
with db() as con:
dm_only(lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_templates WHERE lobby_id=?", (lid,))
hub.notify(lid, "map")
return {"ok": True}
def clean_mark(body, m):
icon = body.get("icon") if body.get("icon") in MARK_ICONS else MARK_ICONS[0]
return (num(body, "x", 0, m["w"]), num(body, "y", 0, m["h"]), icon, str(body.get("title", "")).strip()[:40] or "Marker",
str(body.get("note", ""))[:1000], int(bool(body.get("shared"))))
@api.post("/lobbies/{lid}/marks")
def add_mark(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""A pin with a note on the current map. Private to its author unless shared with the table."""
with db() as con:
lobby = lobby_for(con, lid, user)
m = need_map(con, lobby)
x, y, icon, title, note, shared = clean_mark(body, m)
if con.execute("SELECT COUNT(*) FROM lobby_marks WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone()[0] >= MAX_MARKS:
raise HTTPException(409, "Too many markers - remove some first")
kid = uuid.uuid4().hex[:12]
con.execute("INSERT INTO lobby_marks (id, lobby_id, map_id, user_id, x, y, icon, title, note, shared, created) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(kid, lid, m["id"], user["id"], x, y, icon, title, note, shared, time.time()))
hub.notify(lid, "map")
return {"id": kid}
@api.put("/lobbies/{lid}/marks/{kid}")
def edit_mark(lid: str, kid: str, body: dict = Body(...), user: dict = Depends(current_user)):
with db() as con:
lobby = lobby_for(con, lid, user)
m = need_map(con, lobby)
k = con.execute("SELECT * FROM lobby_marks WHERE id=? AND lobby_id=? AND user_id=?", (kid, lid, user["id"])).fetchone()
if not k:
raise HTTPException(404, "Marker not found")
merged = {"x": k["x"], "y": k["y"], "icon": k["icon"], "title": k["title"], "note": k["note"], "shared": k["shared"], **body}
x, y, icon, title, note, shared = clean_mark(merged, m)
con.execute("UPDATE lobby_marks SET x=?, y=?, icon=?, title=?, note=?, shared=? WHERE id=?", (x, y, icon, title, note, shared, kid))
hub.notify(lid, "map")
return {"ok": True}
@api.delete("/lobbies/{lid}/marks/{kid}")
def remove_mark(lid: str, kid: str, user: dict = Depends(current_user)):
"""Authors remove their own markers; the DM can also remove markers that were shared with the table."""
with db() as con:
lobby = lobby_for(con, lid, user)
if lobby["owner_id"] == user["id"]:
con.execute("DELETE FROM lobby_marks WHERE id=? AND lobby_id=? AND (user_id=? OR shared=1)", (kid, lid, user["id"]))
else:
con.execute("DELETE FROM lobby_marks WHERE id=? AND lobby_id=? AND user_id=?", (kid, lid, user["id"]))
hub.notify(lid, "map")
return {"ok": True}
@api.put("/lobbies/{lid}/sight") @api.put("/lobbies/{lid}/sight")
def set_sight(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): def set_sight(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""DM: switch line of sight on or off for the table (off: players see the whole map).""" """DM: switch line of sight on or off for the table (off: players see the whole map)."""
@@ -774,7 +948,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
# ---- scenes: saved states of the table (map + tokens + doors + reveals + explored ground + combat) # ---- scenes: saved states of the table (map + tokens + doors + reveals + explored ground + combat)
TOKEN_COLS = ("id", "name", "x", "y", "color", "size", "hidden", "npc_id", "hp", "hp_max", "conds", "show_hp", "show_cond", "prop", "rot", "size_h") TOKEN_COLS = ("id", "name", "x", "y", "color", "size", "hidden", "npc_id", "hp", "hp_max", "conds", "show_hp", "show_cond", "prop", "rot", "size_h", "note")
def snapshot(con, lid) -> str: def snapshot(con, lid) -> str:
lobby = con.execute("SELECT * FROM lobbies WHERE id=?", (lid,)).fetchone() lobby = con.execute("SELECT * FROM lobbies WHERE id=?", (lid,)).fetchone()
@@ -795,6 +969,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
"fog": lobby["fog"], "los": lobby["los"], "combat": lobby["combat"]}) "fog": lobby["fog"], "los": lobby["los"], "combat": lobby["combat"]})
def restore(con, lid, state: dict): def restore(con, lid, state: dict):
con.execute("DELETE FROM lobby_templates WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_explored WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobby_explored WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_reveals WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobby_reveals WHERE lobby_id=?", (lid,))
@@ -820,6 +995,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
def fresh_state(con, lid, r, user): def fresh_state(con, lid, r, user):
"""The state of a map that was just put on the table: its prepared tokens, nothing explored.""" """The state of a map that was just put on the table: its prepared tokens, nothing explored."""
con.execute("DELETE FROM lobby_templates WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_explored WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobby_explored WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_reveals WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobby_reveals WHERE lobby_id=?", (lid,))
@@ -870,7 +1046,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
new_scene(con, lid, r["id"], snapshot(con, lid)) new_scene(con, lid, r["id"], snapshot(con, lid))
event(con, lid, f"The DM put the map \"{r['name']}\" on the table", user["id"]) event(con, lid, f"The DM put the map \"{r['name']}\" on the table", user["id"])
else: else:
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"): for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_templates"):
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,)) con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
con.execute("UPDATE lobbies SET map_id=NULL, sight=NULL, doors='{}', combat=NULL WHERE id=?", (lid,)) con.execute("UPDATE lobbies SET map_id=NULL, sight=NULL, doors='{}', combat=NULL WHERE id=?", (lid,))
event(con, lid, "The DM cleared the table", user["id"]) event(con, lid, "The DM cleared the table", user["id"])
@@ -983,22 +1159,23 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
t = con.execute("SELECT * FROM lobby_tokens WHERE id=? AND lobby_id=?", (tid, lid)).fetchone() t = con.execute("SELECT * FROM lobby_tokens WHERE id=? AND lobby_id=?", (tid, lid)).fetchone()
if not t or (not is_dm and t["user_id"] != user["id"]): if not t or (not is_dm and t["user_id"] != user["id"]):
raise HTTPException(404, "Token not found") raise HTTPException(404, "Token not found")
if not is_dm and t["x"] is None and lobby["los"] and json.loads(m["walls"]): if not is_dm and t["x"] is None and vision_on(lobby, m):
raise HTTPException(403, "The DM puts your token on the map") # with line of sight on you could not see where raise HTTPException(403, "The DM puts your token on the map") # with line of sight on you could not see where
merged = {"name": t["name"], "x": t["x"], "y": t["y"], "color": t["color"], "size": t["size"], "hidden": t["hidden"], merged = {"name": t["name"], "x": t["x"], "y": t["y"], "color": t["color"], "size": t["size"], "hidden": t["hidden"],
"prop": t["prop"], "rot": t["rot"], "size_h": t["size_h"]} "prop": t["prop"], "rot": t["rot"], "size_h": t["size_h"], "note": t["note"] or ""}
if "x" in body or "y" in body: if "x" in body or "y" in body:
merged["x"], merged["y"] = body.get("x"), body.get("y") merged["x"], merged["y"] = body.get("x"), body.get("y")
if is_dm and t["user_id"] is None: if is_dm and t["user_id"] is None:
for k in ("name", "color", "size", "hidden", "rot", "size_h"): for k in ("name", "color", "size", "hidden", "rot", "size_h", "note"):
if k in body: if k in body:
merged[k] = body[k] merged[k] = body[k]
elif is_dm and "size" in body: elif is_dm and "size" in body:
merged["size"] = body["size"] merged["size"] = body["size"]
c = clean_token(merged, m["w"], m["h"]) c = clean_token(merged, m["w"], m["h"])
con.execute("UPDATE lobby_tokens SET name=?, x=?, y=?, color=?, size=?, hidden=?, rot=?, size_h=? WHERE id=?", con.execute("UPDATE lobby_tokens SET name=?, x=?, y=?, color=?, size=?, hidden=?, rot=?, size_h=?, note=? WHERE id=?",
(c["name"] if t["user_id"] is None else t["name"], c["x"], c["y"], c["color"], c["size"], int(c["hidden"]), (c["name"] if t["user_id"] is None else t["name"], c["x"], c["y"], c["color"], c["size"], int(c["hidden"]),
c["rot"] if t["user_id"] is None else 0, c["size_h"] if t["user_id"] is None else None, tid)) c["rot"] if t["user_id"] is None else 0, c["size_h"] if t["user_id"] is None else None,
(c["note"] or None) if t["user_id"] is None else None, tid))
if is_dm and any(k in body for k in ("hp", "hp_max", "conds", "show_hp", "show_cond")): if is_dm and any(k in body for k in ("hp", "hp_max", "conds", "show_hp", "show_cond")):
hp_max, hp = t["hp_max"], t["hp"] hp_max, hp = t["hp_max"], t["hp"]
if "hp_max" in body: if "hp_max" in body:
+3 -3
View File
@@ -6,8 +6,8 @@ from fastapi import Body, Depends, HTTPException, Request
from fastapi.responses import Response from fastapi.responses import Response
MAX_SOUND = 1_500_000 # bytes per file MAX_SOUND = 1_500_000 # bytes per file
SLOTS = ("roll", "crit_success", "crit_failure") SLOTS = ("roll", "crit_success", "crit_failure", "ping")
FLAGS = ("result", "crit_success", "crit_failure") # result = the short tones for success / failure of a check FLAGS = ("result", "crit_success", "crit_failure", "ping") # result = the short tones for success / failure of a check
def schema(con): def schema(con):
@@ -20,7 +20,7 @@ def settings(raw) -> dict:
s = json.loads(raw or "{}") s = json.loads(raw or "{}")
except ValueError: except ValueError:
s = {} s = {}
return {"result": s.get("result", True), "crit_success": s.get("crit_success", True), "crit_failure": s.get("crit_failure", True), return {"result": s.get("result", True), "crit_success": s.get("crit_success", True), "crit_failure": s.get("crit_failure", True), "ping": s.get("ping", True),
"custom": {k: bool(s.get("custom", {}).get(k)) for k in SLOTS}, "v": int(s.get("v", 0))} "custom": {k: bool(s.get("custom", {}).get(k)) for k in SLOTS}, "v": int(s.get("v", 0))}
+3 -2
View File
@@ -124,7 +124,7 @@ def inside_any(pt, polys) -> bool:
def cut(path, polys, key): def cut(path, polys, key):
"""The part of the image inside the polygon (everything else black), cropped to its bounding box. """The part of the image inside the polygon (everything else transparent), cropped to its bounding box.
Returns (webp bytes, (x, y, w, h)).""" Returns (webp bytes, (x, y, w, h))."""
hit = _cuts.fetch(key) hit = _cuts.fetch(key)
if hit: if hit:
@@ -139,7 +139,8 @@ def cut(path, polys, key):
for poly in polys: for poly in polys:
if len(poly) > 2: if len(poly) > 2:
draw.polygon([(x - x0, y - y0) for x, y in poly], fill=255) draw.polygon([(x - x0, y - y0) for x, y in poly], fill=255)
piece = Image.composite(im.crop((x0, y0, x0 + w, y0 + h)), Image.new("RGB", (w, h), (0, 0, 0)), mask) piece = im.crop((x0, y0, x0 + w, y0 + h)).convert("RGBA")
piece.putalpha(mask) # transparent outside the view, so remembered (dimmed) ground underneath stays visible
out = io.BytesIO() out = io.BytesIO()
piece.save(out, "WEBP", quality=82, method=1) piece.save(out, "WEBP", quality=82, method=1)
res = (out.getvalue(), (x0, y0, w, h)) res = (out.getvalue(), (x0, y0, w, h))
+34 -12
View File
@@ -1,6 +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, playPing, preload, DEFAULT_ROLL } from './sound.js'
import { useTableTools } from './Tools.jsx'
import { MapView, MapLibrary, NpcPicker, PropForm, PropPicker, RadialMenu, RevealLayer, StatusForm, TokenForm, WallLayer } from './MapView.jsx' import { MapView, MapLibrary, NpcPicker, PropForm, PropPicker, RadialMenu, RevealLayer, StatusForm, TokenForm, WallLayer } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' } const J = { 'Content-Type': 'application/json' }
@@ -608,7 +609,7 @@ function SoundSettings({ lobby, id, act, sfx }) {
<div className="row small"> <div className="row small">
<span className="muted">{sn.custom[slot] ? 'Your file' : defaultText}</span> <span className="muted">{sn.custom[slot] ? 'Your file' : defaultText}</span>
<span className="row"> <span className="row">
<button className="ghost" onClick={() => playRoll(slot === 'roll' ? 20 : 100, 1, slot === 'roll' ? undefined : slot, { ...sfx, [`${slot}_on`]: true })}>▶ Test</button> <button className="ghost" onClick={() => (slot === 'ping' ? playPing({ ...sfx, ping_on: true }) : playRoll(slot === 'roll' ? 20 : 100, 1, slot === 'roll' ? undefined : slot, { ...sfx, [`${slot}_on`]: true }))}>▶ Test</button>
<label className="uploadbtn small">Upload MP3<input type="file" accept="audio/mpeg,.mp3" hidden onChange={(e) => upload(slot, e)} /></label> <label className="uploadbtn small">Upload MP3<input type="file" accept="audio/mpeg,.mp3" hidden onChange={(e) => upload(slot, e)} /></label>
{sn.custom[slot] && <button className="ghost" onClick={() => act(`/api/lobbies/${id}/sounds/${slot}`, 'DELETE')}>Reset</button>} {sn.custom[slot] && <button className="ghost" onClick={() => act(`/api/lobbies/${id}/sounds/${slot}`, 'DELETE')}>Reset</button>}
</span> </span>
@@ -623,6 +624,7 @@ function SoundSettings({ lobby, id, act, sfx }) {
<label className="small chk"><input type="checkbox" checked={!!sn.result} onChange={(e) => flag('result', e.target.checked)} /> Short tones for success / failure of a check</label> <label className="small chk"><input type="checkbox" checked={!!sn.result} onChange={(e) => flag('result', e.target.checked)} /> Short tones for success / failure of a check</label>
{row('crit_success', 'Critical success', 'Built-in tone', 'crit_success')} {row('crit_success', 'Critical success', 'Built-in tone', 'crit_success')}
{row('crit_failure', 'Critical failure', 'Built-in tone', 'crit_failure')} {row('crit_failure', 'Critical failure', 'Built-in tone', 'crit_failure')}
{row('ping', 'Map ping', 'Built-in tone', 'ping')}
{err && <p className="warn small">{err}</p>} {err && <p className="warn small">{err}</p>}
</div> </div>
) )
@@ -722,7 +724,12 @@ function LobbyView({ id, rules, user }) {
const fogImg = useFogImage(id, mapState.vision?.fog, viewAs) const fogImg = useFogImage(id, mapState.vision?.fog, viewAs)
const sock = useRef({}) const sock = useRef({})
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 }) const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
const [pings, setPings] = useState([])
const [pingMode, setPingMode] = useState(false)
const tools = useTableTools({ id, lobby, mapState })
const up = useSocket(id, { const up = useSocket(id, {
ping: (n) => sock.current.ping?.(n),
measure: (n) => sock.current.measure?.(n),
open: () => { sock.current.load?.(); sock.current.poll?.(); sock.current.loadMap?.() }, open: () => { sock.current.load?.(); sock.current.poll?.(); sock.current.loadMap?.() },
map: () => sock.current.loadMap?.(), map: () => sock.current.loadMap?.(),
messages: () => sock.current.poll?.(), messages: () => sock.current.poll?.(),
@@ -740,6 +747,7 @@ function LobbyView({ id, rules, user }) {
sfx.current = sn ? { roll: sn.custom.roll ? `/api/lobbies/${id}/sounds/roll?v=${sn.v}` : DEFAULT_ROLL, sfx.current = sn ? { roll: sn.custom.roll ? `/api/lobbies/${id}/sounds/roll?v=${sn.v}` : DEFAULT_ROLL,
crit_success: sn.custom.crit_success ? `/api/lobbies/${id}/sounds/crit_success?v=${sn.v}` : null, crit_success: sn.custom.crit_success ? `/api/lobbies/${id}/sounds/crit_success?v=${sn.v}` : null,
crit_failure: sn.custom.crit_failure ? `/api/lobbies/${id}/sounds/crit_failure?v=${sn.v}` : null, crit_failure: sn.custom.crit_failure ? `/api/lobbies/${id}/sounds/crit_failure?v=${sn.v}` : null,
ping: sn.custom.ping ? `/api/lobbies/${id}/sounds/ping?v=${sn.v}` : null, ping_on: sn.ping,
result: sn.result, crit_success_on: sn.crit_success, crit_failure_on: sn.crit_failure } : { roll: DEFAULT_ROLL } result: sn.result, crit_success_on: sn.crit_success, crit_failure_on: sn.crit_failure } : { roll: DEFAULT_ROLL }
useEffect(() => { preload(sfx.current) }, [sn?.v]) // eslint-disable-line useEffect(() => { preload(sfx.current) }, [sn?.v]) // eslint-disable-line
const onRoll = useCallback((m) => { const onRoll = useCallback((m) => {
@@ -750,7 +758,16 @@ function LobbyView({ id, rules, user }) {
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000) setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
}, []) }, [])
const feed = useFeed(id, load, onRoll, up) const feed = useFeed(id, load, onRoll, up)
sock.current = { load, poll: feed.poll, loadMap } const showPing = (n) => {
if (!lobby) return
const mem = lobby.members.find((m) => m.username === n.user)
const color = n.dm ? lobby.dm_color : lobby.palette.find((p) => p.id === mem?.color)?.hex || '#ffffff'
const key = `${Date.now()}${Math.random()}`
setPings((l) => [...l.slice(-5), { key, x: n.x, y: n.y, color, name: n.dm ? 'DM' : mem?.character?.name || n.user }])
setTimeout(() => setPings((l) => l.filter((p) => p.key !== key)), 3500)
playPing(sfx.current)
}
sock.current = { load, poll: feed.poll, loadMap, ping: showPing, measure: tools.receive }
const act = async (url, method, body, done) => { const act = async (url, method, body, done) => {
if (!url) return load() if (!url) return load()
@@ -776,6 +793,8 @@ function LobbyView({ id, rules, user }) {
useEffect(() => () => document.documentElement.style.removeProperty('--chatw'), []) useEffect(() => () => document.documentElement.style.removeProperty('--chatw'), [])
useEffect(() => { store('aw_sheetw', String(sheetW)) }, [sheetW]) useEffect(() => { store('aw_sheetw', String(sheetW)) }, [sheetW])
const [rv, setRv] = useState(null) // DM "show an area" tool: {origin, target, hover, fov, to} const [rv, setRv] = useState(null) // DM "show an area" tool: {origin, target, hover, fov, to}
useEffect(() => { if (rv || pingMode) tools.stop() }, [rv, pingMode]) // eslint-disable-line
useEffect(() => { if (tools.mode) { setRv(null); setPingMode(false) } }, [tools.mode])
const rvShape = (() => { const rvShape = (() => {
if (!rv?.origin) return null if (!rv?.origin) return null
const t = rv.target || rv.hover const t = rv.target || rv.hover
@@ -792,7 +811,7 @@ function LobbyView({ id, rules, user }) {
const addPropToken = (p) => { const addPropToken = (p) => {
const m = mapState.map const m = mapState.map
setPropPick(false) setPropPick(false)
send(`/api/lobbies/${id}/tokens`, 'POST', { name: p.name, prop: p.prop, x: m.w / 2, y: m.h / 2, size: p.w, size_h: p.h, hidden: false }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {}) send(`/api/lobbies/${id}/tokens`, 'POST', { name: p.name, prop: p.prop, x: m.w / 2, y: m.h / 2, size: p.w, size_h: p.h, hidden: true }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
} }
const addNpcToken = (n) => { const addNpcToken = (n) => {
const hp = n.hp > 0 ? n.hp : undefined const hp = n.hp > 0 ? n.hp : undefined
@@ -899,15 +918,15 @@ function LobbyView({ id, rules, user }) {
const selProp = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'prop') : null const selProp = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'prop') : null
const walls = full && showWalls ? mapState.walls || [] : [] const walls = full && showWalls ? mapState.walls || [] : []
return ( return (
<MapView map={mapState.map} image={mapImg} fog={fogImg} propSrc={(pid) => `/api/lobbies/${id}/prop-image/${pid}`} 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} fog={fogImg} pings={pings} onPing={(x, y) => send(`/api/lobbies/${id}/ping`, 'POST', { x, y }).catch(() => null)} propSrc={(pid) => `/api/lobbies/${id}/prop-image/${pid}`} 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} 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) }} 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) }}
tool={rv ? 'reveal' : undefined} tool={rv ? 'reveal' : pingMode ? 'ping' : tools.tool}
onClick={rv ? (p) => setRv((s) => (s.origin && !s.target ? { ...s, target: p } : s.origin ? { ...s, target: p } : { ...s, origin: p })) : undefined} onClick={rv ? (p) => setRv((s) => (s.origin && !s.target ? { ...s, target: p } : s.origin ? { ...s, target: p } : { ...s, origin: p })) : pingMode ? (p) => { setPingMode(false); send(`/api/lobbies/${id}/ping`, 'POST', { x: p.x, y: p.y }).catch(() => null) } : tools.active ? tools.onClick : undefined}
onHover={rv && rv.origin && !rv.target ? (p) => setRv((s) => (s ? { ...s, hover: p } : s)) : undefined} onHover={rv && rv.origin && !rv.target ? (p) => setRv((s) => (s ? { ...s, hover: p } : s)) : tools.active ? tools.onHover : undefined}
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}
{full && <RevealLayer map={mapState.map} zones={mapState.reveals} draft={rv && rvShape && rv.origin ? rvShape : null} />}</>}> {full && <RevealLayer map={mapState.map} zones={mapState.reveals} draft={rv && rvShape && rv.origin ? rvShape : null} />}{tools.overlay}</>}>
{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">
{lobby.is_dm && ( {lobby.is_dm && (
@@ -916,16 +935,18 @@ function LobbyView({ id, rules, user }) {
{lobby.members.map((m) => <option key={m.username} value={m.username}>View as {m.character?.name || m.username}</option>)} {lobby.members.map((m) => <option key={m.username} value={m.username}>View as {m.character?.name || m.username}</option>)}
</select> </select>
)} )}
{lobby.is_dm && mapState.wall_count > 0 && mapState.los_on && <> {lobby.is_dm && mapState.los_on && <>
<label className="small sightrange" title="How far players see, in squares (0 = unlimited). Resets to the map's value when you switch maps."><input type="number" min="0" max="1000" key={mapState.sight} defaultValue={mapState.sight} aria-label="Sight range in squares" <label className="small sightrange" title="How far players see, in squares (0 = unlimited). Resets to the map's value when you switch maps."><input type="number" min="0" max="1000" key={mapState.sight} defaultValue={mapState.sight} aria-label="Sight range in squares"
onBlur={(e) => e.target.value !== String(mapState.sight) && act(`/api/lobbies/${id}/sight`, 'PUT', { range: e.target.value }, () => loadMap())} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} /> sq.</label> onBlur={(e) => e.target.value !== String(mapState.sight) && act(`/api/lobbies/${id}/sight`, 'PUT', { range: e.target.value }, () => loadMap())} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} /> sq.</label>
<button className={mapState.fog_on ? 'on' : 'ghost'} aria-pressed={!!mapState.fog_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { fog: !mapState.fog_on }, () => loadMap())} title="Fog of war: players keep a dim memory of what they have seen">Fog</button> <button className={mapState.fog_on ? 'on' : 'ghost'} aria-pressed={!!mapState.fog_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { fog: !mapState.fog_on }, () => loadMap())} title="Fog of war: players keep a dim memory of what they have seen">Fog</button>
{mapState.fog_on && <button className="ghost" onClick={() => window.confirm('Forget everything the players have explored?') && act(`/api/lobbies/${id}/explored`, 'DELETE', undefined, () => loadMap())} title="Forget the explored ground">↺</button>} {mapState.fog_on && <button className="ghost" onClick={() => window.confirm('Forget everything the players have explored?') && act(`/api/lobbies/${id}/explored`, 'DELETE', undefined, () => loadMap())} title="Forget the explored ground">↺</button>}
</>} </>}
{lobby.is_dm && mapState.wall_count > 0 && mapState.los_on && <button disabled={!full} className={rv ? 'on' : 'ghost'} aria-pressed={!!rv} onClick={() => setRv(rv ? null : { origin: null, target: null, hover: null, fov: 90, to: null })} title={full ? 'Show players a far away area: click the spot, then click where it ends' : 'Switch to the full view to use this'}>👁 Reveal</button>} {lobby.is_dm && mapState.los_on && <button disabled={!full} className={rv ? 'on' : 'ghost'} aria-pressed={!!rv} onClick={() => setRv(rv ? null : { origin: null, target: null, hover: null, fov: 90, to: null })} title={full ? 'Show players a far away area: click the spot, then click where it ends' : 'Switch to the full view to use this'}>👁 Reveal</button>}
{lobby.is_dm && mapState.wall_count > 0 && <button className={mapState.los_on ? 'on' : 'ghost'} aria-pressed={!!mapState.los_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { on: !mapState.los_on }, () => loadMap())} title="Line of sight: players only see what their character can see">Sight {mapState.los_on ? 'on' : 'off'}</button>} {lobby.is_dm && <button className={mapState.los_on ? 'on' : 'ghost'} aria-pressed={!!mapState.los_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { on: !mapState.los_on }, () => loadMap())} title="Players only see what their character can see: limited by walls and by the sight range (needs one of them)">Sight {mapState.los_on ? 'on' : 'off'}</button>}
{lobby.is_dm && mapState.wall_count > 0 && <button disabled={!full} className={showWalls ? 'on' : 'ghost'} aria-pressed={showWalls} onClick={() => setShowWalls(!showWalls)} title="Show walls and doors (click a door to open or close it)">Walls</button>} {lobby.is_dm && mapState.wall_count > 0 && <button disabled={!full} className={showWalls ? 'on' : 'ghost'} aria-pressed={showWalls} onClick={() => setShowWalls(!showWalls)} title="Show walls and doors (click a door to open or close it)">Walls</button>}
<button className={showGrid ? 'on' : 'ghost'} aria-pressed={showGrid} onClick={() => setShowGrid(!showGrid)}>Grid</button> <button className={showGrid ? 'on' : 'ghost'} aria-pressed={showGrid} onClick={() => setShowGrid(!showGrid)}>Grid</button>
{tools.buttons}
<button className={pingMode ? 'on' : 'ghost'} aria-pressed={pingMode} onClick={() => { setPingMode(!pingMode); setRv(null) }} title="Point at a spot for everybody: click here, then the map (or just double-click the map)">📍 Ping</button>
{lobby.is_dm && <button className="ghost" onClick={addToken}>+ Token</button>} {lobby.is_dm && <button className="ghost" onClick={addToken}>+ Token</button>}
{lobby.is_dm && <button className={npcPick ? 'on' : 'ghost'} onClick={() => { setNpcPick(!npcPick); setPropPick(false) }}>+ NPC</button>} {lobby.is_dm && <button className={npcPick ? 'on' : 'ghost'} onClick={() => { setNpcPick(!npcPick); setPropPick(false) }}>+ NPC</button>}
{lobby.is_dm && <button className={propPick ? 'on' : 'ghost'} onClick={() => { setPropPick(!propPick); setNpcPick(false) }} title="Decor such as chests, tables or doors">+ Prop</button>} {lobby.is_dm && <button className={propPick ? 'on' : 'ghost'} onClick={() => { setPropPick(!propPick); setNpcPick(false) }} title="Decor such as chests, tables or doors">+ Prop</button>}
@@ -954,6 +975,7 @@ function LobbyView({ id, rules, user }) {
</>} </>}
</div> </div>
)} )}
{tools.panel}
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>} {npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>}
{propPick && <div className="mapui npcpickwrap"><PropPicker onPick={addPropToken} onClose={() => setPropPick(false)} /></div>} {propPick && <div className="mapui npcpickwrap"><PropPicker onPick={addPropToken} onClose={() => setPropPick(false)} /></div>}
{selProp && <div className="tokenpanel mapui"><PropForm t={selProp} onChange={(p) => patchToken(selProp.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selProp.id}`, 'DELETE'); setMapSel(null) }} /></div>} {selProp && <div className="tokenpanel mapui"><PropForm t={selProp} onChange={(p) => patchToken(selProp.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selProp.id}`, 'DELETE'); setMapSel(null) }} /></div>}
+36 -6
View File
@@ -7,7 +7,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, fog, portraitUrl, propSrc, onMenu, isTurn, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) { export function MapView({ map, image, fog, portraitUrl, propSrc, pings, onPing, 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)
@@ -47,7 +47,7 @@ export function MapView({ map, image, fog, portraitUrl, propSrc, onMenu, isTurn,
}, []) }, [])
const down = (e) => { const down = (e) => {
if (e.target.closest('.maptoken, .mapui, .mapdoor')) return if (e.target.closest('.maptoken, .mapui, .mapdoor, .mappin')) return
box.current.setPointerCapture(e.pointerId) box.current.setPointerCapture(e.pointerId)
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY }) pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY })
if (pointers.current.size === 1) onSelect?.(null) if (pointers.current.size === 1) onSelect?.(null)
@@ -113,12 +113,18 @@ export function MapView({ map, image, fog, portraitUrl, propSrc, onMenu, isTurn,
const unplaced = tokens.filter((t) => t.x == null && t.can_move && t.kind !== 'prop') const unplaced = tokens.filter((t) => t.x == null && t.can_move && t.kind !== 'prop')
const d = (t) => t.size * map.grid const d = (t) => t.size * map.grid
return ( return (
<div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}> <div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}
onDoubleClick={(e) => { if (onPing && !e.target.closest('.maptoken, .mapui, .mapdoor, .mappin')) { const p = toWorld(e); if (p.inside) onPing(p.x, p.y) } }}>
<div className="mapworld" style={{ width: map.w, height: map.h, transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.s})` }}> <div className="mapworld" style={{ width: map.w, height: map.h, transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.s})` }}>
{fog && <img className="mapfog" src={fog} width={map.w} height={map.h} alt="" draggable={false} />} {fog && <img className="mapfog" src={fog} width={map.w} height={map.h} alt="" draggable={false} />}
{image && <img src={image.src} style={{ position: 'absolute', left: image.x, top: image.y }} width={image.w} height={image.h} alt="" draggable={false} />} {image && <img src={image.src} style={{ position: 'absolute', left: image.x, top: image.y }} width={image.w} height={image.h} alt="" draggable={false} />}
{showGrid && <div className="mapgrid" style={{ backgroundSize: `${map.grid}px ${map.grid}px` }} />} {showGrid && <div className="mapgrid" style={{ backgroundSize: `${map.grid}px ${map.grid}px` }} />}
{overlay} {overlay}
{pings?.map((p) => (
<div key={p.key} className="mapping" style={{ left: p.x, top: p.y, '--pc': p.color, '--ps': `${map.grid * 1.6}px` }}>
<i /><i /><b style={{ fontSize: Math.max(11, map.grid * 0.3) }}>{p.name}</b>
</div>
))}
{[...tokens].sort((a, b) => (b.kind === 'prop') - (a.kind === 'prop')).map((t) => { {[...tokens].sort((a, b) => (b.kind === 'prop') - (a.kind === 'prop')).map((t) => {
const dr = drag?.id === t.id ? drag : null const dr = drag?.id === t.id ? drag : null
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
@@ -129,8 +135,9 @@ export function MapView({ map, image, fog, portraitUrl, propSrc, onMenu, isTurn,
<div key={t.id} className={`maptoken prop ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`} <div key={t.id} className={`maptoken prop ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`}
onContextMenu={(e) => { if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }} onContextMenu={(e) => { if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }}
style={{ left: x - w / 2, top: y - h / 2, width: w, height: h, transform: `rotate(${t.rot || 0}deg)` }} style={{ left: x - w / 2, top: y - h / 2, width: w, height: h, transform: `rotate(${t.rot || 0}deg)` }}
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)' : '') + (t.note ? `\n\n${t.note}` : '')}>
<PropArt prop={t.prop} customSrc={propSrc} /> <PropArt prop={t.prop} customSrc={propSrc} />
{t.note && <b className="toknote" aria-hidden="true">📝</b>}
</div> </div>
) )
} }
@@ -138,7 +145,8 @@ export function MapView({ map, image, fog, portraitUrl, propSrc, onMenu, isTurn,
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''} ${isTurn?.(t) ? 'turn' : ''}`} <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) } }} 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)' : '') + (t.note ? `\n\n${t.note}` : '')}>
{t.note && <b className="toknote" aria-hidden="true">📝</b>}
{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>}
<em style={{ fontSize: Math.max(9, map.grid * 0.28) }}>{t.name}</em> <em style={{ fontSize: Math.max(9, map.grid * 0.28) }}>{t.name}</em>
{t.hp_max > 0 && t.hp != null && <i className="tokhp" title={`${t.hp} / ${t.hp_max}`}><b style={{ width: `${Math.max(0, Math.min(100, (t.hp / t.hp_max) * 100))}%`, background: t.hp / t.hp_max > 0.5 ? '#3fb950' : t.hp / t.hp_max > 0.25 ? '#e6c52e' : '#e0524d' }} /></i>} {t.hp_max > 0 && t.hp != null && <i className="tokhp" title={`${t.hp} / ${t.hp_max}`}><b style={{ width: `${Math.max(0, Math.min(100, (t.hp / t.hp_max) * 100))}%`, background: t.hp / t.hp_max > 0.5 ? '#3fb950' : t.hp / t.hp_max > 0.25 ? '#e6c52e' : '#e0524d' }} /></i>}
@@ -191,6 +199,26 @@ export function RadialMenu({ x, y, title, items, onClose }) {
</div>, document.body) </div>, document.body)
} }
// DM-only note on a token or prop (loot of a chest, tactics of an enemy ...). Saves shortly after typing stops.
export function NoteField({ t, onChange }) {
const [v, setV] = useState(t.note || '')
const timer = useRef(null)
const last = useRef(t.note || '')
useEffect(() => { if ((t.note || '') !== last.current) { last.current = t.note || ''; setV(t.note || '') } }, [t.note])
useEffect(() => () => clearTimeout(timer.current), [])
const type = (e) => {
const val = e.target.value
setV(val); last.current = val
clearTimeout(timer.current)
timer.current = setTimeout(() => onChange({ note: val }), 500)
}
return (
<label className="small notefield">Notes (only you see these)
<textarea rows="3" maxLength={2000} value={v} onChange={type} onBlur={() => { clearTimeout(timer.current); if (v !== (t.note || '')) onChange({ note: v }) }} placeholder="Contents, tactics, secrets ..." />
</label>
)
}
// 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 (
@@ -199,6 +227,7 @@ export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
<label className="small chk">Size <input type="number" min="0.25" max="8" step="0.25" value={t.size} onChange={(e) => onChange({ size: Number(e.target.value) || 1 })} /> sq.</label> <label className="small chk">Size <input type="number" min="0.25" max="8" step="0.25" value={t.size} onChange={(e) => onChange({ size: Number(e.target.value) || 1 })} /> sq.</label>
<span className="swatches">{COLORS.map((c) => <button key={c} className={`swatch ${t.color === c ? 'on' : ''}`} style={{ background: c }} aria-label={`Colour ${c}`} onClick={() => onChange({ color: c })} />)}</span> <span className="swatches">{COLORS.map((c) => <button key={c} className={`swatch ${t.color === c ? 'on' : ''}`} style={{ background: c }} aria-label={`Colour ${c}`} onClick={() => onChange({ color: c })} />)}</span>
<label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label> <label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label>
<NoteField key={t.id} t={t} onChange={onChange} />
<span className="row"> <span className="row">
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>} {onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
<button className="ghost danger" onClick={onDelete}>Remove</button> <button className="ghost danger" onClick={onDelete}>Remove</button>
@@ -321,6 +350,7 @@ export function PropForm({ t, onChange, onDelete, onDuplicate }) {
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 90) % 360 })} title="Rotate right 90°">⟳ 90°</button> <button className="ghost mini" onClick={() => onChange({ rot: (rot + 90) % 360 })} title="Rotate right 90°">⟳ 90°</button>
</span> </span>
<label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label> <label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label>
<NoteField key={t.id} t={t} onChange={onChange} />
<span className="row"> <span className="row">
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>} {onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
<button className="ghost danger" onClick={onDelete}>Remove</button> <button className="ghost danger" onClick={onDelete}>Remove</button>
@@ -457,7 +487,7 @@ export function MapEditor({ id, onClose, live, onSaved }) {
setErr('') setErr('')
const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, grid: Number(grid), sight: Number(sight) || 0, walls, body: JSON.stringify({ name, grid: Number(grid), sight: Number(sight) || 0, walls,
tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h }) => ({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h })) }) }) tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h, note }) => ({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h, note })) }) })
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false } if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false }
setDirty(false); onSaved?.(); return true setDirty(false); onSaved?.(); return true
} }
+48
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { MapEditor } from './MapView.jsx' import { MapEditor } from './MapView.jsx'
import { BUILTIN } from './props.jsx'
const J = { 'Content-Type': 'application/json' } const J = { 'Content-Type': 'application/json' }
const errText = async (r, fallback) => { const errText = async (r, fallback) => {
@@ -48,6 +49,52 @@ function SharePanel({ m, reload }) {
) )
} }
// The DM's prop library: built-in drawings (fixed) and own images (add, rename, remove). Props can be used on every map.
function PropLibrary() {
const [mine, setMine] = useState(null)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(() => fetch('/api/props').then((r) => (r.ok ? r.json() : [])).then(setMine).catch(() => setMine([])), [])
useEffect(() => { load() }, [load])
const upload = async (e) => {
const files = [...(e.target.files || [])]
e.target.value = ''
if (!files.length) return
setErr(''); setBusy(true)
for (const f of files) {
const r = await fetch(`/api/props?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f }).catch(() => null)
if (!r || !r.ok) { setErr(r ? (r.status === 413 ? 'An image is too big (at most 3 MB, or the web server limit).' : await errText(r, 'Upload failed')) : 'Upload failed'); break }
}
setBusy(false); load()
}
return (
<>
<h2>Props</h2>
<p className="muted">Decor for your maps: chests, tables, doors and so on. Usable on every map, in the map editor and on the table. Your own images work best as PNGs with a transparent background, seen from above.</p>
<label className="uploadbtn">{busy ? 'Uploading …' : '+ Add own props (PNG, JPEG, WebP, up to 3 MB each)'}<input type="file" multiple accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
{err && <p className="warn">{err}</p>}
<h3>Your props</h3>
{mine && !mine.length && <p className="muted small">None yet.</p>}
<ul className="propmgr">
{(mine || []).map((p) => (
<li key={p.id}>
<img src={`/api/props/${p.id}/image`} alt="" loading="lazy" />
<strong className="small">{p.name}</strong>
<span className="row">
<button className="ghost mini" onClick={() => { const n = window.prompt('New name', p.name); if (n && n.trim()) fetch(`/api/props/${p.id}`, { method: 'PUT', headers: J, body: JSON.stringify({ name: n }) }).then(load) }}>Rename</button>
<button className="ghost mini danger" onClick={() => window.confirm(`Remove the prop "${p.name}"? Places where it is used on maps lose their picture.`) && fetch(`/api/props/${p.id}`, { method: 'DELETE' }).then(load)}>Remove</button>
</span>
</li>
))}
</ul>
<h3>Built in</h3>
<ul className="propmgr">
{BUILTIN.map((p) => <li key={p.id}><svg viewBox="0 0 100 100" aria-hidden="true">{p.art}</svg><strong className="small">{p.name}</strong></li>)}
</ul>
</>
)
}
// All maps of the user (prepare, share, copy) plus the maps other users shared with them // All maps of the user (prepare, share, copy) plus the maps other users shared with them
export default function Maps() { export default function Maps() {
const [list, setList] = useState(null) const [list, setList] = useState(null)
@@ -104,6 +151,7 @@ export default function Maps() {
{list && !mine.length && <p className="muted">You have no maps yet.</p>} {list && !mine.length && <p className="muted">You have no maps yet.</p>}
<ul className="mapcards">{mine.map(card)}</ul> <ul className="mapcards">{mine.map(card)}</ul>
{shared.length > 0 && <><h2>Shared with me</h2><ul className="mapcards">{shared.map(card)}</ul></>} {shared.length > 0 && <><h2>Shared with me</h2><ul className="mapcards">{shared.map(card)}</ul></>}
<PropLibrary />
{editing && <MapEditor id={editing} onClose={() => setEditing(null)} />} {editing && <MapEditor id={editing} onClose={() => setEditing(null)} />}
</section> </section>
) )
+182
View File
@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from 'react'
// Table tools for everybody: tape measure, area templates (spells) and map markers with notes.
const J = { 'Content-Type': 'application/json' }
const post = (url, body) => fetch(url, { method: 'POST', headers: J, body: JSON.stringify(body) }).catch(() => null)
const del = (url) => fetch(url, { method: 'DELETE' }).catch(() => null)
const rad = (d) => (d * Math.PI) / 180
const half = (v) => Math.round(v * 2) / 2
const KINDS = [['circle', '◯ Circle'], ['cone', '◔ Cone'], ['line', '▭ Line'], ['square', '▢ Square']]
const ICONS = ['📌', '⚠️', '💰', '🔒', '🚪', '❓', '⭐', '☠️', '🏕️', '👁️']
// shape (in map pixels) of a template: {kind, x, y, dir, size (squares), width}
function Shape({ t, g, color, ghost }) {
const L = t.size * g
const common = { fill: color, fillOpacity: ghost ? 0.18 : 0.3, stroke: color, strokeWidth: 3, strokeDasharray: ghost ? '8 6' : undefined }
if (t.kind === 'circle') return <circle cx={t.x} cy={t.y} r={L} {...common} />
if (t.kind === 'cone') {
const a1 = rad(t.dir - t.width / 2), a2 = rad(t.dir + t.width / 2)
return <path d={`M${t.x} ${t.y} L${t.x + L * Math.cos(a1)} ${t.y + L * Math.sin(a1)} A${L} ${L} 0 0 1 ${t.x + L * Math.cos(a2)} ${t.y + L * Math.sin(a2)} Z`} {...common} />
}
if (t.kind === 'line') return <rect x={t.x} y={t.y - (t.width * g) / 2} width={L} height={t.width * g} transform={`rotate(${t.dir} ${t.x} ${t.y})`} {...common} />
return <rect x={t.x - L / 2} y={t.y - L / 2} width={L} height={L} transform={`rotate(${t.dir} ${t.x} ${t.y})`} {...common} />
}
// where the label of a shape goes (its far end)
const tip = (t, g) => {
const L = t.size * g
if (t.kind === 'circle' || t.kind === 'square') return { x: t.x, y: t.y }
return { x: t.x + (L / 2) * Math.cos(rad(t.dir)), y: t.y + (L / 2) * Math.sin(rad(t.dir)) }
}
const sizeLabel = (t) => (t.kind === 'circle' ? `r ${t.size}` : t.kind === 'square' ? `${t.size}×${t.size}` : `${t.size}`) + ' sq'
export function useTableTools({ id, lobby: lobbyIn, mapState }) {
const lobby = lobbyIn || { members: [], palette: [], dm_color: '#d4af37' }
const [mode, setMode] = useState(null) // 'ruler' | 'tpl' | 'mark'
const [kind, setKind] = useState('cone')
const [angle, setAngle] = useState(60)
const [lineW, setLineW] = useState(1)
const [origin, setOrigin] = useState(null)
const [hover, setHover] = useState(null)
const [draft, setDraft] = useState(null) // new marker being written
const [selMark, setSelMark] = useState(null)
const [measures, setMeasures] = useState([])
const map = mapState.map
const g = map?.grid || 50
const colorOf = (user, dm) => (dm ? lobby.dm_color : lobby.palette.find((p) => p.id === lobby.members.find((m) => m.username === user)?.color)?.hex || '#ffffff')
const me = lobby.members.find((m) => m.me)
const myColor = lobby.is_dm ? lobby.dm_color : lobby.palette.find((p) => p.id === me?.color)?.hex || '#ffffff'
useEffect(() => {
const k = (e) => { if (e.key === 'Escape') { setOrigin(null); setDraft(null); setMode(null) } }
window.addEventListener('keydown', k)
return () => window.removeEventListener('keydown', k)
}, [])
useEffect(() => { setOrigin(null); setHover(null); if (mode !== 'mark') setDraft(null) }, [mode, kind])
const shapeFrom = (p) => {
const dx = p.x - origin.x, dy = p.y - origin.y
const dist = Math.hypot(dx, dy) / g
const size = Math.max(0.5, half(kind === 'square' ? dist * 2 : dist))
return { kind, x: origin.x, y: origin.y, dir: (Math.atan2(dy, dx) * 180) / Math.PI, size, width: kind === 'cone' ? angle : kind === 'line' ? lineW : 1 }
}
const onClick = (raw) => {
const p = { x: Math.min(Math.max(raw.x, 0), map.w), y: Math.min(Math.max(raw.y, 0), map.h) } // clicks next to the map count as its edge
if (mode === 'ruler') {
if (!origin) return setOrigin(p)
post(`/api/lobbies/${id}/measure`, { x1: origin.x, y1: origin.y, x2: p.x, y2: p.y })
setOrigin(null)
} else if (mode === 'tpl') {
if (!origin) return setOrigin(p)
post(`/api/lobbies/${id}/templates`, shapeFrom(p))
setOrigin(null); setHover(null)
} else if (mode === 'mark') {
setSelMark(null)
setDraft({ x: p.x, y: p.y, icon: '📌', title: '', note: '', shared: false })
}
}
const onHover = origin ? (p) => setHover({ x: Math.min(Math.max(p.x, 0), map.w), y: Math.min(Math.max(p.y, 0), map.h) }) : undefined
const receive = (n) => { // a measurement somebody shared
const key = `${Date.now()}${Math.random()}`
setMeasures((l) => [...l.slice(-4), { key, ...n, color: colorOf(n.user, n.dm) }])
setTimeout(() => setMeasures((l) => l.filter((x) => x.key !== key)), 6000)
}
const templates = mapState.templates || []
const marks = mapState.marks || []
const dist = (a, b) => half(Math.hypot(b.x - a.x, b.y - a.y) / g)
const overlay = !map ? null : (
<>
<svg className="toollayer" width={map.w} height={map.h} aria-hidden="true">
{templates.map((t) => {
const c = colorOf(t.user, t.dm), e = tip(t, g)
return <g key={t.id}><Shape t={t} g={g} color={c} /><circle cx={t.x} cy={t.y} r="5" fill={c} />
<text x={e.x} y={e.y} className="toolabel" fontSize={Math.max(12, g * 0.32)} textAnchor="middle">{sizeLabel(t)}</text></g>
})}
{mode === 'tpl' && origin && hover && (() => { const t = shapeFrom(hover); const e = tip(t, g)
return <g><Shape t={t} g={g} color={myColor} ghost /><text x={e.x} y={e.y} className="toolabel" fontSize={Math.max(12, g * 0.32)} textAnchor="middle">{sizeLabel(t)}</text></g> })()}
{mode === 'ruler' && origin && hover && <Line a={origin} b={hover} g={g} color={myColor} label={`${dist(origin, hover)} sq`} />}
{measures.map((m) => <Line key={m.key} a={{ x: m.x1, y: m.y1 }} b={{ x: m.x2, y: m.y2 }} g={g} color={m.color} label={`${dist({ x: m.x1, y: m.y1 }, { x: m.x2, y: m.y2 })} sq${m.dm ? '' : ` · ${m.user}`}`} />)}
</svg>
{marks.map((k) => (
<button key={k.id} className={`mappin ${selMark === k.id ? 'sel' : ''} ${k.shared ? 'shared' : ''}`} style={{ left: k.x, top: k.y, fontSize: Math.max(18, g * 0.7) }}
title={`${k.title}${k.note ? `\n${k.note}` : ''}${k.mine ? '' : `\n(${k.user})`}`}
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); setDraft(null); setSelMark(selMark === k.id ? null : k.id) }}>{k.icon}</button>
))}
{draft && <span className="mappin draft" style={{ left: draft.x, top: draft.y, fontSize: Math.max(18, g * 0.7) }}>{draft.icon}</span>}
</>
)
const buttons = (
<>
<button className={mode === 'ruler' ? 'on' : 'ghost'} aria-pressed={mode === 'ruler'} onClick={() => setMode(mode === 'ruler' ? null : 'ruler')} title="Tape measure: click the start, then the end. Everybody sees the distance for a moment.">📏 Measure</button>
<button className={mode === 'tpl' ? 'on' : 'ghost'} aria-pressed={mode === 'tpl'} onClick={() => setMode(mode === 'tpl' ? null : 'tpl')} title="Area templates for spells: circle, cone, line, square">⬡ Area</button>
<button className={mode === 'mark' ? 'on' : 'ghost'} aria-pressed={mode === 'mark'} onClick={() => setMode(mode === 'mark' ? null : 'mark')} title="Place a marker with a note">📌 Marker</button>
</>
)
const cur = marks.find((k) => k.id === selMark)
const panel = !map ? null : (
<>
{mode === 'ruler' && <div className="tokenpanel toolspanel mapui"><b className="small">Tape measure</b><p className="small muted">{origin ? 'Click the end point. Esc cancels.' : 'Click the start point.'} 1 square = 1 grid cell. The result is shown to everybody for a few seconds.</p></div>}
{mode === 'tpl' && (
<div className="tokenpanel toolspanel mapui">
<b className="small">Area template</b>
<div className="toolrow" role="group" aria-label="Shape">{KINDS.map(([k, l]) => <button key={k} className={kind === k ? 'on mini' : 'ghost mini'} onClick={() => setKind(k)}>{l}</button>)}</div>
{kind === 'cone' && <label className="small">Angle {angle}°<input type="range" min="30" max="180" step="10" value={angle} onChange={(e) => setAngle(Number(e.target.value))} /></label>}
{kind === 'line' && <label className="small">Width {lineW} sq<input type="range" min="1" max="6" step="1" value={lineW} onChange={(e) => setLineW(Number(e.target.value))} /></label>}
<p className="small muted">{!origin ? (kind === 'cone' || kind === 'line' ? 'Click where it starts.' : 'Click the centre.') : 'Click to set direction and size.'} It stays for everybody until removed.</p>
</div>
)}
{(mode === 'tpl' || templates.length > 0) && templates.length > 0 && (
<div className={`tokenpanel toolspanel tpllist mapui ${mode ? 'low' : ''}`}>
<b className="small">Areas on the map</b>
{templates.map((t) => (
<div key={t.id} className="row small"><i className="dot" style={{ background: colorOf(t.user, t.dm) }} /> {KINDS.find((k) => k[0] === t.kind)?.[1]} {sizeLabel(t)} · {t.dm ? 'DM' : t.user}
{(t.mine || lobby.is_dm) && <button className="ghost mini danger" aria-label="Remove area" onClick={() => del(`/api/lobbies/${id}/templates/${t.id}`)}>✕</button>}</div>
))}
{lobby.is_dm && templates.length > 1 && <button className="ghost mini" onClick={() => del(`/api/lobbies/${id}/templates`)}>Remove all</button>}
</div>
)}
{mode === 'mark' && !draft && <div className="tokenpanel toolspanel mapui"><b className="small">Marker</b><p className="small muted">Click the map to place a marker with a note. Markers are private unless you share them with the table.</p></div>}
{draft && <MarkForm key="new" init={draft} title="New marker" onSave={async (v) => { await post(`/api/lobbies/${id}/marks`, { ...draft, ...v }); setDraft(null); setMode(null) }} onCancel={() => setDraft(null)} />}
{cur && !draft && (cur.mine
? <MarkForm key={cur.id} init={cur} title="Your marker" onSave={async (v) => { await fetch(`/api/lobbies/${id}/marks/${cur.id}`, { method: 'PUT', headers: J, body: JSON.stringify(v) }).catch(() => null); setSelMark(null) }}
onCancel={() => setSelMark(null)} onDelete={() => { del(`/api/lobbies/${id}/marks/${cur.id}`); setSelMark(null) }} />
: <div className="tokenpanel toolspanel mapui"><div className="row"><b>{cur.icon} {cur.title}</b><button className="ghost mini" onClick={() => setSelMark(null)} aria-label="Close">×</button></div>
<p className="small notetext">{cur.note || <span className="muted">No note.</span>}</p><p className="small muted">Shared by {cur.user}</p>
{lobby.is_dm && <button className="ghost mini danger" onClick={() => { del(`/api/lobbies/${id}/marks/${cur.id}`); setSelMark(null) }}>Remove</button>}</div>)}
</>
)
return { mode, active: !!mode, tool: mode ? 'draw' : undefined, onClick, onHover, overlay, buttons, panel, receive, stop: () => setMode(null) }
}
function Line({ a, b, g, color, label }) {
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2
return (
<g>
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke="#000" strokeWidth="6" strokeLinecap="round" opacity=".5" />
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={color} strokeWidth="3" strokeLinecap="round" strokeDasharray="10 7" />
<circle cx={a.x} cy={a.y} r="6" fill={color} /><circle cx={b.x} cy={b.y} r="6" fill={color} />
<text x={mx} y={my - 8} className="toolabel" fontSize={Math.max(13, g * 0.34)} textAnchor="middle">{label}</text>
</g>
)
}
function MarkForm({ init, title, onSave, onCancel, onDelete }) {
const [v, setV] = useState({ icon: init.icon, title: init.title, note: init.note, shared: !!init.shared })
const busy = useRef(false)
const save = async () => { if (busy.current) return; busy.current = true; await onSave(v) }
return (
<div className="tokenpanel toolspanel mapui">
<div className="row"><b className="small">{title}</b><button className="ghost mini" onClick={onCancel} aria-label="Close">×</button></div>
<div className="iconrow" role="group" aria-label="Icon">{ICONS.map((i) => <button key={i} className={v.icon === i ? 'on mini' : 'ghost mini'} onClick={() => setV({ ...v, icon: i })}>{i}</button>)}</div>
<input value={v.title} maxLength={40} placeholder="Title" aria-label="Marker title" onChange={(e) => setV({ ...v, title: e.target.value })} autoFocus />
<textarea rows="4" maxLength={1000} placeholder="Your note ..." aria-label="Marker note" value={v.note} onChange={(e) => setV({ ...v, note: e.target.value })} />
<label className="small chk"><input type="checkbox" checked={v.shared} onChange={(e) => setV({ ...v, shared: e.target.checked })} /> Show to everybody at the table</label>
<div className="row"><button className="on" onClick={save}>Save</button>{onDelete && <button className="ghost danger" onClick={onDelete}>Delete</button>}</div>
</div>
)
}
+15 -1
View File
@@ -36,7 +36,7 @@ const buffer = (url) => {
return buffers.get(url) return buffers.get(url)
} }
// load everything of a lobby up front, so the first roll does not wait for the download // load everything of a lobby up front, so the first roll does not wait for the download
export const preload = (cfg) => { if (cfg) [cfg.roll, cfg.crit_success, cfg.crit_failure].forEach(buffer) } export const preload = (cfg) => { if (cfg) [cfg.roll, cfg.crit_success, cfg.crit_failure, cfg.ping].forEach(buffer) }
const clip = (c, out, buf, t, rate, vol) => { const clip = (c, out, buf, t, rate, vol) => {
const s = c.createBufferSource() const s = c.createBufferSource()
@@ -98,3 +98,17 @@ export async function playRoll(sides, n, outcome, cfg = {}) {
else cue[outcome](c, out, t1) else cue[outcome](c, out, t1)
} else if (cfg.result !== false) cue[outcome](c, out, t1) } else if (cfg.result !== false) cue[outcome](c, out, t1)
} }
// A ping on the map: the DM's MP3 if there is one, otherwise a short two-tone "blip". cfg.ping_on === false switches it off.
export async function playPing(cfg = {}) {
if (!sound.enabled || sound.volume <= 0 || document.hidden || cfg.ping_on === false) return
const c = audio()
if (!c) return
const custom = await buffer(cfg.ping)
const out = c.createGain()
out.gain.value = sound.volume
out.connect(c.destination)
const t = c.currentTime + 0.02
if (custom) clip(c, out, custom, t, 1, 1)
else { tone(c, out, t, 880, 0.18, 'sine', 0.3); tone(c, out, t + 0.11, 1320, 0.35, 'sine', 0.25) }
}
+35
View File
@@ -636,3 +636,38 @@ textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
.maptools button:disabled { opacity: .45; } .maptools button:disabled { opacity: .45; }
.maptools .sightrange input { height: 1.6rem; padding: 0 .3rem; } .maptools .sightrange input { height: 1.6rem; padding: 0 .3rem; }
.maptools .sightrange { color: #f1f1f1; } .maptools .sightrange { color: #f1f1f1; }
.notefield { display: grid; gap: .2rem; }
.notefield textarea { width: 100%; resize: vertical; font: inherit; font-size: .85rem; }
.toknote { position: absolute; top: -.5em; right: -.5em; font-size: .75rem; line-height: 1; pointer-events: none; transform: none; filter: drop-shadow(0 1px 1px #000); }
.maptoken.prop .toknote { transform: none; }
.propmgr { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: .5rem; list-style: none; padding: 0; }
.propmgr li { display: grid; justify-items: center; gap: .3rem; padding: .5rem; border: 1px solid var(--line); border-radius: 8px; }
.propmgr svg, .propmgr img { width: 64px; height: 64px; object-fit: contain; }
.propmgr .row { display: flex; gap: .25rem; }
/* map pings */
.mapping { position: absolute; width: 0; height: 0; pointer-events: none; z-index: 6; }
.mapping i { position: absolute; left: calc(var(--ps) / -2); top: calc(var(--ps) / -2); width: var(--ps); height: var(--ps); border-radius: 50%; border: 4px solid var(--pc); box-shadow: 0 0 10px var(--pc), inset 0 0 10px var(--pc); opacity: 0; animation: pingring 1.6s ease-out 2; }
.mapping i + i { animation-delay: .35s; }
.mapping b { position: absolute; left: 0; top: calc(var(--ps) / 2 + 2px); transform: translateX(-50%); white-space: nowrap; color: #fff; text-shadow: 0 0 3px #000, 0 0 3px #000; animation: pingname 3.4s forwards; }
@keyframes pingring { 0% { transform: scale(.1); opacity: .95; } 100% { transform: scale(1.4); opacity: 0; } }
@keyframes pingname { 0%, 80% { opacity: 1; } 100% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .mapping i { animation-duration: 3s; animation-iteration-count: 1; } }
/* table tools: measure, areas, markers */
.toollayer { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; }
.toolabel { fill: #fff; stroke: #000; stroke-width: 3px; paint-order: stroke; font-weight: 700; }
.mappin { position: absolute; transform: translate(-50%, -92%); padding: 0; margin: 0; min-height: 0; background: none; border: 0; line-height: 1; cursor: pointer; filter: drop-shadow(0 2px 2px rgba(0,0,0,.8)); z-index: 4; }
.mappin.shared { filter: drop-shadow(0 0 4px #fff) drop-shadow(0 2px 2px rgba(0,0,0,.8)); }
.mappin.sel { outline: 2px solid var(--accent, gold); border-radius: 6px; }
.mappin.draft { opacity: .6; pointer-events: none; }
.toolspanel { right: auto; left: .5rem; top: .5rem; bottom: auto; display: grid; gap: .4rem; max-width: 18rem; }
.toolspanel.tpllist { top: .5rem; left: .5rem; }
.toolspanel.tpllist.low { left: 15.8rem; }
.toolspanel textarea, .toolspanel input:not([type=range]):not([type=checkbox]) { width: 100%; font: inherit; }
.toolspanel .toolrow, .toolspanel .iconrow { display: flex; flex-wrap: wrap; gap: .25rem; }
.toolspanel .dot { display: inline-block; width: .7rem; height: .7rem; border-radius: 50%; }
.toolspanel .notetext { white-space: pre-wrap; margin: 0; }
.toolspanel { padding: .45rem .55rem; max-width: 15rem; gap: .3rem; font-size: .8rem; }
.toolspanel button { padding: .15rem .5rem; font-size: .78rem; min-height: 0; width: auto; }
.toolspanel p { margin: 0; }
.toolspanel input[type=range] { width: 100%; }
.toolspanel input:not([type=range]):not([type=checkbox]), .toolspanel textarea { padding: .25rem .4rem; font-size: .82rem; }