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:
@@ -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 |
|
||||||
|
|||||||
+209
-21
@@ -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,17 +293,56 @@ 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"]
|
||||||
|
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
|
continue
|
||||||
name = r["name"]
|
name = r["name"]
|
||||||
if player:
|
if player:
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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' }} />
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user