diff --git a/README.md b/README.md index 559a4dd..eb8e74a 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,11 @@ The top of the lobby is one compact row: small buttons on the left (start fight, - **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). +### Sight groups and revealing far away areas + +- **Sight groups** (Lobby actions, DM): players in the same group (A–D) share what they see; "Everyone shares sight" / "Everyone alone" are shortcuts. Tokens and ground seen by one member are visible to the whole group. +- **👁 Reveal** (DM, line of sight on): click the spot the view starts from, then click where it ends (distance = range, direction = where it looks), pick the cone width and who sees it (everyone or chosen players), then **Reveal**. Walls block it like any other view. The area stays visible until you press **Hide**; afterwards it remains as dimmed memory (fog of war). Everything is computed on the server, so players only receive the pixels you showed them. + ### 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 f5ed21e..b28997e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -674,7 +674,7 @@ def read_lobby(lid: str, user: dict = Depends(current_user)): lobby = _lobby_for(con, lid, user) is_dm = lobby["owner_id"] == user["id"] _ensure_colors(con, lid) - rows = con.execute("""SELECT m.user_id, m.character_id, m.color, m.char_mod, u.username, c.data FROM lobby_members m + rows = con.execute("""SELECT m.user_id, m.character_id, m.color, m.char_mod, m.vgroup, u.username, c.data FROM lobby_members m JOIN users u ON u.id = m.user_id LEFT JOIN characters c ON c.id = m.character_id WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall() combat = combat_view(con, lobby, is_dm) @@ -685,7 +685,7 @@ def read_lobby(lid: str, user: dict = Depends(current_user)): card = _card(r["data"], rules) if r["data"] else None # The id is only given out where the viewer may open the full character (the DM sees all, a player only their own) members.append({"username": r["username"], "me": mine, "character": card, "color": r["color"], - "mod": r["char_mod"] if (is_dm or mine) else None, "online": r["user_id"] in online, + "mod": r["char_mod"] if (is_dm or mine) else None, "online": r["user_id"] in online, "vgroup": r["vgroup"] if is_dm else None, "character_id": r["character_id"] if (is_dm or mine) and card else None}) hidden = bool(lobby["diff_hidden"]) diff = {"label": lobby["diff_label"], "mod": lobby["diff_mod"], "hidden": hidden} diff --git a/backend/app/maps.py b/backend/app/maps.py index 83a5f1f..71929f8 100644 --- a/backend/app/maps.py +++ b/backend/app/maps.py @@ -5,6 +5,7 @@ Images are re-encoded with Pillow on upload, so only real images get through and import hashlib import io import json +import math import shutil import time import uuid @@ -41,13 +42,17 @@ def schema(con): 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 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 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", "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")): + ("lobby_tokens", "show_cond", "INTEGER NOT NULL DEFAULT 0"), + ("lobby_members", "vgroup", "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}") @@ -345,7 +350,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa 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): + def explore(con, lobby, viewer, m, polys, 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) @@ -362,7 +367,9 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa 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) + for poly in polys: + if len(poly) > 2: + 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: @@ -392,29 +399,54 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa viewer = user["id"] walls = json.loads(m["walls"]) doors = json.loads(lobby["doors"] or "{}") - ctx = {"is_dm": is_dm, "viewer": viewer, "walls": walls, "doors": doors, "los": False, "empty": False, "poly": None, + ctx = {"is_dm": is_dm, "viewer": viewer, "walls": walls, "doors": doors, "los": False, "empty": False, "polys": [], "rev": "full:" + m["id"], "key": None} if viewer is None: return ctx ctx["rev"] = "open:" + m["id"] if not (lobby["los"] and walls): return ctx - tok = con.execute("SELECT x, y FROM lobby_tokens WHERE lobby_id=? AND user_id=?", (lobby["id"], viewer)).fetchone() ctx["los"] = True - if not tok or tok["x"] is None: # not on the map yet: sees nothing - ctx.update(empty=True, rev="empty") - return ctx - x, y = round(tok["x"]), round(tok["y"]) blocking = [(w["x1"], w["y1"], w["x2"], w["y2"]) for w in walls if w["kind"] == "wall" or not doors.get(w["id"])] 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]) + me = con.execute("""SELECT u.username, mm.vgroup FROM lobby_members mm JOIN users u ON u.id = mm.user_id + WHERE mm.lobby_id=? AND mm.user_id=?""", (lobby["id"], viewer)).fetchone() + if me and me["vgroup"]: # a sight group: everybody in it sees what any of them sees + toks = con.execute("""SELECT t.x, t.y FROM lobby_tokens t JOIN lobby_members mm ON mm.lobby_id = t.lobby_id AND mm.user_id = t.user_id + WHERE t.lobby_id=? AND mm.vgroup=? AND t.x IS NOT NULL ORDER BY t.rowid""", (lobby["id"], me["vgroup"])).fetchall() + else: + toks = con.execute("SELECT x, y FROM lobby_tokens WHERE lobby_id=? AND user_id=? AND x IS NOT NULL", (lobby["id"], viewer)).fetchall() + origins = [(round(t["x"]), round(t["y"])) for t in toks] + zones = [] + for z in con.execute("SELECT * FROM lobby_reveals WHERE lobby_id=? ORDER BY created", (lobby["id"],)): + tg = json.loads(z["targets"]) if z["targets"] else None + if tg is None or (me and me["username"] in tg): + zones.append(z) + if not origins and not zones: # not on the map yet and nothing revealed: sees nothing + ctx.update(empty=True, rev="empty") + return ctx + polys = [] + for (x, y) in origins: + k = hashlib.sha1(json.dumps([m["id"], blocking, radius, x, y]).encode()).hexdigest() + polys.append(vision.cached_polygon(k, lambda x=x, y=y: vision.polygon((x, y), blocking, m["w"], m["h"], radius))) + zkeys = [] + for z in zones: + ctx_poly, zk = zone_polygon(m, blocking, z) + polys.append(ctx_poly) + zkeys.append(zk) + key = hashlib.sha1(json.dumps([m["id"], blocking, radius, origins, zkeys]).encode()).hexdigest() + ctx.update(polys=polys, key=key, rev=key[:12]) if lobby["fog"]: - ctx["fog"] = explore(con, lobby, viewer, m, poly, key) + ctx["fog"] = explore(con, lobby, viewer, m, polys, key) return ctx + def zone_polygon(m, blocking, z): + zr = round(max(0.5, z["radius"]) * m["grid"], 1) + sector = None if z["fov"] >= 359 else (math.radians(z["dir"]), math.radians(z["fov"]) / 2) + zk = hashlib.sha1(json.dumps([m["id"], blocking, zr, z["x"], z["y"], z["dir"], z["fov"]]).encode()).hexdigest() + return vision.cached_polygon(zk, lambda: vision.polygon((z["x"], z["y"]), blocking, m["w"], m["h"], zr, sector)), zk + def tokens_for(con, lobby, user, ctx) -> list: sync_players(con, lobby["id"]) rows = con.execute("""SELECT t.*, u.username, m.color AS mcolor, c.data, n.data AS ndata FROM lobby_tokens t @@ -438,7 +470,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa if not full and not own: if r["hidden"] or r["x"] is None: continue - if ctx["los"] and (ctx["empty"] or not vision.inside((r["x"], r["y"]), ctx["poly"])): + if ctx["los"] and (ctx["empty"] or not vision.inside_any((r["x"], r["y"]), ctx["polys"])): continue name = r["name"] if player: @@ -485,6 +517,9 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa res["sight"] = lobby["sight"] if lobby["sight"] is not None else m["sight"] res["sight_map"] = m["sight"] res["fog_on"] = bool(lobby["fog"]) + res["reveals"] = [{"id": z["id"], "x": z["x"], "y": z["y"], "radius": z["radius"], "dir": z["dir"], "fov": z["fov"], + "to": json.loads(z["targets"]) if z["targets"] else None} + for z in con.execute("SELECT * FROM lobby_reveals WHERE lobby_id=? ORDER BY created", (lobby["id"],))] if ctx["viewer"] is None: res["walls"] = [{**w, "open": bool(ctx["doors"].get(w["id"]))} for w in ctx["walls"]] return res @@ -502,7 +537,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa if ctx["empty"]: raise HTTPException(404, "Nothing to see yet") if ctx["los"]: - data, (x, y, w, h) = vision.cut(path(m["id"]), ctx["poly"], ctx["key"]) + data, (x, y, w, h) = vision.cut(path(m["id"]), ctx["polys"], ctx["key"]) return Response(data, media_type="image/webp", headers={"X-Crop": f"{x},{y},{w},{h}", "Cache-Control": "no-store"}) return Response(path(m["id"]).read_bytes(), media_type="image/webp", headers={"X-Crop": f"0,0,{m['w']},{m['h']}", "Cache-Control": "private, max-age=3600"}) @@ -533,6 +568,65 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa vision.fog_cache.put((m["id"], row["rev"]), hit) return Response(hit, media_type="image/webp", headers={"Cache-Control": "private, max-age=3600"}) + @api.put("/lobbies/{lid}/vision-groups") + def set_vision_groups(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): + """DM: players in the same group (1-4) share what they see. 0 = sees only for themselves.""" + with db() as con: + dm_only(lobby_for(con, lid, user), user) + if body.get("preset") in ("shared", "separate"): + con.execute("UPDATE lobby_members SET vgroup=? WHERE lobby_id=?", (1 if body["preset"] == "shared" else 0, lid)) + for name, g in (body.get("groups") or {}).items(): + con.execute("""UPDATE lobby_members SET vgroup=? WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)""", + (clean_int(g, 0, 4) or 0, lid, str(name))) + hub.notify(lid, "map") + hub.notify(lid, "lobby") + return {"ok": True} + + @api.post("/lobbies/{lid}/reveals") + def add_reveal(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): + """DM: show an area (circle or cone, blocked by walls) to all players or some of them. Stays until removed; + what was shown is also added to the players' memory of explored ground.""" + with db() as con: + lobby = lobby_for(con, lid, user) + dm_only(lobby, user) + m = need_map(con, lobby) + if con.execute("SELECT COUNT(*) FROM lobby_reveals WHERE lobby_id=?", (lid,)).fetchone()[0] >= 12: + raise HTTPException(409, "Too many revealed areas, hide some first") + try: + x, y = min(max(float(body["x"]), 0), m["w"]), min(max(float(body["y"]), 0), m["h"]) + radius = min(max(float(body.get("radius", 8)), 0.5), 200) + dr = float(body.get("dir", 0)) % 360 + fov = min(max(float(body.get("fov", 360)), 10), 360) + except (KeyError, TypeError, ValueError): + raise HTTPException(422, "Invalid area") + names = body.get("to") + targets = None + members = {r["username"]: r["user_id"] for r in con.execute("""SELECT u.username, mm.user_id FROM lobby_members mm + JOIN users u ON u.id = mm.user_id WHERE mm.lobby_id=?""", (lid,))} + if isinstance(names, list): + targets = [n for n in names if n in members] + if not targets: + raise HTTPException(422, "Pick at least one player") + rid = uuid.uuid4().hex[:12] + con.execute("INSERT INTO lobby_reveals (id, lobby_id, x, y, radius, dir, fov, targets, created) VALUES (?,?,?,?,?,?,?,?,?)", + (rid, lid, x, y, radius, dr, fov, json.dumps(targets) if targets is not None else None, time.time())) + if lobby["fog"]: # the memory keeps it after the area is hidden again + walls, doors = json.loads(m["walls"]), json.loads(lobby["doors"] or "{}") + blocking = [(w["x1"], w["y1"], w["x2"], w["y2"]) for w in walls if w["kind"] == "wall" or not doors.get(w["id"])] + poly, _ = zone_polygon(m, blocking, {"radius": radius, "fov": fov, "dir": dr, "x": x, "y": y}) + for n in (targets if targets is not None else members): + explore(con, lobby, members[n], m, [poly], "reveal:" + rid) + hub.notify(lid, "map") + return {"id": rid} + + @api.delete("/lobbies/{lid}/reveals/{rid}") + def remove_reveal(lid: str, rid: str, user: dict = Depends(current_user)): + with db() as con: + dm_only(lobby_for(con, lid, user), user) + con.execute("DELETE FROM lobby_reveals WHERE id=? AND lobby_id=?", (rid, lid)) + hub.notify(lid, "map") + return {"ok": True} + @api.delete("/lobbies/{lid}/explored") def forget_explored(lid: str, user: dict = Depends(current_user)): with db() as con: @@ -581,6 +675,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa 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("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) diff --git a/backend/app/vision.py b/backend/app/vision.py index b87ee6b..87f68a4 100644 --- a/backend/app/vision.py +++ b/backend/app/vision.py @@ -11,8 +11,9 @@ from PIL import Image, ImageDraw EPS = 1e-5 -def polygon(origin, segs, w, h, radius=0.0): - """Points of the area visible from origin. segs: [(x1, y1, x2, y2)] that block sight; radius 0 means unlimited.""" +def polygon(origin, segs, w, h, radius=0.0, sector=None): + """Points of the area visible from origin. segs: [(x1, y1, x2, y2)] that block sight; radius 0 means unlimited. + sector = (direction, half angle) in radians limits the view to a cone.""" ox, oy = origin border = [(0, 0, w, 0), (w, 0, w, h), (w, h, 0, h), (0, h, 0, 0)] if radius: @@ -34,7 +35,13 @@ def polygon(origin, segs, w, h, radius=0.0): angles += (a - EPS, a, a + EPS) if radius: angles += [i * (2 * math.pi / 120) for i in range(120)] - angles.sort() + if sector: + d, half = sector + rel = [(a - d + math.pi) % (2 * math.pi) - math.pi for a in angles] + rel = [a for a in rel if abs(a) <= half] + [-half, half] + [-half + i * (2 * half / 48) for i in range(49)] + angles = [d + a for a in sorted(rel)] + else: + angles.sort() pts = [] for a in angles: dx, dy = math.cos(a), math.sin(a) @@ -54,7 +61,7 @@ def polygon(origin, segs, w, h, radius=0.0): if best == float("inf"): continue pts.append((ox + dx * best, oy + dy * best)) - return pts + return [(ox, oy)] + pts if sector else pts def inside(pt, poly) -> bool: @@ -112,19 +119,26 @@ def source(path): return im -def cut(path, poly, key): +def inside_any(pt, polys) -> bool: + return any(inside(pt, p) for p in polys) + + +def cut(path, polys, key): """The part of the image inside the polygon (everything else black), cropped to its bounding box. Returns (webp bytes, (x, y, w, h)).""" hit = _cuts.fetch(key) if hit: return hit im = source(path) - xs, ys = [p[0] for p in poly], [p[1] for p in poly] + xs, ys = [p[0] for poly in polys for p in poly], [p[1] for poly in polys for p in poly] x0, y0 = max(0, int(math.floor(min(xs)))), max(0, int(math.floor(min(ys)))) x1, y1 = min(im.width, int(math.ceil(max(xs)))), min(im.height, int(math.ceil(max(ys)))) w, h = max(1, x1 - x0), max(1, y1 - y0) mask = Image.new("L", (w, h), 0) - ImageDraw.Draw(mask).polygon([(x - x0, y - y0) for x, y in poly], fill=255) + draw = ImageDraw.Draw(mask) + for poly in polys: + if len(poly) > 2: + draw.polygon([(x - x0, y - y0) for x, y in poly], fill=255) piece = Image.composite(im.crop((x0, y0, x0 + w, y0 + h)), Image.new("RGB", (w, h), (0, 0, 0)), mask) out = io.BytesIO() piece.save(out, "WEBP", quality=82, method=1) diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index ce466f3..2b6a4db 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, StatusForm, TokenForm, WallLayer } from './MapView.jsx' +import { MapView, MapLibrary, NpcPicker, 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) }) @@ -565,6 +565,25 @@ function CharTab({ cid, rules, onRoll, tick, editable }) { ) } +// DM: players in the same sight group see what anyone in the group sees +function VisionGroups({ lobby, id, act }) { + const set = (b) => act(`/api/lobbies/${id}/vision-groups`, 'PUT', b) + return ( +
+

