diff --git a/README.md b/README.md index eb8e74a..2292207 100644 --- a/README.md +++ b/README.md @@ -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=` 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 | diff --git a/backend/app/main.py b/backend/app/main.py index b28997e..818931a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/maps.py b/backend/app/maps.py index 71929f8..e5cc064 100644 --- a/backend/app/maps.py +++ b/backend/app/maps.py @@ -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} + # ---- 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,)) + 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 + 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 map's prepared tokens are copied to the lobby.""" + """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") - 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) - 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"))) - sync_players(con, lid) + 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: diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index c992a1e..01ab171 100644 --- a/frontend/src/Lobbies.jsx +++ b/frontend/src/Lobbies.jsx @@ -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 ( - 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} + `/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 && } {full && } - {full && } + {full && } + {full && } {full && (rv || mapState.reveals?.length > 0) && (
@@ -947,13 +955,24 @@ function LobbyView({ id, rules, user }) {
)} {npcPick &&
setNpcPick(false)} />
} - {full && mapSel && (() => { const st = mapState.tokens.find((t) => t.id === mapSel); return st && !selTok ?
{st.name || 'Player'} patchToken(st.id, p)} />
: null })()} + {propPick &&
setPropPick(false)} />
} + {selProp &&
patchToken(selProp.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selProp.id}`, 'DELETE'); setMapSel(null) }} />
} + {full && mapSel && (() => { const st = mapState.tokens.find((t) => t.id === mapSel); return st && !selTok && !selProp ?
{st.name || 'Player'} patchToken(st.id, p)} />
: null })()} {selTok &&
patchToken(selTok.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selTok.id}`, 'DELETE'); setMapSel(null) }} /> patchToken(selTok.id, p)} />
}
) })()} {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 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 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 }) }, diff --git a/frontend/src/MapView.jsx b/frontend/src/MapView.jsx index a12cea8..eac9933 100644 --- a/frontend/src/MapView.jsx +++ b/frontend/src/MapView.jsx @@ -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 (
@@ -118,10 +119,21 @@ export function MapView({ map, image, fog, portraitUrl, onMenu, isTurn, tokens, {image && } {showGrid &&
} {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 ( +
{ 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)' : '')}> + +
+ ) + } return (
{ 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 ( +
+
Props
+
+ {BUILTIN.map((p) => ( + + ))} + {mine.map((p) => ( + + + + + ))} +
+ + {err &&

{err}

} +
+ ) +} + +// Name, size, rotation and visibility of a decor token +export function PropForm({ t, onChange, onDelete, onDuplicate }) { + const rot = Math.round(t.rot || 0) + return ( +
+ onChange({ name: e.target.value })} /> + + + + + + + + + + + + + {onDuplicate && } + + +
+ ) +} + 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 }) {
-

Enemies and NPCs

-
+

{live ? 'Prepared tokens (used when the map is put on the table again)' : 'Enemies, NPCs and props'}

+
{picker && setPicker(false)} />} + {propPick && setPropPick(false)} />}
    - {tokens.map((t) =>
  • )} - {!tokens.length &&
  • None yet. Add a token, then drag it onto the map.
  • } + {tokens.map((t) => ( +
  • + + +
  • + ))} + {!tokens.length &&
  • None yet. Add a token or a prop, then drag it onto the map.
  • }
- {cur && 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' ? : + })()} {err &&

{err}

}
- (t.npc_id && t.portrait ? `/api/characters/${t.npc_id}/portrait?v=${t.portrait}` : null)} grid={showGrid} selected={sel} onSelect={setSel} tool={tool} + `/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={} 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 }) { {m.name} {m.w}×{m.h}, {m.tokens} token{m.tokens === 1 ? '' : 's'}, {m.walls} wall{m.walls === 1 ? '' : 's'}{!m.mine && ` · from ${m.author}`} {m.id === activeId - ? - : } - {m.mine && } + ? + : } + {m.mine && } {m.mine && } ))} {!list.length &&
  • No maps yet. Upload an image, then prepare enemies in the editor.
  • } - {editing && setEditing(null)} />} + {lobbyId && ( + <> +

    Scenes

    +

    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.

    +
      + {scenes.map((sc) => ( +
    • + {sc.name} {sc.map_name ? `Map: ${sc.map_name}` : 'map deleted'} · saved {new Date(sc.updated * 1000).toLocaleString()}{sc.current ? ' · running' : ''} + + {!sc.current && } + {sc.current && } + + + +
    • + ))} + {!scenes.length &&
    • No scenes yet. Put a map on the table.
    • } +
    + {activeId && } + + )} + {editing && setEditing(null)} />}
    ) } diff --git a/frontend/src/props.jsx b/frontend/src/props.jsx new file mode 100644 index 0000000..44b1590 --- /dev/null +++ b/frontend/src/props.jsx @@ -0,0 +1,37 @@ +// Decor tokens ("props"): drawn from above, purely visual. Built-in ones are SVG (id "b:"), own uploads are images (id "c:"). +// 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: <> }, + { id: 'barrel', name: 'Barrel', w: 1, h: 1, art: <> }, + { id: 'crate', name: 'Crate', w: 1, h: 1, art: <> }, + { id: 'table', name: 'Table', w: 2, h: 1, art: <> }, + { id: 'roundtable', name: 'Round table', w: 2, h: 2, art: <> }, + { id: 'chair', name: 'Chair', w: 0.6, h: 0.6, art: <> }, + { id: 'bed', name: 'Bed', w: 1, h: 2, art: <> }, + { id: 'door', name: 'Door (decor)', w: 1, h: 0.25, art: <> }, + { id: 'campfire', name: 'Campfire', w: 1, h: 1, art: <> }, + { id: 'torch', name: 'Torch', w: 0.5, h: 0.5, art: <> }, + { id: 'tree', name: 'Tree', w: 3, h: 3, art: <> }, + { id: 'bush', name: 'Bush', w: 1.2, h: 1.2, art: <> }, + { id: 'rock', name: 'Rock', w: 1.2, h: 1, art: }, + { id: 'rug', name: 'Rug', w: 3, h: 2, art: <> }, + { id: 'stairs', name: 'Stairs', w: 1, h: 2, art: <>{[18, 33, 48, 63, 78].map((y) => )} }, + { id: 'pillar', name: 'Pillar', w: 1, h: 1, art: <> }, + { id: 'altar', name: 'Altar', w: 2, h: 1, art: <> }, + { id: 'bones', name: 'Bones', w: 1, h: 1, art: <> }, + { id: 'cart', name: 'Wreck / cart', w: 2, h: 1, art: <> }, +] + +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 + const b = builtin(prop) + return +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ea4c2f7..ea7bb3e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; }