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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-23 14:23:17 +00:00
parent e8755582d5
commit 82e4ac9458
8 changed files with 632 additions and 58 deletions
+14 -10
View File
@@ -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. - **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. - **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. - **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** (*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. - **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**: 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). - **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.
- **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. - **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).
- **Full view / Player view** (DM): player view hides what the players cannot see (currently hidden tokens). - **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 <player>** (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. 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}` | | 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) | | 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) | | 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 / POST | `/api/maps` | Your maps and the ones shared with you / upload (raw image body, `?name=`) |
| GET / PUT / DELETE | `/api/maps/{id}` | Map with prepared tokens (name, grid, tokens) / change / delete | | GET / PUT / DELETE | `/api/maps/{id}` | Map with tokens and walls / change (name, grid, sight, tokens, walls) / 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) | | POST / DELETE | `/api/maps/{id}/shares[/{username}]` | Share with a user / stop sharing |
| GET | `/api/lobbies/{id}/map/image` | The map image | | 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=<player>` for the DM) / DM: show map (`map_id`, null clears) |
| GET | `/api/lobbies/{id}/map/view` | The map image for this viewer, cut to their line of sight (`X-Crop` = x,y,w,h) |
| PUT | `/api/lobbies/{id}/doors/{wall}` , `/sight` | DM: open / close a door; line of sight on / off |
| POST / PUT / DELETE | `/api/lobbies/{id}/tokens[/{tid}]` | Add NPC token (DM) / move (own token or DM) and edit (DM) / remove (DM) | | 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) | | 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=<id>` to poll) / send, optional `to` = whisper | | GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper |
+210 -22
View File
@@ -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). 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.""" Images are re-encoded with Pillow on upload, so only real images get through and metadata is gone."""
import hashlib
import io import io
import json import json
import shutil
import time import time
import uuid import uuid
from pathlib import Path 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.concurrency import run_in_threadpool
from fastapi.responses import Response from fastapi.responses import Response
from PIL import Image, ImageOps from PIL import Image, ImageOps
from . import vision
MAX_UPLOAD = 12_000_000 # bytes of the uploaded file MAX_UPLOAD = 12_000_000 # bytes of the uploaded file
MAX_SIDE = 4096 # larger images are scaled down MAX_SIDE = 4096 # larger images are scaled down
MAX_PIXELS = 60_000_000 # decompression bomb guard (before scaling) MAX_PIXELS = 60_000_000 # decompression bomb guard (before scaling)
MAX_MAPS = 30 # per DM MAX_MAPS = 30 # per DM
MAX_TOKENS = 120 # per map / lobby 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") 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, 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)""") 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)") 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("CREATE TABLE IF NOT EXISTS map_shares (map_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (map_id, user_id))")
con.execute("ALTER TABLE lobbies ADD COLUMN map_id TEXT") 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]: 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"))} "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): def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Path):
store_dir.mkdir(parents=True, exist_ok=True) 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") raise HTTPException(404, "Map not found")
return row 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: 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 # ---- the DM's library
@api.get("/maps") @api.get("/maps")
def list_maps(user: dict = Depends(current_user)): def list_maps(user: dict = Depends(current_user)):
with db() as con: with db() as con:
rows = con.execute("SELECT * FROM maps WHERE owner_id=? ORDER BY created DESC", (user["id"],)).fetchall() rows = con.execute("""SELECT m.*, u.username AS author FROM maps m JOIN users u ON u.id = m.owner_id
return [{**meta(r), "tokens": len(json.loads(r["tokens"]))} for r in rows] 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") @api.post("/maps")
async def upload_map(request: Request, name: str = "", user: dict = Depends(current_user)): 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)): def update_map(mid: str, body: dict = Body(...), user: dict = Depends(current_user)):
with db() as con: with db() as con:
r = own_map(con, mid, user) 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: if "name" in body:
name = str(body["name"]).strip()[:60] or r["name"] name = str(body["name"]).strip()[:60] or r["name"]
if "grid" in body: 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: if not isinstance(body["tokens"], list) or len(body["tokens"]) > MAX_TOKENS:
raise HTTPException(422, f"At most {MAX_TOKENS} tokens per map") 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"]]) 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,))] lobbies = [x["id"] for x in con.execute("SELECT id FROM lobbies WHERE map_id=?", (mid,))]
for lid in lobbies: for lid in lobbies:
hub.notify(lid, "map") 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) own_map(con, mid, user)
lobbies = [x["id"] for x in con.execute("SELECT id FROM lobbies WHERE map_id=?", (mid,))] 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("UPDATE lobbies SET map_id=NULL WHERE map_id=?", (mid,))
con.execute("DELETE FROM map_shares WHERE map_id=?", (mid,))
for lid in lobbies: for lid in lobbies:
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM maps WHERE id=?", (mid,)) con.execute("DELETE FROM maps WHERE id=?", (mid,))
path(mid).unlink(missing_ok=True) path(mid).unlink(missing_ok=True)
(store_dir / f"{mid}.thumb.webp").unlink(missing_ok=True)
for lid in lobbies: for lid in lobbies:
hub.notify(lid, "map") hub.notify(lid, "map")
return {"ok": True} 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) own_map(con, mid, user)
return image_response(path(mid)) 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): def image_response(p: Path):
if not p.exists(): if not p.exists():
raise HTTPException(404, "Image missing") 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): def lobby_map(con, lobby):
return con.execute("SELECT * FROM maps WHERE id=?", (lobby["map_id"],)).fetchone() if lobby["map_id"] else None return con.execute("SELECT * FROM maps WHERE id=?", (lobby["map_id"],)).fetchone() if lobby["map_id"] else None
def 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"]) sync_players(con, lobby["id"])
rows = con.execute("""SELECT t.*, u.username, m.color AS mcolor, c.data FROM lobby_tokens t 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 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 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 LEFT JOIN characters c ON c.id = m.character_id
WHERE t.lobby_id=? ORDER BY t.rowid""", (lobby["id"],)).fetchall() WHERE t.lobby_id=? ORDER BY t.rowid""", (lobby["id"],)).fetchall()
full = ctx["viewer"] is None
out = [] out = []
for r in rows: for r in rows:
player = r["user_id"] is not None player = r["user_id"] is not None
if r["hidden"] and not is_dm: own = player and r["user_id"] == ctx["viewer"]
continue 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"] name = r["name"]
if player: if player:
try: 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, 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"]), "x": r["x"], "y": r["y"], "size": r["size"], "hidden": bool(r["hidden"]),
"color": (r["mcolor"] or "#999999") if player else r["color"], "color": (r["mcolor"] or "#999999") if player else r["color"],
"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 return out
@api.get("/lobbies/{lid}/map") @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: with db() as con:
lobby = lobby_for(con, lid, user) lobby = lobby_for(con, lid, user)
is_dm = lobby["owner_id"] == user["id"]
m = lobby_map(con, lobby) 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") @api.get("/lobbies/{lid}/map/view")
def lobby_map_image(lid: str, user: dict = Depends(current_user)): 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: with db() as con:
lobby = lobby_for(con, lid, user) lobby = lobby_for(con, lid, user)
if not lobby["map_id"]: m = lobby_map(con, lobby)
if not m:
raise HTTPException(404, "No map") raise HTTPException(404, "No map")
mid = lobby["map_id"] ctx = view_ctx(con, lobby, m, user, as_)
return image_response(path(mid)) 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") @api.put("/lobbies/{lid}/map")
def set_lobby_map(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): 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") mid = body.get("map_id")
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
if mid: if mid:
r = own_map(con, str(mid), user) r = usable_map(con, str(mid), user)
for t in json.loads(r["tokens"]): for t in json.loads(r["tokens"]):
con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden) VALUES (?,?,?,?,?,?,?,?)", 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"]))) (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"]) event(con, lid, f"The DM put the map \"{r['name']}\" on the table", user["id"])
else: else:
event(con, lid, "The DM cleared the table", user["id"]) 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") hub.notify(lid, "map")
return {"ok": True} 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() t = con.execute("SELECT * FROM lobby_tokens WHERE id=? AND lobby_id=?", (tid, lid)).fetchone()
if not t or (not is_dm and t["user_id"] != user["id"]): if not t or (not is_dm and t["user_id"] != user["id"]):
raise HTTPException(404, "Token not found") raise HTTPException(404, "Token not found")
if not is_dm and t["x"] is None and lobby["los"] and json.loads(m["walls"]):
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"]}
if "x" in body or "y" in body: if "x" in body or "y" in body:
merged["x"], merged["y"] = body.get("x"), body.get("y") merged["x"], merged["y"] = body.get("x"), body.get("y")
+132
View File
@@ -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
+3 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Creator from './Creator.jsx' import Creator from './Creator.jsx'
import Rules from './Rules.jsx' import Rules from './Rules.jsx'
import Maps from './Maps.jsx'
import Characters from './Characters.jsx' import Characters from './Characters.jsx'
import Presets from './Presets.jsx' import Presets from './Presets.jsx'
import Lobbies from './Lobbies.jsx' import Lobbies from './Lobbies.jsx'
@@ -54,7 +55,7 @@ export default function App() {
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main> if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
const page = hash.split('/')[0] 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 ( return (
<> <>
@@ -75,6 +76,7 @@ export default function App() {
{rules && page === 'creator' && <Creator rules={rules} user={username} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} onName={setCharName} />} {rules && page === 'creator' && <Creator rules={rules} user={username} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} onName={setCharName} />}
{rules && page === 'rules' && <Rules rules={rules} section={hash.split('/')[1]} />} {rules && page === 'rules' && <Rules rules={rules} section={hash.split('/')[1]} />}
{rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />} {rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />}
{page === 'maps' && <Maps />}
{rules && page === 'characters' && ( {rules && page === 'characters' && (
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} <Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} /> onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
+45 -8
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx' import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
import { MapView, MapLibrary, TokenForm } from './MapView.jsx' import { MapView, MapLibrary, TokenForm, WallLayer } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' } const J = { 'Content-Type': 'application/json' }
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) }) const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
@@ -142,6 +142,31 @@ function useSocket(id, handlers) {
return up 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. // 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) { function useFeed(id, onEvent, onRoll, socketUp) {
const [msgs, setMsgs] = useState([]) 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]) }).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 [mapState, setMapState] = useState({ map: null, tokens: [] })
const [mapSel, setMapSel] = useState(null) 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 [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 sock = useRef({})
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 }) const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
const up = useSocket(id, { const up = useSocket(id, {
@@ -672,15 +699,25 @@ function LobbyView({ id, rules, user }) {
<div className="table"> <div className="table">
{showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />} {showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
{mapState.map && (() => { {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 selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null
const walls = full && showWalls ? mapState.walls || [] : []
return ( return (
<MapView map={mapState.map} src={`/api/lobbies/${id}/map/image?m=${mapState.map.id}`} tokens={shown} grid={showGrid} <MapView map={mapState.map} image={mapImg} tokens={mapState.tokens} grid={showGrid}
onMove={moveToken} selected={mapSel} onSelect={setMapSel}> onMove={moveToken} selected={mapSel} onSelect={setMapSel}
overlay={walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}>
{mapState.vision?.empty && <div className="mapempty">{lobby.is_dm ? 'This player is not on the map yet. Switch to the full view and drag their token onto it.' : 'Waiting for the DM to put your token on the map.'}</div>}
<div className="maptools mapui"> <div className="maptools mapui">
{lobby.is_dm && <button className={playerView ? 'on' : 'ghost'} aria-pressed={playerView} onClick={() => setPlayerView(!playerView)} title="Show the table the way your players see it">{playerView ? 'Player view' : 'Full view'}</button>} {lobby.is_dm && (
<select value={viewAs} onChange={(e) => { setViewAs(e.target.value); setMapSel(null) }} aria-label="View the table as" title="Look at the table through a player's eyes">
<option value="">Full view</option>
{lobby.members.map((m) => <option key={m.username} value={m.username}>View as {m.character?.name || m.username}</option>)}
</select>
)}
{lobby.is_dm && mapState.walls?.length > 0 && <button className={mapState.los_on ? 'on' : 'ghost'} aria-pressed={!!mapState.los_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { on: !mapState.los_on }, () => loadMap())} title="Line of sight: players only see what their character can see">Sight {mapState.los_on ? 'on' : 'off'}</button>}
{full && mapState.walls?.length > 0 && <button className={showWalls ? 'on' : 'ghost'} aria-pressed={showWalls} onClick={() => setShowWalls(!showWalls)} title="Show walls and doors (click a door to open or close it)">Walls</button>}
<button className={showGrid ? 'on' : 'ghost'} aria-pressed={showGrid} onClick={() => setShowGrid(!showGrid)}>Grid</button> <button className={showGrid ? 'on' : 'ghost'} aria-pressed={showGrid} onClick={() => setShowGrid(!showGrid)}>Grid</button>
{lobby.is_dm && <button className="ghost" onClick={addToken}>+ NPC</button>} {full && <button className="ghost" onClick={addToken}>+ NPC</button>}
</div> </div>
{selTok && <div className="tokenpanel mapui"><TokenForm t={selTok} onChange={(p) => patchToken(selTok.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selTok.id}`, 'DELETE'); setMapSel(null) }} /></div>} {selTok && <div className="tokenpanel mapui"><TokenForm t={selTok} onChange={(p) => patchToken(selTok.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selTok.id}`, 'DELETE'); setMapSel(null) }} /></div>}
</MapView> </MapView>
+117 -17
View File
@@ -6,7 +6,7 @@ const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
// Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels. // Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels.
// Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise. // Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise.
export function MapView({ map, 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 box = useRef(null)
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 }) const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
const [drag, setDrag] = useState(null) const [drag, setDrag] = useState(null)
@@ -46,12 +46,14 @@ export function MapView({ map, src, tokens, onMove, selected, onSelect, grid: sh
}, []) }, [])
const down = (e) => { const down = (e) => {
if (e.target.closest('.maptoken, .mapui')) return if (e.target.closest('.maptoken, .mapui, .mapdoor')) return
box.current.setPointerCapture(e.pointerId) 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) 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) => { const move = (e) => {
if (onHover) onHover(toWorld(e), viewRef.current.s)
const p = pointers.current.get(e.pointerId) const p = pointers.current.get(e.pointerId)
if (!p) return if (!p) return
const r = box.current.getBoundingClientRect() 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 [a, b] = [...pointers.current.values()]
const before = Math.hypot(a.x - b.x, a.y - b.y) const before = Math.hypot(a.x - b.x, a.y - b.y)
const o = pointers.current.get(e.pointerId) 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) pointers.current.set(e.pointerId, now)
const [c, d] = [...pointers.current.values()] const [c, d] = [...pointers.current.values()]
const after = Math.hypot(c.x - d.x, c.y - d.y) 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 return
} }
const dx = e.clientX - p.x, dy = e.clientY - p.y 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 })) 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 toWorld = (ev) => {
const r = box.current.getBoundingClientRect(), v = viewRef.current 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 unplaced = tokens.filter((t) => t.x == null && t.can_move)
const d = (t) => t.size * map.grid const d = (t) => t.size * map.grid
return ( return (
<div className="mapbox" ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}> <div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
<div className="mapworld" style={{ width: map.w, height: map.h, transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.s})` }}> <div className="mapworld" style={{ width: map.w, height: map.h, transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.s})` }}>
<img src={src} width={map.w} height={map.h} alt="" draggable={false} /> {image && <img src={image.src} style={{ position: 'absolute', left: image.x, top: image.y }} width={image.w} height={image.h} alt="" draggable={false} />}
{showGrid && <div className="mapgrid" style={{ backgroundSize: `${map.grid}px ${map.grid}px` }} />} {showGrid && <div className="mapgrid" style={{ backgroundSize: `${map.grid}px ${map.grid}px` }} />}
{overlay}
{tokens.map((t) => { {tokens.map((t) => {
const dr = drag?.id === t.id ? drag : null const dr = drag?.id === t.id ? drag : null
const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y
@@ -159,36 +167,108 @@ export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
const uid = () => Math.random().toString(36).slice(2, 12) 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 = {}) => (
<line key={w.id} x1={w.x1} y1={w.y1} x2={w.x2} y2={w.y2} vectorEffect="non-scaling-stroke"
className={`wl ${w.kind} ${w.open ? 'open' : ''}`} {...extra} />
)
return (
<svg className="walllayer" width={map.w} height={map.h} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', pointerEvents: 'none' }}>
{walls.filter((w) => !(onDoor && w.kind === 'door')).map((w) => line(w))}
{walls.filter((w) => onDoor && w.kind === 'door').map((w) => (
<g key={w.id} className="mapdoor" onClick={() => onDoor(w)} style={{ pointerEvents: 'stroke', cursor: 'pointer' }}>
<title>{w.open ? 'Open door, click to close' : 'Closed door, click to open'}</title>
<line x1={w.x1} y1={w.y1} x2={w.x2} y2={w.y2} stroke="transparent" strokeWidth="16" vectorEffect="non-scaling-stroke" style={{ pointerEvents: 'stroke' }} />
{line(w, { style: { pointerEvents: 'none' } })}
</g>
))}
{draft && hover && <line x1={draft.x} y1={draft.y} x2={hover.x} y2={hover.y} vectorEffect="non-scaling-stroke" className="wl draft" />}
{draft && <circle cx={draft.x} cy={draft.y} r="4" vectorEffect="non-scaling-stroke" className="wldot" />}
</svg>
)
}
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 }) { export function MapEditor({ id, onClose }) {
const [m, setM] = useState(null) const [m, setM] = useState(null)
const [tokens, setTokens] = useState([]) const [tokens, setTokens] = useState([])
const [walls, setWalls] = useState([])
const [name, setName] = useState('') const [name, setName] = useState('')
const [grid, setGrid] = useState(40) const [grid, setGrid] = useState(40)
const [sight, setSight] = useState(0)
const [showGrid, setShowGrid] = useState(true) 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 [sel, setSel] = useState(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [dirty, setDirty] = useState(false) const [dirty, setDirty] = useState(false)
useEffect(() => { 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]) }, [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 if (!m) return null
const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid
const cur = tokens.find((t) => t.id === sel) 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 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 add = () => {
const n = tokens.length + 1 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) 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 () => { const save = async () => {
setErr('') setErr('')
const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, grid: Number(grid), 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 } if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false }
setDirty(false); return true setDirty(false); return true
} }
const close = async () => { if (!dirty || window.confirm('Close without saving your changes?')) onClose() } 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( return createPortal(
<div className="mapeditor" role="dialog" aria-label="Map editor"> <div className="mapeditor" role="dialog" aria-label="Map editor">
<aside className="card"> <aside className="card">
@@ -197,6 +277,24 @@ export function MapEditor({ id, onClose }) {
<label className="small">Grid: pixels per square <input type="number" min="4" value={grid} onChange={(e) => { setGrid(e.target.value); setDirty(true) }} /></label> <label className="small">Grid: pixels per square <input type="number" min="4" value={grid} onChange={(e) => { setGrid(e.target.value); setDirty(true) }} /></label>
<label className="small chk"><input type="checkbox" checked={showGrid} onChange={(e) => setShowGrid(e.target.checked)} /> Show grid</label> <label className="small chk"><input type="checkbox" checked={showGrid} onChange={(e) => setShowGrid(e.target.checked)} /> Show grid</label>
<p className="small muted">Match the grid to the squares on your image. Token sizes are counted in squares.</p> <p className="small muted">Match the grid to the squares on your image. Token sizes are counted in squares.</p>
<h4>Walls and doors</h4>
<div className="toolrow" role="group" aria-label="Tool">
{tools.map(([k, label]) => <button key={k} className={tool === k ? 'on' : 'ghost'} aria-pressed={tool === k} onClick={() => { setTool(k); setDraft(null) }}>{label}</button>)}
</div>
<label className="small chk"><input type="checkbox" checked={snap} onChange={(e) => setSnap(e.target.checked)} /> Snap to grid corners</label>
<p className="small muted">
{tool === 'wall' && 'Click to set points, the line continues from the last one. Click the same point twice or press Esc to stop.'}
{tool === 'rect' && 'Click two opposite corners of a room.'}
{tool === 'door' && 'Click both ends of the door. The DM opens and closes it during play.'}
{tool === 'erase' && 'Click a wall or door to remove it.'}
{tool === 'select' && `${walls.length} wall segment${walls.length === 1 ? '' : 's'}. Walls end at existing corners automatically. Players only see what their token can see past them.`}
</p>
<div className="row">
<button className="ghost" disabled={!walls.length} onClick={() => { setWalls(walls.slice(0, -1)); setDirty(true) }}>Undo last</button>
<button className="ghost danger" disabled={!walls.length} onClick={() => window.confirm('Remove all walls and doors of this map?') && (setWalls([]), setDirty(true))}>Remove all</button>
</div>
<label className="small">Sight range in squares (0 = unlimited)
<input type="number" min="0" max="1000" value={sight} onChange={(e) => { setSight(e.target.value); setDirty(true) }} /></label>
<h4>Enemies and NPCs</h4> <h4>Enemies and NPCs</h4>
<button className="on" onClick={add}>+ Add token</button> <button className="on" onClick={add}>+ Add token</button>
<ul className="tokenlist"> <ul className="tokenlist">
@@ -210,7 +308,9 @@ export function MapEditor({ id, onClose }) {
<div className="row editactions"><button className="on" disabled={!dirty} onClick={save}>Save</button><button className="ghost" onClick={close}>Close</button></div> <div className="row editactions"><button className="on" disabled={!dirty} onClick={save}>Save</button><button className="ghost" onClick={close}>Close</button></div>
</aside> </aside>
<div className="mapstage"> <div className="mapstage">
<MapView map={{ ...m, grid: gridNum }} src={`/api/maps/${id}/image`} tokens={tokens} grid={showGrid} selected={sel} onSelect={setSel} <MapView map={{ ...m, grid: gridNum }} image={image} tokens={tokens} grid={showGrid} selected={sel} onSelect={setSel} tool={tool}
onClick={click} onHover={draft ? (p, s) => setHover(p) : undefined}
overlay={<WallLayer map={m} walls={walls} draft={draft} hover={draft ? hover : null} />}
onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} /> onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
</div> </div>
</div>, document.body) </div>, document.body)
@@ -242,13 +342,13 @@ export function MapLibrary({ activeId, lobbyId, act }) {
<ul className="maplist"> <ul className="maplist">
{list.map((m) => ( {list.map((m) => (
<li key={m.id} className={m.id === activeId ? 'active' : ''}> <li key={m.id} className={m.id === activeId ? 'active' : ''}>
<strong>{m.name}</strong> <span className="muted small">{m.w}×{m.h}, {m.tokens} token{m.tokens === 1 ? '' : 's'}</span> <strong>{m.name}</strong> <span className="muted small">{m.w}×{m.h}, {m.tokens} token{m.tokens === 1 ? '' : 's'}, {m.walls} wall{m.walls === 1 ? '' : 's'}{!m.mine && ` · from ${m.author}`}</span>
<span className="row"> <span className="row">
{m.id === activeId {m.id === activeId
? <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null })}>Clear table</button> ? <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null })}>Clear table</button>
: <button className="on" onClick={() => (!activeId || window.confirm('Replace the current map? Tokens on it are reset to the prepared ones.')) && act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: m.id })}>Show on table</button>} : <button className="on" onClick={() => (!activeId || window.confirm('Replace the current map? Tokens on it are reset to the prepared ones.')) && act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: m.id })}>Show on table</button>}
<button className="ghost" onClick={() => setEditing(m.id)}>Edit</button> {m.mine && <button className="ghost" onClick={() => setEditing(m.id)}>Edit</button>}
<button className="ghost danger" onClick={() => window.confirm(`Delete the map "${m.name}"?`) && fetch(`/api/maps/${m.id}`, { method: 'DELETE' }).then(load)}>Delete</button> {m.mine && <button className="ghost danger" onClick={() => window.confirm(`Delete the map "${m.name}"?`) && fetch(`/api/maps/${m.id}`, { method: 'DELETE' }).then(load)}>Delete</button>}
</span> </span>
</li> </li>
))} ))}
+92
View File
@@ -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 (
<div className="sharepanel">
<div className="small muted">Shared with</div>
<div className="chips">
{m.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
{m.shared_with.map((u) => <span key={u} className="chip">{u} <button className="ghost" aria-label={`Stop sharing with ${u}`} onClick={() => remove(u)}>×</button></span>)}
</div>
<form className="row" onSubmit={add}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Username" aria-label="Username to share with" />
<button type="submit" disabled={!name.trim()}>Share</button>
</form>
{err && <p className="warn small">{err}</p>}
</div>
)
}
// 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) => (
<li key={m.id} className="mapcard card">
<img src={`/api/maps/${m.id}/thumb`} alt="" loading="lazy" />
<div className="mapcardbody">
<strong>{m.name}</strong>
<span className="small muted">{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(', ')}`}</span>
<span className="row">
{m.mine && <button className="on" onClick={() => setEditing(m.id)}>Edit</button>}
{m.mine && <button className="ghost" onClick={() => setOpen(open === m.id ? null : m.id)}>Share</button>}
<button className="ghost" onClick={() => copy(m)} title="Make your own editable copy">{m.mine ? 'Duplicate' : 'Copy to my maps'}</button>
{m.mine && <button className="ghost danger" onClick={() => del(m)}>Delete</button>}
</span>
{open === m.id && m.mine && <SharePanel m={m} reload={load} />}
</div>
</li>
)
return (
<section className="page">
<h1>Maps</h1>
<p className="muted">Prepare maps with grid, enemies, NPCs, walls and doors. The DM puts one on the table from a lobby (<em>Lobby actions</em>). Shared maps can be used on your table or copied, but not changed.</p>
<label className="uploadbtn">{busy ? 'Uploading …' : '+ Upload map (PNG, JPEG, WebP, up to 12 MB)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
{err && <p className="warn">{err}</p>}
{list === null && <p className="muted">Loading …</p>}
{list && !mine.length && <p className="muted">You have no maps yet.</p>}
<ul className="mapcards">{mine.map(card)}</ul>
{shared.length > 0 && <><h2>Shared with me</h2><ul className="mapcards">{shared.map(card)}</ul></>}
{editing && <MapEditor id={editing} onClose={() => setEditing(null)} />}
</section>
)
}
+19
View File
@@ -441,3 +441,22 @@ label.chk { display: flex; align-items: center; gap: .45rem; }
label.chk input[type=checkbox] { width: auto; margin: 0; } label.chk input[type=checkbox] { width: auto; margin: 0; }
.mapeditor label.small:not(.chk) { display: grid; gap: .2rem; } .mapeditor label.small:not(.chk) { display: grid; gap: .2rem; }
.editactions { position: sticky; bottom: 0; background: var(--panel); padding: .5rem 0; } .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; }