Sight groups

+

Players in the same group share what they see (with line of sight on). “Alone” means only their own view.

+
+ {lobby.members.map((m) => ( + + ))} +
+ ) +} + // lobby settings: join code, rename, colour, character switch, members, leave / delete // DM: replace the dice sound, add sounds for critical results, switch cues on or off function SoundSettings({ lobby, id, act, sfx }) { @@ -756,6 +775,14 @@ function LobbyView({ id, rules, user }) { useEffect(() => { document.documentElement.style.setProperty('--chatw', `${chatW}px`); store('aw_chatw', String(chatW)) }, [chatW]) useEffect(() => () => document.documentElement.style.removeProperty('--chatw'), []) useEffect(() => { store('aw_sheetw', String(sheetW)) }, [sheetW]) + const [rv, setRv] = useState(null) // DM "show an area" tool: {origin, target, hover, fov, to} + const rvShape = (() => { + if (!rv?.origin) return null + const t = rv.target || rv.hover + if (!t) return { x: rv.origin.x, y: rv.origin.y, radius: 0.01, dir: 0, fov: rv.fov } + const dx = t.x - rv.origin.x, dy = t.y - rv.origin.y + return { x: rv.origin.x, y: rv.origin.y, radius: Math.max(0.5, Math.hypot(dx, dy) / (mapState.map?.grid || 50)), dir: (Math.atan2(dy, dx) * 180) / Math.PI, fov: rv.fov } + })() const [menu, setMenu] = useState(null) // right click menu on a token const [topOpen, setTopOpen] = useState(() => store('aw_topopen') === '1') // difficulty and player bar during a fight const toggleTop = () => { setTopOpen((v) => { store('aw_topopen', v ? '0' : '1'); return !v }) } @@ -824,7 +851,7 @@ function LobbyView({ id, rules, user }) { {(!lobby.is_dm || leftTab === 'sheet') && } {lobby.is_dm && leftTab === 'lobby' - ? <> + ? <> : npcTok ? check(k, i, n, npcTok.id)} /> : cur ? :

