Scenes (switch maps without losing state), decor props with rotation, editor visibility toggle
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -157,6 +157,13 @@ The top of the lobby is one compact row: small buttons on the left (start fight,
|
||||
|
||||
Players only see a short card of the other players, never their sheets. Guessing join codes is rate limited. Deleting a character takes it out of the lobbies (the player can pick another); deleting a lobby does not delete characters.
|
||||
|
||||
### Scenes and props
|
||||
|
||||
- **Scenes**: every map you put on the table becomes a *scene* of the lobby. Switching to another map (or back, in *Lobby actions → Scenes*) first saves the current one - token positions, HP and conditions, doors, explored ground, revealed areas, sight settings and the running fight - and restores the other exactly as you left it. State also survives pauses, since the table lives in the database. Per lobby up to 12 scenes; rename, delete, *Save now* and *Save a copy of the current table* (checkpoint) are available. Only one scene runs at a time (no simultaneous split party).
|
||||
- **Edit live**: the map editor can be opened for the running map. Walls, grid and sight range apply to the game at once; the prepared tokens are only used the next time the map is put on the table.
|
||||
- **Visibility in the editor**: the 👁 button beside each prepared token toggles "hidden from players".
|
||||
- **Props**: decor tokens (chest, barrel, table, chair, bed, door, campfire, tree, rug, stairs, ... 19 built-in drawings, plus your own PNG/WebP uploads up to 3 MB, 40 per DM). Add them in the map editor (*+ Prop*) or on the table (*+ Prop*); drag like tokens, size width/height in squares, rotate by slider, 45° or 90° steps (also in the right click menu), hide from players. They are drawn below tokens; players cannot move them.
|
||||
|
||||
## API
|
||||
|
||||
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
|
||||
@@ -186,6 +193,11 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
|
||||
| GET / PUT | `/api/lobbies/{id}/map` | Map and tokens as the viewer may see them (`?as=<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 |
|
||||
| GET / POST | `/api/lobbies/{id}/scenes` | DM: saved scenes / save a copy of the table (`name`) |
|
||||
| PUT / DELETE | `/api/lobbies/{id}/scenes/{sid}` | DM: rename (`name`), save now (`save`) / delete |
|
||||
| PUT | `/api/lobbies/{id}/scene` | DM: switch to a scene (`scene_id`); the current one is saved first |
|
||||
| GET / POST / DELETE | `/api/props[/{id}]` | Your own prop images / upload (raw body, `?name=`) / delete |
|
||||
| GET | `/api/props/{id}/image`, `/api/lobbies/{id}/prop-image/{pid}` | Prop image (owner / lobby members, DM's props) |
|
||||
| POST / PUT / DELETE | `/api/lobbies/{id}/tokens[/{tid}]` | Add NPC token (DM) / move (own token or DM) and edit (DM) / remove (DM) |
|
||||
| WebSocket | `/api/lobbies/{id}/ws` | Live notes `{t: messages / lobby / sheet / map}` for lobby members (cookie login, same origin) |
|
||||
| PUT | `/api/lobbies/{id}/sounds` | DM: switch result / critical sounds on or off |
|
||||
|
||||
+2
-1
@@ -928,7 +928,8 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)):
|
||||
_dm_only(_lobby_for(con, lid, user), user)
|
||||
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
|
||||
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
|
||||
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
|
||||
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_scenes"):
|
||||
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
|
||||
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
|
||||
purge_sounds(lid)
|
||||
hub.close_lobby(lid)
|
||||
|
||||
+285
-25
@@ -2,10 +2,12 @@
|
||||
|
||||
Coordinates are pixels of the stored image. A token's size is in grid squares (the map's `grid` is pixels per square).
|
||||
Images are re-encoded with Pillow on upload, so only real images get through and metadata is gone."""
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
@@ -26,6 +28,8 @@ MAX_MAPS = 30 # per DM
|
||||
MAX_TOKENS = 120 # per map / lobby
|
||||
MAX_WALLS = 400 # wall and door segments per map
|
||||
MAX_SHARES = 30
|
||||
MAX_SCENES = 12 # saved scenes per lobby
|
||||
MAX_PROPS = 40 # custom prop images per user
|
||||
COLORS = ("#c0392b", "#8e44ad", "#16a085", "#7f8c8d", "#d35400", "#2c3e50", "#27ae60", "#b9770e")
|
||||
|
||||
|
||||
@@ -45,6 +49,10 @@ def schema(con):
|
||||
con.execute("""CREATE TABLE IF NOT EXISTS lobby_reveals (
|
||||
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, x REAL NOT NULL, y REAL NOT NULL, radius REAL NOT NULL, dir REAL NOT NULL,
|
||||
fov REAL NOT NULL, targets TEXT, created REAL NOT NULL)""")
|
||||
con.execute("""CREATE TABLE IF NOT EXISTS lobby_scenes (
|
||||
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, name TEXT NOT NULL, map_id TEXT, state TEXT NOT NULL DEFAULT '{}', updated REAL NOT NULL)""")
|
||||
con.execute("""CREATE TABLE IF NOT EXISTS user_props (
|
||||
id TEXT PRIMARY KEY, owner_id INTEGER NOT NULL, name TEXT NOT NULL, created REAL NOT NULL)""")
|
||||
con.execute("CREATE TABLE IF NOT EXISTS map_shares (map_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (map_id, user_id))")
|
||||
for table, col, ddl in (("lobbies", "map_id", "TEXT"), ("lobbies", "doors", "TEXT NOT NULL DEFAULT '{}'"),
|
||||
("lobbies", "los", "INTEGER NOT NULL DEFAULT 1"), ("maps", "sight", "REAL NOT NULL DEFAULT 0"),
|
||||
@@ -52,7 +60,9 @@ def schema(con):
|
||||
("lobby_tokens", "hp", "INTEGER"), ("lobby_tokens", "hp_max", "INTEGER"),
|
||||
("lobby_tokens", "conds", "TEXT NOT NULL DEFAULT '{}'"), ("lobby_tokens", "show_hp", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("lobby_tokens", "show_cond", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("lobby_members", "vgroup", "INTEGER NOT NULL DEFAULT 0")):
|
||||
("lobby_members", "vgroup", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("lobby_tokens", "prop", "TEXT"), ("lobby_tokens", "rot", "REAL NOT NULL DEFAULT 0"), ("lobby_tokens", "size_h", "REAL"),
|
||||
("lobbies", "scene_id", "TEXT")):
|
||||
if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
||||
con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
||||
|
||||
@@ -97,9 +107,17 @@ def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict:
|
||||
tid = str(t.get("id", "")) if keep_id else ""
|
||||
npc = str(t.get("npc_id") or "")
|
||||
hp_max, hp = clean_int(t.get("hp_max"), 1, 9999), clean_int(t.get("hp"), -999, 9999)
|
||||
prop = str(t.get("prop") or "")
|
||||
prop = prop if re.fullmatch(r"[bc]:[a-z0-9_]{1,24}", prop) else ""
|
||||
try:
|
||||
rot = float(t.get("rot") or 0) % 360
|
||||
size_h = None if t.get("size_h") in (None, "") else min(max(float(t["size_h"]), 0.25), 8)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(422, "Invalid rotation or size")
|
||||
return {"id": tid if tid.isalnum() and len(tid) <= 16 else uuid.uuid4().hex[:12], "name": name, "x": x, "y": y,
|
||||
"size": size, "color": clean_color(t.get("color")), "hidden": bool(t.get("hidden")),
|
||||
"npc_id": npc if npc.isalnum() and len(npc) <= 16 else "", "hp": hp, "hp_max": hp_max}
|
||||
"npc_id": npc if npc.isalnum() and len(npc) <= 16 else "", "hp": hp, "hp_max": hp_max,
|
||||
"prop": prop, "rot": rot, "size_h": size_h if prop else None}
|
||||
|
||||
|
||||
def clean_int(v, lo, hi):
|
||||
@@ -269,7 +287,10 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
||||
con.execute("UPDATE lobbies SET map_id=NULL WHERE map_id=?", (mid,))
|
||||
con.execute("DELETE FROM map_shares WHERE map_id=?", (mid,))
|
||||
for lid in lobbies:
|
||||
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
|
||||
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"):
|
||||
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
|
||||
con.execute("UPDATE lobbies SET scene_id=NULL WHERE scene_id IN (SELECT id FROM lobby_scenes WHERE map_id=?)", (mid,))
|
||||
con.execute("DELETE FROM lobby_scenes WHERE map_id=?", (mid,))
|
||||
con.execute("DELETE FROM maps WHERE id=?", (mid,))
|
||||
path(mid).unlink(missing_ok=True)
|
||||
(store_dir / f"{mid}.thumb.webp").unlink(missing_ok=True)
|
||||
@@ -334,6 +355,80 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
||||
raise HTTPException(404, "Image missing")
|
||||
return Response(p.read_bytes(), media_type="image/webp", headers={"Cache-Control": "private, max-age=3600"})
|
||||
|
||||
# ---- custom prop images (decor tokens such as chests or tables); the built-in ones are drawn by the frontend
|
||||
|
||||
props_dir = store_dir.parent / "props"
|
||||
props_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def encode_prop(raw: bytes) -> bytes:
|
||||
try:
|
||||
im = Image.open(io.BytesIO(raw))
|
||||
if im.format not in ("PNG", "JPEG", "WEBP"):
|
||||
raise ValueError("format")
|
||||
im.load()
|
||||
except Exception:
|
||||
raise HTTPException(422, "Please upload a PNG, JPEG or WebP image")
|
||||
im = ImageOps.exif_transpose(im)
|
||||
im.thumbnail((512, 512))
|
||||
im = im.convert("RGBA")
|
||||
out = io.BytesIO()
|
||||
im.save(out, "WEBP", quality=88, method=4)
|
||||
return out.getvalue()
|
||||
|
||||
@api.get("/props")
|
||||
def list_props(user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
return [{"id": r["id"], "name": r["name"]} for r in
|
||||
con.execute("SELECT id, name FROM user_props WHERE owner_id=? ORDER BY created", (user["id"],))]
|
||||
|
||||
@api.post("/props")
|
||||
async def upload_prop(request: Request, name: str = "", user: dict = Depends(current_user)):
|
||||
"""Raw image bytes in the body, the name as ?name=. Transparent PNGs work best (top view)."""
|
||||
if int(request.headers.get("content-length") or 0) > 3_000_000:
|
||||
raise HTTPException(413, "The file is too large (at most 3 MB)")
|
||||
chunks, size = [], 0
|
||||
async for c in request.stream():
|
||||
size += len(c)
|
||||
if size > 3_000_000:
|
||||
raise HTTPException(413, "The file is too large (at most 3 MB)")
|
||||
chunks.append(c)
|
||||
with db() as con:
|
||||
if con.execute("SELECT COUNT(*) FROM user_props WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_PROPS:
|
||||
raise HTTPException(409, "Prop limit reached, delete one first")
|
||||
data = await run_in_threadpool(encode_prop, b"".join(chunks))
|
||||
pid = uuid.uuid4().hex[:12]
|
||||
(props_dir / f"{pid}.webp").write_bytes(data)
|
||||
with db() as con:
|
||||
con.execute("INSERT INTO user_props (id, owner_id, name, created) VALUES (?,?,?,?)", (pid, user["id"], name.strip()[:30] or "Prop", time.time()))
|
||||
return {"id": pid}
|
||||
|
||||
@api.delete("/props/{pid}")
|
||||
def delete_prop(pid: str, user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
if not con.execute("SELECT 1 FROM user_props WHERE id=? AND owner_id=?", (pid, user["id"])).fetchone():
|
||||
raise HTTPException(404, "Prop not found")
|
||||
con.execute("DELETE FROM user_props WHERE id=?", (pid,))
|
||||
(props_dir / f"{pid}.webp").unlink(missing_ok=True)
|
||||
return {"ok": True}
|
||||
|
||||
@api.get("/props/{pid}/image")
|
||||
def prop_image(pid: str, user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
ok = con.execute("SELECT 1 FROM user_props WHERE id=? AND owner_id=?", (pid, user["id"])).fetchone()
|
||||
if not ok:
|
||||
raise HTTPException(404, "Prop not found")
|
||||
return image_response(props_dir / f"{pid}.webp")
|
||||
|
||||
@api.get("/lobbies/{lid}/prop-image/{pid}")
|
||||
def lobby_prop_image(lid: str, pid: str, user: dict = Depends(current_user)):
|
||||
"""Members of a lobby may load the prop images of the lobby's DM."""
|
||||
with db() as con:
|
||||
lobby = lobby_for(con, lid, user)
|
||||
ok = con.execute("SELECT 1 FROM user_props WHERE id=? AND owner_id=?", (pid, lobby["owner_id"])).fetchone()
|
||||
if not ok or not re.fullmatch(r"[0-9a-f]{12}", pid):
|
||||
raise HTTPException(404, "Prop not found")
|
||||
return image_response(props_dir / f"{pid}.webp")
|
||||
|
||||
# ---- the map on a lobby's table
|
||||
|
||||
def sync_players(con, lid: str):
|
||||
@@ -346,6 +441,13 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
||||
for uid in have - ids:
|
||||
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=? AND user_id=?", (lid, uid))
|
||||
|
||||
def put_token(con, lid, t, npc=None, tid=None):
|
||||
"""Inserts a (cleaned or snapshot) token row for the lobby."""
|
||||
con.execute("""INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id, hp, hp_max, prop, rot, size_h)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(tid or uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(bool(t["hidden"])), npc,
|
||||
t.get("hp") if t.get("hp") is not None else t.get("hp_max"), t.get("hp_max"), t.get("prop") or None, t.get("rot") or 0, t.get("size_h")))
|
||||
|
||||
def mask_dims(m):
|
||||
f = max(1, -(-max(m["w"], m["h"]) // 512))
|
||||
return f, -(-m["w"] // f), -(-m["h"] // f)
|
||||
@@ -478,7 +580,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
||||
name = (json.loads(r["data"]).get("name") if r["data"] else "") or r["username"]
|
||||
except ValueError:
|
||||
name = r["username"]
|
||||
if not player and not full and r["id"] != turn_tok:
|
||||
if not player and not full and r["id"] != turn_tok and not r["prop"]:
|
||||
name = ""
|
||||
pt = ""
|
||||
src = r["data"] if player else r["ndata"]
|
||||
@@ -494,7 +596,9 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
||||
status["conds"] = json.loads(r["conds"] or "{}")
|
||||
if full:
|
||||
status.update(show_hp=bool(r["show_hp"]), show_cond=bool(r["show_cond"]), hp_hint=health(src) if src else None)
|
||||
out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name, "portrait": pt, **status,
|
||||
if r["prop"]:
|
||||
status = {}
|
||||
out.append({"id": r["id"], "kind": "player" if player else "prop" if r["prop"] else "npc", "prop": r["prop"], "rot": r["rot"], "size_h": r["size_h"], "username": r["username"], "name": name, "portrait": pt, **status,
|
||||
"x": r["x"], "y": r["y"], "size": r["size"], "hidden": bool(r["hidden"]),
|
||||
"color": (r["mcolor"] or "#999999") if player else r["color"],
|
||||
"can_move": ctx["is_dm"] or (r["user_id"] == user["id"] and not (ctx["los"] and r["x"] is None)),
|
||||
@@ -667,29 +771,185 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
||||
hub.notify(lid, "map")
|
||||
return {"ok": True}
|
||||
|
||||
@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")
|
||||
# ---- scenes: saved states of the table (map + tokens + doors + reveals + explored ground + combat)
|
||||
|
||||
TOKEN_COLS = ("id", "name", "x", "y", "color", "size", "hidden", "npc_id", "hp", "hp_max", "conds", "show_hp", "show_cond", "prop", "rot", "size_h")
|
||||
|
||||
def snapshot(con, lid) -> str:
|
||||
lobby = con.execute("SELECT * FROM lobbies WHERE id=?", (lid,)).fetchone()
|
||||
names = {r["id"]: r["username"] for r in con.execute("SELECT id, username FROM users WHERE id IN (SELECT user_id FROM lobby_members WHERE lobby_id=?)", (lid,))}
|
||||
toks = []
|
||||
for r in con.execute("SELECT * FROM lobby_tokens WHERE lobby_id=? ORDER BY rowid", (lid,)):
|
||||
d = {k: r[k] for k in TOKEN_COLS}
|
||||
if r["user_id"] is not None:
|
||||
if r["user_id"] not in names:
|
||||
continue
|
||||
d["username"] = names[r["user_id"]]
|
||||
toks.append(d)
|
||||
reveals = [{k: z[k] for k in ("id", "x", "y", "radius", "dir", "fov", "targets", "created")}
|
||||
for z in con.execute("SELECT * FROM lobby_reveals WHERE lobby_id=?", (lid,))]
|
||||
explored = [{"username": names[z["user_id"]], "map_id": z["map_id"], "rev": z["rev"], "data": base64.b64encode(z["data"]).decode()}
|
||||
for z in con.execute("SELECT * FROM lobby_explored WHERE lobby_id=?", (lid,)) if z["user_id"] in names]
|
||||
return json.dumps({"tokens": toks, "reveals": reveals, "explored": explored, "doors": lobby["doors"], "sight": lobby["sight"],
|
||||
"fog": lobby["fog"], "los": lobby["los"], "combat": lobby["combat"]})
|
||||
|
||||
def restore(con, lid, state: dict):
|
||||
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
|
||||
con.execute("DELETE FROM lobby_explored WHERE lobby_id=?", (lid,))
|
||||
con.execute("DELETE FROM lobby_reveals WHERE lobby_id=?", (lid,))
|
||||
con.execute("UPDATE lobbies SET sight=NULL WHERE id=?", (lid,))
|
||||
if mid:
|
||||
r = usable_map(con, str(mid), user)
|
||||
ids = {r["username"]: r["user_id"] for r in con.execute("SELECT u.username, m.user_id FROM lobby_members m JOIN users u ON u.id = m.user_id WHERE m.lobby_id=?", (lid,))}
|
||||
sync_players(con, lid)
|
||||
for d in state.get("tokens", []):
|
||||
if d.get("username"):
|
||||
uid = ids.get(d["username"])
|
||||
if uid is not None:
|
||||
con.execute("""UPDATE lobby_tokens SET x=?, y=?, hidden=?, hp=?, hp_max=?, conds=?, show_hp=?, show_cond=?, size=? WHERE lobby_id=? AND user_id=?""",
|
||||
(d["x"], d["y"], d["hidden"], d["hp"], d["hp_max"], d["conds"], d["show_hp"], d["show_cond"], d["size"], lid, uid))
|
||||
else:
|
||||
con.execute(f"INSERT INTO lobby_tokens (lobby_id, {', '.join(TOKEN_COLS)}) VALUES (?{',?' * len(TOKEN_COLS)})", (lid, *[d[k] for k in TOKEN_COLS]))
|
||||
for z in state.get("reveals", []):
|
||||
con.execute("INSERT INTO lobby_reveals (id, lobby_id, x, y, radius, dir, fov, targets, created) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(z["id"], lid, z["x"], z["y"], z["radius"], z["dir"], z["fov"], z["targets"], z["created"]))
|
||||
for z in state.get("explored", []):
|
||||
if z["username"] in ids:
|
||||
con.execute("INSERT INTO lobby_explored (lobby_id, user_id, map_id, data, rev, last) VALUES (?,?,?,?,?,NULL)",
|
||||
(lid, ids[z["username"]], z["map_id"], base64.b64decode(z["data"]), z["rev"]))
|
||||
con.execute("UPDATE lobbies SET doors=?, sight=?, fog=?, los=?, combat=? WHERE id=?",
|
||||
(state.get("doors", "{}"), state.get("sight"), state.get("fog", 1), state.get("los", 1), state.get("combat"), lid))
|
||||
|
||||
def fresh_state(con, lid, r, user):
|
||||
"""The state of a map that was just put on the table: its prepared tokens, nothing explored."""
|
||||
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
|
||||
con.execute("DELETE FROM lobby_explored WHERE lobby_id=?", (lid,))
|
||||
con.execute("DELETE FROM lobby_reveals WHERE lobby_id=?", (lid,))
|
||||
con.execute("UPDATE lobbies SET sight=NULL, doors='{}', combat=NULL WHERE id=?", (lid,))
|
||||
for t in json.loads(r["tokens"]):
|
||||
npc = t.get("npc_id") if t.get("npc_id") and can_read(con, t["npc_id"], user) else None # only NPCs this DM may open
|
||||
con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id, hp, hp_max) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]), npc,
|
||||
t.get("hp") if t.get("hp") is not None else t.get("hp_max"), t.get("hp_max")))
|
||||
put_token(con, lid, t, npc)
|
||||
sync_players(con, lid)
|
||||
|
||||
def autosave(con, lid):
|
||||
"""Stores the table's current state in its scene (creating the scene for tables that predate scenes)."""
|
||||
lobby = con.execute("SELECT * FROM lobbies WHERE id=?", (lid,)).fetchone()
|
||||
if not lobby["map_id"]:
|
||||
return
|
||||
now = time.time()
|
||||
if lobby["scene_id"] and con.execute("SELECT 1 FROM lobby_scenes WHERE id=? AND lobby_id=?", (lobby["scene_id"], lid)).fetchone():
|
||||
con.execute("UPDATE lobby_scenes SET state=?, updated=? WHERE id=?", (snapshot(con, lid), now, lobby["scene_id"]))
|
||||
else:
|
||||
new_scene(con, lid, lobby["map_id"], snapshot(con, lid))
|
||||
|
||||
def new_scene(con, lid, mid, state, name=None) -> str:
|
||||
if con.execute("SELECT COUNT(*) FROM lobby_scenes WHERE lobby_id=?", (lid,)).fetchone()[0] >= MAX_SCENES:
|
||||
raise HTTPException(409, f"Too many saved scenes (max {MAX_SCENES}) - delete one first")
|
||||
m = con.execute("SELECT name FROM maps WHERE id=?", (mid,)).fetchone()
|
||||
base = (name or (m["name"] if m else "Scene")).strip()[:50] or "Scene"
|
||||
taken = {r["name"] for r in con.execute("SELECT name FROM lobby_scenes WHERE lobby_id=?", (lid,))}
|
||||
nm, i = base, 2
|
||||
while nm in taken:
|
||||
nm, i = f"{base} ({i})", i + 1
|
||||
sid = uuid.uuid4().hex[:12]
|
||||
con.execute("INSERT INTO lobby_scenes (id, lobby_id, name, map_id, state, updated) VALUES (?,?,?,?,?,?)", (sid, lid, nm, mid, state, time.time()))
|
||||
con.execute("UPDATE lobbies SET scene_id=? WHERE id=?", (sid, lid))
|
||||
return sid
|
||||
|
||||
@api.put("/lobbies/{lid}/map")
|
||||
def set_lobby_map(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""DM: show a map from the library (or null to clear the table). The table's current state is kept as a scene;
|
||||
the new map starts fresh with its prepared tokens as a scene of its own."""
|
||||
with db() as con:
|
||||
dm_only(lobby_for(con, lid, user), user)
|
||||
mid = body.get("map_id")
|
||||
r = usable_map(con, str(mid), user) if mid else None
|
||||
autosave(con, lid)
|
||||
con.execute("UPDATE lobbies SET scene_id=NULL WHERE id=?", (lid,))
|
||||
if r:
|
||||
fresh_state(con, lid, r, user)
|
||||
con.execute("UPDATE lobbies SET map_id=? WHERE id=?", (r["id"], lid))
|
||||
new_scene(con, lid, r["id"], snapshot(con, lid))
|
||||
event(con, lid, f"The DM put the map \"{r['name']}\" on the table", user["id"])
|
||||
else:
|
||||
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"):
|
||||
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
|
||||
con.execute("UPDATE lobbies SET map_id=NULL, sight=NULL, doors='{}', combat=NULL WHERE id=?", (lid,))
|
||||
event(con, lid, "The DM cleared the table", user["id"])
|
||||
con.execute("UPDATE lobbies SET map_id=?, doors='{}' WHERE id=?", (str(mid) if mid else None, lid))
|
||||
hub.notify(lid, "map")
|
||||
hub.notify(lid, "lobby")
|
||||
return {"ok": True}
|
||||
|
||||
@api.get("/lobbies/{lid}/scenes")
|
||||
def list_scenes(lid: str, user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
lobby = lobby_for(con, lid, user)
|
||||
dm_only(lobby, user)
|
||||
if lobby["map_id"] and not con.execute("SELECT 1 FROM lobby_scenes WHERE id=? AND lobby_id=?", (lobby["scene_id"] or "", lid)).fetchone():
|
||||
try:
|
||||
autosave(con, lid) # a table that predates scenes becomes one
|
||||
except HTTPException:
|
||||
pass
|
||||
lobby = lobby_for(con, lid, user)
|
||||
rows = con.execute("""SELECT s.id, s.name, s.map_id, s.updated, m.name AS map_name, LENGTH(s.state) AS size FROM lobby_scenes s
|
||||
LEFT JOIN maps m ON m.id = s.map_id WHERE s.lobby_id=? ORDER BY s.updated DESC""", (lid,)).fetchall()
|
||||
return [{"id": r["id"], "name": r["name"], "map_id": r["map_id"], "map_name": r["map_name"] or "", "updated": r["updated"],
|
||||
"current": r["id"] == lobby["scene_id"]} for r in rows]
|
||||
|
||||
@api.put("/lobbies/{lid}/scene")
|
||||
def switch_scene(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""DM: continue another saved scene. The current table is saved first, so nothing is lost when switching back."""
|
||||
with db() as con:
|
||||
dm_only(lobby_for(con, lid, user), user)
|
||||
sc = con.execute("SELECT * FROM lobby_scenes WHERE id=? AND lobby_id=?", (str(body.get("scene_id", "")), lid)).fetchone()
|
||||
if not sc:
|
||||
raise HTTPException(404, "Scene not found")
|
||||
r = usable_map(con, sc["map_id"], user) if sc["map_id"] else None
|
||||
if not r:
|
||||
raise HTTPException(409, "The map of this scene is no longer available")
|
||||
autosave(con, lid)
|
||||
restore(con, lid, json.loads(sc["state"]))
|
||||
con.execute("UPDATE lobbies SET map_id=?, scene_id=? WHERE id=?", (r["id"], sc["id"], lid))
|
||||
event(con, lid, f"The DM switched to the scene \"{sc['name']}\"", user["id"])
|
||||
hub.notify(lid, "map")
|
||||
hub.notify(lid, "lobby")
|
||||
return {"ok": True}
|
||||
|
||||
@api.post("/lobbies/{lid}/scenes")
|
||||
def save_scene_copy(lid: str, body: dict = Body(default={}), user: dict = Depends(current_user)):
|
||||
"""DM: store a copy of the current table (a checkpoint to come back to). The table keeps running on its own scene."""
|
||||
with db() as con:
|
||||
lobby = lobby_for(con, lid, user)
|
||||
dm_only(lobby, user)
|
||||
if not lobby["map_id"]:
|
||||
raise HTTPException(409, "There is no map on the table")
|
||||
autosave(con, lid)
|
||||
current = con.execute("SELECT scene_id FROM lobbies WHERE id=?", (lid,)).fetchone()["scene_id"]
|
||||
sid = new_scene(con, lid, lobby["map_id"], snapshot(con, lid), str(body.get("name", "")) or None)
|
||||
con.execute("UPDATE lobbies SET scene_id=? WHERE id=?", (current, lid))
|
||||
hub.notify(lid, "lobby")
|
||||
return {"id": sid}
|
||||
|
||||
@api.put("/lobbies/{lid}/scenes/{sid}")
|
||||
def edit_scene(lid: str, sid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
dm_only(lobby_for(con, lid, user), user)
|
||||
if not con.execute("SELECT 1 FROM lobby_scenes WHERE id=? AND lobby_id=?", (sid, lid)).fetchone():
|
||||
raise HTTPException(404, "Scene not found")
|
||||
name = str(body.get("name", "")).strip()[:50]
|
||||
if name:
|
||||
if con.execute("SELECT 1 FROM lobby_scenes WHERE lobby_id=? AND name=? AND id<>?", (lid, name, sid)).fetchone():
|
||||
raise HTTPException(409, "There is already a scene with this name")
|
||||
con.execute("UPDATE lobby_scenes SET name=? WHERE id=?", (name, sid))
|
||||
if body.get("save"):
|
||||
autosave(con, lid)
|
||||
hub.notify(lid, "lobby")
|
||||
return {"ok": True}
|
||||
|
||||
@api.delete("/lobbies/{lid}/scenes/{sid}")
|
||||
def delete_scene(lid: str, sid: str, user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
dm_only(lobby_for(con, lid, user), user)
|
||||
con.execute("DELETE FROM lobby_scenes WHERE id=? AND lobby_id=?", (sid, lid))
|
||||
con.execute("UPDATE lobbies SET scene_id=NULL WHERE id=? AND scene_id=?", (lid, sid))
|
||||
hub.notify(lid, "lobby")
|
||||
return {"ok": True}
|
||||
|
||||
def need_map(con, lobby):
|
||||
@@ -708,9 +968,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
||||
raise HTTPException(409, "Too many tokens")
|
||||
t = clean_token(body, m["w"], m["h"], keep_id=False)
|
||||
npc = t["npc_id"] if t["npc_id"] and can_read(con, t["npc_id"], user) else None
|
||||
con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id, hp, hp_max) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(t["id"], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]), npc,
|
||||
t["hp"] if t["hp"] is not None else t["hp_max"], t["hp_max"]))
|
||||
put_token(con, lid, t, npc, t["id"])
|
||||
hub.notify(lid, "map")
|
||||
return {"id": t["id"]}
|
||||
|
||||
@@ -726,18 +984,20 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
||||
raise HTTPException(404, "Token not found")
|
||||
if not is_dm and t["x"] is None and lobby["los"] and json.loads(m["walls"]):
|
||||
raise HTTPException(403, "The DM puts your token on the map") # with line of sight on you could not see where
|
||||
merged = {"name": t["name"], "x": t["x"], "y": t["y"], "color": t["color"], "size": t["size"], "hidden": t["hidden"]}
|
||||
merged = {"name": t["name"], "x": t["x"], "y": t["y"], "color": t["color"], "size": t["size"], "hidden": t["hidden"],
|
||||
"prop": t["prop"], "rot": t["rot"], "size_h": t["size_h"]}
|
||||
if "x" in body or "y" in body:
|
||||
merged["x"], merged["y"] = body.get("x"), body.get("y")
|
||||
if is_dm and t["user_id"] is None:
|
||||
for k in ("name", "color", "size", "hidden"):
|
||||
for k in ("name", "color", "size", "hidden", "rot", "size_h"):
|
||||
if k in body:
|
||||
merged[k] = body[k]
|
||||
elif is_dm and "size" in body:
|
||||
merged["size"] = body["size"]
|
||||
c = clean_token(merged, m["w"], m["h"])
|
||||
con.execute("UPDATE lobby_tokens SET name=?, x=?, y=?, color=?, size=?, hidden=? WHERE id=?",
|
||||
(c["name"] if t["user_id"] is None else t["name"], c["x"], c["y"], c["color"], c["size"], int(c["hidden"]), tid))
|
||||
con.execute("UPDATE lobby_tokens SET name=?, x=?, y=?, color=?, size=?, hidden=?, rot=?, size_h=? WHERE id=?",
|
||||
(c["name"] if t["user_id"] is None else t["name"], c["x"], c["y"], c["color"], c["size"], int(c["hidden"]),
|
||||
c["rot"] if t["user_id"] is None else 0, c["size_h"] if t["user_id"] is None else None, tid))
|
||||
if is_dm and any(k in body for k in ("hp", "hp_max", "conds", "show_hp", "show_cond")):
|
||||
hp_max, hp = t["hp_max"], t["hp"]
|
||||
if "hp_max" in body:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
|
||||
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
|
||||
import { MapView, MapLibrary, NpcPicker, RadialMenu, RevealLayer, StatusForm, TokenForm, WallLayer } from './MapView.jsx'
|
||||
import { MapView, MapLibrary, NpcPicker, PropForm, PropPicker, RadialMenu, RevealLayer, StatusForm, TokenForm, WallLayer } from './MapView.jsx'
|
||||
|
||||
const J = { 'Content-Type': 'application/json' }
|
||||
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
|
||||
@@ -788,6 +788,12 @@ function LobbyView({ id, rules, user }) {
|
||||
const toggleTop = () => { setTopOpen((v) => { store('aw_topopen', v ? '0' : '1'); return !v }) }
|
||||
const combatOp = (b) => act(`/api/lobbies/${id}/combat`, 'PUT', b)
|
||||
const [npcPick, setNpcPick] = useState(false)
|
||||
const [propPick, setPropPick] = useState(false)
|
||||
const addPropToken = (p) => {
|
||||
const m = mapState.map
|
||||
setPropPick(false)
|
||||
send(`/api/lobbies/${id}/tokens`, 'POST', { name: p.name, prop: p.prop, x: m.w / 2, y: m.h / 2, size: p.w, size_h: p.h, hidden: false }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
|
||||
}
|
||||
const addNpcToken = (n) => {
|
||||
const hp = n.hp > 0 ? n.hp : undefined
|
||||
const m = mapState.map
|
||||
@@ -890,9 +896,10 @@ function LobbyView({ id, rules, user }) {
|
||||
{mapState.map && (() => {
|
||||
const full = lobby.is_dm && !viewAs
|
||||
const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null
|
||||
const selProp = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'prop') : null
|
||||
const walls = full && showWalls ? mapState.walls || [] : []
|
||||
return (
|
||||
<MapView map={mapState.map} image={mapImg} fog={fogImg} portraitUrl={(t) => t.portrait ? (t.kind === 'npc' ? `/api/lobbies/${id}/token-portrait/${t.id}?v=${t.portrait}` : `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}`) : null} tokens={mapState.tokens} grid={showGrid}
|
||||
<MapView map={mapState.map} image={mapImg} fog={fogImg} propSrc={(pid) => `/api/lobbies/${id}/prop-image/${pid}`} portraitUrl={(t) => t.portrait ? (t.kind === 'npc' ? `/api/lobbies/${id}/token-portrait/${t.id}?v=${t.portrait}` : `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}`) : null} tokens={mapState.tokens} grid={showGrid}
|
||||
onMove={moveToken} selected={mapSel} onSelect={setMapSel}
|
||||
onMenu={lobby.is_dm && !viewAs ? (t, x, y) => setMenu({ t, x, y }) : undefined}
|
||||
isTurn={(t) => { const e = lobby.combat?.entries.find((x) => x.id === lobby.combat.current); return !!e && (e.kind === 'npc' ? e.token === t.id : e.user === t.username) }}
|
||||
@@ -920,7 +927,8 @@ function LobbyView({ id, rules, user }) {
|
||||
{full && mapState.walls?.length > 0 && <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>
|
||||
{full && <button className="ghost" onClick={addToken}>+ Token</button>}
|
||||
{full && <button className={npcPick ? 'on' : 'ghost'} onClick={() => setNpcPick(!npcPick)}>+ NPC</button>}
|
||||
{full && <button className={npcPick ? 'on' : 'ghost'} onClick={() => { setNpcPick(!npcPick); setPropPick(false) }}>+ NPC</button>}
|
||||
{full && <button className={propPick ? 'on' : 'ghost'} onClick={() => { setPropPick(!propPick); setNpcPick(false) }} title="Decor such as chests, tables or doors">+ Prop</button>}
|
||||
</div>
|
||||
{full && (rv || mapState.reveals?.length > 0) && (
|
||||
<div className="tokenpanel mapui revealpanel">
|
||||
@@ -947,13 +955,24 @@ function LobbyView({ id, rules, user }) {
|
||||
</div>
|
||||
)}
|
||||
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>}
|
||||
{full && mapSel && (() => { const st = mapState.tokens.find((t) => t.id === mapSel); return st && !selTok ? <div className="tokenpanel mapui"><b className="small">{st.name || 'Player'}</b><StatusForm t={st} conditions={rules.conditions || []} onChange={(p) => patchToken(st.id, p)} /></div> : null })()}
|
||||
{propPick && <div className="mapui npcpickwrap"><PropPicker onPick={addPropToken} onClose={() => setPropPick(false)} /></div>}
|
||||
{selProp && <div className="tokenpanel mapui"><PropForm t={selProp} onChange={(p) => patchToken(selProp.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selProp.id}`, 'DELETE'); setMapSel(null) }} /></div>}
|
||||
{full && mapSel && (() => { const st = mapState.tokens.find((t) => t.id === mapSel); return st && !selTok && !selProp ? <div className="tokenpanel mapui"><b className="small">{st.name || 'Player'}</b><StatusForm t={st} conditions={rules.conditions || []} onChange={(p) => patchToken(st.id, p)} /></div> : null })()}
|
||||
{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) }} /><StatusForm t={selTok} conditions={rules.conditions || []} onChange={(p) => patchToken(selTok.id, p)} /></div>}
|
||||
</MapView>
|
||||
)
|
||||
})()}
|
||||
{menu && (() => {
|
||||
const t = menu.t, e = lobby.combat?.entries.find((x) => (t.kind === 'npc' ? x.token === t.id : x.user === t.username))
|
||||
const t = menu.t
|
||||
if (t.kind === 'prop') {
|
||||
const rot = t.rot || 0
|
||||
return <RadialMenu x={menu.x} y={menu.y} title={t.name} onClose={() => setMenu(null)} items={[
|
||||
{ icon: '⟲', label: 'Turn left', onClick: () => patchToken(t.id, { rot: (rot + 315) % 360 }) },
|
||||
{ icon: '⟳', label: 'Turn right', onClick: () => patchToken(t.id, { rot: (rot + 45) % 360 }) },
|
||||
{ icon: t.hidden ? '👁' : '🚫', label: t.hidden ? 'Reveal' : 'Hide', onClick: () => patchToken(t.id, { hidden: !t.hidden }) },
|
||||
{ icon: '🗑', label: 'Remove', onClick: () => { send(`/api/lobbies/${id}/tokens/${t.id}`, 'DELETE'); setMapSel(null) } }]} />
|
||||
}
|
||||
const e = lobby.combat?.entries.find((x) => (t.kind === 'npc' ? x.token === t.id : x.user === t.username))
|
||||
const fight = !!lobby.combat
|
||||
return <RadialMenu x={menu.x} y={menu.y} title={t.name} onClose={() => setMenu(null)} items={[
|
||||
{ icon: '⚔', label: e ? 'In fight' : fight ? 'Add to fight' : 'No fight', disabled: !fight || !!e, onClick: () => combatOp(t.kind === 'npc' ? { op: 'add', token: t.id } : { op: 'add', user: t.username }) },
|
||||
|
||||
+136
-19
@@ -1,12 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { BUILTIN, PropArt } from './props.jsx'
|
||||
|
||||
const COLORS = ['#c0392b', '#8e44ad', '#16a085', '#7f8c8d', '#d35400', '#2c3e50', '#27ae60', '#b9770e']
|
||||
const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
|
||||
|
||||
// Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels.
|
||||
// Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise.
|
||||
export function MapView({ map, image, fog, portraitUrl, onMenu, isTurn, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
|
||||
export function MapView({ map, image, fog, portraitUrl, propSrc, onMenu, isTurn, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
|
||||
const box = useRef(null)
|
||||
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
|
||||
const [drag, setDrag] = useState(null)
|
||||
@@ -109,7 +110,7 @@ export function MapView({ map, image, fog, portraitUrl, onMenu, isTurn, tokens,
|
||||
window.addEventListener('pointermove', mv); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end)
|
||||
}
|
||||
|
||||
const unplaced = tokens.filter((t) => t.x == null && t.can_move)
|
||||
const unplaced = tokens.filter((t) => t.x == null && t.can_move && t.kind !== 'prop')
|
||||
const d = (t) => t.size * map.grid
|
||||
return (
|
||||
<div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
|
||||
@@ -118,10 +119,21 @@ export function MapView({ map, image, fog, portraitUrl, onMenu, isTurn, tokens,
|
||||
{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` }} />}
|
||||
{overlay}
|
||||
{tokens.map((t) => {
|
||||
{[...tokens].sort((a, b) => (b.kind === 'prop') - (a.kind === 'prop')).map((t) => {
|
||||
const dr = drag?.id === t.id ? drag : null
|
||||
const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y
|
||||
if (x == null || (dr && dr.out)) return null
|
||||
if (t.kind === 'prop') { // decor: below the tokens, no label, rotated
|
||||
const w = t.size * map.grid, h = (t.size_h || t.size) * map.grid
|
||||
return (
|
||||
<div key={t.id} className={`maptoken prop ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`}
|
||||
onContextMenu={(e) => { if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }}
|
||||
style={{ left: x - w / 2, top: y - h / 2, width: w, height: h, transform: `rotate(${t.rot || 0}deg)` }}
|
||||
onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}>
|
||||
<PropArt prop={t.prop} customSrc={propSrc} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''} ${isTurn?.(t) ? 'turn' : ''}`}
|
||||
onContextMenu={(e) => { if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }}
|
||||
@@ -252,6 +264,71 @@ export function NpcPicker({ onPick, onClose }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Library of decor tokens: built-in drawings and the DM's own uploaded images. onPick({prop, name, w, h}).
|
||||
export function PropPicker({ onPick, onClose }) {
|
||||
const [mine, setMine] = useState([])
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const load = useCallback(() => fetch('/api/props').then((r) => (r.ok ? r.json() : [])).then(setMine).catch(() => {}), [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const upload = async (e) => {
|
||||
const f = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!f) return
|
||||
setErr(''); setBusy(true)
|
||||
const r = await fetch(`/api/props?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f })
|
||||
setBusy(false)
|
||||
if (!r.ok) { setErr(r.status === 413 ? 'The image is too large (at most 3 MB).' : (await r.json().catch(() => ({}))).detail || 'Upload failed'); return }
|
||||
load()
|
||||
}
|
||||
return (
|
||||
<div className="npcpicker card proppicker" role="dialog" aria-label="Prop library">
|
||||
<div className="row"><strong>Props</strong><button className="ghost" onClick={onClose} aria-label="Close">×</button></div>
|
||||
<div className="propgrid">
|
||||
{BUILTIN.map((p) => (
|
||||
<button key={p.id} className="ghost propbtn" title={p.name} onClick={() => onPick({ prop: `b:${p.id}`, name: p.name, w: p.w, h: p.h })}>
|
||||
<svg viewBox="0 0 100 100" aria-hidden="true">{p.art}</svg><span>{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{mine.map((p) => (
|
||||
<span key={p.id} className="propbtn own">
|
||||
<button className="ghost" title={p.name} onClick={() => onPick({ prop: `c:${p.id}`, name: p.name, w: 1, h: 1 })}><img src={`/api/props/${p.id}/image`} alt="" /><span>{p.name}</span></button>
|
||||
<button className="ghost mini danger" aria-label={`Delete ${p.name}`} onClick={() => window.confirm(`Delete the prop "${p.name}"? It disappears from all maps.`) && fetch(`/api/props/${p.id}`, { method: 'DELETE' }).then(load)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<label className="uploadbtn">{busy ? 'Uploading…' : '+ Own image (PNG with transparency)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
|
||||
{err && <p className="warn small">{err}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Name, size, rotation and visibility of a decor token
|
||||
export function PropForm({ t, onChange, onDelete, onDuplicate }) {
|
||||
const rot = Math.round(t.rot || 0)
|
||||
return (
|
||||
<div className="tokenform">
|
||||
<input value={t.name} maxLength={40} aria-label="Prop name" onChange={(e) => onChange({ name: e.target.value })} />
|
||||
<span className="row">
|
||||
<label className="small chk">Width <input type="number" min="0.25" max="8" step="0.25" value={t.size} onChange={(e) => onChange({ size: Number(e.target.value) || 1 })} /></label>
|
||||
<label className="small chk">Height <input type="number" min="0.25" max="8" step="0.25" value={t.size_h || t.size} onChange={(e) => onChange({ size_h: Number(e.target.value) || 1 })} /></label>
|
||||
</span>
|
||||
<label className="small">Rotation {rot}°
|
||||
<input type="range" min="0" max="359" step="5" value={rot} onChange={(e) => onChange({ rot: Number(e.target.value) })} /></label>
|
||||
<span className="row">
|
||||
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 315) % 360 })} title="Rotate left 45°">⟲ 45°</button>
|
||||
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 45) % 360 })} title="Rotate right 45°">⟳ 45°</button>
|
||||
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 90) % 360 })} title="Rotate right 90°">⟳ 90°</button>
|
||||
</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)
|
||||
|
||||
// Areas the DM shows to players: a circle or cone. zones = revealed ones, draft = being placed. Units: pixels / degrees.
|
||||
@@ -301,7 +378,7 @@ const distToSeg = (p, w) => {
|
||||
}
|
||||
|
||||
// Full-screen editor for a map in the DM's library: grid, enemies and NPCs, walls and doors for line of sight.
|
||||
export function MapEditor({ id, onClose }) {
|
||||
export function MapEditor({ id, onClose, live, onSaved }) {
|
||||
const [m, setM] = useState(null)
|
||||
const [tokens, setTokens] = useState([])
|
||||
const [walls, setWalls] = useState([])
|
||||
@@ -318,7 +395,7 @@ export function MapEditor({ id, onClose }) {
|
||||
const [dirty, setDirty] = useState(false)
|
||||
useEffect(() => {
|
||||
fetch(`/api/maps/${id}`).then((r) => r.json()).then((d) => {
|
||||
setM(d); setTokens(d.tokens.map((t) => ({ ...t, kind: 'npc', can_move: true }))); setWalls(d.walls); setName(d.name); setGrid(d.grid); setSight(d.sight || 0)
|
||||
setM(d); setTokens(d.tokens.map((t) => ({ ...t, kind: t.prop ? 'prop' : 'npc', can_move: true }))); setWalls(d.walls); setName(d.name); setGrid(d.grid); setSight(d.sight || 0)
|
||||
})
|
||||
}, [id])
|
||||
useEffect(() => {
|
||||
@@ -330,6 +407,7 @@ export function MapEditor({ id, onClose }) {
|
||||
const [image, setImage] = useState(null)
|
||||
useEffect(() => { if (m) setImage({ src: `/api/maps/${id}/image`, x: 0, y: 0, w: m.w, h: m.h }) }, [m?.id]) // eslint-disable-line
|
||||
const [picker, setPicker] = useState(false)
|
||||
const [propPick, setPropPick] = useState(false)
|
||||
if (!m) return null
|
||||
const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid
|
||||
const cur = tokens.find((t) => t.id === sel)
|
||||
@@ -340,6 +418,11 @@ export function MapEditor({ id, onClose }) {
|
||||
const t = { id: uid(), kind: 'npc', can_move: true, name: n.name, npc_id: n.id, portrait: n.portrait || '', x: m.w / 2 + (k % 5) * gridNum, y: m.h / 2 + (k % 3) * gridNum, size: n.size || 1, color: COLORS[0], hidden: false, hp_max: n.hp > 0 ? n.hp : null, hp: n.hp > 0 ? n.hp : null }
|
||||
setTokens([...tokens, t]); setSel(t.id); setDirty(true); setPicker(false)
|
||||
}
|
||||
const addProp = (p) => {
|
||||
const k = tokens.length + 1
|
||||
const t = { id: uid(), kind: 'prop', can_move: true, name: p.name, prop: p.prop, rot: 0, x: m.w / 2 + (k % 5) * gridNum, y: m.h / 2 + (k % 3) * gridNum, size: p.w, size_h: p.h, color: COLORS[0], hidden: false }
|
||||
setTokens([...tokens, t]); setSel(t.id); setDirty(true); setPropPick(false)
|
||||
}
|
||||
const add = () => {
|
||||
const n = tokens.length + 1
|
||||
const t = { id: uid(), kind: 'npc', can_move: true, name: `Enemy ${n}`, x: m.w / 2 + (n % 5) * gridNum, y: m.h / 2 + (n % 3) * gridNum, size: 1, color: COLORS[0], hidden: false }
|
||||
@@ -374,9 +457,9 @@ export function MapEditor({ id, onClose }) {
|
||||
setErr('')
|
||||
const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, grid: Number(grid), sight: Number(sight) || 0, walls,
|
||||
tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max }) => ({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max })) }) })
|
||||
tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h }) => ({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h })) }) })
|
||||
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false }
|
||||
setDirty(false); return true
|
||||
setDirty(false); onSaved?.(); return true
|
||||
}
|
||||
const close = async () => { if (!dirty || window.confirm('Close without saving your changes?')) onClose() }
|
||||
const tools = [['select', 'Move tokens'], ['wall', 'Wall'], ['rect', 'Room'], ['door', 'Door'], ['erase', 'Erase']]
|
||||
@@ -406,21 +489,31 @@ export function MapEditor({ id, onClose }) {
|
||||
</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>
|
||||
<div className="row"><button className="on" onClick={add}>+ Add token</button><button className="ghost" onClick={() => setPicker(!picker)}>+ From NPC library</button></div>
|
||||
<h4>{live ? 'Prepared tokens (used when the map is put on the table again)' : 'Enemies, NPCs and props'}</h4>
|
||||
<div className="row"><button className="on" onClick={add}>+ Add token</button><button className="ghost" onClick={() => { setPicker(!picker); setPropPick(false) }}>+ From NPC library</button><button className="ghost" onClick={() => { setPropPick(!propPick); setPicker(false) }}>+ Prop</button></div>
|
||||
{picker && <NpcPicker onPick={addNpc} onClose={() => setPicker(false)} />}
|
||||
{propPick && <PropPicker onPick={addProp} onClose={() => setPropPick(false)} />}
|
||||
<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.npc_id ? ' ★' : ''}{t.hidden ? ' (hidden)' : ''}</button></li>)}
|
||||
{!tokens.length && <li className="muted small">None yet. Add a token, then drag it onto the map.</li>}
|
||||
{tokens.map((t) => (
|
||||
<li key={t.id} className="tokrow">
|
||||
<button className={sel === t.id ? 'on' : 'ghost'} onClick={() => setSel(t.id)}>{t.kind === 'prop' ? <i className="propdot" /> : <i style={{ background: t.color }} />} {t.name}{t.npc_id ? ' ★' : ''}{t.hidden ? ' (hidden)' : ''}</button>
|
||||
<button className="ghost mini" aria-pressed={!t.hidden} aria-label={t.hidden ? `Show ${t.name} to players` : `Hide ${t.name} from players`} title={t.hidden ? 'Hidden from players - click to show' : 'Visible to players - click to hide'}
|
||||
onClick={() => patch(t.id, { hidden: !t.hidden })}>{t.hidden ? '🚫' : '👁'}</button>
|
||||
</li>
|
||||
))}
|
||||
{!tokens.length && <li className="muted small">None yet. Add a token or a prop, 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) }} />}
|
||||
{cur && (() => {
|
||||
const props = { t: cur, onChange: (p) => patch(cur.id, p),
|
||||
onDelete: () => { setTokens(tokens.filter((t) => t.id !== cur.id)); setSel(null); setDirty(true) },
|
||||
onDuplicate: () => { const c = { ...cur, id: uid(), name: cur.name.replace(/\s*\d*$/, '') + ' ' + (tokens.length + 1), x: cur.x + gridNum, y: cur.y + gridNum }; setTokens([...tokens, c]); setSel(c.id); setDirty(true) } }
|
||||
return cur.kind === 'prop' ? <PropForm {...props} /> : <TokenForm {...props} />
|
||||
})()}
|
||||
{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 }} image={image} tokens={tokens} portraitUrl={(t) => (t.npc_id && t.portrait ? `/api/characters/${t.npc_id}/portrait?v=${t.portrait}` : null)} grid={showGrid} selected={sel} onSelect={setSel} tool={tool}
|
||||
<MapView map={{ ...m, grid: gridNum }} image={image} tokens={tokens} propSrc={(pid) => `/api/props/${pid}/image`} portraitUrl={(t) => (t.npc_id && t.portrait ? `/api/characters/${t.npc_id}/portrait?v=${t.portrait}` : null)} grid={showGrid} selected={sel} onSelect={setSel} tool={tool}
|
||||
onClick={click} onHover={draft ? (p, s) => setHover(p) : undefined}
|
||||
overlay={<WallLayer map={m} walls={walls} draft={draft} hover={draft ? hover : null} />}
|
||||
onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
|
||||
@@ -434,6 +527,9 @@ export function MapLibrary({ activeId, lobbyId, act }) {
|
||||
const [err, setErr] = useState('')
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [scenes, setScenes] = useState([])
|
||||
const loadScenes = useCallback(() => (lobbyId ? fetch(`/api/lobbies/${lobbyId}/scenes`).then((r) => (r.ok ? r.json() : [])).then(setScenes).catch(() => {}) : null), [lobbyId])
|
||||
useEffect(() => { loadScenes() }, [loadScenes, activeId])
|
||||
const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => {}), [])
|
||||
useEffect(() => { load() }, [load, editing])
|
||||
const upload = async (e) => {
|
||||
@@ -457,16 +553,37 @@ export function MapLibrary({ activeId, lobbyId, act }) {
|
||||
<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">
|
||||
{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>}
|
||||
{m.mine && <button className="ghost" onClick={() => setEditing(m.id)}>Edit</button>}
|
||||
? <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null }, () => { loadScenes() })}>Clear table</button>
|
||||
: <button className="on" onClick={() => (!activeId || window.confirm('Put this map on the table? The current table is saved as a scene first - you can switch back to it below.')) && act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: m.id }, () => { loadScenes() })}>Show on table</button>}
|
||||
{m.mine && <button className="ghost" onClick={() => setEditing(m.id)} title={m.id === activeId ? 'Walls, grid and sight apply to the running game at once' : ''}>{m.id === activeId ? 'Edit live' : 'Edit'}</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>
|
||||
</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)} />}
|
||||
{lobbyId && (
|
||||
<>
|
||||
<h4>Scenes</h4>
|
||||
<p className="small muted">Every map you put on the table is a scene. Switching saves the current one (tokens, doors, explored ground, revealed areas, fight) and loads the other exactly as you left it.</p>
|
||||
<ul className="maplist">
|
||||
{scenes.map((sc) => (
|
||||
<li key={sc.id} className={sc.current ? 'active' : ''}>
|
||||
<strong>{sc.name}</strong> <span className="muted small">{sc.map_name ? `Map: ${sc.map_name}` : 'map deleted'} · saved {new Date(sc.updated * 1000).toLocaleString()}{sc.current ? ' · running' : ''}</span>
|
||||
<span className="row">
|
||||
{!sc.current && <button className="on" disabled={!sc.map_name} onClick={() => act(`/api/lobbies/${lobbyId}/scene`, 'PUT', { scene_id: sc.id }, () => { loadScenes(); load() })}>Switch to</button>}
|
||||
{sc.current && <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'PUT', { save: true }, () => loadScenes())}>Save now</button>}
|
||||
<button className="ghost" onClick={() => { const n = window.prompt('New name', sc.name); if (n) act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'PUT', { name: n }, () => loadScenes()) }}>Rename</button>
|
||||
<button className="ghost danger" onClick={() => window.confirm(`Delete the scene "${sc.name}"?${sc.current ? ' The table keeps running, but this state is no longer stored.' : ''}`) && act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'DELETE', undefined, () => loadScenes())}>Delete</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{!scenes.length && <li className="muted small">No scenes yet. Put a map on the table.</li>}
|
||||
</ul>
|
||||
{activeId && <button className="ghost" onClick={() => { const n = window.prompt('Name for the checkpoint', ''); if (n !== null) act(`/api/lobbies/${lobbyId}/scenes`, 'POST', { name: n }, () => loadScenes()) }}>Save a copy of the current table</button>}
|
||||
</>
|
||||
)}
|
||||
{editing && <MapEditor id={editing} live={editing === activeId} onClose={() => setEditing(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Decor tokens ("props"): drawn from above, purely visual. Built-in ones are SVG (id "b:<name>"), own uploads are images (id "c:<id>").
|
||||
// w / h are the default size in grid squares.
|
||||
const wood = '#8a5a2b', dark = '#5b3a18', light = '#b98545', metal = '#8d949b', stone = '#7d7f84', green = '#3f7d3a'
|
||||
|
||||
export const BUILTIN = [
|
||||
{ id: 'chest', name: 'Chest', w: 1, h: 0.7, art: <><rect x="4" y="12" width="92" height="76" rx="8" fill={wood} stroke={dark} strokeWidth="5" /><rect x="4" y="40" width="92" height="8" fill={dark} /><rect x="42" y="34" width="16" height="20" rx="3" fill="#e0b84a" stroke={dark} strokeWidth="3" /><path d="M24 12v76M76 12v76" stroke={dark} strokeWidth="5" /></> },
|
||||
{ id: 'barrel', name: 'Barrel', w: 1, h: 1, art: <><circle cx="50" cy="50" r="45" fill={wood} stroke={dark} strokeWidth="5" /><circle cx="50" cy="50" r="33" fill="none" stroke={dark} strokeWidth="4" /><circle cx="50" cy="50" r="20" fill={light} stroke={dark} strokeWidth="3" /><path d="M30 50h40" stroke={dark} strokeWidth="3" /></> },
|
||||
{ id: 'crate', name: 'Crate', w: 1, h: 1, art: <><rect x="5" y="5" width="90" height="90" fill={light} stroke={dark} strokeWidth="5" /><path d="M5 5l90 90M95 5L5 95" stroke={dark} strokeWidth="5" /><rect x="5" y="5" width="90" height="90" fill="none" stroke={dark} strokeWidth="10" opacity=".4" /></> },
|
||||
{ id: 'table', name: 'Table', w: 2, h: 1, art: <><rect x="3" y="12" width="94" height="76" rx="6" fill={light} stroke={dark} strokeWidth="5" /><path d="M12 30h76M12 50h76M12 70h76" stroke={wood} strokeWidth="2" opacity=".7" /></> },
|
||||
{ id: 'roundtable', name: 'Round table', w: 2, h: 2, art: <><circle cx="50" cy="50" r="46" fill={light} stroke={dark} strokeWidth="5" /><circle cx="50" cy="50" r="30" fill="none" stroke={wood} strokeWidth="2" opacity=".7" /></> },
|
||||
{ id: 'chair', name: 'Chair', w: 0.6, h: 0.6, art: <><rect x="12" y="25" width="76" height="68" rx="8" fill={wood} stroke={dark} strokeWidth="6" /><rect x="8" y="6" width="84" height="22" rx="6" fill={dark} /></> },
|
||||
{ id: 'bed', name: 'Bed', w: 1, h: 2, art: <><rect x="4" y="3" width="92" height="94" rx="8" fill={dark} /><rect x="9" y="8" width="82" height="84" rx="6" fill="#a8423b" /><rect x="14" y="12" width="72" height="22" rx="8" fill="#eee" stroke="#bbb" strokeWidth="2" /><path d="M9 46h82" stroke="#7b2d28" strokeWidth="4" /></> },
|
||||
{ id: 'door', name: 'Door (decor)', w: 1, h: 0.25, art: <><rect x="2" y="15" width="96" height="70" rx="6" fill={wood} stroke={dark} strokeWidth="8" /><circle cx="80" cy="50" r="7" fill="#e0b84a" /></> },
|
||||
{ id: 'campfire', name: 'Campfire', w: 1, h: 1, art: <><circle cx="50" cy="50" r="46" fill="#3b2f2a" /><path d="M20 70L80 30M20 30L80 70" stroke={dark} strokeWidth="9" strokeLinecap="round" /><path d="M50 18c14 16 22 26 14 42-5 9-23 9-28 0-8-16 6-26 14-42z" fill="#f08a24" /><path d="M50 40c8 9 10 14 6 21-3 5-10 5-12 0-3-7 2-12 6-21z" fill="#ffd24a" /></> },
|
||||
{ id: 'torch', name: 'Torch', w: 0.5, h: 0.5, art: <><circle cx="50" cy="50" r="46" fill="#ffcf5a" opacity=".35" /><circle cx="50" cy="50" r="22" fill="#f08a24" /><circle cx="50" cy="50" r="11" fill="#ffd24a" /></> },
|
||||
{ id: 'tree', name: 'Tree', w: 3, h: 3, art: <><circle cx="50" cy="50" r="48" fill="#2f6a2c" /><circle cx="36" cy="40" r="26" fill={green} /><circle cx="62" cy="56" r="28" fill="#47903f" /><circle cx="50" cy="50" r="10" fill="#6b4a25" opacity=".8" /></> },
|
||||
{ id: 'bush', name: 'Bush', w: 1.2, h: 1.2, art: <><circle cx="35" cy="55" r="28" fill="#2f6a2c" /><circle cx="62" cy="42" r="28" fill={green} /><circle cx="64" cy="66" r="24" fill="#47903f" /></> },
|
||||
{ id: 'rock', name: 'Rock', w: 1.2, h: 1, art: <path d="M10 62L22 24l32-14 30 12 8 34-18 22-34 4z" fill={stone} stroke="#4c4e52" strokeWidth="5" strokeLinejoin="round" /> },
|
||||
{ id: 'rug', name: 'Rug', w: 3, h: 2, art: <><rect x="2" y="6" width="96" height="88" rx="4" fill="#8e2f3a" stroke="#d9b45a" strokeWidth="5" /><rect x="12" y="16" width="76" height="68" fill="none" stroke="#d9b45a" strokeWidth="3" /><circle cx="50" cy="50" r="14" fill="#d9b45a" opacity=".8" /></> },
|
||||
{ id: 'stairs', name: 'Stairs', w: 1, h: 2, art: <><rect x="3" y="3" width="94" height="94" fill="#9b9ea3" stroke="#555" strokeWidth="4" />{[18, 33, 48, 63, 78].map((y) => <path key={y} d={`M3 ${y}h94`} stroke="#555" strokeWidth="4" />)}</> },
|
||||
{ id: 'pillar', name: 'Pillar', w: 1, h: 1, art: <><circle cx="50" cy="50" r="44" fill="#a7a9ae" stroke="#5c5e63" strokeWidth="6" /><circle cx="50" cy="50" r="28" fill="#c4c6cb" stroke="#5c5e63" strokeWidth="3" /></> },
|
||||
{ id: 'altar', name: 'Altar', w: 2, h: 1, art: <><rect x="3" y="12" width="94" height="76" rx="4" fill="#6c6f77" stroke="#3d3f45" strokeWidth="5" /><path d="M50 26v48M30 44h40" stroke="#d9b45a" strokeWidth="7" strokeLinecap="round" /></> },
|
||||
{ id: 'bones', name: 'Bones', w: 1, h: 1, art: <><g stroke="#4a4338" strokeWidth="3" fill="#e8e1cf"><circle cx="40" cy="42" r="17" /><rect x="30" y="52" width="20" height="12" rx="3" /><path d="M58 70l30-28" strokeWidth="9" stroke="#e8e1cf" strokeLinecap="round" /><path d="M12 82l34-14" strokeWidth="9" stroke="#e8e1cf" strokeLinecap="round" /></g><circle cx="34" cy="40" r="4" fill="#222" /><circle cx="47" cy="40" r="4" fill="#222" /></> },
|
||||
{ id: 'cart', name: 'Wreck / cart', w: 2, h: 1, art: <><rect x="6" y="14" width="88" height="72" rx="5" fill="#6d5a45" stroke="#3b2f22" strokeWidth="5" /><path d="M6 36h88M6 62h88" stroke="#3b2f22" strokeWidth="3" /><rect x="14" y="2" width="14" height="12" fill="#222" /><rect x="72" y="86" width="14" height="12" fill="#222" /></> },
|
||||
]
|
||||
|
||||
const byId = Object.fromEntries(BUILTIN.map((p) => [p.id, p]))
|
||||
|
||||
export const isCustom = (id) => typeof id === 'string' && id.startsWith('c:')
|
||||
export const builtin = (id) => byId[String(id).replace(/^b:/, '')]
|
||||
|
||||
// Picture of a prop filling its box. customSrc(id) gives the image URL for own uploads.
|
||||
export function PropArt({ prop, customSrc }) {
|
||||
if (isCustom(prop)) return <img src={customSrc?.(prop.slice(2))} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }} />
|
||||
const b = builtin(prop)
|
||||
return <svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: '100%', display: 'block', pointerEvents: 'none' }} aria-hidden="true">{b ? b.art : <rect width="100" height="100" fill="#888" />}</svg>
|
||||
}
|
||||
@@ -605,3 +605,27 @@ textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
|
||||
.radialbtn { width: 58px; height: 58px; left: -29px; top: -29px; }
|
||||
.radialtitle { background: #000; color: #fff; font-weight: 700; font-size: .8rem; border: 1px solid #8fd44c; }
|
||||
.radialbtn:disabled { opacity: .78; background: #262b22 !important; border-color: #5a634f !important; cursor: not-allowed; }
|
||||
|
||||
/* decor tokens (props) */
|
||||
.maptoken.prop { border: 0; border-radius: 0; background: none; box-shadow: none; display: block; transform-origin: center; }
|
||||
.maptoken.prop:not(.mov) { pointer-events: none; }
|
||||
.maptoken.prop.hid { opacity: .5; outline: 2px dashed rgba(255,255,255,.5); border-style: none; }
|
||||
.maptoken.prop.sel { outline: 3px solid var(--accent, gold); outline-offset: 2px; }
|
||||
.maptoken.prop em { display: none !important; }
|
||||
.proppicker { max-width: 360px; }
|
||||
.propgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(78px, 1fr)); gap: .3rem; max-height: 300px; overflow: auto; }
|
||||
.propbtn { display: grid; justify-items: center; gap: .15rem; padding: .3rem; font-size: .72rem; position: relative; }
|
||||
.propbtn svg, .propbtn img { width: 46px; height: 46px; object-fit: contain; }
|
||||
.propbtn.own { padding: 0; }
|
||||
.propbtn.own > .ghost:first-child { display: grid; justify-items: center; gap: .15rem; width: 100%; }
|
||||
.propbtn.own .mini { position: absolute; top: 0; right: 0; padding: 0 .3rem; min-height: 0; }
|
||||
.tokrow { display: flex; gap: .25rem; }
|
||||
.tokrow > button:first-child { flex: 1; min-width: 0; }
|
||||
.propdot { display: inline-block; width: .8rem; height: .8rem; border-radius: 2px; background: #b98545; vertical-align: middle; }
|
||||
.tokenform input[type=range] { width: 100%; }
|
||||
.tokenform .row { display: flex; flex-wrap: wrap; gap: .35rem; justify-content: flex-start; align-items: center; }
|
||||
.tokenform label.small { display: flex; align-items: center; gap: .3rem; flex-wrap: wrap; }
|
||||
.tokenform label.small input[type=number] { width: 4rem; }
|
||||
.tokenform .mini { min-height: 0; padding: .15rem .5rem; }
|
||||
.tokenlist .tokrow > button:first-child { width: auto; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tokenlist .tokrow > button.mini { width: auto; flex: none; }
|
||||
|
||||
Reference in New Issue
Block a user