From 82e4ac945813a221bf7db0bbfd3523b43d8a4dcd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 14:23:17 +0000 Subject: [PATCH] Maps tab with sharing, walls/doors and strict line of sight Maps page (list, upload, edit, share, copy), wall/room/door tools in the map editor, server-side visibility polygon: the image is cut per player and other tokens are filtered, DM can view as a player, open doors and switch sight on/off. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K --- README.md | 24 ++-- backend/app/maps.py | 232 +++++++++++++++++++++++++++++++++++---- backend/app/vision.py | 132 ++++++++++++++++++++++ frontend/src/App.jsx | 4 +- frontend/src/Lobbies.jsx | 53 +++++++-- frontend/src/MapView.jsx | 134 +++++++++++++++++++--- frontend/src/Maps.jsx | 92 ++++++++++++++++ frontend/src/styles.css | 19 ++++ 8 files changed, 632 insertions(+), 58 deletions(-) create mode 100644 backend/app/vision.py create mode 100644 frontend/src/Maps.jsx diff --git a/README.md b/README.md index 3304212..f05b30a 100644 --- a/README.md +++ b/README.md @@ -110,13 +110,13 @@ The lobby page is built for desktop with three columns: the **character sheet** - **Colours**: every player has a different colour (lobby menu -> Your colour); the DM is gold. - **Lobby menu** (top right): join code, rename, remove players, colour, switch character, leave or delete the lobby. -### Maps and tokens +### Maps, tokens and line of sight -- **Library** (DM, left column -> *Lobby actions*): upload PNG, JPEG or WebP (up to 12 MB, 30 maps per DM). The server checks and re-encodes every image as WebP (max 4096 px per side) into `db/maps/`. Images are only served to logged-in members of a lobby that shows the map. -- **Map editor** (*Edit*): set the grid (pixels per square) and place enemies and NPCs in advance: add tokens, drag them, name, size (in squares), colour, *Hidden from players*, duplicate, remove. Save writes them into the map preset. -- **Show on table**: puts the map on the lobby table (dice still fall on top of it). The prepared tokens are copied into the lobby, so moving them during play never changes the preset. Every player gets a token of their own, which they **drag from the tray onto the map**; players can only move their own token, the DM moves everything and can rename, resize, hide or remove NPC tokens in play (*+ NPC* adds one). -- **Pan and zoom** are personal: drag the background, mouse wheel or pinch, buttons at the bottom right, *Grid* toggles the grid. Moves are synced live over the WebSocket. -- **Full view / Player view** (DM): player view hides what the players cannot see (currently hidden tokens). +- **Maps tab** (top navigation): all your maps with preview. Upload PNG, JPEG or WebP (up to 12 MB, 30 maps each; the server checks and re-encodes every image as WebP, max 4096 px per side, into `db/maps/`). **Edit** opens the map editor, **Share** gives other users access by username (like characters: they can put the map on their own table or **Copy** it, but not change it), **Duplicate** copies a map. +- **Map editor**: grid (pixels per square, token sizes are counted in squares), **enemies and NPCs** placed in advance (name, size, colour, *Hidden from players*, duplicate), and **walls and doors** for line of sight: tools *Wall* (click points, chains lines), *Room* (two corners), *Door* (two ends), *Erase*, *Undo last*; new lines snap to existing corners, optionally to grid corners. An optional **sight range** in squares limits how far characters see (0 = unlimited). +- **Show on table** (lobby, DM, *Lobby actions*): shows one of your or a shared map; the prepared tokens are copied into the lobby, so moving them during play never changes the map. Doors start closed. Every player has a token of their own. Without walls the players drag it onto the map themselves; with walls (line of sight) the DM places it from the tray. +- **At the table**: pan (drag the background), zoom (wheel, pinch, buttons) and *Grid* are personal. Players move only their own token; the DM moves everything, edits NPC tokens (click one), adds NPCs (*+ NPC*) and clicks doors to open or close them (*Walls* shows the layout in the full view). +- **Line of sight is strict**: the server calculates what each character can see from the walls and closed doors, cuts the map image to that area (everything else is black, only that piece is ever sent) and leaves out all other tokens. Walls are never sent to players. The DM chooses **Full view** or **View as ** (exactly what that player gets) and can switch **Sight on/off** for the table. 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. @@ -137,10 +137,14 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l | POST | `/api/lobbies/join` | Join with `{code, character_id}` | | GET / PUT / DELETE | `/api/lobbies/{id}` | Members and cards (DM sees all ids) / rename / delete (DM only) | | POST | `/api/lobbies/{id}/code` | New join code (DM only) | -| GET / POST | `/api/maps` | The DM's map library / upload (raw image body, `?name=`) | -| GET / PUT / DELETE | `/api/maps/{id}` | Map with prepared tokens (name, grid, tokens) / change / delete | -| GET / PUT | `/api/lobbies/{id}/map` | Map and tokens on the table (hidden tokens only for the DM) / DM: show map (`map_id`, null clears) | -| GET | `/api/lobbies/{id}/map/image` | The map image | +| GET / POST | `/api/maps` | Your maps and the ones shared with you / upload (raw image body, `?name=`) | +| GET / PUT / DELETE | `/api/maps/{id}` | Map with tokens and walls / change (name, grid, sight, tokens, walls) / delete | +| POST / DELETE | `/api/maps/{id}/shares[/{username}]` | Share with a user / stop sharing | +| POST | `/api/maps/{id}/copy` | Your own editable copy | +| GET | `/api/maps/{id}/thumb` | Preview image | +| 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 | | 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) | | GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=` to poll) / send, optional `to` = whisper | diff --git a/backend/app/maps.py b/backend/app/maps.py index 50162c3..1ed6dfc 100644 --- a/backend/app/maps.py +++ b/backend/app/maps.py @@ -2,22 +2,28 @@ 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 hashlib import io import json +import shutil import time import uuid from pathlib import Path -from fastapi import Body, Depends, HTTPException, Request +from fastapi import Body, Depends, HTTPException, Query, Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import Response from PIL import Image, ImageOps +from . import vision + MAX_UPLOAD = 12_000_000 # bytes of the uploaded file MAX_SIDE = 4096 # larger images are scaled down MAX_PIXELS = 60_000_000 # decompression bomb guard (before scaling) MAX_MAPS = 30 # per DM MAX_TOKENS = 120 # per map / lobby +MAX_WALLS = 400 # wall and door segments per map +MAX_SHARES = 30 COLORS = ("#c0392b", "#8e44ad", "#16a085", "#7f8c8d", "#d35400", "#2c3e50", "#27ae60", "#b9770e") @@ -29,8 +35,11 @@ def schema(con): id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, user_id INTEGER, name TEXT NOT NULL, x REAL, y REAL, color TEXT, size REAL NOT NULL DEFAULT 1, hidden INTEGER NOT NULL DEFAULT 0)""") con.execute("CREATE INDEX IF NOT EXISTS lobby_tokens_lobby ON lobby_tokens (lobby_id)") - if "map_id" not in [r["name"] for r in con.execute("PRAGMA table_info(lobbies)")]: - con.execute("ALTER TABLE lobbies ADD COLUMN map_id TEXT") + 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")): + 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}") def encode(raw: bytes) -> tuple[bytes, int, int]: @@ -75,6 +84,26 @@ def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict: "size": size, "color": clean_color(t.get("color")), "hidden": bool(t.get("hidden"))} +def clean_walls(raw, w: int, h: int) -> list: + if not isinstance(raw, list) or len(raw) > MAX_WALLS: + raise HTTPException(422, f"At most {MAX_WALLS} wall segments per map") + out = [] + for t in raw: + try: + x1, y1, x2, y2 = (float(t[k]) for k in ("x1", "y1", "x2", "y2")) + except (TypeError, ValueError, KeyError, IndexError): + raise HTTPException(422, "Invalid wall") + x1, x2 = min(max(x1, 0), w), min(max(x2, 0), w) + y1, y2 = min(max(y1, 0), h), min(max(y2, 0), h) + if (x1, y1) == (x2, y2): + continue + wid = str(t.get("id", "")) + out.append({"id": wid if wid.isalnum() and len(wid) <= 16 else uuid.uuid4().hex[:10], + "x1": round(x1, 1), "y1": round(y1, 1), "x2": round(x2, 1), "y2": round(y2, 1), + "kind": "door" if t.get("kind") == "door" else "wall"}) + return out + + def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Path): store_dir.mkdir(parents=True, exist_ok=True) @@ -87,16 +116,33 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa raise HTTPException(404, "Map not found") return row + def usable_map(con, mid, user): + """A map the user owns or that was shared with them (shared maps can be put on the table but not changed).""" + row = con.execute("""SELECT * FROM maps WHERE id=? AND (owner_id=? OR EXISTS + (SELECT 1 FROM map_shares s WHERE s.map_id = maps.id AND s.user_id=?))""", (mid, user["id"], user["id"])).fetchone() + if not row: + raise HTTPException(404, "Map not found") + return row + def meta(r) -> dict: - return {"id": r["id"], "name": r["name"], "w": r["w"], "h": r["h"], "grid": r["grid"]} + return {"id": r["id"], "name": r["name"], "w": r["w"], "h": r["h"], "grid": r["grid"], "sight": r["sight"]} # ---- the DM's library @api.get("/maps") def list_maps(user: dict = Depends(current_user)): with db() as con: - rows = con.execute("SELECT * FROM maps WHERE owner_id=? ORDER BY created DESC", (user["id"],)).fetchall() - return [{**meta(r), "tokens": len(json.loads(r["tokens"]))} for r in rows] + rows = con.execute("""SELECT m.*, u.username AS author FROM maps m JOIN users u ON u.id = m.owner_id + WHERE m.owner_id=? OR EXISTS (SELECT 1 FROM map_shares s WHERE s.map_id = m.id AND s.user_id=?) + ORDER BY m.created DESC""", (user["id"], user["id"])).fetchall() + out = [] + for r in rows: + mine = r["owner_id"] == user["id"] + shared = [x["username"] for x in con.execute( + "SELECT u.username FROM map_shares s JOIN users u ON u.id = s.user_id WHERE s.map_id=? ORDER BY u.username", (r["id"],))] if mine else [] + out.append({**meta(r), "tokens": len(json.loads(r["tokens"])), "walls": len(json.loads(r["walls"])), + "mine": mine, "author": r["author"], "shared_with": shared}) + return out @api.post("/maps") async def upload_map(request: Request, name: str = "", user: dict = Depends(current_user)): @@ -130,7 +176,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa def update_map(mid: str, body: dict = Body(...), user: dict = Depends(current_user)): with db() as con: r = own_map(con, mid, user) - name, grid, tokens = r["name"], r["grid"], r["tokens"] + name, grid, tokens, walls, sight = r["name"], r["grid"], r["tokens"], r["walls"], r["sight"] if "name" in body: name = str(body["name"]).strip()[:60] or r["name"] if "grid" in body: @@ -144,7 +190,16 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa if not isinstance(body["tokens"], list) or len(body["tokens"]) > MAX_TOKENS: raise HTTPException(422, f"At most {MAX_TOKENS} tokens per map") tokens = json.dumps([clean_token(t, r["w"], r["h"]) for t in body["tokens"]]) - con.execute("UPDATE maps SET name=?, grid=?, tokens=? WHERE id=?", (name, grid, tokens, mid)) + if "walls" in body: + walls = json.dumps(clean_walls(body["walls"], r["w"], r["h"])) + if "sight" in body: + try: + sight = float(body["sight"]) + except (TypeError, ValueError): + raise HTTPException(422, "The sight range must be a number") + if not 0 <= sight <= 1000: + raise HTTPException(422, "Sight range: 0 (unlimited) to 1000 squares") + con.execute("UPDATE maps SET name=?, grid=?, tokens=?, walls=?, sight=? WHERE id=?", (name, grid, tokens, walls, sight, mid)) lobbies = [x["id"] for x in con.execute("SELECT id FROM lobbies WHERE map_id=?", (mid,))] for lid in lobbies: hub.notify(lid, "map") @@ -156,10 +211,12 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa own_map(con, mid, user) lobbies = [x["id"] for x in con.execute("SELECT id FROM lobbies WHERE map_id=?", (mid,))] 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,)) con.execute("DELETE FROM maps WHERE id=?", (mid,)) path(mid).unlink(missing_ok=True) + (store_dir / f"{mid}.thumb.webp").unlink(missing_ok=True) for lid in lobbies: hub.notify(lid, "map") return {"ok": True} @@ -170,6 +227,52 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa own_map(con, mid, user) return image_response(path(mid)) + @api.get("/maps/{mid}/thumb") + def map_thumb(mid: str, user: dict = Depends(current_user)): + with db() as con: + usable_map(con, mid, user) + t = store_dir / f"{mid}.thumb.webp" + if not t.exists(): + im = Image.open(path(mid)).convert("RGB") + im.thumbnail((320, 320)) + im.save(t, "WEBP", quality=80) + return image_response(t) + + @api.post("/maps/{mid}/shares") + def share_map(mid: str, body: dict = Body(...), user: dict = Depends(current_user)): + name = str(body.get("username", "")).strip() + with db() as con: + own_map(con, mid, user) + target = con.execute("SELECT id, username FROM users WHERE username=?", (name,)).fetchone() + if not target: + raise HTTPException(404, "No user with this name") + if target["id"] == user["id"]: + raise HTTPException(422, "This is your own map") + if con.execute("SELECT COUNT(*) FROM map_shares WHERE map_id=?", (mid,)).fetchone()[0] >= MAX_SHARES: + raise HTTPException(409, "Shared with too many users") + con.execute("INSERT OR IGNORE INTO map_shares VALUES (?,?)", (mid, target["id"])) + return {"username": target["username"]} + + @api.delete("/maps/{mid}/shares/{username}") + def unshare_map(mid: str, username: str, user: dict = Depends(current_user)): + with db() as con: + own_map(con, mid, user) + con.execute("DELETE FROM map_shares WHERE map_id=? AND user_id=(SELECT id FROM users WHERE username=?)", (mid, username)) + return {"ok": True} + + @api.post("/maps/{mid}/copy") + def copy_map(mid: str, user: dict = Depends(current_user)): + """Your own editable copy (image, grid, tokens, walls) of a map you own or that was shared with you.""" + with db() as con: + r = usable_map(con, mid, user) + if con.execute("SELECT COUNT(*) FROM maps WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_MAPS: + raise HTTPException(409, "Map limit reached, delete one first") + nid = uuid.uuid4().hex[:12] + shutil.copyfile(path(mid), path(nid)) + con.execute("INSERT INTO maps (id, owner_id, name, w, h, grid, tokens, walls, sight, created) VALUES (?,?,?,?,?,?,?,?,?,?)", + (nid, user["id"], f"{r['name']} (copy)"[:60], r["w"], r["h"], r["grid"], r["tokens"], r["walls"], r["sight"], time.time())) + return {"id": nid} + def image_response(p: Path): if not p.exists(): raise HTTPException(404, "Image missing") @@ -190,18 +293,57 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa def lobby_map(con, lobby): return con.execute("SELECT * FROM maps WHERE id=?", (lobby["map_id"],)).fetchone() if lobby["map_id"] else None - def tokens_for(con, lobby, user, is_dm: bool) -> list: + def view_ctx(con, lobby, m, user, as_name=None): + """Whose eyes the table is shown through. Players: their own token. DM: everything, or (as=name) one player's view.""" + is_dm = lobby["owner_id"] == user["id"] + viewer = None + if is_dm and as_name: + row = con.execute("""SELECT m.user_id FROM lobby_members m JOIN users u ON u.id = m.user_id + WHERE m.lobby_id=? AND u.username=?""", (lobby["id"], as_name)).fetchone() + if not row: + raise HTTPException(404, "No such player in this lobby") + viewer = row["user_id"] + elif not is_dm: + viewer = user["id"] + walls = json.loads(m["walls"]) + doors = json.loads(lobby["doors"] or "{}") + ctx = {"is_dm": is_dm, "viewer": viewer, "walls": walls, "doors": doors, "los": False, "empty": False, "poly": None, + "rev": "full:" + m["id"], "key": None} + if viewer is None: + return ctx + ctx["rev"] = "open:" + m["id"] + if not (lobby["los"] and walls): + return ctx + tok = con.execute("SELECT x, y FROM lobby_tokens WHERE lobby_id=? AND user_id=?", (lobby["id"], viewer)).fetchone() + ctx["los"] = True + if not tok or tok["x"] is None: # not on the map yet: sees nothing + ctx.update(empty=True, rev="empty") + return ctx + x, y = round(tok["x"]), round(tok["y"]) + blocking = [(w["x1"], w["y1"], w["x2"], w["y2"]) for w in walls if w["kind"] == "wall" or not doors.get(w["id"])] + radius = round(m["sight"] * m["grid"], 1) + key = hashlib.sha1(json.dumps([m["id"], blocking, radius, x, y]).encode()).hexdigest() + poly = vision.cached_polygon(key, lambda: vision.polygon((x, y), blocking, m["w"], m["h"], radius)) + ctx.update(poly=poly, key=key, rev=key[:12]) + return ctx + + def tokens_for(con, lobby, user, ctx) -> list: sync_players(con, lobby["id"]) rows = con.execute("""SELECT t.*, u.username, m.color AS mcolor, c.data FROM lobby_tokens t LEFT JOIN users u ON u.id = t.user_id LEFT JOIN lobby_members m ON m.lobby_id = t.lobby_id AND m.user_id = t.user_id LEFT JOIN characters c ON c.id = m.character_id WHERE t.lobby_id=? ORDER BY t.rowid""", (lobby["id"],)).fetchall() + full = ctx["viewer"] is None out = [] for r in rows: player = r["user_id"] is not None - if r["hidden"] and not is_dm: - continue + own = player and r["user_id"] == ctx["viewer"] + if not full and not own: + if r["hidden"] or r["x"] is None: + continue + if ctx["los"] and (ctx["empty"] or not vision.inside((r["x"], r["y"]), ctx["poly"])): + continue name = r["name"] if player: try: @@ -211,25 +353,69 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name, "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": is_dm or r["user_id"] == user["id"]}) + "can_move": ctx["is_dm"] or (r["user_id"] == user["id"] and not (ctx["los"] and r["x"] is None))}) return out @api.get("/lobbies/{lid}/map") - def read_lobby_map(lid: str, user: dict = Depends(current_user)): + def read_lobby_map(lid: str, as_: str = Query("", alias="as"), user: dict = Depends(current_user)): + """Map and tokens as the viewer may see them. Walls are only sent to the DM (in the full view).""" with db() as con: lobby = lobby_for(con, lid, user) - is_dm = lobby["owner_id"] == user["id"] m = lobby_map(con, lobby) - return {"map": meta(m) if m else None, "tokens": tokens_for(con, lobby, user, is_dm) if m else []} + if not m: + return {"map": None, "tokens": []} + ctx = view_ctx(con, lobby, m, user, as_) + res = {"map": meta(m), "tokens": tokens_for(con, lobby, user, ctx), + "vision": {"los": ctx["los"], "empty": ctx["empty"], "rev": ctx["rev"]}} + if ctx["is_dm"]: + res["los_on"] = bool(lobby["los"]) + if ctx["viewer"] is None: + res["walls"] = [{**w, "open": bool(ctx["doors"].get(w["id"]))} for w in ctx["walls"]] + return res - @api.get("/lobbies/{lid}/map/image") - def lobby_map_image(lid: str, user: dict = Depends(current_user)): + @api.get("/lobbies/{lid}/map/view") + def lobby_map_view(lid: str, as_: str = Query("", alias="as"), user: dict = Depends(current_user)): + """The map image for this viewer. With line of sight on, only the part their character sees is sent + (cut out on the server, the rest is black), together with its position in the X-Crop header (x,y,w,h).""" with db() as con: lobby = lobby_for(con, lid, user) - if not lobby["map_id"]: + m = lobby_map(con, lobby) + if not m: raise HTTPException(404, "No map") - mid = lobby["map_id"] - return image_response(path(mid)) + ctx = view_ctx(con, lobby, m, user, as_) + if ctx["empty"]: + raise HTTPException(404, "Nothing to see yet") + if ctx["los"]: + data, (x, y, w, h) = vision.cut(path(m["id"]), ctx["poly"], ctx["key"]) + return Response(data, media_type="image/webp", headers={"X-Crop": f"{x},{y},{w},{h}", "Cache-Control": "no-store"}) + return Response(path(m["id"]).read_bytes(), media_type="image/webp", + headers={"X-Crop": f"0,0,{m['w']},{m['h']}", "Cache-Control": "private, max-age=3600"}) + + @api.put("/lobbies/{lid}/doors/{wid}") + def set_door(lid: str, wid: str, body: dict = Body(...), user: dict = Depends(current_user)): + with db() as con: + lobby = lobby_for(con, lid, user) + dm_only(lobby, user) + m = need_map(con, lobby) + if not any(w["id"] == wid and w["kind"] == "door" for w in json.loads(m["walls"])): + raise HTTPException(404, "No such door") + doors = json.loads(lobby["doors"] or "{}") + if body.get("open"): + doors[wid] = True + else: + doors.pop(wid, None) + con.execute("UPDATE lobbies SET doors=? WHERE id=?", (json.dumps(doors), lid)) + hub.notify(lid, "map") + return {"ok": True} + + @api.put("/lobbies/{lid}/sight") + def set_sight(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): + """DM: switch line of sight on or off for the table (off: players see the whole map).""" + with db() as con: + dm_only(lobby_for(con, lid, user), user) + con.execute("UPDATE lobbies SET los=? WHERE id=?", (1 if body.get("on") else 0, lid)) + 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)): @@ -239,7 +425,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa mid = body.get("map_id") con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,)) if mid: - r = own_map(con, str(mid), user) + r = usable_map(con, str(mid), user) for t in json.loads(r["tokens"]): con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden) VALUES (?,?,?,?,?,?,?,?)", (uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]))) @@ -247,7 +433,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa event(con, lid, f"The DM put the map \"{r['name']}\" on the table", user["id"]) else: event(con, lid, "The DM cleared the table", user["id"]) - con.execute("UPDATE lobbies SET map_id=? WHERE id=?", (str(mid) if mid else None, lid)) + con.execute("UPDATE lobbies SET map_id=?, doors='{}' WHERE id=?", (str(mid) if mid else None, lid)) hub.notify(lid, "map") return {"ok": True} @@ -281,6 +467,8 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa t = con.execute("SELECT * FROM lobby_tokens WHERE id=? AND lobby_id=?", (tid, lid)).fetchone() if not t or (not is_dm and t["user_id"] != user["id"]): 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"]} if "x" in body or "y" in body: merged["x"], merged["y"] = body.get("x"), body.get("y") diff --git a/backend/app/vision.py b/backend/app/vision.py new file mode 100644 index 0000000..562fa10 --- /dev/null +++ b/backend/app/vision.py @@ -0,0 +1,132 @@ +"""Line of sight: visibility polygon from a point around wall segments, and cutting the map image to it. + +The server cuts the image, so a player's browser only ever receives the pixels their character can see (no peeking through +dev tools). Pure Python on purpose (no numpy): a few hundred wall segments are fast enough, and results are cached.""" +import io +import math +from collections import OrderedDict + +from PIL import Image, ImageDraw + +EPS = 1e-5 + + +def polygon(origin, segs, w, h, radius=0.0): + """Points of the area visible from origin. segs: [(x1, y1, x2, y2)] that block sight; radius 0 means unlimited.""" + ox, oy = origin + border = [(0, 0, w, 0), (w, 0, w, h), (w, h, 0, h), (0, h, 0, 0)] + if radius: + r2 = (radius + 1) ** 2 + def near(s): + x1, y1, x2, y2 = s + dx, dy = x2 - x1, y2 - y1 + L = dx * dx + dy * dy + t = 0 if L == 0 else max(0, min(1, ((ox - x1) * dx + (oy - y1) * dy) / L)) + return (x1 + t * dx - ox) ** 2 + (y1 + t * dy - oy) ** 2 <= r2 + segs = [s for s in segs if near(s)] + allsegs = segs + border + angles = [] + for x1, y1, x2, y2 in allsegs: + for x, y in ((x1, y1), (x2, y2)): + if radius and (x - ox) ** 2 + (y - oy) ** 2 > (radius * 3) ** 2: + continue + a = math.atan2(y - oy, x - ox) + angles += (a - EPS, a, a + EPS) + if radius: + angles += [i * (2 * math.pi / 120) for i in range(120)] + angles.sort() + pts = [] + for a in angles: + dx, dy = math.cos(a), math.sin(a) + best = radius if radius else float("inf") + for x1, y1, x2, y2 in allsegs: + ex, ey = x2 - x1, y2 - y1 + den = dx * ey - dy * ex + if abs(den) < 1e-12: + continue + px, py = x1 - ox, y1 - oy + t = (px * ey - py * ex) / den + if t < 0 or t >= best: + continue + u = (px * dy - py * dx) / den + if -1e-9 <= u <= 1 + 1e-9: + best = t + if best == float("inf"): + continue + pts.append((ox + dx * best, oy + dy * best)) + return pts + + +def inside(pt, poly) -> bool: + x, y = pt + c = False + j = len(poly) - 1 + for i in range(len(poly)): + xi, yi = poly[i] + xj, yj = poly[j] + if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi: + c = not c + j = i + return c + + +class Lru(OrderedDict): + def __init__(self, size): + super().__init__() + self.size = size + + def put(self, k, v): + self[k] = v + self.move_to_end(k) + while len(self) > self.size: + self.popitem(last=False) + + def fetch(self, k): + if k in self: + self.move_to_end(k) + return self[k] + + +_images = Lru(2) +_polys = Lru(128) +_cuts = Lru(24) + + +def cached_polygon(key, make): + v = _polys.fetch(key) + if v is None: + v = make() + _polys.put(key, v) + return v + + +def source(path): + key = (str(path), path.stat().st_mtime_ns) + im = _images.fetch(key) + if im is None: + im = Image.open(path) + im.load() + im = im.convert("RGB") + _images.put(key, im) + return im + + +def cut(path, poly, key): + """The part of the image inside the polygon (everything else black), cropped to its bounding box. + Returns (webp bytes, (x, y, w, h)).""" + hit = _cuts.fetch(key) + if hit: + return hit + im = source(path) + xs, ys = [p[0] for p in poly], [p[1] for p in poly] + x0, y0 = max(0, int(math.floor(min(xs)))), max(0, int(math.floor(min(ys)))) + x1, y1 = min(im.width, int(math.ceil(max(xs)))), min(im.height, int(math.ceil(max(ys)))) + w, h = max(1, x1 - x0), max(1, y1 - y0) + mask = Image.new("L", (w, h), 0) + ImageDraw.Draw(mask).polygon([(x - x0, y - y0) for x, y in poly], fill=255) + piece = Image.composite(im.crop((x0, y0, x0 + w, y0 + h)), Image.new("RGB", (w, h), (0, 0, 0)), mask) + out = io.BytesIO() + piece.save(out, "WEBP", quality=82, method=1) + res = (out.getvalue(), (x0, y0, w, h)) + _cuts.put(key, res) + return res diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 631d95a..8f309f5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import Creator from './Creator.jsx' import Rules from './Rules.jsx' +import Maps from './Maps.jsx' import Characters from './Characters.jsx' import Presets from './Presets.jsx' import Lobbies from './Lobbies.jsx' @@ -54,7 +55,7 @@ export default function App() { if (!auth.user) return
setAuth({ ...auth, user: u })} />
const page = hash.split('/')[0] - const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])] + const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['maps', 'Maps'], ['characters', 'My characters'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])] return ( <> @@ -75,6 +76,7 @@ export default function App() { {rules && page === 'creator' && setLoadRequest(null)} onName={setCharName} />} {rules && page === 'rules' && } {rules && page === 'presets' && } + {page === 'maps' && } {rules && page === 'characters' && ( { setLoadRequest(entry); window.location.hash = 'creator' }} onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} /> diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index 0f0c7bf..2b7248e 100644 --- a/frontend/src/Lobbies.jsx +++ b/frontend/src/Lobbies.jsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx' -import { MapView, MapLibrary, TokenForm } from './MapView.jsx' +import { MapView, MapLibrary, 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) }) @@ -142,6 +142,31 @@ function useSocket(id, handlers) { return up } +// The map picture for this viewer. With line of sight the server sends only the visible part (position in X-Crop), so the picture +// is fetched again whenever the server's `rev` for this viewer changes; the old one stays until the new one has arrived. +function useMapImage(id, vision, as) { + const [img, setImg] = useState(null) + const last = useRef({ t: 0, n: 0, timer: 0 }) + const rev = vision?.rev + useEffect(() => { + if (!rev || vision.empty) { setImg(null); return } + const n = ++last.current.n + const go = () => fetch(`/api/lobbies/${id}/map/view${as ? `?as=${encodeURIComponent(as)}` : ''}`).then(async (r) => { + if (!r.ok) throw new Error(r.status) + const [x, y, w, h] = (r.headers.get('X-Crop') || '0,0,0,0').split(',').map(Number) + const url = URL.createObjectURL(await r.blob()) + if (n !== last.current.n) { URL.revokeObjectURL(url); return } // a newer one was asked for meanwhile + setImg((old) => { if (old) URL.revokeObjectURL(old.src); return { src: url, x, y, w, h } }) + }).catch(() => {}) + const wait = Math.max(0, 200 - (Date.now() - last.current.t)) // at most ~5 pictures per second while a token is dragged + clearTimeout(last.current.timer) + last.current.timer = setTimeout(() => { last.current.t = Date.now(); go() }, wait) + return () => clearTimeout(last.current.timer) + }, [id, rev, as]) // eslint-disable-line + useEffect(() => () => setImg((old) => { old && URL.revokeObjectURL(old.src); return null }), []) + return img +} + // Chat, whispers, event log, dice rolls and checks arrive through one feed that is polled while the page is open. function useFeed(id, onEvent, onRoll, socketUp) { const [msgs, setMsgs] = useState([]) @@ -552,9 +577,11 @@ function LobbyView({ id, rules, user }) { }).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') }), [id]) const [mapState, setMapState] = useState({ map: null, tokens: [] }) const [mapSel, setMapSel] = useState(null) - const [playerView, setPlayerView] = useState(false) // the DM looks at the table the way the players see it + const [viewAs, setViewAs] = useState('') // the DM can look at the table through one player's eyes const [showGrid, setShowGrid] = useState(false) - const loadMap = useCallback(() => fetch(`/api/lobbies/${id}/map`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setMapState(d)).catch(() => {}), [id]) + const [showWalls, setShowWalls] = useState(true) + const loadMap = useCallback(() => fetch(`/api/lobbies/${id}/map${viewAs ? `?as=${encodeURIComponent(viewAs)}` : ''}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setMapState(d)).catch(() => {}), [id, viewAs]) + const mapImg = useMapImage(id, mapState.vision, viewAs) const sock = useRef({}) const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 }) const up = useSocket(id, { @@ -672,15 +699,25 @@ function LobbyView({ id, rules, user }) {
{showSettings && !lobby.is_dm && setShowSettings(false)} />} {mapState.map && (() => { - const shown = mapState.tokens.filter((t) => !(playerView && t.hidden)) + const full = lobby.is_dm && !viewAs const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null + const walls = full && showWalls ? mapState.walls || [] : [] return ( - + act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}> + {mapState.vision?.empty &&
{lobby.is_dm ? 'This player is not on the map yet. Switch to the full view and drag their token onto it.' : 'Waiting for the DM to put your token on the map.'}
}
- {lobby.is_dm && } + {lobby.is_dm && ( + + )} + {lobby.is_dm && mapState.walls?.length > 0 && } + {full && mapState.walls?.length > 0 && } - {lobby.is_dm && } + {full && }
{selTok &&
patchToken(selTok.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selTok.id}`, 'DELETE'); setMapSel(null) }} />
}
diff --git a/frontend/src/MapView.jsx b/frontend/src/MapView.jsx index 783862e..7a2f5bd 100644 --- a/frontend/src/MapView.jsx +++ b/frontend/src/MapView.jsx @@ -6,7 +6,7 @@ const clamp = (v, a, b) => Math.min(b, Math.max(a, v)) // Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels. // Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise. -export function MapView({ map, src, tokens, onMove, selected, onSelect, grid: showGrid, children }) { +export function MapView({ map, image, 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) @@ -46,12 +46,14 @@ export function MapView({ map, src, tokens, onMove, selected, onSelect, grid: sh }, []) const down = (e) => { - if (e.target.closest('.maptoken, .mapui')) return + if (e.target.closest('.maptoken, .mapui, .mapdoor')) return box.current.setPointerCapture(e.pointerId) - pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }) + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY }) if (pointers.current.size === 1) onSelect?.(null) + else pointers.current.forEach((p, k) => pointers.current.set(k, { ...p, sx: -1e9 })) // two fingers: pinch, not a click } const move = (e) => { + if (onHover) onHover(toWorld(e), viewRef.current.s) const p = pointers.current.get(e.pointerId) if (!p) return const r = box.current.getBoundingClientRect() @@ -59,7 +61,7 @@ export function MapView({ map, src, tokens, onMove, selected, onSelect, grid: sh const [a, b] = [...pointers.current.values()] const before = Math.hypot(a.x - b.x, a.y - b.y) const o = pointers.current.get(e.pointerId) - const now = { ...o, x: e.clientX, y: e.clientY } + const now = { ...o, x: e.clientX, y: e.clientY, sx: -1e9 } pointers.current.set(e.pointerId, now) const [c, d] = [...pointers.current.values()] const after = Math.hypot(c.x - d.x, c.y - d.y) @@ -67,10 +69,15 @@ export function MapView({ map, src, tokens, onMove, selected, onSelect, grid: sh return } const dx = e.clientX - p.x, dy = e.clientY - p.y - pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }) + pointers.current.set(e.pointerId, { ...p, x: e.clientX, y: e.clientY }) setView((v) => ({ ...v, tx: v.tx + dx, ty: v.ty + dy })) } - const up = (e) => pointers.current.delete(e.pointerId) + const up = (e) => { + const p = pointers.current.get(e.pointerId) + pointers.current.delete(e.pointerId) + // a press that barely moved is a click on the map (used by the drawing tools) + if (p && onClick && e.type === 'pointerup' && Math.hypot(e.clientX - p.sx, e.clientY - p.sy) < 5) onClick(toWorld(e), viewRef.current.s, e) + } const toWorld = (ev) => { const r = box.current.getBoundingClientRect(), v = viewRef.current @@ -105,10 +112,11 @@ export function MapView({ map, src, tokens, onMove, selected, onSelect, grid: sh const unplaced = tokens.filter((t) => t.x == null && t.can_move) const d = (t) => t.size * map.grid return ( -
+
- + {image && } {showGrid &&
} + {overlay} {tokens.map((t) => { const dr = drag?.id === t.id ? drag : null const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y @@ -159,36 +167,108 @@ export function TokenForm({ t, onChange, onDelete, onDuplicate }) { const uid = () => Math.random().toString(36).slice(2, 12) -// Full-screen editor for a map in the DM's library: set the grid, place enemies and NPCs in advance. +// Walls and doors as a line layer on top of the map (DM only). In the live view doors can be clicked to open or close them. +export function WallLayer({ map, walls, draft, hover, onDoor }) { + const line = (w, extra = {}) => ( + + ) + return ( + + {walls.filter((w) => !(onDoor && w.kind === 'door')).map((w) => line(w))} + {walls.filter((w) => onDoor && w.kind === 'door').map((w) => ( + onDoor(w)} style={{ pointerEvents: 'stroke', cursor: 'pointer' }}> + {w.open ? 'Open door, click to close' : 'Closed door, click to open'} + + {line(w, { style: { pointerEvents: 'none' } })} + + ))} + {draft && hover && } + {draft && } + + ) +} + +const distToSeg = (p, w) => { + const dx = w.x2 - w.x1, dy = w.y2 - w.y1, L = dx * dx + dy * dy + const t = L === 0 ? 0 : clamp(((p.x - w.x1) * dx + (p.y - w.y1) * dy) / L, 0, 1) + return Math.hypot(w.x1 + t * dx - p.x, w.y1 + t * dy - p.y) +} + +// 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 }) { const [m, setM] = useState(null) const [tokens, setTokens] = useState([]) + const [walls, setWalls] = useState([]) const [name, setName] = useState('') const [grid, setGrid] = useState(40) + const [sight, setSight] = useState(0) const [showGrid, setShowGrid] = useState(true) + const [snap, setSnap] = useState(false) + const [tool, setTool] = useState('select') + const [draft, setDraft] = useState(null) + const [hover, setHover] = useState(null) const [sel, setSel] = useState(null) const [err, setErr] = useState('') 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 }))); setName(d.name); setGrid(d.grid) }) + 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) + }) }, [id]) + useEffect(() => { + const k = (e) => { if (e.key === 'Escape') { setDraft(null); if (tool !== 'select') setTool('select') } } + window.addEventListener('keydown', k) + return () => window.removeEventListener('keydown', k) + }, [tool]) + // the full-size image, loaded once for the editor + 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 if (!m) return null + const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid const cur = tokens.find((t) => t.id === sel) const patch = (tid, p) => { setTokens((ts) => ts.map((t) => (t.id === tid ? { ...t, ...p } : t))); setDirty(true) } + const addWalls = (list) => { setWalls((w) => [...w, ...list.map((x) => ({ id: uid(), ...x }))]); setDirty(true) } 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) * m.grid, y: m.h / 2 + (n % 3) * m.grid, size: 1, color: COLORS[0], hidden: false } + 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 } setTokens([...tokens, t]); setSel(t.id); setDirty(true) } + const snapPt = (p, s) => { // join onto existing corners first, then the grid + let best = null, bd = 12 / s + for (const w of walls) for (const [x, y] of [[w.x1, w.y1], [w.x2, w.y2]]) { const d = Math.hypot(x - p.x, y - p.y); if (d < bd) { bd = d; best = { x, y } } } + if (best) return best + if (snap) return { x: clamp(Math.round(p.x / gridNum) * gridNum, 0, m.w), y: clamp(Math.round(p.y / gridNum) * gridNum, 0, m.h) } + return { x: clamp(p.x, 0, m.w), y: clamp(p.y, 0, m.h) } + } + const click = (world, s) => { + if (tool === 'select') return + if (tool === 'erase') { + let best = null, bd = 10 / s + for (const w of walls) { const d = distToSeg(world, w); if (d < bd) { bd = d; best = w } } + if (best) { setWalls(walls.filter((w) => w.id !== best.id)); setDirty(true) } + return + } + const p = snapPt(world, s) + if (!draft) { setDraft(p); return } + if (Math.hypot(p.x - draft.x, p.y - draft.y) < 1) { setDraft(null); return } // click the same point again: finish + if (tool === 'wall') { addWalls([{ x1: draft.x, y1: draft.y, x2: p.x, y2: p.y, kind: 'wall' }]); setDraft(p) } + else if (tool === 'door') { addWalls([{ x1: draft.x, y1: draft.y, x2: p.x, y2: p.y, kind: 'door' }]); setDraft(null) } + else if (tool === 'rect') { + addWalls([[draft.x, draft.y, p.x, draft.y], [p.x, draft.y, p.x, p.y], [p.x, p.y, draft.x, p.y], [draft.x, p.y, draft.x, draft.y]].map(([x1, y1, x2, y2]) => ({ x1, y1, x2, y2, kind: 'wall' }))) + setDraft(null) + } + } const save = async () => { setErr('') const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, grid: Number(grid), tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden }) => ({ id: i, name: n, x, y, size, color, hidden })) }) }) + body: JSON.stringify({ name, grid: Number(grid), sight: Number(sight) || 0, walls, + tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden }) => ({ id: i, name: n, x, y, size, color, hidden })) }) }) if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false } setDirty(false); return true } const close = async () => { if (!dirty || window.confirm('Close without saving your changes?')) onClose() } - const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid + const tools = [['select', 'Move tokens'], ['wall', 'Wall'], ['rect', 'Room'], ['door', 'Door'], ['erase', 'Erase']] return createPortal(
- setHover(p) : undefined} + overlay={} onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
, document.body) @@ -242,13 +342,13 @@ export function MapLibrary({ activeId, lobbyId, act }) {
    {list.map((m) => (
  • - {m.name} {m.w}×{m.h}, {m.tokens} token{m.tokens === 1 ? '' : 's'} + {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 && }
  • ))} diff --git a/frontend/src/Maps.jsx b/frontend/src/Maps.jsx new file mode 100644 index 0000000..7ccaf54 --- /dev/null +++ b/frontend/src/Maps.jsx @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useState } from 'react' +import { MapEditor } from './MapView.jsx' + +const J = { 'Content-Type': 'application/json' } +const errText = async (r, fallback) => (await r.json().catch(() => ({}))).detail || fallback + +function SharePanel({ m, reload }) { + const [name, setName] = useState('') + const [err, setErr] = useState('') + const add = async (e) => { + e.preventDefault() + setErr('') + const r = await fetch(`/api/maps/${m.id}/shares`, { method: 'POST', headers: J, body: JSON.stringify({ username: name }) }) + if (!r.ok) return setErr(await errText(r, 'Could not share.')) + setName(''); reload() + } + const remove = async (u) => { await fetch(`/api/maps/${m.id}/shares/${encodeURIComponent(u)}`, { method: 'DELETE' }).catch(() => {}); reload() } + return ( +
    +
    Shared with
    +
    + {m.shared_with.length === 0 && Nobody yet.} + {m.shared_with.map((u) => {u} )} +
    +
    + setName(e.target.value)} placeholder="Username" aria-label="Username to share with" /> + +
    + {err &&

    {err}

    } +
    + ) +} + +// All maps of the user (prepare, share, copy) plus the maps other users shared with them +export default function Maps() { + const [list, setList] = useState(null) + const [editing, setEditing] = useState(null) + const [open, setOpen] = useState(null) + const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => setList([])), []) + useEffect(() => { load() }, [load, editing]) + const upload = async (e) => { + const f = e.target.files?.[0] + e.target.value = '' + if (!f) return + setErr(''); setBusy(true) + const r = await fetch(`/api/maps?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f }).catch(() => null) + setBusy(false) + if (!r || !r.ok) return setErr(r ? await errText(r, 'Upload failed') : 'Upload failed') + load() + } + const copy = async (m) => { + const r = await fetch(`/api/maps/${m.id}/copy`, { method: 'POST' }) + if (!r.ok) return setErr(await errText(r, 'Could not copy')) + load() + } + const del = async (m) => { + if (!window.confirm(`Delete the map "${m.name}"? Lobbies that show it get an empty table, shared users lose it.`)) return + await fetch(`/api/maps/${m.id}`, { method: 'DELETE' }); load() + } + const mine = (list || []).filter((m) => m.mine), shared = (list || []).filter((m) => !m.mine) + const card = (m) => ( +
  • + +
    + {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.mine && m.shared_with.length > 0 && ` · shared with ${m.shared_with.join(', ')}`} + + {m.mine && } + {m.mine && } + + {m.mine && } + + {open === m.id && m.mine && } +
    +
  • + ) + return ( +
    +

    Maps

    +

    Prepare maps with grid, enemies, NPCs, walls and doors. The DM puts one on the table from a lobby (Lobby actions). Shared maps can be used on your table or copied, but not changed.

    + + {err &&

    {err}

    } + {list === null &&

    Loading …

    } + {list && !mine.length &&

    You have no maps yet.

    } +
      {mine.map(card)}
    + {shared.length > 0 && <>

    Shared with me

      {shared.map(card)}
    } + {editing && setEditing(null)} />} +
    + ) +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e4bd266..ba3b6d4 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -441,3 +441,22 @@ label.chk { display: flex; align-items: center; gap: .45rem; } label.chk input[type=checkbox] { width: auto; margin: 0; } .mapeditor label.small:not(.chk) { display: grid; gap: .2rem; } .editactions { position: sticky; bottom: 0; background: var(--panel); padding: .5rem 0; } +.mapbox.drawing { cursor: crosshair; } +.mapworld { background: #000; } +.wl { stroke: #ff4d4d; stroke-width: 3; stroke-linecap: round; } +.wl.door { stroke: #ff9f1a; stroke-width: 5; } +.wl.door.open { stroke: #4cd964; stroke-dasharray: 6 6; stroke-width: 4; } +.wl.draft { stroke: #fff; stroke-dasharray: 5 5; stroke-width: 2; } +.wldot { fill: #fff; } +.toolrow { display: flex; flex-wrap: wrap; gap: .3rem; } +.mapeditor input[type=number] { width: 100%; } +.mapempty { position: absolute; inset: 0; display: grid; place-items: center; color: #ddd; z-index: 2; pointer-events: none; text-align: center; padding: 1rem; } +.mapcards { list-style: none; padding: 0; margin: 1rem 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr)); gap: 1rem; } +.mapcard { display: grid; grid-template-columns: 8rem 1fr; gap: .8rem; padding: .8rem; align-items: start; } +.mapcard img { width: 8rem; height: 6rem; object-fit: cover; border-radius: 6px; background: #111; } +.mapcardbody { display: grid; gap: .4rem; min-width: 0; } +.mapcardbody .row { flex-wrap: wrap; } +.maptools { flex-wrap: wrap; justify-content: flex-end; max-width: 60%; } +.maptools button, .maptools select { padding: .2rem .55rem; font-size: .8rem; min-height: 0; white-space: nowrap; width: auto; } +.maptray { max-width: 38%; } +.traytoken { font-size: .85rem; }