{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}

} @@ -869,7 +896,11 @@ function LobbyView({ id, rules, user }) { 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) }} - overlay={walls.length ? act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}> + tool={rv ? 'reveal' : undefined} + onClick={rv ? (p) => setRv((s) => (s.origin && !s.target ? { ...s, target: p } : s.origin ? { ...s, target: p } : { ...s, origin: p })) : undefined} + onHover={rv && rv.origin && !rv.target ? (p) => setRv((s) => (s ? { ...s, hover: p } : s)) : undefined} + overlay={<>{walls.length ? act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null} + {full && }}> {mapState.vision?.empty &&
{lobby.is_dm ? 'This player is not on the map yet. Switch to the full view and drag their token onto it.' : 'Waiting for the DM to put your token on the map.'}
}
{lobby.is_dm && ( @@ -884,12 +915,37 @@ function LobbyView({ id, rules, user }) { {mapState.fog_on && } } + {full && mapState.walls?.length > 0 && mapState.los_on && } {lobby.is_dm && mapState.walls?.length > 0 && } {full && mapState.walls?.length > 0 && } {full && } {full && }
+ {full && (rv || mapState.reveals?.length > 0) && ( +
+ Reveal an area + {rv && <> +

{!rv.origin ? '1. Click the spot the view starts from.' : !rv.target ? '2. Move and click where the view ends (distance = range, direction = where it looks).' : 'Ready. Click again to change the target.'}

+ +
Shown to: + + {lobby.members.map((mm) => )} +
+
+ + +
+ } + {mapState.reveals?.length > 0 && <> + Shown now + {mapState.reveals.map((z, i) =>
Area {i + 1} · {z.to ? z.to.join(', ') : 'everyone'} +
)} + } +
+ )} {npcPick &&
setNpcPick(false)} />
} {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)} />
} diff --git a/frontend/src/MapView.jsx b/frontend/src/MapView.jsx index ef35ed4..aa6d87b 100644 --- a/frontend/src/MapView.jsx +++ b/frontend/src/MapView.jsx @@ -254,6 +254,24 @@ export function NpcPicker({ onPick, onClose }) { 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. +export function RevealLayer({ map, zones, draft }) { + const shape = (z, key, cls) => { + const R = z.radius * map.grid + if (z.fov >= 359) return + const a0 = ((z.dir - z.fov / 2) * Math.PI) / 180, a1 = ((z.dir + z.fov / 2) * Math.PI) / 180 + const d = `M ${z.x} ${z.y} L ${z.x + R * Math.cos(a0)} ${z.y + R * Math.sin(a0)} A ${R} ${R} 0 ${z.fov > 180 ? 1 : 0} 1 ${z.x + R * Math.cos(a1)} ${z.y + R * Math.sin(a1)} Z` + return + } + return ( + + {(zones || []).map((z) => shape(z, z.id, 'rz'))} + {draft && shape(draft, 'draft', 'rz draft')} + {draft && } + + ) +} + // Walls and doors as a line layer on top of the map (DM only). In the live view doors can be clicked to open or close them. export function WallLayer({ map, walls, draft, hover, onDoor }) { const line = (w, extra = {}) => ( diff --git a/frontend/src/Maps.jsx b/frontend/src/Maps.jsx index 7ccaf54..9d770d0 100644 --- a/frontend/src/Maps.jsx +++ b/frontend/src/Maps.jsx @@ -2,7 +2,24 @@ import { useCallback, useEffect, useState } from 'react' import { MapEditor } from './MapView.jsx' const J = { 'Content-Type': 'application/json' } -const errText = async (r, fallback) => (await r.json().catch(() => ({}))).detail || fallback +const errText = async (r, fallback) => { + if (r.status === 413) return 'The file is too big for the web server (nginx client_max_body_size). Use a smaller image or ask the server admin to raise the limit.' + return (await r.json().catch(() => ({}))).detail || fallback +} + +// Big images are scaled down and re-encoded in the browser first (the server does it again anyway), so they fit through proxies with small upload limits. +async function shrink(f) { + if (f.size < 900_000) return f + try { + const bmp = await createImageBitmap(f) + const k = Math.min(1, 4096 / Math.max(bmp.width, bmp.height)) + const c = document.createElement('canvas') + c.width = Math.max(1, Math.round(bmp.width * k)); c.height = Math.max(1, Math.round(bmp.height * k)) + c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height) + const blob = await new Promise((res) => c.toBlob(res, 'image/webp', 0.9)) + return blob && blob.size < f.size ? blob : f + } catch { return f } +} function SharePanel({ m, reload }) { const [name, setName] = useState('') @@ -45,7 +62,8 @@ export default function Maps() { e.target.value = '' if (!f) return setErr(''); setBusy(true) - const r = await fetch(`/api/maps?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f }).catch(() => null) + const body = await shrink(f) + const r = await fetch(`/api/maps?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': body.type || 'application/octet-stream' }, body }).catch(() => null) setBusy(false) if (!r || !r.ok) return setErr(r ? await errText(r, 'Upload failed') : 'Upload failed') load() diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e829ec2..99e6b82 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -587,3 +587,11 @@ textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); } .condrow { display: flex; flex-wrap: wrap; gap: .25rem; } .sightrange input { width: 3.4rem; } .tokenpanel { max-height: calc(100% - 4rem); overflow-y: auto; } + +/* reveal areas, sight groups */ +.rz { fill: rgba(255, 215, 0, .14); stroke: rgba(255, 215, 0, .9); stroke-width: 2; stroke-dasharray: 6 4; } +.rz.draft { fill: rgba(255, 215, 0, .25); } +.rzo { fill: gold; stroke: #000; stroke-width: 1; } +.revealpanel { right: auto; left: .5rem; bottom: .5rem; top: auto; max-width: 18rem; display: grid; gap: .4rem; } +.visiongroups { display: grid; gap: .4rem; } +.vgrow { justify-content: space-between; }