Scenes (switch maps without losing state), decor props with rotation, editor visibility toggle

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
2026-10-24 19:37:55 +00:00
co-authored by Claude Sonnet 5.5
parent 928e018fcd
commit c1fdb7cc76
7 changed files with 521 additions and 51 deletions
+12
View File
@@ -157,6 +157,13 @@ The top of the lobby is one compact row: small buttons on the left (start fight,
Players only see a short card of the other players, never their sheets. Guessing join codes is rate limited. Deleting a character takes it out of the lobbies (the player can pick another); deleting a lobby does not delete characters.
### Scenes and props
- **Scenes**: every map you put on the table becomes a *scene* of the lobby. Switching to another map (or back, in *Lobby actions → Scenes*) first saves the current one - token positions, HP and conditions, doors, explored ground, revealed areas, sight settings and the running fight - and restores the other exactly as you left it. State also survives pauses, since the table lives in the database. Per lobby up to 12 scenes; rename, delete, *Save now* and *Save a copy of the current table* (checkpoint) are available. Only one scene runs at a time (no simultaneous split party).
- **Edit live**: the map editor can be opened for the running map. Walls, grid and sight range apply to the game at once; the prepared tokens are only used the next time the map is put on the table.
- **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.
## API
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
@@ -186,6 +193,11 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
| GET / PUT | `/api/lobbies/{id}/map` | Map and tokens as the viewer may see them (`?as=<player>` for the DM) / DM: show map (`map_id`, null clears) |
| GET | `/api/lobbies/{id}/map/view` | The map image for this viewer, cut to their line of sight (`X-Crop` = x,y,w,h) |
| 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`) |
| PUT / DELETE | `/api/lobbies/{id}/scenes/{sid}` | DM: rename (`name`), save now (`save`) / delete |
| 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 | `/api/props/{id}/image`, `/api/lobbies/{id}/prop-image/{pid}` | Prop image (owner / lobby members, DM's props) |
| POST / PUT / DELETE | `/api/lobbies/{id}/tokens[/{tid}]` | Add NPC token (DM) / move (own token or DM) and edit (DM) / remove (DM) |
| WebSocket | `/api/lobbies/{id}/ws` | Live notes `{t: messages / lobby / sheet / map}` for lobby members (cookie login, same origin) |
| PUT | `/api/lobbies/{id}/sounds` | DM: switch result / critical sounds on or off |
+2 -1
View File
@@ -928,7 +928,8 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)):
_dm_only(_lobby_for(con, lid, user), user)
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_tokens WHERE lobby_id=?", (lid,))
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_scenes"):
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
purge_sounds(lid)
hub.close_lobby(lid)
+285 -25
View File
@@ -2,10 +2,12 @@
Coordinates are pixels of the stored image. A token's size is in grid squares (the map's `grid` is pixels per square).
Images are re-encoded with Pillow on upload, so only real images get through and metadata is gone."""
import base64
import hashlib
import io
import json
import math
import re
import shutil
import time
import uuid
@@ -26,6 +28,8 @@ MAX_MAPS = 30 # per DM
MAX_TOKENS = 120 # per map / lobby
MAX_WALLS = 400 # wall and door segments per map
MAX_SHARES = 30
MAX_SCENES = 12 # saved scenes per lobby
MAX_PROPS = 40 # custom prop images per user
COLORS = ("#c0392b", "#8e44ad", "#16a085", "#7f8c8d", "#d35400", "#2c3e50", "#27ae60", "#b9770e")
@@ -45,6 +49,10 @@ def schema(con):
con.execute("""CREATE TABLE IF NOT EXISTS lobby_reveals (
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, x REAL NOT NULL, y REAL NOT NULL, radius REAL NOT NULL, dir REAL NOT NULL,
fov REAL NOT NULL, targets TEXT, created REAL NOT NULL)""")
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)""")
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)""")
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))")
for table, col, ddl in (("lobbies", "map_id", "TEXT"), ("lobbies", "doors", "TEXT NOT NULL DEFAULT '{}'"),
("lobbies", "los", "INTEGER NOT NULL DEFAULT 1"), ("maps", "sight", "REAL NOT NULL DEFAULT 0"),
@@ -52,7 +60,9 @@ def schema(con):
("lobby_tokens", "hp", "INTEGER"), ("lobby_tokens", "hp_max", "INTEGER"),
("lobby_tokens", "conds", "TEXT NOT NULL DEFAULT '{}'"), ("lobby_tokens", "show_hp", "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"),
("lobbies", "scene_id", "TEXT")):
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}")
@@ -97,9 +107,17 @@ def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict:
tid = str(t.get("id", "")) if keep_id else ""
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)
prop = str(t.get("prop") or "")
prop = prop if re.fullmatch(r"[bc]:[a-z0-9_]{1,24}", prop) else ""
try:
rot = float(t.get("rot") or 0) % 360
size_h = None if t.get("size_h") in (None, "") else min(max(float(t["size_h"]), 0.25), 8)
except (TypeError, ValueError):
raise HTTPException(422, "Invalid rotation or size")
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")),
"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}
def clean_int(v, lo, hi):
@@ -269,7 +287,10 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
con.execute("UPDATE lobbies SET map_id=NULL WHERE map_id=?", (mid,))
con.execute("DELETE FROM map_shares WHERE map_id=?", (mid,))
for lid in lobbies:
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"):
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
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 maps WHERE id=?", (mid,))
path(mid).unlink(missing_ok=True)
(store_dir / f"{mid}.thumb.webp").unlink(missing_ok=True)
@@ -334,6 +355,80 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
raise HTTPException(404, "Image missing")
return Response(p.read_bytes(), media_type="image/webp", headers={"Cache-Control": "private, max-age=3600"})
# ---- custom prop images (decor tokens such as chests or tables); the built-in ones are drawn by the frontend
props_dir = store_dir.parent / "props"
props_dir.mkdir(parents=True, exist_ok=True)
def encode_prop(raw: bytes) -> bytes:
try:
im = Image.open(io.BytesIO(raw))
if im.format not in ("PNG", "JPEG", "WEBP"):
raise ValueError("format")
im.load()
except Exception:
raise HTTPException(422, "Please upload a PNG, JPEG or WebP image")
im = ImageOps.exif_transpose(im)
im.thumbnail((512, 512))
im = im.convert("RGBA")
out = io.BytesIO()
im.save(out, "WEBP", quality=88, method=4)
return out.getvalue()
@api.get("/props")
def list_props(user: dict = Depends(current_user)):
with db() as con:
return [{"id": r["id"], "name": r["name"]} for r in
con.execute("SELECT id, name FROM user_props WHERE owner_id=? ORDER BY created", (user["id"],))]
@api.post("/props")
async def upload_prop(request: Request, name: str = "", user: dict = Depends(current_user)):
"""Raw image bytes in the body, the name as ?name=. Transparent PNGs work best (top view)."""
if int(request.headers.get("content-length") or 0) > 3_000_000:
raise HTTPException(413, "The file is too large (at most 3 MB)")
chunks, size = [], 0
async for c in request.stream():
size += len(c)
if size > 3_000_000:
raise HTTPException(413, "The file is too large (at most 3 MB)")
chunks.append(c)
with db() as con:
if con.execute("SELECT COUNT(*) FROM user_props WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_PROPS:
raise HTTPException(409, "Prop limit reached, delete one first")
data = await run_in_threadpool(encode_prop, b"".join(chunks))
pid = uuid.uuid4().hex[:12]
(props_dir / f"{pid}.webp").write_bytes(data)
with db() as con:
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}
@api.delete("/props/{pid}")
def delete_prop(pid: str, user: dict = Depends(current_user)):
with db() as con:
if 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("DELETE FROM user_props WHERE id=?", (pid,))
(props_dir / f"{pid}.webp").unlink(missing_ok=True)
return {"ok": True}
@api.get("/props/{pid}/image")
def prop_image(pid: str, user: dict = Depends(current_user)):
with db() as con:
ok = con.execute("SELECT 1 FROM user_props WHERE id=? AND owner_id=?", (pid, user["id"])).fetchone()
if not ok:
raise HTTPException(404, "Prop not found")
return image_response(props_dir / f"{pid}.webp")
@api.get("/lobbies/{lid}/prop-image/{pid}")
def lobby_prop_image(lid: str, pid: str, user: dict = Depends(current_user)):
"""Members of a lobby may load the prop images of the lobby's DM."""
with db() as con:
lobby = lobby_for(con, lid, user)
ok = con.execute("SELECT 1 FROM user_props WHERE id=? AND owner_id=?", (pid, lobby["owner_id"])).fetchone()
if not ok or not re.fullmatch(r"[0-9a-f]{12}", pid):
raise HTTPException(404, "Prop not found")
return image_response(props_dir / f"{pid}.webp")
# ---- the map on a lobby's table
def sync_players(con, lid: str):
@@ -346,6 +441,13 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
for uid in have - ids:
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=? AND user_id=?", (lid, uid))
def put_token(con, lid, t, npc=None, tid=None):
"""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)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(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")))
def mask_dims(m):
f = max(1, -(-max(m["w"], m["h"]) // 512))
return f, -(-m["w"] // f), -(-m["h"] // f)
@@ -478,7 +580,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
name = (json.loads(r["data"]).get("name") if r["data"] else "") or r["username"]
except ValueError:
name = r["username"]
if not player and not full and r["id"] != turn_tok:
if not player and not full and r["id"] != turn_tok and not r["prop"]:
name = ""
pt = ""
src = r["data"] if player else r["ndata"]
@@ -494,7 +596,9 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
status["conds"] = json.loads(r["conds"] or "{}")
if full:
status.update(show_hp=bool(r["show_hp"]), show_cond=bool(r["show_cond"]), hp_hint=health(src) if src else None)
out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name, "portrait": pt, **status,
if r["prop"]:
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"]),
"color": (r["mcolor"] or "#999999") if player else r["color"],
"can_move": ctx["is_dm"] or (r["user_id"] == user["id"] and not (ctx["los"] and r["x"] is None)),
@@ -667,29 +771,185 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
hub.notify(lid, "map")
return {"ok": True}
@api.put("/lobbies/{lid}/map")
def set_lobby_map(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""DM: show a map from the library (or null to clear the table). The map's prepared tokens are copied to the lobby."""
with db() as con:
dm_only(lobby_for(con, lid, user), user)
mid = body.get("map_id")
# ---- 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")
def snapshot(con, lid) -> str:
lobby = con.execute("SELECT * FROM lobbies WHERE id=?", (lid,)).fetchone()
names = {r["id"]: r["username"] for r in con.execute("SELECT id, username FROM users WHERE id IN (SELECT user_id FROM lobby_members WHERE lobby_id=?)", (lid,))}
toks = []
for r in con.execute("SELECT * FROM lobby_tokens WHERE lobby_id=? ORDER BY rowid", (lid,)):
d = {k: r[k] for k in TOKEN_COLS}
if r["user_id"] is not None:
if r["user_id"] not in names:
continue
d["username"] = names[r["user_id"]]
toks.append(d)
reveals = [{k: z[k] for k in ("id", "x", "y", "radius", "dir", "fov", "targets", "created")}
for z in con.execute("SELECT * FROM lobby_reveals WHERE lobby_id=?", (lid,))]
explored = [{"username": names[z["user_id"]], "map_id": z["map_id"], "rev": z["rev"], "data": base64.b64encode(z["data"]).decode()}
for z in con.execute("SELECT * FROM lobby_explored WHERE lobby_id=?", (lid,)) if z["user_id"] in names]
return json.dumps({"tokens": toks, "reveals": reveals, "explored": explored, "doors": lobby["doors"], "sight": lobby["sight"],
"fog": lobby["fog"], "los": lobby["los"], "combat": lobby["combat"]})
def restore(con, lid, state: dict):
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_reveals WHERE lobby_id=?", (lid,))
con.execute("UPDATE lobbies SET sight=NULL WHERE id=?", (lid,))
if mid:
r = usable_map(con, str(mid), user)
ids = {r["username"]: r["user_id"] for r in con.execute("SELECT u.username, m.user_id FROM lobby_members m JOIN users u ON u.id = m.user_id WHERE m.lobby_id=?", (lid,))}
sync_players(con, lid)
for d in state.get("tokens", []):
if d.get("username"):
uid = ids.get(d["username"])
if uid is not None:
con.execute("""UPDATE lobby_tokens SET x=?, y=?, hidden=?, hp=?, hp_max=?, conds=?, show_hp=?, show_cond=?, size=? WHERE lobby_id=? AND user_id=?""",
(d["x"], d["y"], d["hidden"], d["hp"], d["hp_max"], d["conds"], d["show_hp"], d["show_cond"], d["size"], lid, uid))
else:
con.execute(f"INSERT INTO lobby_tokens (lobby_id, {', '.join(TOKEN_COLS)}) VALUES (?{',?' * len(TOKEN_COLS)})", (lid, *[d[k] for k in TOKEN_COLS]))
for z in state.get("reveals", []):
con.execute("INSERT INTO lobby_reveals (id, lobby_id, x, y, radius, dir, fov, targets, created) VALUES (?,?,?,?,?,?,?,?,?)",
(z["id"], lid, z["x"], z["y"], z["radius"], z["dir"], z["fov"], z["targets"], z["created"]))
for z in state.get("explored", []):
if z["username"] in ids:
con.execute("INSERT INTO lobby_explored (lobby_id, user_id, map_id, data, rev, last) VALUES (?,?,?,?,?,NULL)",
(lid, ids[z["username"]], z["map_id"], base64.b64decode(z["data"]), z["rev"]))
con.execute("UPDATE lobbies SET doors=?, sight=?, fog=?, los=?, combat=? WHERE id=?",
(state.get("doors", "{}"), state.get("sight"), state.get("fog", 1), state.get("los", 1), state.get("combat"), lid))
def fresh_state(con, lid, r, user):
"""The state of a map that was just put on the table: its prepared tokens, nothing explored."""
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_reveals WHERE lobby_id=?", (lid,))
con.execute("UPDATE lobbies SET sight=NULL, doors='{}', combat=NULL WHERE id=?", (lid,))
for t in json.loads(r["tokens"]):
npc = t.get("npc_id") if t.get("npc_id") and can_read(con, t["npc_id"], user) else None # only NPCs this DM may open
con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id, hp, hp_max) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]), npc,
t.get("hp") if t.get("hp") is not None else t.get("hp_max"), t.get("hp_max")))
put_token(con, lid, t, npc)
sync_players(con, lid)
def autosave(con, lid):
"""Stores the table's current state in its scene (creating the scene for tables that predate scenes)."""
lobby = con.execute("SELECT * FROM lobbies WHERE id=?", (lid,)).fetchone()
if not lobby["map_id"]:
return
now = time.time()
if lobby["scene_id"] and con.execute("SELECT 1 FROM lobby_scenes WHERE id=? AND lobby_id=?", (lobby["scene_id"], lid)).fetchone():
con.execute("UPDATE lobby_scenes SET state=?, updated=? WHERE id=?", (snapshot(con, lid), now, lobby["scene_id"]))
else:
new_scene(con, lid, lobby["map_id"], snapshot(con, lid))
def new_scene(con, lid, mid, state, name=None) -> str:
if con.execute("SELECT COUNT(*) FROM lobby_scenes WHERE lobby_id=?", (lid,)).fetchone()[0] >= MAX_SCENES:
raise HTTPException(409, f"Too many saved scenes (max {MAX_SCENES}) - delete one first")
m = con.execute("SELECT name FROM maps WHERE id=?", (mid,)).fetchone()
base = (name or (m["name"] if m else "Scene")).strip()[:50] or "Scene"
taken = {r["name"] for r in con.execute("SELECT name FROM lobby_scenes WHERE lobby_id=?", (lid,))}
nm, i = base, 2
while nm in taken:
nm, i = f"{base} ({i})", i + 1
sid = uuid.uuid4().hex[:12]
con.execute("INSERT INTO lobby_scenes (id, lobby_id, name, map_id, state, updated) VALUES (?,?,?,?,?,?)", (sid, lid, nm, mid, state, time.time()))
con.execute("UPDATE lobbies SET scene_id=? WHERE id=?", (sid, lid))
return sid
@api.put("/lobbies/{lid}/map")
def set_lobby_map(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""DM: show a map from the library (or null to clear the table). The table's current state is kept as a scene;
the new map starts fresh with its prepared tokens as a scene of its own."""
with db() as con:
dm_only(lobby_for(con, lid, user), user)
mid = body.get("map_id")
r = usable_map(con, str(mid), user) if mid else None
autosave(con, lid)
con.execute("UPDATE lobbies SET scene_id=NULL WHERE id=?", (lid,))
if r:
fresh_state(con, lid, r, user)
con.execute("UPDATE lobbies SET map_id=? WHERE id=?", (r["id"], 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"])
else:
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"):
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,))
event(con, lid, "The DM cleared the table", user["id"])
con.execute("UPDATE lobbies SET map_id=?, doors='{}' WHERE id=?", (str(mid) if mid else None, lid))
hub.notify(lid, "map")
hub.notify(lid, "lobby")
return {"ok": True}
@api.get("/lobbies/{lid}/scenes")
def list_scenes(lid: str, user: dict = Depends(current_user)):
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
if lobby["map_id"] and not con.execute("SELECT 1 FROM lobby_scenes WHERE id=? AND lobby_id=?", (lobby["scene_id"] or "", lid)).fetchone():
try:
autosave(con, lid) # a table that predates scenes becomes one
except HTTPException:
pass
lobby = lobby_for(con, lid, user)
rows = con.execute("""SELECT s.id, s.name, s.map_id, s.updated, m.name AS map_name, LENGTH(s.state) AS size FROM lobby_scenes s
LEFT JOIN maps m ON m.id = s.map_id WHERE s.lobby_id=? ORDER BY s.updated DESC""", (lid,)).fetchall()
return [{"id": r["id"], "name": r["name"], "map_id": r["map_id"], "map_name": r["map_name"] or "", "updated": r["updated"],
"current": r["id"] == lobby["scene_id"]} for r in rows]
@api.put("/lobbies/{lid}/scene")
def switch_scene(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""DM: continue another saved scene. The current table is saved first, so nothing is lost when switching back."""
with db() as con:
dm_only(lobby_for(con, lid, user), user)
sc = con.execute("SELECT * FROM lobby_scenes WHERE id=? AND lobby_id=?", (str(body.get("scene_id", "")), lid)).fetchone()
if not sc:
raise HTTPException(404, "Scene not found")
r = usable_map(con, sc["map_id"], user) if sc["map_id"] else None
if not r:
raise HTTPException(409, "The map of this scene is no longer available")
autosave(con, lid)
restore(con, lid, json.loads(sc["state"]))
con.execute("UPDATE lobbies SET map_id=?, scene_id=? WHERE id=?", (r["id"], sc["id"], lid))
event(con, lid, f"The DM switched to the scene \"{sc['name']}\"", user["id"])
hub.notify(lid, "map")
hub.notify(lid, "lobby")
return {"ok": True}
@api.post("/lobbies/{lid}/scenes")
def save_scene_copy(lid: str, body: dict = Body(default={}), user: dict = Depends(current_user)):
"""DM: store a copy of the current table (a checkpoint to come back to). The table keeps running on its own scene."""
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
if not lobby["map_id"]:
raise HTTPException(409, "There is no map on the table")
autosave(con, lid)
current = con.execute("SELECT scene_id FROM lobbies WHERE id=?", (lid,)).fetchone()["scene_id"]
sid = new_scene(con, lid, lobby["map_id"], snapshot(con, lid), str(body.get("name", "")) or None)
con.execute("UPDATE lobbies SET scene_id=? WHERE id=?", (current, lid))
hub.notify(lid, "lobby")
return {"id": sid}
@api.put("/lobbies/{lid}/scenes/{sid}")
def edit_scene(lid: str, sid: str, body: dict = Body(...), user: dict = Depends(current_user)):
with db() as con:
dm_only(lobby_for(con, lid, user), user)
if not con.execute("SELECT 1 FROM lobby_scenes WHERE id=? AND lobby_id=?", (sid, lid)).fetchone():
raise HTTPException(404, "Scene not found")
name = str(body.get("name", "")).strip()[:50]
if name:
if con.execute("SELECT 1 FROM lobby_scenes WHERE lobby_id=? AND name=? AND id<>?", (lid, name, sid)).fetchone():
raise HTTPException(409, "There is already a scene with this name")
con.execute("UPDATE lobby_scenes SET name=? WHERE id=?", (name, sid))
if body.get("save"):
autosave(con, lid)
hub.notify(lid, "lobby")
return {"ok": True}
@api.delete("/lobbies/{lid}/scenes/{sid}")
def delete_scene(lid: str, sid: str, user: dict = Depends(current_user)):
with db() as con:
dm_only(lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_scenes WHERE id=? AND lobby_id=?", (sid, lid))
con.execute("UPDATE lobbies SET scene_id=NULL WHERE id=? AND scene_id=?", (lid, sid))
hub.notify(lid, "lobby")
return {"ok": True}
def need_map(con, lobby):
@@ -708,9 +968,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
raise HTTPException(409, "Too many tokens")
t = clean_token(body, m["w"], m["h"], keep_id=False)
npc = t["npc_id"] if t["npc_id"] and can_read(con, t["npc_id"], user) else None
con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id, hp, hp_max) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(t["id"], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]), npc,
t["hp"] if t["hp"] is not None else t["hp_max"], t["hp_max"]))
put_token(con, lid, t, npc, t["id"])
hub.notify(lid, "map")
return {"id": t["id"]}
@@ -726,18 +984,20 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
raise HTTPException(404, "Token not found")
if not is_dm and t["x"] is None and lobby["los"] and json.loads(m["walls"]):
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"]}
if "x" in body or "y" in body:
merged["x"], merged["y"] = body.get("x"), body.get("y")
if is_dm and t["user_id"] is None:
for k in ("name", "color", "size", "hidden"):
for k in ("name", "color", "size", "hidden", "rot", "size_h"):
if k in body:
merged[k] = body[k]
elif is_dm and "size" in body:
merged["size"] = body["size"]
c = clean_token(merged, m["w"], m["h"])
con.execute("UPDATE lobby_tokens SET name=?, x=?, y=?, color=?, size=?, hidden=? WHERE id=?",
(c["name"] if t["user_id"] is None else t["name"], c["x"], c["y"], c["color"], c["size"], int(c["hidden"]), tid))
con.execute("UPDATE lobby_tokens SET name=?, x=?, y=?, color=?, size=?, hidden=?, rot=?, size_h=? 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["rot"] if t["user_id"] is None else 0, c["size_h"] 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")):
hp_max, hp = t["hp_max"], t["hp"]
if "hp_max" in body:
+24 -5
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
import { MapView, MapLibrary, NpcPicker, 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 send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
@@ -788,6 +788,12 @@ function LobbyView({ id, rules, user }) {
const toggleTop = () => { setTopOpen((v) => { store('aw_topopen', v ? '0' : '1'); return !v }) }
const combatOp = (b) => act(`/api/lobbies/${id}/combat`, 'PUT', b)
const [npcPick, setNpcPick] = useState(false)
const [propPick, setPropPick] = useState(false)
const addPropToken = (p) => {
const m = mapState.map
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(() => {})
}
const addNpcToken = (n) => {
const hp = n.hp > 0 ? n.hp : undefined
const m = mapState.map
@@ -890,9 +896,10 @@ function LobbyView({ id, rules, user }) {
{mapState.map && (() => {
const full = lobby.is_dm && !viewAs
const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null
const selProp = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'prop') : null
const walls = full && showWalls ? mapState.walls || [] : []
return (
<MapView map={mapState.map} image={mapImg} fog={fogImg} 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} 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}
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) }}
@@ -920,7 +927,8 @@ function LobbyView({ id, rules, user }) {
{full && mapState.walls?.length > 0 && <button 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>
{full && <button className="ghost" onClick={addToken}>+ Token</button>}
{full && <button className={npcPick ? 'on' : 'ghost'} onClick={() => setNpcPick(!npcPick)}>+ NPC</button>}
{full && <button className={npcPick ? 'on' : 'ghost'} onClick={() => { setNpcPick(!npcPick); setPropPick(false) }}>+ NPC</button>}
{full && <button className={propPick ? 'on' : 'ghost'} onClick={() => { setPropPick(!propPick); setNpcPick(false) }} title="Decor such as chests, tables or doors">+ Prop</button>}
</div>
{full && (rv || mapState.reveals?.length > 0) && (
<div className="tokenpanel mapui revealpanel">
@@ -947,13 +955,24 @@ function LobbyView({ id, rules, user }) {
</div>
)}
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>}
{full && mapSel && (() => { const st = mapState.tokens.find((t) => t.id === mapSel); return st && !selTok ? <div className="tokenpanel mapui"><b className="small">{st.name || 'Player'}</b><StatusForm t={st} conditions={rules.conditions || []} onChange={(p) => patchToken(st.id, p)} /></div> : null })()}
{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>}
{full && mapSel && (() => { const st = mapState.tokens.find((t) => t.id === mapSel); return st && !selTok && !selProp ? <div className="tokenpanel mapui"><b className="small">{st.name || 'Player'}</b><StatusForm t={st} conditions={rules.conditions || []} onChange={(p) => patchToken(st.id, p)} /></div> : null })()}
{selTok && <div className="tokenpanel mapui"><TokenForm t={selTok} onChange={(p) => patchToken(selTok.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selTok.id}`, 'DELETE'); setMapSel(null) }} /><StatusForm t={selTok} conditions={rules.conditions || []} onChange={(p) => patchToken(selTok.id, p)} /></div>}
</MapView>
)
})()}
{menu && (() => {
const t = menu.t, e = lobby.combat?.entries.find((x) => (t.kind === 'npc' ? x.token === t.id : x.user === t.username))
const t = menu.t
if (t.kind === 'prop') {
const rot = t.rot || 0
return <RadialMenu x={menu.x} y={menu.y} title={t.name} onClose={() => setMenu(null)} items={[
{ icon: '⟲', label: 'Turn left', onClick: () => patchToken(t.id, { rot: (rot + 315) % 360 }) },
{ icon: '⟳', label: 'Turn right', onClick: () => patchToken(t.id, { rot: (rot + 45) % 360 }) },
{ 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) } }]} />
}
const 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 }) },
+136 -19
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { BUILTIN, PropArt } from './props.jsx'
const COLORS = ['#c0392b', '#8e44ad', '#16a085', '#7f8c8d', '#d35400', '#2c3e50', '#27ae60', '#b9770e']
const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
// Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels.
// Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise.
export function MapView({ map, image, fog, portraitUrl, onMenu, isTurn, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
export function MapView({ map, image, fog, portraitUrl, propSrc, onMenu, isTurn, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
const box = useRef(null)
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
const [drag, setDrag] = useState(null)
@@ -109,7 +110,7 @@ export function MapView({ map, image, fog, portraitUrl, onMenu, isTurn, tokens,
window.addEventListener('pointermove', mv); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end)
}
const unplaced = tokens.filter((t) => t.x == null && t.can_move)
const unplaced = tokens.filter((t) => t.x == null && t.can_move && t.kind !== 'prop')
const d = (t) => t.size * map.grid
return (
<div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
@@ -118,10 +119,21 @@ export function MapView({ map, image, fog, portraitUrl, onMenu, isTurn, tokens,
{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` }} />}
{overlay}
{tokens.map((t) => {
{[...tokens].sort((a, b) => (b.kind === 'prop') - (a.kind === 'prop')).map((t) => {
const dr = drag?.id === t.id ? drag : null
const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y
if (x == null || (dr && dr.out)) return null
if (t.kind === 'prop') { // decor: below the tokens, no label, rotated
const w = t.size * map.grid, h = (t.size_h || t.size) * map.grid
return (
<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) } }}
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)' : '')}>
<PropArt prop={t.prop} customSrc={propSrc} />
</div>
)
}
return (
<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) } }}
@@ -252,6 +264,71 @@ export function NpcPicker({ onPick, onClose }) {
)
}
// Library of decor tokens: built-in drawings and the DM's own uploaded images. onPick({prop, name, w, h}).
export function PropPicker({ onPick, onClose }) {
const [mine, setMine] = useState([])
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(() => fetch('/api/props').then((r) => (r.ok ? r.json() : [])).then(setMine).catch(() => {}), [])
useEffect(() => { load() }, [load])
const upload = async (e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr(''); setBusy(true)
const r = await fetch(`/api/props?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f })
setBusy(false)
if (!r.ok) { setErr(r.status === 413 ? 'The image is too large (at most 3 MB).' : (await r.json().catch(() => ({}))).detail || 'Upload failed'); return }
load()
}
return (
<div className="npcpicker card proppicker" role="dialog" aria-label="Prop library">
<div className="row"><strong>Props</strong><button className="ghost" onClick={onClose} aria-label="Close">×</button></div>
<div className="propgrid">
{BUILTIN.map((p) => (
<button key={p.id} className="ghost propbtn" title={p.name} onClick={() => onPick({ prop: `b:${p.id}`, name: p.name, w: p.w, h: p.h })}>
<svg viewBox="0 0 100 100" aria-hidden="true">{p.art}</svg><span>{p.name}</span>
</button>
))}
{mine.map((p) => (
<span key={p.id} className="propbtn own">
<button className="ghost" title={p.name} onClick={() => onPick({ prop: `c:${p.id}`, name: p.name, w: 1, h: 1 })}><img src={`/api/props/${p.id}/image`} alt="" /><span>{p.name}</span></button>
<button className="ghost mini danger" aria-label={`Delete ${p.name}`} onClick={() => window.confirm(`Delete the prop "${p.name}"? It disappears from all maps.`) && fetch(`/api/props/${p.id}`, { method: 'DELETE' }).then(load)}>×</button>
</span>
))}
</div>
<label className="uploadbtn">{busy ? 'Uploading…' : '+ Own image (PNG with transparency)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
{err && <p className="warn small">{err}</p>}
</div>
)
}
// Name, size, rotation and visibility of a decor token
export function PropForm({ t, onChange, onDelete, onDuplicate }) {
const rot = Math.round(t.rot || 0)
return (
<div className="tokenform">
<input value={t.name} maxLength={40} aria-label="Prop name" onChange={(e) => onChange({ name: e.target.value })} />
<span className="row">
<label className="small chk">Width <input type="number" min="0.25" max="8" step="0.25" value={t.size} onChange={(e) => onChange({ size: Number(e.target.value) || 1 })} /></label>
<label className="small chk">Height <input type="number" min="0.25" max="8" step="0.25" value={t.size_h || t.size} onChange={(e) => onChange({ size_h: Number(e.target.value) || 1 })} /></label>
</span>
<label className="small">Rotation {rot}°
<input type="range" min="0" max="359" step="5" value={rot} onChange={(e) => onChange({ rot: Number(e.target.value) })} /></label>
<span className="row">
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 315) % 360 })} title="Rotate left 45°">⟲ 45°</button>
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 45) % 360 })} title="Rotate right 45°">⟳ 45°</button>
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 90) % 360 })} title="Rotate right 90°">⟳ 90°</button>
</span>
<label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label>
<span className="row">
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
<button className="ghost danger" onClick={onDelete}>Remove</button>
</span>
</div>
)
}
const uid = () => Math.random().toString(36).slice(2, 12)
// Areas the DM shows to players: a circle or cone. zones = revealed ones, draft = being placed. Units: pixels / degrees.
@@ -301,7 +378,7 @@ const distToSeg = (p, w) => {
}
// Full-screen editor for a map in the DM's library: grid, enemies and NPCs, walls and doors for line of sight.
export function MapEditor({ id, onClose }) {
export function MapEditor({ id, onClose, live, onSaved }) {
const [m, setM] = useState(null)
const [tokens, setTokens] = useState([])
const [walls, setWalls] = useState([])
@@ -318,7 +395,7 @@ export function MapEditor({ id, onClose }) {
const [dirty, setDirty] = useState(false)
useEffect(() => {
fetch(`/api/maps/${id}`).then((r) => r.json()).then((d) => {
setM(d); setTokens(d.tokens.map((t) => ({ ...t, kind: 'npc', can_move: true }))); setWalls(d.walls); setName(d.name); setGrid(d.grid); setSight(d.sight || 0)
setM(d); setTokens(d.tokens.map((t) => ({ ...t, kind: t.prop ? 'prop' : 'npc', can_move: true }))); setWalls(d.walls); setName(d.name); setGrid(d.grid); setSight(d.sight || 0)
})
}, [id])
useEffect(() => {
@@ -330,6 +407,7 @@ export function MapEditor({ id, onClose }) {
const [image, setImage] = useState(null)
useEffect(() => { if (m) setImage({ src: `/api/maps/${id}/image`, x: 0, y: 0, w: m.w, h: m.h }) }, [m?.id]) // eslint-disable-line
const [picker, setPicker] = useState(false)
const [propPick, setPropPick] = useState(false)
if (!m) return null
const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid
const cur = tokens.find((t) => t.id === sel)
@@ -340,6 +418,11 @@ export function MapEditor({ id, onClose }) {
const t = { id: uid(), kind: 'npc', can_move: true, name: n.name, npc_id: n.id, portrait: n.portrait || '', x: m.w / 2 + (k % 5) * gridNum, y: m.h / 2 + (k % 3) * gridNum, size: n.size || 1, color: COLORS[0], hidden: false, hp_max: n.hp > 0 ? n.hp : null, hp: n.hp > 0 ? n.hp : null }
setTokens([...tokens, t]); setSel(t.id); setDirty(true); setPicker(false)
}
const addProp = (p) => {
const k = tokens.length + 1
const t = { id: uid(), kind: 'prop', can_move: true, name: p.name, prop: p.prop, rot: 0, x: m.w / 2 + (k % 5) * gridNum, y: m.h / 2 + (k % 3) * gridNum, size: p.w, size_h: p.h, color: COLORS[0], hidden: false }
setTokens([...tokens, t]); setSel(t.id); setDirty(true); setPropPick(false)
}
const add = () => {
const n = tokens.length + 1
const t = { id: uid(), kind: 'npc', can_move: true, name: `Enemy ${n}`, x: m.w / 2 + (n % 5) * gridNum, y: m.h / 2 + (n % 3) * gridNum, size: 1, color: COLORS[0], hidden: false }
@@ -374,9 +457,9 @@ export function MapEditor({ id, onClose }) {
setErr('')
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,
tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max }) => ({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max })) }) })
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 })) }) })
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false }
setDirty(false); return true
setDirty(false); onSaved?.(); return true
}
const close = async () => { if (!dirty || window.confirm('Close without saving your changes?')) onClose() }
const tools = [['select', 'Move tokens'], ['wall', 'Wall'], ['rect', 'Room'], ['door', 'Door'], ['erase', 'Erase']]
@@ -406,21 +489,31 @@ export function MapEditor({ id, onClose }) {
</div>
<label className="small">Sight range in squares (0 = unlimited)
<input type="number" min="0" max="1000" value={sight} onChange={(e) => { setSight(e.target.value); setDirty(true) }} /></label>
<h4>Enemies and NPCs</h4>
<div className="row"><button className="on" onClick={add}>+ Add token</button><button className="ghost" onClick={() => setPicker(!picker)}>+ From NPC library</button></div>
<h4>{live ? 'Prepared tokens (used when the map is put on the table again)' : 'Enemies, NPCs and props'}</h4>
<div className="row"><button className="on" onClick={add}>+ Add token</button><button className="ghost" onClick={() => { setPicker(!picker); setPropPick(false) }}>+ From NPC library</button><button className="ghost" onClick={() => { setPropPick(!propPick); setPicker(false) }}>+ Prop</button></div>
{picker && <NpcPicker onPick={addNpc} onClose={() => setPicker(false)} />}
{propPick && <PropPicker onPick={addProp} onClose={() => setPropPick(false)} />}
<ul className="tokenlist">
{tokens.map((t) => <li key={t.id}><button className={sel === t.id ? 'on' : 'ghost'} onClick={() => setSel(t.id)}><i style={{ background: t.color }} /> {t.name}{t.npc_id ? ' ★' : ''}{t.hidden ? ' (hidden)' : ''}</button></li>)}
{!tokens.length && <li className="muted small">None yet. Add a token, then drag it onto the map.</li>}
{tokens.map((t) => (
<li key={t.id} className="tokrow">
<button className={sel === t.id ? 'on' : 'ghost'} onClick={() => setSel(t.id)}>{t.kind === 'prop' ? <i className="propdot" /> : <i style={{ background: t.color }} />} {t.name}{t.npc_id ? ' ★' : ''}{t.hidden ? ' (hidden)' : ''}</button>
<button className="ghost mini" aria-pressed={!t.hidden} aria-label={t.hidden ? `Show ${t.name} to players` : `Hide ${t.name} from players`} title={t.hidden ? 'Hidden from players - click to show' : 'Visible to players - click to hide'}
onClick={() => patch(t.id, { hidden: !t.hidden })}>{t.hidden ? '🚫' : '👁'}</button>
</li>
))}
{!tokens.length && <li className="muted small">None yet. Add a token or a prop, then drag it onto the map.</li>}
</ul>
{cur && <TokenForm t={cur} onChange={(p) => patch(cur.id, p)}
onDelete={() => { setTokens(tokens.filter((t) => t.id !== cur.id)); setSel(null); setDirty(true) }}
onDuplicate={() => { const c = { ...cur, id: uid(), name: cur.name.replace(/\s*\d*$/, '') + ' ' + (tokens.length + 1), x: cur.x + gridNum, y: cur.y + gridNum }; setTokens([...tokens, c]); setSel(c.id); setDirty(true) }} />}
{cur && (() => {
const props = { t: cur, onChange: (p) => patch(cur.id, p),
onDelete: () => { setTokens(tokens.filter((t) => t.id !== cur.id)); setSel(null); setDirty(true) },
onDuplicate: () => { const c = { ...cur, id: uid(), name: cur.name.replace(/\s*\d*$/, '') + ' ' + (tokens.length + 1), x: cur.x + gridNum, y: cur.y + gridNum }; setTokens([...tokens, c]); setSel(c.id); setDirty(true) } }
return cur.kind === 'prop' ? <PropForm {...props} /> : <TokenForm {...props} />
})()}
{err && <p className="warn small">{err}</p>}
<div className="row editactions"><button className="on" disabled={!dirty} onClick={save}>Save</button><button className="ghost" onClick={close}>Close</button></div>
</aside>
<div className="mapstage">
<MapView map={{ ...m, grid: gridNum }} image={image} tokens={tokens} portraitUrl={(t) => (t.npc_id && t.portrait ? `/api/characters/${t.npc_id}/portrait?v=${t.portrait}` : null)} grid={showGrid} selected={sel} onSelect={setSel} tool={tool}
<MapView map={{ ...m, grid: gridNum }} image={image} tokens={tokens} propSrc={(pid) => `/api/props/${pid}/image`} portraitUrl={(t) => (t.npc_id && t.portrait ? `/api/characters/${t.npc_id}/portrait?v=${t.portrait}` : null)} grid={showGrid} selected={sel} onSelect={setSel} tool={tool}
onClick={click} onHover={draft ? (p, s) => setHover(p) : undefined}
overlay={<WallLayer map={m} walls={walls} draft={draft} hover={draft ? hover : null} />}
onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
@@ -434,6 +527,9 @@ export function MapLibrary({ activeId, lobbyId, act }) {
const [err, setErr] = useState('')
const [editing, setEditing] = useState(null)
const [busy, setBusy] = useState(false)
const [scenes, setScenes] = useState([])
const loadScenes = useCallback(() => (lobbyId ? fetch(`/api/lobbies/${lobbyId}/scenes`).then((r) => (r.ok ? r.json() : [])).then(setScenes).catch(() => {}) : null), [lobbyId])
useEffect(() => { loadScenes() }, [loadScenes, activeId])
const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => {}), [])
useEffect(() => { load() }, [load, editing])
const upload = async (e) => {
@@ -457,16 +553,37 @@ export function MapLibrary({ activeId, lobbyId, act }) {
<strong>{m.name}</strong> <span className="muted small">{m.w}×{m.h}, {m.tokens} token{m.tokens === 1 ? '' : 's'}, {m.walls} wall{m.walls === 1 ? '' : 's'}{!m.mine && ` · from ${m.author}`}</span>
<span className="row">
{m.id === activeId
? <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null })}>Clear table</button>
: <button className="on" onClick={() => (!activeId || window.confirm('Replace the current map? Tokens on it are reset to the prepared ones.')) && act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: m.id })}>Show on table</button>}
{m.mine && <button className="ghost" onClick={() => setEditing(m.id)}>Edit</button>}
? <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null }, () => { loadScenes() })}>Clear table</button>
: <button className="on" onClick={() => (!activeId || window.confirm('Put this map on the table? The current table is saved as a scene first - you can switch back to it below.')) && act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: m.id }, () => { loadScenes() })}>Show on table</button>}
{m.mine && <button className="ghost" onClick={() => setEditing(m.id)} title={m.id === activeId ? 'Walls, grid and sight apply to the running game at once' : ''}>{m.id === activeId ? 'Edit live' : 'Edit'}</button>}
{m.mine && <button className="ghost danger" onClick={() => window.confirm(`Delete the map "${m.name}"?`) && fetch(`/api/maps/${m.id}`, { method: 'DELETE' }).then(load)}>Delete</button>}
</span>
</li>
))}
{!list.length && <li className="muted small">No maps yet. Upload an image, then prepare enemies in the editor.</li>}
</ul>
{editing && <MapEditor id={editing} onClose={() => setEditing(null)} />}
{lobbyId && (
<>
<h4>Scenes</h4>
<p className="small muted">Every map you put on the table is a scene. Switching saves the current one (tokens, doors, explored ground, revealed areas, fight) and loads the other exactly as you left it.</p>
<ul className="maplist">
{scenes.map((sc) => (
<li key={sc.id} className={sc.current ? 'active' : ''}>
<strong>{sc.name}</strong> <span className="muted small">{sc.map_name ? `Map: ${sc.map_name}` : 'map deleted'} · saved {new Date(sc.updated * 1000).toLocaleString()}{sc.current ? ' · running' : ''}</span>
<span className="row">
{!sc.current && <button className="on" disabled={!sc.map_name} onClick={() => act(`/api/lobbies/${lobbyId}/scene`, 'PUT', { scene_id: sc.id }, () => { loadScenes(); load() })}>Switch to</button>}
{sc.current && <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'PUT', { save: true }, () => loadScenes())}>Save now</button>}
<button className="ghost" onClick={() => { const n = window.prompt('New name', sc.name); if (n) act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'PUT', { name: n }, () => loadScenes()) }}>Rename</button>
<button className="ghost danger" onClick={() => window.confirm(`Delete the scene "${sc.name}"?${sc.current ? ' The table keeps running, but this state is no longer stored.' : ''}`) && act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'DELETE', undefined, () => loadScenes())}>Delete</button>
</span>
</li>
))}
{!scenes.length && <li className="muted small">No scenes yet. Put a map on the table.</li>}
</ul>
{activeId && <button className="ghost" onClick={() => { const n = window.prompt('Name for the checkpoint', ''); if (n !== null) act(`/api/lobbies/${lobbyId}/scenes`, 'POST', { name: n }, () => loadScenes()) }}>Save a copy of the current table</button>}
</>
)}
{editing && <MapEditor id={editing} live={editing === activeId} onClose={() => setEditing(null)} />}
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
// Decor tokens ("props"): drawn from above, purely visual. Built-in ones are SVG (id "b:<name>"), own uploads are images (id "c:<id>").
// w / h are the default size in grid squares.
const wood = '#8a5a2b', dark = '#5b3a18', light = '#b98545', metal = '#8d949b', stone = '#7d7f84', green = '#3f7d3a'
export const BUILTIN = [
{ id: 'chest', name: 'Chest', w: 1, h: 0.7, art: <><rect x="4" y="12" width="92" height="76" rx="8" fill={wood} stroke={dark} strokeWidth="5" /><rect x="4" y="40" width="92" height="8" fill={dark} /><rect x="42" y="34" width="16" height="20" rx="3" fill="#e0b84a" stroke={dark} strokeWidth="3" /><path d="M24 12v76M76 12v76" stroke={dark} strokeWidth="5" /></> },
{ id: 'barrel', name: 'Barrel', w: 1, h: 1, art: <><circle cx="50" cy="50" r="45" fill={wood} stroke={dark} strokeWidth="5" /><circle cx="50" cy="50" r="33" fill="none" stroke={dark} strokeWidth="4" /><circle cx="50" cy="50" r="20" fill={light} stroke={dark} strokeWidth="3" /><path d="M30 50h40" stroke={dark} strokeWidth="3" /></> },
{ id: 'crate', name: 'Crate', w: 1, h: 1, art: <><rect x="5" y="5" width="90" height="90" fill={light} stroke={dark} strokeWidth="5" /><path d="M5 5l90 90M95 5L5 95" stroke={dark} strokeWidth="5" /><rect x="5" y="5" width="90" height="90" fill="none" stroke={dark} strokeWidth="10" opacity=".4" /></> },
{ id: 'table', name: 'Table', w: 2, h: 1, art: <><rect x="3" y="12" width="94" height="76" rx="6" fill={light} stroke={dark} strokeWidth="5" /><path d="M12 30h76M12 50h76M12 70h76" stroke={wood} strokeWidth="2" opacity=".7" /></> },
{ id: 'roundtable', name: 'Round table', w: 2, h: 2, art: <><circle cx="50" cy="50" r="46" fill={light} stroke={dark} strokeWidth="5" /><circle cx="50" cy="50" r="30" fill="none" stroke={wood} strokeWidth="2" opacity=".7" /></> },
{ id: 'chair', name: 'Chair', w: 0.6, h: 0.6, art: <><rect x="12" y="25" width="76" height="68" rx="8" fill={wood} stroke={dark} strokeWidth="6" /><rect x="8" y="6" width="84" height="22" rx="6" fill={dark} /></> },
{ id: 'bed', name: 'Bed', w: 1, h: 2, art: <><rect x="4" y="3" width="92" height="94" rx="8" fill={dark} /><rect x="9" y="8" width="82" height="84" rx="6" fill="#a8423b" /><rect x="14" y="12" width="72" height="22" rx="8" fill="#eee" stroke="#bbb" strokeWidth="2" /><path d="M9 46h82" stroke="#7b2d28" strokeWidth="4" /></> },
{ id: 'door', name: 'Door (decor)', w: 1, h: 0.25, art: <><rect x="2" y="15" width="96" height="70" rx="6" fill={wood} stroke={dark} strokeWidth="8" /><circle cx="80" cy="50" r="7" fill="#e0b84a" /></> },
{ id: 'campfire', name: 'Campfire', w: 1, h: 1, art: <><circle cx="50" cy="50" r="46" fill="#3b2f2a" /><path d="M20 70L80 30M20 30L80 70" stroke={dark} strokeWidth="9" strokeLinecap="round" /><path d="M50 18c14 16 22 26 14 42-5 9-23 9-28 0-8-16 6-26 14-42z" fill="#f08a24" /><path d="M50 40c8 9 10 14 6 21-3 5-10 5-12 0-3-7 2-12 6-21z" fill="#ffd24a" /></> },
{ id: 'torch', name: 'Torch', w: 0.5, h: 0.5, art: <><circle cx="50" cy="50" r="46" fill="#ffcf5a" opacity=".35" /><circle cx="50" cy="50" r="22" fill="#f08a24" /><circle cx="50" cy="50" r="11" fill="#ffd24a" /></> },
{ id: 'tree', name: 'Tree', w: 3, h: 3, art: <><circle cx="50" cy="50" r="48" fill="#2f6a2c" /><circle cx="36" cy="40" r="26" fill={green} /><circle cx="62" cy="56" r="28" fill="#47903f" /><circle cx="50" cy="50" r="10" fill="#6b4a25" opacity=".8" /></> },
{ id: 'bush', name: 'Bush', w: 1.2, h: 1.2, art: <><circle cx="35" cy="55" r="28" fill="#2f6a2c" /><circle cx="62" cy="42" r="28" fill={green} /><circle cx="64" cy="66" r="24" fill="#47903f" /></> },
{ id: 'rock', name: 'Rock', w: 1.2, h: 1, art: <path d="M10 62L22 24l32-14 30 12 8 34-18 22-34 4z" fill={stone} stroke="#4c4e52" strokeWidth="5" strokeLinejoin="round" /> },
{ id: 'rug', name: 'Rug', w: 3, h: 2, art: <><rect x="2" y="6" width="96" height="88" rx="4" fill="#8e2f3a" stroke="#d9b45a" strokeWidth="5" /><rect x="12" y="16" width="76" height="68" fill="none" stroke="#d9b45a" strokeWidth="3" /><circle cx="50" cy="50" r="14" fill="#d9b45a" opacity=".8" /></> },
{ id: 'stairs', name: 'Stairs', w: 1, h: 2, art: <><rect x="3" y="3" width="94" height="94" fill="#9b9ea3" stroke="#555" strokeWidth="4" />{[18, 33, 48, 63, 78].map((y) => <path key={y} d={`M3 ${y}h94`} stroke="#555" strokeWidth="4" />)}</> },
{ id: 'pillar', name: 'Pillar', w: 1, h: 1, art: <><circle cx="50" cy="50" r="44" fill="#a7a9ae" stroke="#5c5e63" strokeWidth="6" /><circle cx="50" cy="50" r="28" fill="#c4c6cb" stroke="#5c5e63" strokeWidth="3" /></> },
{ id: 'altar', name: 'Altar', w: 2, h: 1, art: <><rect x="3" y="12" width="94" height="76" rx="4" fill="#6c6f77" stroke="#3d3f45" strokeWidth="5" /><path d="M50 26v48M30 44h40" stroke="#d9b45a" strokeWidth="7" strokeLinecap="round" /></> },
{ id: 'bones', name: 'Bones', w: 1, h: 1, art: <><g stroke="#4a4338" strokeWidth="3" fill="#e8e1cf"><circle cx="40" cy="42" r="17" /><rect x="30" y="52" width="20" height="12" rx="3" /><path d="M58 70l30-28" strokeWidth="9" stroke="#e8e1cf" strokeLinecap="round" /><path d="M12 82l34-14" strokeWidth="9" stroke="#e8e1cf" strokeLinecap="round" /></g><circle cx="34" cy="40" r="4" fill="#222" /><circle cx="47" cy="40" r="4" fill="#222" /></> },
{ id: 'cart', name: 'Wreck / cart', w: 2, h: 1, art: <><rect x="6" y="14" width="88" height="72" rx="5" fill="#6d5a45" stroke="#3b2f22" strokeWidth="5" /><path d="M6 36h88M6 62h88" stroke="#3b2f22" strokeWidth="3" /><rect x="14" y="2" width="14" height="12" fill="#222" /><rect x="72" y="86" width="14" height="12" fill="#222" /></> },
]
const byId = Object.fromEntries(BUILTIN.map((p) => [p.id, p]))
export const isCustom = (id) => typeof id === 'string' && id.startsWith('c:')
export const builtin = (id) => byId[String(id).replace(/^b:/, '')]
// Picture of a prop filling its box. customSrc(id) gives the image URL for own uploads.
export function PropArt({ prop, customSrc }) {
if (isCustom(prop)) return <img src={customSrc?.(prop.slice(2))} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }} />
const b = builtin(prop)
return <svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: '100%', display: 'block', pointerEvents: 'none' }} aria-hidden="true">{b ? b.art : <rect width="100" height="100" fill="#888" />}</svg>
}
+24
View File
@@ -605,3 +605,27 @@ textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
.radialbtn { width: 58px; height: 58px; left: -29px; top: -29px; }
.radialtitle { background: #000; color: #fff; font-weight: 700; font-size: .8rem; border: 1px solid #8fd44c; }
.radialbtn:disabled { opacity: .78; background: #262b22 !important; border-color: #5a634f !important; cursor: not-allowed; }
/* decor tokens (props) */
.maptoken.prop { border: 0; border-radius: 0; background: none; box-shadow: none; display: block; transform-origin: center; }
.maptoken.prop:not(.mov) { pointer-events: none; }
.maptoken.prop.hid { opacity: .5; outline: 2px dashed rgba(255,255,255,.5); border-style: none; }
.maptoken.prop.sel { outline: 3px solid var(--accent, gold); outline-offset: 2px; }
.maptoken.prop em { display: none !important; }
.proppicker { max-width: 360px; }
.propgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(78px, 1fr)); gap: .3rem; max-height: 300px; overflow: auto; }
.propbtn { display: grid; justify-items: center; gap: .15rem; padding: .3rem; font-size: .72rem; position: relative; }
.propbtn svg, .propbtn img { width: 46px; height: 46px; object-fit: contain; }
.propbtn.own { padding: 0; }
.propbtn.own > .ghost:first-child { display: grid; justify-items: center; gap: .15rem; width: 100%; }
.propbtn.own .mini { position: absolute; top: 0; right: 0; padding: 0 .3rem; min-height: 0; }
.tokrow { display: flex; gap: .25rem; }
.tokrow > button:first-child { flex: 1; min-width: 0; }
.propdot { display: inline-block; width: .8rem; height: .8rem; border-radius: 2px; background: #b98545; vertical-align: middle; }
.tokenform input[type=range] { width: 100%; }
.tokenform .row { display: flex; flex-wrap: wrap; gap: .35rem; justify-content: flex-start; align-items: center; }
.tokenform label.small { display: flex; align-items: center; gap: .3rem; flex-wrap: wrap; }
.tokenform label.small input[type=number] { width: 4rem; }
.tokenform .mini { min-height: 0; padding: .15rem .5rem; }
.tokenlist .tokrow > button:first-child { width: auto; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.tokenlist .tokrow > button.mini { width: auto; flex: none; }