diff --git a/README.md b/README.md index 2292207..7bf9b32 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,10 @@ Players only see a short card of the other players, never their sheets. Guessing - **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. +- **Prop library**: the *Maps* page lists all props (built-in and your own) and lets you upload, rename and remove your own ones. +- **DM notes**: NPC tokens and props have a "Notes (only you see these)" field (map editor and table; e.g. the contents of a chest). On the table click the token to edit; a 📝 marks tokens with notes and the text shows as tooltip. Players never receive notes. +- **Fog without walls**: sight range and fog also work on maps without walls (circle around the token). Sight needs walls or a range above 0; the explored memory is now stored at up to 1024 px. + ## API All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie. diff --git a/backend/app/maps.py b/backend/app/maps.py index 1713050..610c7cc 100644 --- a/backend/app/maps.py +++ b/backend/app/maps.py @@ -62,7 +62,7 @@ def schema(con): ("lobby_tokens", "show_cond", "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")): + ("lobbies", "scene_id", "TEXT"), ("lobby_tokens", "note", "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}") @@ -107,6 +107,7 @@ 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) + note = str(t.get("note") or "")[:2000] prop = str(t.get("prop") or "") prop = prop if re.fullmatch(r"[bc]:[a-z0-9_]{1,24}", prop) else "" try: @@ -117,7 +118,7 @@ def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict: 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, - "prop": prop, "rot": rot, "size_h": size_h if prop else None} + "prop": prop, "rot": rot, "size_h": size_h if prop else None, "note": note} def clean_int(v, lo, hi): @@ -402,6 +403,15 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa 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.put("/props/{pid}") + def rename_prop(pid: str, body: dict = Body(...), user: dict = Depends(current_user)): + name = str(body.get("name", "")).strip()[:30] + with db() as con: + if not name or 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("UPDATE user_props SET name=? WHERE id=?", (name, pid)) + return {"ok": True} + @api.delete("/props/{pid}") def delete_prop(pid: str, user: dict = Depends(current_user)): with db() as con: @@ -443,13 +453,13 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + con.execute("""INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id, hp, hp_max, prop, rot, size_h, note) + 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"))) + 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"), t.get("note") or None)) def mask_dims(m): - f = max(1, -(-max(m["w"], m["h"]) // 512)) + f = max(1, -(-max(m["w"], m["h"]) // 1024)) return f, -(-m["w"] // f), -(-m["h"] // f) def explore(con, lobby, viewer, m, polys, key): @@ -466,7 +476,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa old = Image.open(io.BytesIO(row["data"])) old.load() if old.size != (mw, mh): - old = None + old = old.resize((mw, mh), Image.BILINEAR) # memory made at another resolution is kept base = old or Image.new("L", (mw, mh), 0) cur = Image.new("L", (mw, mh), 0) for poly in polys: @@ -487,6 +497,11 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa def lobby_map(con, lobby): return con.execute("SELECT * FROM maps WHERE id=?", (lobby["map_id"],)).fetchone() if lobby["map_id"] else None + def vision_on(lobby, m) -> bool: + """Players only see what their token sees: needs walls to block the view or a sight range to limit it.""" + sight = lobby["sight"] if lobby["sight"] is not None else m["sight"] + return bool(lobby["los"] and (json.loads(m["walls"]) or sight > 0)) + 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"] @@ -506,7 +521,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa if viewer is None: return ctx ctx["rev"] = "open:" + m["id"] - if not (lobby["los"] and walls): + if not vision_on(lobby, m): return ctx ctx["los"] = True blocking = [(w["x1"], w["y1"], w["x2"], w["y2"]) for w in walls if w["kind"] == "wall" or not doors.get(w["id"])] @@ -598,6 +613,8 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa status.update(show_hp=bool(r["show_hp"]), show_cond=bool(r["show_cond"]), hp_hint=health(src) if src else None) if r["prop"]: status = {} + if full and not player: + status["note"] = r["note"] or "" 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"], @@ -774,7 +791,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa # ---- 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") + TOKEN_COLS = ("id", "name", "x", "y", "color", "size", "hidden", "npc_id", "hp", "hp_max", "conds", "show_hp", "show_cond", "prop", "rot", "size_h", "note") def snapshot(con, lid) -> str: lobby = con.execute("SELECT * FROM lobbies WHERE id=?", (lid,)).fetchone() @@ -983,22 +1000,23 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa t = con.execute("SELECT * FROM lobby_tokens WHERE id=? AND lobby_id=?", (tid, lid)).fetchone() if not t or (not is_dm and t["user_id"] != user["id"]): raise HTTPException(404, "Token not found") - if not is_dm and t["x"] is None and lobby["los"] and json.loads(m["walls"]): + if not is_dm and t["x"] is None and vision_on(lobby, m): 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"], - "prop": t["prop"], "rot": t["rot"], "size_h": t["size_h"]} + "prop": t["prop"], "rot": t["rot"], "size_h": t["size_h"], "note": t["note"] or ""} 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", "rot", "size_h"): + for k in ("name", "color", "size", "hidden", "rot", "size_h", "note"): 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=?, rot=?, size_h=? WHERE id=?", + con.execute("UPDATE lobby_tokens SET name=?, x=?, y=?, color=?, size=?, hidden=?, rot=?, size_h=?, note=? 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)) + c["rot"] if t["user_id"] is None else 0, c["size_h"] if t["user_id"] is None else None, + (c["note"] or None) 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: diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index 3c21914..4e7f3dc 100644 --- a/frontend/src/Lobbies.jsx +++ b/frontend/src/Lobbies.jsx @@ -916,14 +916,14 @@ function LobbyView({ id, rules, user }) { {lobby.members.map((m) => )} )} - {lobby.is_dm && mapState.wall_count > 0 && mapState.los_on && <> + {lobby.is_dm && mapState.los_on && <> {mapState.fog_on && } } - {lobby.is_dm && mapState.wall_count > 0 && mapState.los_on && } - {lobby.is_dm && mapState.wall_count > 0 && } + {lobby.is_dm && mapState.los_on && } + {lobby.is_dm && } {lobby.is_dm && mapState.wall_count > 0 && } {lobby.is_dm && } diff --git a/frontend/src/MapView.jsx b/frontend/src/MapView.jsx index eac9933..211908c 100644 --- a/frontend/src/MapView.jsx +++ b/frontend/src/MapView.jsx @@ -129,8 +129,9 @@ export function MapView({ map, image, fog, portraitUrl, propSrc, onMenu, isTurn,
{ 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)' : '')}> + onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '') + (t.note ? `\n\n${t.note}` : '')}> + {t.note && }
) } @@ -138,7 +139,8 @@ export function MapView({ map, image, fog, portraitUrl, propSrc, onMenu, isTurn,
{ if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }} style={{ left: x - d(t) / 2, top: y - d(t) / 2, width: d(t), height: d(t), '--tc': t.color, fontSize: Math.max(8, d(t) * 0.45) }} - onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}> + onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '') + (t.note ? `\n\n${t.note}` : '')}> + {t.note && } {portraitUrl?.(t) ? : {(t.name || '?').trim().slice(0, 2)}} {t.name} {t.hp_max > 0 && t.hp != null && 0.5 ? '#3fb950' : t.hp / t.hp_max > 0.25 ? '#e6c52e' : '#e0524d' }} />} @@ -191,6 +193,26 @@ export function RadialMenu({ x, y, title, items, onClose }) {
, document.body) } +// DM-only note on a token or prop (loot of a chest, tactics of an enemy ...). Saves shortly after typing stops. +export function NoteField({ t, onChange }) { + const [v, setV] = useState(t.note || '') + const timer = useRef(null) + const last = useRef(t.note || '') + useEffect(() => { if ((t.note || '') !== last.current) { last.current = t.note || ''; setV(t.note || '') } }, [t.note]) + useEffect(() => () => clearTimeout(timer.current), []) + const type = (e) => { + const val = e.target.value + setV(val); last.current = val + clearTimeout(timer.current) + timer.current = setTimeout(() => onChange({ note: val }), 500) + } + return ( +