Token HP and conditions with DM visibility toggles, fog of war, live sight range

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-24 18:21:39 +00:00
parent a2103566a0
commit 6aac03f112
7 changed files with 248 additions and 21 deletions
+6
View File
@@ -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.
+9 -1
View File
@@ -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")
+154 -13
View File
@@ -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}
+1
View File
@@ -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):
+26 -4
View File
@@ -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 (
<MapView map={mapState.map} image={mapImg} 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} 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) }}
@@ -865,6 +878,12 @@ function LobbyView({ id, rules, user }) {
{lobby.members.map((m) => <option key={m.username} value={m.username}>View as {m.character?.name || m.username}</option>)}
</select>
)}
{lobby.is_dm && mapState.walls?.length > 0 && mapState.los_on && <>
<label className="small sightrange" title="How far players see, in squares (0 = unlimited). Resets to the map's value when you switch maps."><input type="number" min="0" max="1000" key={mapState.sight} defaultValue={mapState.sight} aria-label="Sight range in squares"
onBlur={(e) => e.target.value !== String(mapState.sight) && act(`/api/lobbies/${id}/sight`, 'PUT', { range: e.target.value }, () => loadMap())} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} /> sq.</label>
<button className={mapState.fog_on ? 'on' : 'ghost'} aria-pressed={!!mapState.fog_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { fog: !mapState.fog_on }, () => loadMap())} title="Fog of war: players keep a dim memory of what they have seen">Fog</button>
{mapState.fog_on && <button className="ghost" onClick={() => window.confirm('Forget everything the players have explored?') && act(`/api/lobbies/${id}/explored`, 'DELETE', undefined, () => loadMap())} title="Forget the explored ground">↺</button>}
</>}
{lobby.is_dm && mapState.walls?.length > 0 && <button className={mapState.los_on ? 'on' : 'ghost'} aria-pressed={!!mapState.los_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { on: !mapState.los_on }, () => loadMap())} title="Line of sight: players only see what their character can see">Sight {mapState.los_on ? 'on' : 'off'}</button>}
{full && mapState.walls?.length > 0 && <button className={showWalls ? 'on' : 'ghost'} aria-pressed={showWalls} onClick={() => setShowWalls(!showWalls)} title="Show walls and doors (click a door to open or close it)">Walls</button>}
<button className={showGrid ? 'on' : 'ghost'} aria-pressed={showGrid} onClick={() => setShowGrid(!showGrid)}>Grid</button>
@@ -872,7 +891,8 @@ function LobbyView({ id, rules, user }) {
{full && <button className={npcPick ? 'on' : 'ghost'} onClick={() => setNpcPick(!npcPick)}>+ NPC</button>}
</div>
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>}
{selTok && <div className="tokenpanel mapui"><TokenForm t={selTok} onChange={(p) => patchToken(selTok.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selTok.id}`, 'DELETE'); setMapSel(null) }} /></div>}
{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 })()}
{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>
)
})()}
@@ -882,6 +902,8 @@ function LobbyView({ id, rules, user }) {
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 }) },
{ 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) } }] : []),
+39 -3
View File
@@ -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 (
<div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
<div className="mapworld" style={{ width: map.w, height: map.h, transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.s})` }}>
{fog && <img className="mapfog" src={fog} width={map.w} height={map.h} alt="" draggable={false} />}
{image && <img src={image.src} style={{ position: 'absolute', left: image.x, top: image.y }} width={image.w} height={image.h} alt="" draggable={false} />}
{showGrid && <div className="mapgrid" style={{ backgroundSize: `${map.grid}px ${map.grid}px` }} />}
{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) ? <img className="tokpic" src={portraitUrl(t)} alt="" draggable={false} /> : <span>{(t.name || '?').trim().slice(0, 2)}</span>}
<em style={{ fontSize: Math.max(9, map.grid * 0.28) }}>{t.name}</em>
{t.hp_max > 0 && t.hp != null && <i className="tokhp" title={`${t.hp} / ${t.hp_max}`}><b style={{ width: `${Math.max(0, Math.min(100, (t.hp / t.hp_max) * 100))}%`, background: t.hp / t.hp_max > 0.5 ? '#3fb950' : t.hp / t.hp_max > 0.25 ? '#e6c52e' : '#e0524d' }} /></i>}
{t.conds && Object.keys(t.conds).length > 0 && <span className="tokconds">{Object.entries(t.conds).map(([k, lv]) => <u key={k} title={`${k} ${lv}`}>{k.slice(0, 2)}{lv}</u>)}</span>}
</div>
)
})}
@@ -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 (
<div className="statusform">
<div className="row hprow">
<b className="small">HP</b>
{tracking ? <>
<button className="ghost mini" onClick={() => onChange({ hp: t.hp - 5 })}>−5</button>
<button className="ghost mini" onClick={() => onChange({ hp: t.hp - 1 })}>−1</button>
<input type="number" value={t.hp} aria-label="Current HP" onChange={(e) => onChange({ hp: e.target.value })} />
<span className="small">/</span>
<input type="number" min="1" value={t.hp_max} aria-label="Maximum HP" onChange={(e) => onChange({ hp_max: e.target.value })} />
<button className="ghost mini" onClick={() => onChange({ hp: t.hp + 1 })}>+1</button>
<button className="ghost mini" onClick={() => onChange({ hp: t.hp + 5 })}>+5</button>
<button className="ghost mini" onClick={() => onChange({ hp_max: null })} title="Stop tracking HP">×</button>
</> : <button className="ghost" onClick={() => onChange({ hp_max: hint, hp: hint })}>Track HP ({hint})</button>}
</div>
<div className="condrow">
{conditions.map((c) => (
<button key={c.id} className={conds[c.id] ? 'on mini' : 'ghost mini'} onClick={() => cycle(c.id)} title={`${c.levels?.[(conds[c.id] || 1) - 1] || ''} (click to raise the level, 3 → off)`}>
{c.name}{conds[c.id] ? ` ${conds[c.id]}` : ''}
</button>
))}
</div>
<p className="small muted">Players see HP and conditions only where you switched them on (right click the token).</p>
</div>
)
}
// 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
}
+13
View File
@@ -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; }