diff --git a/README.md b/README.md index 52dc35f..559a4dd 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,12 @@ The bar shows the order, the round and whose turn it is (the token pulses on the The top of the lobby is one compact row: small buttons on the left (start fight, previous/next turn, end fight, ▾ to expand players and difficulty), the player bar – replaced by a scrollable initiative bar during a fight – and the lobby settings button. Players only see the name of whoever's turn it is; NPC names are not sent to players otherwise, and token names show on hover, selection or turn. The sheet and the chat can be resized by dragging the thin lines next to them (remembered per browser). +### HP, conditions and fog of war + +- **HP and conditions:** select a token (DM, full view) to edit its HP (−5/−1/+1/+5, max) and conditions from the rules (click raises the level 1 → 3 → off). NPC tokens from the library start with the NPC's Health value. Right click the token and use the **HP** and **Cond.** toggles of the radial menu to decide when players may see them; by default players see their own and other players' values but not NPCs'. +- **Fog of war:** with line of sight on, ground a player has already seen stays visible as a dimmed memory (**Fog** button; **↺** forgets it for everybody). The memory is a small mask per player and lobby, the dim picture is made on the server, so players never get pixels they have not seen. +- **Sight range:** the map's value is the default (map editor); the number next to the Sight button changes it live for the lobby (reset when you switch maps). + ### Maps, tokens and line of sight - **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. diff --git a/backend/app/main.py b/backend/app/main.py index f017ee5..f5ed21e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -385,6 +385,7 @@ def _entry(r, shares=None, owner=None) -> dict: if r["npc"]: e["kind"] = str(ch.get("npc_kind") or "") e["size"] = ch.get("token_size", 1) + e["hp"] = _health(r["data"]) if shares is not None: e["shared_with"] = shares.get(r["id"], []) if owner is not None: @@ -1143,7 +1144,14 @@ def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin return {"ok": True} -maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps", _can_read_character) +def _health(data) -> int | None: + try: + return int(compute(json.loads(data), load_rules())["derived"].get("health")) + except Exception: + return None + + +maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps", _can_read_character, _health) combat_view = combat_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, load_rules) purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds") diff --git a/backend/app/maps.py b/backend/app/maps.py index 8530b3c..83a5f1f 100644 --- a/backend/app/maps.py +++ b/backend/app/maps.py @@ -13,7 +13,7 @@ from pathlib import Path from fastapi import Body, Depends, HTTPException, Query, Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import Response -from PIL import Image, ImageOps +from PIL import Image, ImageChops, ImageDraw, ImageOps from . import vision from .portrait import image as portrait_image, tag as portrait_tag @@ -38,9 +38,16 @@ def schema(con): con.execute("CREATE INDEX IF NOT EXISTS lobby_tokens_lobby ON lobby_tokens (lobby_id)") if "npc_id" not in [r["name"] for r in con.execute("PRAGMA table_info(lobby_tokens)")]: con.execute("ALTER TABLE lobby_tokens ADD COLUMN npc_id TEXT") + con.execute("""CREATE TABLE IF NOT EXISTS lobby_explored ( + lobby_id TEXT NOT NULL, user_id INTEGER NOT NULL, map_id TEXT NOT NULL, data BLOB NOT NULL, rev TEXT NOT NULL, last TEXT, + PRIMARY KEY (lobby_id, user_id))""") 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")): + ("lobbies", "los", "INTEGER NOT NULL DEFAULT 1"), ("maps", "sight", "REAL NOT NULL DEFAULT 0"), + ("lobbies", "sight", "REAL"), ("lobbies", "fog", "INTEGER NOT NULL DEFAULT 1"), + ("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")): 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}") @@ -84,9 +91,37 @@ def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict: x = y = None 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) 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 ""} + "npc_id": npc if npc.isalnum() and len(npc) <= 16 else "", "hp": hp, "hp_max": hp_max} + + +def clean_int(v, lo, hi): + if v is None or v == "": + return None + try: + return max(lo, min(hi, int(float(v)))) + except (TypeError, ValueError): + raise HTTPException(422, "Hit points must be whole numbers") + + +def clean_conds(raw) -> dict: + """{condition id: level 1-3}""" + if not isinstance(raw, dict) or len(raw) > 20: + raise HTTPException(422, "Invalid conditions") + out = {} + for k, v in raw.items(): + k = str(k) + if not (k.replace("_", "").isalnum() and len(k) <= 24): + continue + try: + lv = int(v) + except (TypeError, ValueError): + continue + if 1 <= lv <= 3: + out[k] = lv + return out def clean_walls(raw, w: int, h: int) -> list: @@ -109,7 +144,7 @@ def clean_walls(raw, w: int, h: int) -> list: return out -def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Path, can_read): +def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Path, can_read, health=lambda data: None): store_dir.mkdir(parents=True, exist_ok=True) def path(mid: str) -> Path: @@ -302,10 +337,44 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa have = {r["user_id"] for r in con.execute("SELECT user_id FROM lobby_tokens WHERE lobby_id=? AND user_id IS NOT NULL", (lid,))} ids = {m["user_id"] for m in members} for uid in ids - have: - con.execute("INSERT INTO lobby_tokens (id, lobby_id, user_id, name, size) VALUES (?,?,?,?,1)", (uuid.uuid4().hex[:12], lid, uid, "")) + con.execute("INSERT INTO lobby_tokens (id, lobby_id, user_id, name, size, show_hp, show_cond) VALUES (?,?,?,?,1,1,1)", (uuid.uuid4().hex[:12], lid, uid, "")) for uid in have - ids: con.execute("DELETE FROM lobby_tokens WHERE lobby_id=? AND user_id=?", (lid, uid)) + def mask_dims(m): + f = max(1, -(-max(m["w"], m["h"]) // 512)) + return f, -(-m["w"] // f), -(-m["h"] // f) + + def explore(con, lobby, viewer, m, poly, key): + """Adds what the player sees right now to the lobby's memory of explored ground (a small mask, 1 cell = f pixels).""" + lid = lobby["id"] + f, mw, mh = mask_dims(m) + row = con.execute("SELECT * FROM lobby_explored WHERE lobby_id=? AND user_id=?", (lid, viewer)).fetchone() + if row and row["map_id"] != m["id"]: + row = None + if row and row["last"] == key: + return row["rev"] + old = None + if row: + old = Image.open(io.BytesIO(row["data"])) + old.load() + if old.size != (mw, mh): + old = None + base = old or Image.new("L", (mw, mh), 0) + cur = Image.new("L", (mw, mh), 0) + ImageDraw.Draw(cur).polygon([(x / f, y / f) for x, y in poly], fill=255) + new = ImageChops.lighter(base, cur) + rev = hashlib.md5(new.tobytes()).hexdigest()[:10] + if row and row["rev"] == rev: + con.execute("UPDATE lobby_explored SET last=? WHERE lobby_id=? AND user_id=?", (key, lid, viewer)) + return rev + out = io.BytesIO() + new.save(out, "PNG", optimize=True) + con.execute("""INSERT INTO lobby_explored (lobby_id, user_id, map_id, data, rev, last) VALUES (?,?,?,?,?,?) + ON CONFLICT(lobby_id, user_id) DO UPDATE SET map_id=excluded.map_id, data=excluded.data, rev=excluded.rev, last=excluded.last""", + (lid, viewer, m["id"], out.getvalue(), rev, key)) + return rev + def lobby_map(con, lobby): return con.execute("SELECT * FROM maps WHERE id=?", (lobby["map_id"],)).fetchone() if lobby["map_id"] else None @@ -337,10 +406,13 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa 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) + sight = lobby["sight"] if lobby["sight"] is not None else m["sight"] + radius = round(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]) + if lobby["fog"]: + ctx["fog"] = explore(con, lobby, viewer, m, poly, key) return ctx def tokens_for(con, lobby, user, ctx) -> list: @@ -383,7 +455,14 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa pt = portrait_tag(json.loads(src).get("portrait")) except ValueError: pass - out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name, "portrait": pt, + status = {} + if full or own or r["show_hp"]: + status.update(hp=r["hp"], hp_max=r["hp_max"]) + if full or own or r["show_cond"]: + 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, "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)), @@ -400,9 +479,12 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa 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"]}} + "vision": {"los": ctx["los"], "empty": ctx["empty"], "rev": ctx["rev"], "fog": ctx.get("fog")}} if ctx["is_dm"]: res["los_on"] = bool(lobby["los"]) + res["sight"] = lobby["sight"] if lobby["sight"] is not None else m["sight"] + res["sight_map"] = m["sight"] + res["fog_on"] = bool(lobby["fog"]) if ctx["viewer"] is None: res["walls"] = [{**w, "open": bool(ctx["doors"].get(w["id"]))} for w in ctx["walls"]] return res @@ -425,6 +507,40 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa 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.get("/lobbies/{lid}/map/explored") + def lobby_map_explored(lid: str, as_: str = Query("", alias="as"), user: dict = Depends(current_user)): + """Ground the player has already seen, as a (smaller) picture: black where unexplored. The client dims it.""" + with db() as con: + lobby = lobby_for(con, lid, user) + m = lobby_map(con, lobby) + if not m: + raise HTTPException(404, "No map") + ctx = view_ctx(con, lobby, m, user, as_) + row = con.execute("SELECT data, rev FROM lobby_explored WHERE lobby_id=? AND user_id=? AND map_id=?", + (lid, ctx["viewer"], m["id"])).fetchone() if ctx["viewer"] is not None and lobby["fog"] else None + if not row: + raise HTTPException(404, "Nothing explored") + hit = vision.fog_cache.fetch((m["id"], row["rev"])) + if not hit: + im = vision.source(path(m["id"])) + k = min(1.0, 2048 / max(im.size)) + small = im.resize((max(1, round(im.width * k)), max(1, round(im.height * k)))) if k < 1 else im + mask = Image.open(io.BytesIO(row["data"])).resize(small.size, Image.BILINEAR) + piece = Image.composite(small, Image.new("RGB", small.size, (0, 0, 0)), mask) + out = io.BytesIO() + piece.save(out, "WEBP", quality=70, method=2) + hit = out.getvalue() + vision.fog_cache.put((m["id"], row["rev"]), hit) + return Response(hit, media_type="image/webp", headers={"Cache-Control": "private, max-age=3600"}) + + @api.delete("/lobbies/{lid}/explored") + def forget_explored(lid: str, user: dict = Depends(current_user)): + with db() as con: + dm_only(lobby_for(con, lid, user), user) + con.execute("DELETE FROM lobby_explored WHERE lobby_id=?", (lid,)) + hub.notify(lid, "map") + return {"ok": True} + @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: @@ -447,7 +563,13 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa """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)) + if "on" in body: + con.execute("UPDATE lobbies SET los=? WHERE id=?", (1 if body.get("on") else 0, lid)) + if "range" in body: # squares; null = the map's own value + v = None if body["range"] in (None, "") else clean_int(body["range"], 0, 1000) + con.execute("UPDATE lobbies SET sight=? WHERE id=?", (v, lid)) + if "fog" in body: + con.execute("UPDATE lobbies SET fog=? WHERE id=?", (1 if body["fog"] else 0, lid)) hub.notify(lid, "map") return {"ok": True} @@ -458,12 +580,15 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa dm_only(lobby_for(con, lid, user), user) mid = body.get("map_id") con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,)) + con.execute("DELETE FROM lobby_explored WHERE lobby_id=?", (lid,)) + con.execute("UPDATE lobbies SET sight=NULL WHERE id=?", (lid,)) if mid: r = usable_map(con, str(mid), user) 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) VALUES (?,?,?,?,?,?,?,?,?)", - (uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]), npc)) + 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"))) sync_players(con, lid) event(con, lid, f"The DM put the map \"{r['name']}\" on the table", user["id"]) else: @@ -488,8 +613,9 @@ 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) VALUES (?,?,?,?,?,?,?,?,?)", - (t["id"], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]), npc)) + 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"])) hub.notify(lid, "map") return {"id": t["id"]} @@ -517,6 +643,21 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa 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)) + 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: + hp_max = clean_int(body["hp_max"], 1, 9999) + if "hp" in body: + hp = clean_int(body["hp"], -999, 9999) + if hp_max is None: + hp = None + elif hp is None: + hp = hp_max + elif hp > hp_max: + hp = hp_max + conds = clean_conds(body["conds"]) if "conds" in body else json.loads(t["conds"] or "{}") + con.execute("UPDATE lobby_tokens SET hp=?, hp_max=?, conds=?, show_hp=?, show_cond=? WHERE id=?", + (hp, hp_max, json.dumps(conds), int(bool(body.get("show_hp", t["show_hp"]))), int(bool(body.get("show_cond", t["show_cond"]))), tid)) hub.notify(lid, "map") return {"ok": True} diff --git a/backend/app/vision.py b/backend/app/vision.py index 562fa10..b87ee6b 100644 --- a/backend/app/vision.py +++ b/backend/app/vision.py @@ -90,6 +90,7 @@ class Lru(OrderedDict): _images = Lru(2) _polys = Lru(128) _cuts = Lru(24) +fog_cache = Lru(12) def cached_polygon(key, make): diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index d963a1d..ce466f3 100644 --- a/frontend/src/Lobbies.jsx +++ b/frontend/src/Lobbies.jsx @@ -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, TokenForm, WallLayer } from './MapView.jsx' +import { MapView, MapLibrary, NpcPicker, RadialMenu, 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) }) @@ -168,6 +168,17 @@ function useMapImage(id, vision, as) { return img } +// The ground this player has already seen (dimmed under the live view). Reloaded a moment after the picture changes. +function useFogImage(id, rev, as) { + const [src, setSrc] = useState(null) + useEffect(() => { + if (!rev) { setSrc(null); return } + const t = setTimeout(() => setSrc(`/api/lobbies/${id}/map/explored?v=${rev}${as ? `&as=${encodeURIComponent(as)}` : ''}`), 400) + return () => clearTimeout(t) + }, [id, rev, as]) + return src +} + // 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) { const [msgs, setMsgs] = useState([]) @@ -689,6 +700,7 @@ function LobbyView({ id, rules, user }) { 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 fogImg = useFogImage(id, mapState.vision?.fog, viewAs) const sock = useRef({}) const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 }) const up = useSocket(id, { @@ -750,9 +762,10 @@ function LobbyView({ id, rules, user }) { const combatOp = (b) => act(`/api/lobbies/${id}/combat`, 'PUT', b) const [npcPick, setNpcPick] = useState(false) const addNpcToken = (n) => { + const hp = n.hp > 0 ? n.hp : undefined const m = mapState.map setNpcPick(false) - send(`/api/lobbies/${id}/tokens`, 'POST', { name: n.name, npc_id: n.id, x: m.w / 2, y: m.h / 2, size: n.size || 1, color: '#c0392b' }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {}) + send(`/api/lobbies/${id}/tokens`, 'POST', { name: n.name, npc_id: n.id, x: m.w / 2, y: m.h / 2, size: n.size || 1, color: '#c0392b', hp_max: hp }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {}) } const addToken = () => { const m = mapState.map, n = mapState.tokens.filter((t) => t.kind === 'npc').length + 1 @@ -852,7 +865,7 @@ function LobbyView({ id, rules, user }) { const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null const walls = full && showWalls ? mapState.walls || [] : [] return ( - 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} + 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) }} @@ -865,6 +878,12 @@ function LobbyView({ id, rules, user }) { {lobby.members.map((m) => )} )} + {lobby.is_dm && mapState.walls?.length > 0 && mapState.los_on && <> + + + {mapState.fog_on && } + } {lobby.is_dm && mapState.walls?.length > 0 && } {full && mapState.walls?.length > 0 && } @@ -872,7 +891,8 @@ function LobbyView({ id, rules, user }) { {full && } {npcPick &&
setNpcPick(false)} />
} - {selTok &&
patchToken(selTok.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selTok.id}`, 'DELETE'); setMapSel(null) }} />
} + {full && mapSel && (() => { const st = mapState.tokens.find((t) => t.id === mapSel); return st && !selTok ?
{st.name || 'Player'} patchToken(st.id, p)} />
: null })()} + {selTok &&
patchToken(selTok.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selTok.id}`, 'DELETE'); setMapSel(null) }} /> patchToken(selTok.id, p)} />
}
) })()} @@ -882,6 +902,8 @@ function LobbyView({ id, rules, user }) { return 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 }) }, { icon: '📄', label: 'Sheet', disabled: t.kind === 'npc' && !t.npc_id, onClick: () => { if (t.kind === 'npc') setMapSel(t.id); else { setSel(t.username); setMapSel(null) } setLeftTab('sheet'); setSheetOpen(true) } }, + { icon: '❤', label: t.show_hp ? 'HP shown' : 'HP hidden', disabled: !(t.hp_max > 0), onClick: () => patchToken(t.id, { show_hp: !t.show_hp }) }, + { icon: '☣', label: t.show_cond ? 'Cond. shown' : 'Cond. hidden', onClick: () => patchToken(t.id, { show_cond: !t.show_cond }) }, ...(t.kind === 'npc' ? [ { 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) } }] : []), diff --git a/frontend/src/MapView.jsx b/frontend/src/MapView.jsx index aba13f4..ef35ed4 100644 --- a/frontend/src/MapView.jsx +++ b/frontend/src/MapView.jsx @@ -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. // Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise. -export function MapView({ map, image, portraitUrl, onMenu, isTurn, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) { +export function MapView({ map, image, fog, portraitUrl, 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) @@ -114,6 +114,7 @@ export function MapView({ map, image, portraitUrl, onMenu, isTurn, tokens, onMov return (
+ {fog && } {image && } {showGrid &&
} {overlay} @@ -128,6 +129,8 @@ export function MapView({ map, image, portraitUrl, onMenu, isTurn, tokens, onMov onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}> {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' }} />} + {t.conds && Object.keys(t.conds).length > 0 && {Object.entries(t.conds).map(([k, lv]) => {k.slice(0, 2)}{lv})}}
) })} @@ -192,6 +195,39 @@ export function TokenForm({ t, onChange, onDelete, onDuplicate }) { ) } +// HP and conditions of one token (DM). conditions = rules.conditions +export function StatusForm({ t, conditions, onChange }) { + const conds = t.conds || {} + const cycle = (id) => { const lv = ((conds[id] || 0) + 1) % 4; const n = { ...conds }; if (lv) n[id] = lv; else delete n[id]; onChange({ conds: n }) } + const tracking = t.hp_max > 0 && t.hp != null + const hint = t.hp_hint > 0 ? t.hp_hint : 10 + return ( +
+
+ HP + {tracking ? <> + + + onChange({ hp: e.target.value })} /> + / + onChange({ hp_max: e.target.value })} /> + + + + : } +
+
+ {conditions.map((c) => ( + + ))} +
+

