Lobby: maps, map editor and tokens

DM map library (upload re-encoded with Pillow), map editor with prepared
enemies/NPCs, map on the lobby table with personal pan/zoom, draggable
tokens (players move their own, DM all), full/player view, live sync via
the WebSocket.

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 13:59:21 +00:00
parent 162d8c9224
commit e8755582d5
8 changed files with 679 additions and 9 deletions
+2 -2
View File
@@ -62,7 +62,7 @@ server {
listen 80; listen 80;
server_name aw.yourdomain.tld; server_name aw.yourdomain.tld;
client_max_body_size 1m; client_max_body_size 13m; # map uploads (the app itself limits them to 12 MB and keeps characters small)
location / { location / {
proxy_pass http://127.0.0.1:8090; # same as AW_PORT in .env proxy_pass http://127.0.0.1:8090; # same as AW_PORT in .env
@@ -79,7 +79,7 @@ server {
} }
``` ```
Already running an older config? Add the last four lines inside `location /`, then `sudo nginx -t && sudo systemctl reload nginx`. Nothing else changes; the lobby still works without them, just with a few seconds of delay. Already running an older config? Add the last four lines inside `location /` and change `client_max_body_size` to `13m` (needed for map uploads, otherwise nginx answers 413), then `sudo nginx -t && sudo systemctl reload nginx`. Nothing else changes; the lobby still works without them, just with a few seconds of delay.
```bash ```bash
sudo ln -s /etc/nginx/sites-available/aw /etc/nginx/sites-enabled/aw sudo ln -s /etc/nginx/sites-available/aw /etc/nginx/sites-enabled/aw
+14 -1
View File
@@ -110,6 +110,14 @@ 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
- **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).
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.
## API ## API
@@ -129,7 +137,12 @@ 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) |
| WebSocket | `/api/lobbies/{id}/ws` | Live notes `{t: messages / lobby / sheet}` for lobby members (cookie login, same origin) | | 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 |
| 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=<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 |
| POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?, manual?, mode?}` (d4-d20, d100; up to 10 dice). `manual` = results of real dice, `mode` (DM only) = `hidden`/`mystery`/`public` | | POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?, manual?, mode?}` (d4-d20, d100; up to 10 dice). `manual` = results of real dice, `mode` (DM only) = `hidden`/`mystery`/`public` |
| POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character | | POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character |
+9
View File
@@ -15,6 +15,7 @@ from fastapi.staticfiles import StaticFiles
from . import auth from . import auth
from .derive import compute from .derive import compute
from . import rules_edit from . import rules_edit
from . import maps as maps_mod
from .realtime import hub from .realtime import hub
from .export import to_docx, to_pdf from .export import to_docx, to_pdf
@@ -100,6 +101,7 @@ def db() -> sqlite3.Connection:
("lobby_members", "char_mod", "INTEGER NOT NULL DEFAULT 0")): ("lobby_members", "char_mod", "INTEGER NOT NULL DEFAULT 0")):
if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]: if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}") con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
maps_mod.schema(con)
for table in ("characters", "presets"): # older databases: add the owner column for table in ("characters", "presets"): # older databases: add the owner column
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]: if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER") con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
@@ -581,6 +583,7 @@ def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(cu
con.execute("""INSERT INTO lobby_members (lobby_id, user_id, character_id, joined, color) VALUES (?,?,?,?,?) con.execute("""INSERT INTO lobby_members (lobby_id, user_id, character_id, joined, color) VALUES (?,?,?,?,?)
ON CONFLICT(lobby_id, user_id) DO UPDATE SET character_id=excluded.character_id""", ON CONFLICT(lobby_id, user_id) DO UPDATE SET character_id=excluded.character_id""",
(lobby["id"], user["id"], body["character_id"], time.time(), _free_color(con, lobby["id"]))) (lobby["id"], user["id"], body["character_id"], time.time(), _free_color(con, lobby["id"])))
hub.notify(lobby["id"], "map")
_event(con, lobby["id"], f"{user['username']} {'switched to' if known else 'joined as'} {_char_name(con, body['character_id'])}", user["id"]) _event(con, lobby["id"], f"{user['username']} {'switched to' if known else 'joined as'} {_char_name(con, body['character_id'])}", user["id"])
return {"id": lobby["id"]} return {"id": lobby["id"]}
@@ -836,6 +839,7 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)):
_dm_only(_lobby_for(con, lid, user), user) _dm_only(_lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobbies WHERE id=?", (lid,)) con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
hub.close_lobby(lid) hub.close_lobby(lid)
return {"ok": True} return {"ok": True}
@@ -884,6 +888,7 @@ def leave_lobby(lid: str, user: dict = Depends(current_user)):
if gone: if gone:
_event(con, lid, f"{user['username']} left the lobby", user["id"]) _event(con, lid, f"{user['username']} left the lobby", user["id"])
if gone: if gone:
hub.notify(lid, "map")
hub.close_user(lid, user["id"]) hub.close_user(lid, user["id"])
return {"ok": True} return {"ok": True}
@@ -898,6 +903,7 @@ def kick_member(lid: str, username: str, user: dict = Depends(current_user)):
_event(con, lid, f"{username} was removed by the DM", user["id"]) _event(con, lid, f"{username} was removed by the DM", user["id"])
gone_id = con.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"] gone_id = con.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"]
if gone: if gone:
hub.notify(lid, "map")
hub.close_user(lid, gone_id) hub.close_user(lid, gone_id)
return {"ok": True} return {"ok": True}
@@ -1011,6 +1017,9 @@ def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin
return {"ok": True} return {"ok": True}
maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps")
@app.websocket("/api/lobbies/{lid}/ws") @app.websocket("/api/lobbies/{lid}/ws")
async def lobby_socket(websocket: WebSocket, lid: str): async def lobby_socket(websocket: WebSocket, lid: str):
"""One socket per open lobby page. Only notes go out ("messages", "lobby", "sheet"), the data is fetched through the API.""" """One socket per open lobby page. Only notes go out ("messages", "lobby", "sheet"), the data is fetched through the API."""
+307
View File
@@ -0,0 +1,307 @@
"""Maps for the lobby table: the DM's map library, the active map of a lobby and the tokens on it.
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 io
import json
import time
import uuid
from pathlib import Path
from fastapi import Body, Depends, HTTPException, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import Response
from PIL import Image, ImageOps
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
COLORS = ("#c0392b", "#8e44ad", "#16a085", "#7f8c8d", "#d35400", "#2c3e50", "#27ae60", "#b9770e")
def schema(con):
con.execute("""CREATE TABLE IF NOT EXISTS maps (
id TEXT PRIMARY KEY, owner_id INTEGER NOT NULL, name TEXT NOT NULL, w INTEGER NOT NULL, h INTEGER NOT NULL,
grid REAL NOT NULL, tokens TEXT NOT NULL DEFAULT '[]', walls TEXT NOT NULL DEFAULT '[]', created REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS lobby_tokens (
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")
def encode(raw: bytes) -> tuple[bytes, int, int]:
"""Validates and re-encodes an uploaded image as WebP, scaled to at most MAX_SIDE."""
Image.MAX_IMAGE_PIXELS = MAX_PIXELS
try:
im = Image.open(io.BytesIO(raw))
if im.format not in ("PNG", "JPEG", "WEBP"):
raise ValueError("format")
im.load()
except Exception:
raise HTTPException(422, "Please upload a PNG, JPEG or WebP image")
im = ImageOps.exif_transpose(im)
im.thumbnail((MAX_SIDE, MAX_SIDE))
if im.mode not in ("RGB", "RGBA"):
im = im.convert("RGBA" if "transparency" in im.info or im.mode in ("LA", "PA") else "RGB")
out = io.BytesIO()
im.save(out, "WEBP", quality=90, method=4)
return out.getvalue(), im.width, im.height
def clean_color(c, default="#c0392b") -> str:
c = str(c or "")
return c if len(c) == 7 and c[0] == "#" and all(ch in "0123456789abcdefABCDEF" for ch in c[1:]) else default
def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict:
if not isinstance(t, dict):
raise HTTPException(422, "Invalid token")
name = str(t.get("name", "")).strip()[:40] or "Token"
try:
x, y = t.get("x"), t.get("y")
x = None if x is None else min(max(float(x), 0), w)
y = None if y is None else min(max(float(y), 0), h)
size = min(max(float(t.get("size", 1)), 0.25), 8)
except (TypeError, ValueError):
raise HTTPException(422, "Invalid token position or size")
if (x is None) != (y is None):
x = y = None
tid = str(t.get("id", "")) if keep_id else ""
return {"id": tid if tid.isalnum() and len(tid) <= 16 else uuid.uuid4().hex[:12], "name": name, "x": x, "y": y,
"size": size, "color": clean_color(t.get("color")), "hidden": bool(t.get("hidden"))}
def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Path):
store_dir.mkdir(parents=True, exist_ok=True)
def path(mid: str) -> Path:
return store_dir / f"{mid}.webp"
def own_map(con, mid, user):
row = con.execute("SELECT * FROM maps WHERE id=? AND owner_id=?", (mid, 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"]}
# ---- 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]
@api.post("/maps")
async def upload_map(request: Request, name: str = "", user: dict = Depends(current_user)):
"""Raw image bytes in the body (no multipart), the name as ?name=."""
if int(request.headers.get("content-length") or 0) > MAX_UPLOAD:
raise HTTPException(413, f"The file is too large (at most {MAX_UPLOAD // 1_000_000} MB)")
chunks, size = [], 0
async for c in request.stream():
size += len(c)
if size > MAX_UPLOAD:
raise HTTPException(413, f"The file is too large (at most {MAX_UPLOAD // 1_000_000} MB)")
chunks.append(c)
with db() as con:
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")
data, w, h = await run_in_threadpool(encode, b"".join(chunks))
mid = uuid.uuid4().hex[:12]
path(mid).write_bytes(data)
with db() as con:
con.execute("INSERT INTO maps (id, owner_id, name, w, h, grid, created) VALUES (?,?,?,?,?,?,?)",
(mid, user["id"], name.strip()[:60] or "Map", w, h, round(max(w, h) / 30, 1), time.time()))
return {"id": mid}
@api.get("/maps/{mid}")
def read_map(mid: str, user: dict = Depends(current_user)):
with db() as con:
r = own_map(con, mid, user)
return {**meta(r), "tokens": json.loads(r["tokens"]), "walls": json.loads(r["walls"])}
@api.put("/maps/{mid}")
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"]
if "name" in body:
name = str(body["name"]).strip()[:60] or r["name"]
if "grid" in body:
try:
grid = float(body["grid"])
except (TypeError, ValueError):
raise HTTPException(422, "The grid must be a number")
if not 4 <= grid <= max(r["w"], r["h"]) / 2:
raise HTTPException(422, "Grid size (pixels per square) is out of range")
if "tokens" in body:
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))
lobbies = [x["id"] for x in con.execute("SELECT id FROM lobbies WHERE map_id=?", (mid,))]
for lid in lobbies:
hub.notify(lid, "map")
return {"ok": True}
@api.delete("/maps/{mid}")
def delete_map(mid: str, user: dict = Depends(current_user)):
with db() as con:
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,))
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)
for lid in lobbies:
hub.notify(lid, "map")
return {"ok": True}
@api.get("/maps/{mid}/image")
def map_image(mid: str, user: dict = Depends(current_user)):
with db() as con:
own_map(con, mid, user)
return image_response(path(mid))
def image_response(p: Path):
if not p.exists():
raise HTTPException(404, "Image missing")
return Response(p.read_bytes(), media_type="image/webp", headers={"Cache-Control": "private, max-age=3600"})
# ---- the map on a lobby's table
def sync_players(con, lid: str):
"""One token per member (unplaced until the player drags it onto the map); tokens of people who left are removed."""
members = con.execute("SELECT user_id FROM lobby_members WHERE lobby_id=?", (lid,)).fetchall()
have = {r["user_id"] for r in con.execute("SELECT user_id FROM lobby_tokens WHERE lobby_id=? AND user_id IS NOT NULL", (lid,))}
ids = {m["user_id"] for m in members}
for uid in ids - have:
con.execute("INSERT INTO lobby_tokens (id, lobby_id, user_id, name, size) VALUES (?,?,?,?,1)", (uuid.uuid4().hex[:12], lid, uid, ""))
for uid in have - ids:
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=? AND user_id=?", (lid, uid))
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:
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()
out = []
for r in rows:
player = r["user_id"] is not None
if r["hidden"] and not is_dm:
continue
name = r["name"]
if player:
try:
name = (json.loads(r["data"]).get("name") if r["data"] else "") or r["username"]
except ValueError:
name = r["username"]
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"]})
return out
@api.get("/lobbies/{lid}/map")
def read_lobby_map(lid: str, user: dict = Depends(current_user)):
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 []}
@api.get("/lobbies/{lid}/map/image")
def lobby_map_image(lid: str, user: dict = Depends(current_user)):
with db() as con:
lobby = lobby_for(con, lid, user)
if not lobby["map_id"]:
raise HTTPException(404, "No map")
mid = lobby["map_id"]
return image_response(path(mid))
@api.put("/lobbies/{lid}/map")
def set_lobby_map(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""DM: show a map from the library (or null to clear the table). The map's prepared tokens are copied to the lobby."""
with db() as con:
dm_only(lobby_for(con, lid, user), user)
mid = body.get("map_id")
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
if mid:
r = own_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"])))
sync_players(con, lid)
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))
hub.notify(lid, "map")
return {"ok": True}
def need_map(con, lobby):
m = lobby_map(con, lobby)
if not m:
raise HTTPException(409, "There is no map on the table")
return m
@api.post("/lobbies/{lid}/tokens")
def add_token(lid: 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 con.execute("SELECT COUNT(*) FROM lobby_tokens WHERE lobby_id=?", (lid,)).fetchone()[0] >= MAX_TOKENS:
raise HTTPException(409, "Too many tokens")
t = clean_token(body, m["w"], m["h"], keep_id=False)
con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden) VALUES (?,?,?,?,?,?,?,?)",
(t["id"], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"])))
hub.notify(lid, "map")
return {"id": t["id"]}
@api.put("/lobbies/{lid}/tokens/{tid}")
def move_token(lid: str, tid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""Players move their own token; the DM moves any and can also rename, recolour, resize or hide NPC tokens."""
with db() as con:
lobby = lobby_for(con, lid, user)
is_dm = lobby["owner_id"] == user["id"]
m = need_map(con, lobby)
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")
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")
if is_dm and t["user_id"] is None:
for k in ("name", "color", "size", "hidden"):
if k in body:
merged[k] = body[k]
elif is_dm and "size" in body:
merged["size"] = body["size"]
c = clean_token(merged, m["w"], m["h"])
con.execute("UPDATE lobby_tokens SET name=?, x=?, y=?, color=?, size=?, hidden=? WHERE id=?",
(c["name"] if t["user_id"] is None else t["name"], c["x"], c["y"], c["color"], c["size"], int(c["hidden"]), tid))
hub.notify(lid, "map")
return {"ok": True}
@api.delete("/lobbies/{lid}/tokens/{tid}")
def delete_token(lid: str, tid: str, user: dict = Depends(current_user)):
with db() as con:
dm_only(lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_tokens WHERE id=? AND lobby_id=? AND user_id IS NULL", (tid, lid))
hub.notify(lid, "map")
return {"ok": True}
return sync_players
+1
View File
@@ -2,3 +2,4 @@ fastapi>=0.115
uvicorn[standard]>=0.30 uvicorn[standard]>=0.30
python-docx>=1.1 python-docx>=1.1
reportlab>=4.2 reportlab>=4.2
Pillow>=10
+41 -6
View File
@@ -1,5 +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'
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) })
@@ -549,19 +550,25 @@ function LobbyView({ id, rules, user }) {
if (!r.ok) throw new Error(r.status) if (!r.ok) throw new Error(r.status)
setLobby(await r.json()) setLobby(await r.json())
}).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 [mapSel, setMapSel] = useState(null)
const [playerView, setPlayerView] = useState(false) // the DM looks at the table the way the players see it
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 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, {
open: () => { sock.current.load?.(); sock.current.poll?.() }, open: () => { sock.current.load?.(); sock.current.poll?.(); sock.current.loadMap?.() },
map: () => sock.current.loadMap?.(),
messages: () => sock.current.poll?.(), messages: () => sock.current.poll?.(),
lobby: () => sock.current.load?.(), lobby: () => sock.current.load?.(),
sheet: (n) => setSheetTick((t) => ({ cid: n.cid, n: t.n + 1 })), sheet: (n) => setSheetTick((t) => ({ cid: n.cid, n: t.n + 1 })),
}) })
useEffect(() => { useEffect(() => {
load() load(); loadMap()
const t = setInterval(() => !document.hidden && load(), up ? 30000 : 5000) // joins, colours, difficulty: pushed, with a timer as safety net const t = setInterval(() => { if (!document.hidden) { load(); loadMap() } }, up ? 30000 : 5000) // joins, colours, difficulty: pushed, with a timer as safety net
return () => clearInterval(t) return () => clearInterval(t)
}, [load, up]) }, [load, loadMap, up])
const onRoll = useCallback((m) => { const onRoll = useCallback((m) => {
const key = `${m.id}` const key = `${m.id}`
@@ -569,7 +576,7 @@ function LobbyView({ id, rules, user }) {
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000) setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
}, []) }, [])
const feed = useFeed(id, load, onRoll, up) const feed = useFeed(id, load, onRoll, up)
sock.current = { load, poll: feed.poll } sock.current = { load, poll: feed.poll, loadMap }
const act = async (url, method, body, done) => { const act = async (url, method, body, done) => {
if (!url) return load() if (!url) return load()
@@ -580,6 +587,19 @@ function LobbyView({ id, rules, user }) {
done ? done(d) : load() done ? done(d) : load()
} }
const moveToken = (tid, x, y) => {
setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, x, y } : t)) }))
return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', { x, y }).catch(() => null)
}
const patchToken = (tid, p) => {
setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, ...p } : t)) }))
return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', p).catch(() => null)
}
const addToken = () => {
const m = mapState.map, n = mapState.tokens.filter((t) => t.kind === 'npc').length + 1
send(`/api/lobbies/${id}/tokens`, 'POST', { name: `Enemy ${n}`, x: m.w / 2, y: m.h / 2, size: 1, color: '#c0392b' }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
}
if (err && !lobby) return <section className="page lobbypage"><p className="warn">{err}</p><a href="#lobbies">Back to all lobbies</a></section> if (err && !lobby) return <section className="page lobbypage"><p className="warn">{err}</p><a href="#lobbies">Back to all lobbies</a></section>
if (!lobby) return <section className="page lobbypage"><p className="muted">Loading …</p></section> if (!lobby) return <section className="page lobbypage"><p className="muted">Loading …</p></section>
@@ -639,7 +659,7 @@ function LobbyView({ id, rules, user }) {
<span className="tabright">{(!lobby.is_dm || leftTab === 'sheet') && <ThemeSwitch />} <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide this column">◀</button></span> <span className="tabright">{(!lobby.is_dm || leftTab === 'sheet') && <ThemeSwitch />} <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide this column">◀</button></span>
</div> </div>
{lobby.is_dm && leftTab === 'lobby' {lobby.is_dm && leftTab === 'lobby'
? <Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /> ? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} /> : cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} />
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>} : <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
</div> </div>
@@ -651,6 +671,21 @@ function LobbyView({ id, rules, user }) {
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} /> <DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
<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 && (() => {
const shown = mapState.tokens.filter((t) => !(playerView && t.hidden))
const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null
return (
<MapView map={mapState.map} src={`/api/lobbies/${id}/map/image?m=${mapState.map.id}`} tokens={shown} grid={showGrid}
onMove={moveToken} selected={mapSel} onSelect={setMapSel}>
<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>}
<button className={showGrid ? 'on' : 'ghost'} aria-pressed={showGrid} onClick={() => setShowGrid(!showGrid)}>Grid</button>
{lobby.is_dm && <button className="ghost" onClick={addToken}>+ NPC</button>}
</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>
)
})()}
<DiceTray rolls={tray} /> <DiceTray rolls={tray} />
{canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>} {canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>}
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />} {pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
+260
View File
@@ -0,0 +1,260 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
const COLORS = ['#c0392b', '#8e44ad', '#16a085', '#7f8c8d', '#d35400', '#2c3e50', '#27ae60', '#b9770e']
const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
// Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels.
// Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise.
export function MapView({ map, src, tokens, onMove, selected, onSelect, grid: showGrid, children }) {
const box = useRef(null)
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
const [drag, setDrag] = useState(null)
const viewRef = useRef(view)
viewRef.current = view
const pointers = useRef(new Map())
const fitted = useRef('')
const fit = useCallback(() => {
const r = box.current?.getBoundingClientRect()
if (!r || !r.width || !map) return
const s = Math.min(r.width / map.w, r.height / map.h) * 0.96
setView({ s, tx: (r.width - map.w * s) / 2, ty: (r.height - map.h * s) / 2 })
}, [map?.id, map?.w, map?.h]) // eslint-disable-line
useEffect(() => { // fit once per map, as soon as the box has a size
if (!map || fitted.current === map.id) return
const t = setInterval(() => { if (box.current?.getBoundingClientRect().width) { fit(); fitted.current = map.id; clearInterval(t) } }, 50)
return () => clearInterval(t)
}, [map?.id, fit]) // eslint-disable-line
const zoomAt = (mx, my, f) => setView((v) => {
const s = clamp(v.s * f, 0.05, 8)
return { s, tx: mx - (mx - v.tx) * (s / v.s), ty: my - (my - v.ty) * (s / v.s) }
})
useEffect(() => {
const el = box.current
if (!el) return
const wheel = (e) => {
e.preventDefault()
const r = el.getBoundingClientRect()
zoomAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015)))
}
el.addEventListener('wheel', wheel, { passive: false })
return () => el.removeEventListener('wheel', wheel)
}, [])
const down = (e) => {
if (e.target.closest('.maptoken, .mapui')) return
box.current.setPointerCapture(e.pointerId)
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (pointers.current.size === 1) onSelect?.(null)
}
const move = (e) => {
const p = pointers.current.get(e.pointerId)
if (!p) return
const r = box.current.getBoundingClientRect()
if (pointers.current.size === 2) { // pinch
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 }
pointers.current.set(e.pointerId, now)
const [c, d] = [...pointers.current.values()]
const after = Math.hypot(c.x - d.x, c.y - d.y)
if (before > 0) zoomAt((c.x + d.x) / 2 - r.left, (c.y + d.y) / 2 - r.top, after / before)
return
}
const dx = e.clientX - p.x, dy = e.clientY - p.y
pointers.current.set(e.pointerId, { 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 toWorld = (ev) => {
const r = box.current.getBoundingClientRect(), v = viewRef.current
return { x: (ev.clientX - r.left - v.tx) / v.s, y: (ev.clientY - r.top - v.ty) / v.s,
inside: ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom }
}
const startToken = (e, t) => {
e.stopPropagation()
if (!t.can_move) { onSelect?.(t.id); return }
e.preventDefault()
const from = { x: e.clientX, y: e.clientY }
let moved = false, last = 0
const clampPos = (p) => ({ x: clamp(p.x, 0, map.w), y: clamp(p.y, 0, map.h) })
const mv = (ev) => {
if (!moved && Math.hypot(ev.clientX - from.x, ev.clientY - from.y) < 4) return
moved = true
const p = toWorld(ev)
setDrag({ id: t.id, ...clampPos(p), out: !p.inside })
if (t.x != null && p.inside && Date.now() - last > 120) { last = Date.now(); const c = clampPos(p); onMove(t.id, c.x, c.y) }
}
const end = (ev) => {
window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end)
if (!moved) { setDrag(null); onSelect?.(t.id); return }
const p = toWorld(ev)
if (!p.inside) { setDrag(null); return }
const c = clampPos(p)
Promise.resolve(onMove(t.id, c.x, c.y)).finally(() => setDrag(null)) // keep the local position until the server has it
}
window.addEventListener('pointermove', mv); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end)
}
const unplaced = tokens.filter((t) => t.x == null && t.can_move)
const d = (t) => t.size * map.grid
return (
<div className="mapbox" 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})` }}>
<img src={src} width={map.w} height={map.h} alt="" draggable={false} />
{showGrid && <div className="mapgrid" style={{ backgroundSize: `${map.grid}px ${map.grid}px` }} />}
{tokens.map((t) => {
const dr = drag?.id === t.id ? drag : null
const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y
if (x == null || (dr && dr.out)) return null
return (
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`}
style={{ left: x - d(t) / 2, top: y - d(t) / 2, width: d(t), height: d(t), '--tc': t.color, fontSize: Math.max(8, d(t) * 0.45) }}
onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}>
<span>{(t.name || '?').trim().slice(0, 2)}</span>
<em style={{ fontSize: Math.max(9, map.grid * 0.28) }}>{t.name}</em>
</div>
)
})}
</div>
{unplaced.length > 0 && (
<div className="maptray mapui" role="group" aria-label="Tokens not on the map yet">
<span className="small muted">Drag onto the map:</span>
{unplaced.map((t) => (
<button key={t.id} className="traytoken" style={{ '--tc': t.color }} onPointerDown={(e) => startToken(e, t)} title="Drag onto the map">{t.name}</button>
))}
</div>
)}
<div className="mapzoom mapui">
<button className="ghost" onClick={() => { const r = box.current.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1.3) }} aria-label="Zoom in">+</button>
<button className="ghost" onClick={() => { const r = box.current.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1 / 1.3) }} aria-label="Zoom out">-</button>
<button className="ghost" onClick={fit} aria-label="Fit the map to the window" title="Fit">⤢</button>
</div>
{children}
</div>
)
}
// Name, size, colour and visibility of one token (used in the live table and in the map editor)
export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
return (
<div className="tokenform">
<input value={t.name} maxLength={40} aria-label="Token name" onChange={(e) => onChange({ name: e.target.value })} />
<label className="small chk">Size <input type="number" min="0.25" max="8" step="0.25" value={t.size} onChange={(e) => onChange({ size: Number(e.target.value) || 1 })} /> sq.</label>
<span className="swatches">{COLORS.map((c) => <button key={c} className={`swatch ${t.color === c ? 'on' : ''}`} style={{ background: c }} aria-label={`Colour ${c}`} onClick={() => onChange({ color: c })} />)}</span>
<label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label>
<span className="row">
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
<button className="ghost danger" onClick={onDelete}>Remove</button>
</span>
</div>
)
}
const uid = () => Math.random().toString(36).slice(2, 12)
// Full-screen editor for a map in the DM's library: set the grid, place enemies and NPCs in advance.
export function MapEditor({ id, onClose }) {
const [m, setM] = useState(null)
const [tokens, setTokens] = useState([])
const [name, setName] = useState('')
const [grid, setGrid] = useState(40)
const [showGrid, setShowGrid] = useState(true)
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) })
}, [id])
if (!m) return null
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 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 }
setTokens([...tokens, t]); setSel(t.id); setDirty(true)
}
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 })) }) })
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
return createPortal(
<div className="mapeditor" role="dialog" aria-label="Map editor">
<aside className="card">
<h3>Map editor</h3>
<label className="small">Name <input value={name} maxLength={60} onChange={(e) => { setName(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>
<p className="small muted">Match the grid to the squares on your image. Token sizes are counted in squares.</p>
<h4>Enemies and NPCs</h4>
<button className="on" onClick={add}>+ Add token</button>
<ul className="tokenlist">
{tokens.map((t) => <li key={t.id}><button className={sel === t.id ? 'on' : 'ghost'} onClick={() => setSel(t.id)}><i style={{ background: t.color }} /> {t.name}{t.hidden ? ' (hidden)' : ''}</button></li>)}
{!tokens.length && <li className="muted small">None yet. Add a token, then drag it onto the map.</li>}
</ul>
{cur && <TokenForm t={cur} onChange={(p) => patch(cur.id, p)}
onDelete={() => { setTokens(tokens.filter((t) => t.id !== cur.id)); setSel(null); setDirty(true) }}
onDuplicate={() => { const c = { ...cur, id: uid(), name: cur.name.replace(/\s*\d*$/, '') + ' ' + (tokens.length + 1), x: cur.x + gridNum, y: cur.y + gridNum }; setTokens([...tokens, c]); setSel(c.id); setDirty(true) }} />}
{err && <p className="warn small">{err}</p>}
<div className="row editactions"><button className="on" disabled={!dirty} onClick={save}>Save</button><button className="ghost" onClick={close}>Close</button></div>
</aside>
<div className="mapstage">
<MapView map={{ ...m, grid: gridNum }} src={`/api/maps/${id}/image`} tokens={tokens} grid={showGrid} selected={sel} onSelect={setSel}
onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
</div>
</div>, document.body)
}
// DM: the map library (in the "Lobby actions" tab)
export function MapLibrary({ activeId, lobbyId, act }) {
const [list, setList] = useState([])
const [err, setErr] = useState('')
const [editing, setEditing] = useState(null)
const [busy, setBusy] = useState(false)
const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => {}), [])
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 })
setBusy(false)
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Upload failed'); return }
load()
}
return (
<div className="maplib">
<h4>Maps</h4>
<label className="uploadbtn">{busy ? 'Uploading…' : '+ Upload map (PNG, JPEG, WebP)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
{err && <p className="warn small">{err}</p>}
<ul className="maplist">
{list.map((m) => (
<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>
<span className="row">
{m.id === activeId
? <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null })}>Clear table</button>
: <button className="on" onClick={() => (!activeId || window.confirm('Replace the current map? Tokens on it are reset to the prepared ones.')) && act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: m.id })}>Show on table</button>}
<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>
</span>
</li>
))}
{!list.length && <li className="muted small">No maps yet. Upload an image, then prepare enemies in the editor.</li>}
</ul>
{editing && <MapEditor id={editing} onClose={() => setEditing(null)} />}
</div>
)
}
+45
View File
@@ -396,3 +396,48 @@ body:has(.lobbychat) main { padding: .5rem calc(var(--chatw) + .5rem) 0 .5rem; }
.editor .tabs { margin-top: .6rem; } .editor .tabs { margin-top: .6rem; }
.settings.inline { position: static; max-width: none; margin: 0; border: 0; background: transparent; padding: 0; } .settings.inline { position: static; max-width: none; margin: 0; border: 0; background: transparent; padding: 0; }
/* ---- maps */
.table { background: color-mix(in srgb, var(--panel) 70%, #000); }
.mapbox { position: absolute; inset: 0; overflow: hidden; touch-action: none; cursor: grab; background: #111; z-index: 1; }
.mapbox:active { cursor: grabbing; }
.mapworld { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; }
.mapworld img { display: block; max-width: none; user-select: none; -webkit-user-drag: none; }
.mapgrid { position: absolute; inset: 0; pointer-events: none; background-image: linear-gradient(to right, rgba(255,255,255,.28) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,.28) 1px, transparent 1px); }
.maptoken { position: absolute; border-radius: 50%; background: var(--tc); border: 3px solid #fff; box-shadow: 0 0 0 2px rgba(0,0,0,.6), 0 2px 6px rgba(0,0,0,.6); display: grid; place-items: center; color: #fff; font-weight: 700; text-transform: uppercase; user-select: none; touch-action: none; cursor: default; }
.maptoken.mov { cursor: move; }
.maptoken.player { border-color: var(--tc); background: color-mix(in srgb, var(--tc) 55%, #000); box-shadow: 0 0 0 2px #fff, 0 2px 6px rgba(0,0,0,.6); }
.maptoken.hid { opacity: .55; border-style: dashed; }
.maptoken.sel { outline: 3px solid var(--accent, gold); outline-offset: 3px; }
.maptoken span { pointer-events: none; text-shadow: 0 1px 2px #000; }
.maptoken em { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); margin-top: 2px; font-style: normal; font-weight: 600; text-transform: none; white-space: nowrap; color: #fff; text-shadow: 0 0 3px #000, 0 0 3px #000; pointer-events: none; }
.maptray { position: absolute; top: .5rem; left: .5rem; display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; padding: .3rem .5rem; border-radius: 8px; background: color-mix(in srgb, var(--panel) 90%, transparent); border: 1px solid var(--line); z-index: 4; max-width: 70%; }
.traytoken { border-radius: 999px; border: 2px solid var(--tc); background: color-mix(in srgb, var(--tc) 40%, var(--panel)); color: var(--text); padding: .15rem .7rem; cursor: grab; touch-action: none; }
.mapzoom { position: absolute; right: .5rem; bottom: .5rem; display: grid; gap: .25rem; z-index: 4; }
.mapzoom button, .maptools button { background: color-mix(in srgb, var(--panel) 90%, transparent); }
.maptools { position: absolute; top: .5rem; right: .5rem; display: flex; gap: .3rem; z-index: 4; }
.tokenpanel { position: absolute; right: 3rem; bottom: .5rem; z-index: 5; width: 17rem; max-width: 60%; padding: .6rem; border-radius: 10px; background: var(--panel); border: 1px solid var(--line); cursor: default; }
.tokenform { display: grid; gap: .45rem; }
.tokenform input[type=number] { width: 4.5rem; }
.swatches { display: flex; gap: .3rem; flex-wrap: wrap; }
.swatch { width: 1.4rem; height: 1.4rem; padding: 0; border-radius: 50%; border: 2px solid transparent; }
.swatch.on { border-color: var(--text); }
.maplib { display: grid; gap: .5rem; margin-bottom: 1rem; }
.uploadbtn { display: inline-block; padding: .4rem .8rem; border: 1px dashed var(--line); border-radius: 8px; cursor: pointer; text-align: center; }
.maplist { list-style: none; padding: 0; margin: 0; display: grid; gap: .4rem; }
.maplist li { padding: .5rem; border: 1px solid var(--line); border-radius: 8px; display: grid; gap: .3rem; }
.maplist li.active { border-color: var(--accent, gold); }
.mapeditor { position: fixed; inset: 0; z-index: 100; display: grid; grid-template-columns: 20rem 1fr; background: var(--bg); }
.mapeditor aside { overflow-y: auto; border-radius: 0; display: grid; gap: .6rem; align-content: start; }
.mapstage { position: relative; }
.tokenlist { list-style: none; padding: 0; margin: 0; display: grid; gap: .25rem; }
.tokenlist button { width: 100%; text-align: left; }
.tokenlist i { display: inline-block; width: .8rem; height: .8rem; border-radius: 50%; vertical-align: middle; }
@media (max-width: 800px) { .mapeditor { grid-template-columns: 1fr; grid-template-rows: 45% 1fr; } }
.mapeditor, .mapeditor * { box-sizing: border-box; }
.mapeditor { z-index: 1000; }
.mapeditor .mapstage { min-width: 0; }
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; }