Players see HP and conditions only where you switched them on (right click the token).

+
+ ) +} + // Picks an NPC from the library (own and shared). onPick(npc) gets {id, name, kind, size, portrait}. export function NpcPicker({ onPick, onClose }) { const [list, setList] = useState(null) @@ -283,7 +319,7 @@ export function MapEditor({ id, onClose }) { const [picker, setPicker] = useState(false) const addNpc = (n) => { const k = tokens.length + 1 - 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 } + 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 add = () => { @@ -320,7 +356,7 @@ 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 }) => ({ id: i, name: n, x, y, size, color, hidden, npc_id })) }) }) + 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 })) }) }) if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false } setDirty(false); return true } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index b7b5cb7..e829ec2 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -574,3 +574,16 @@ body.resizing { cursor: col-resize; user-select: none; } textarea.pwrite { background-color: transparent; outline: none; field-sizing: content; } textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); } .pwrite::placeholder { color: var(--pm); opacity: .6; } + +/* token status, fog */ +.mapfog { position: absolute; left: 0; top: 0; filter: brightness(.42) saturate(.55); pointer-events: none; max-width: none; } +.tokhp { position: absolute; left: 8%; right: 8%; bottom: -7px; height: 6px; background: rgba(0,0,0,.75); border-radius: 3px; border: 1px solid rgba(255,255,255,.7); overflow: hidden; pointer-events: none; font-style: normal; } +.tokhp b { display: block; height: 100%; } +.tokconds { position: absolute; left: 50%; top: -9px; transform: translateX(-50%); display: flex; gap: 2px; pointer-events: none; } +.tokconds u { text-decoration: none; font-size: 9px; font-weight: 700; text-transform: none; background: #6a1b9a; border: 1px solid #fff; border-radius: 4px; padding: 0 3px; line-height: 12px; white-space: nowrap; color: #fff; } +.statusform { display: grid; gap: .4rem; margin-top: .5rem; padding-top: .5rem; border-top: 1px solid var(--line); } +.hprow { gap: .3rem; flex-wrap: wrap; align-items: center; } +.hprow input { width: 3.6rem; } +.condrow { display: flex; flex-wrap: wrap; gap: .25rem; } +.sightrange input { width: 3.4rem; } +.tokenpanel { max-height: calc(100% - 4rem); overflow-y: auto; }