Sight groups, DM reveal areas (cones), client-side image shrink for map upload
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -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.
|
||||
|
||||
+2
-2
@@ -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}
|
||||
|
||||
+110
-15
@@ -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,6 +367,8 @@ 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)
|
||||
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]
|
||||
@@ -392,28 +399,53 @@ 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])
|
||||
if lobby["fog"]:
|
||||
ctx["fog"] = explore(con, lobby, viewer, m, poly, key)
|
||||
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, 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"])
|
||||
@@ -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)
|
||||
|
||||
+20
-6
@@ -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,6 +35,12 @@ 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)]
|
||||
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:
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<div className="card visiongroups">
|
||||
<h4>Sight groups</h4>
|
||||
<p className="small muted">Players in the same group share what they see (with line of sight on). “Alone” means only their own view.</p>
|
||||
<div className="row"><button className="ghost" onClick={() => set({ preset: 'shared' })}>Everyone shares sight</button><button className="ghost" onClick={() => set({ preset: 'separate' })}>Everyone alone</button></div>
|
||||
{lobby.members.map((m) => (
|
||||
<label key={m.username} className="small row vgrow">{m.character?.name || m.username}
|
||||
<select value={m.vgroup || 0} onChange={(e) => set({ groups: { [m.username]: Number(e.target.value) } })} aria-label={`Sight group of ${m.username}`}>
|
||||
<option value={0}>Alone</option>{[1, 2, 3, 4].map((g) => <option key={g} value={g}>Group {'ABCD'[g - 1]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
<span className="tabright">{(!lobby.is_dm || leftTab === 'sheet') && <ThemeSwitch />} <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide this column">◀</button></span>
|
||||
</div>
|
||||
{lobby.is_dm && leftTab === 'lobby'
|
||||
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
|
||||
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><VisionGroups lobby={lobby} id={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
|
||||
: npcTok ? <CharTab key={`npc${npcTok.npc_id}`} cid={npcTok.npc_id} rules={rules} tick={sheetTick.cid === npcTok.npc_id ? sheetTick.n : 0} onRoll={(k, i, n) => check(k, i, n, npcTok.id)} />
|
||||
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} editable={cur.me} />
|
||||
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
|
||||
@@ -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 ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => 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 ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}
|
||||
{full && <RevealLayer map={mapState.map} zones={mapState.reveals} draft={rv && rvShape && rv.origin ? rvShape : null} />}</>}>
|
||||
{mapState.vision?.empty && <div className="mapempty">{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.'}</div>}
|
||||
<div className="maptools mapui">
|
||||
{lobby.is_dm && (
|
||||
@@ -884,12 +915,37 @@ function LobbyView({ id, rules, user }) {
|
||||
<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>}
|
||||
</>}
|
||||
{full && mapState.walls?.length > 0 && mapState.los_on && <button className={rv ? 'on' : 'ghost'} aria-pressed={!!rv} onClick={() => setRv(rv ? null : { origin: null, target: null, hover: null, fov: 90, to: null })} title="Show players a far away area: click the spot, then click where it ends">👁 Reveal</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>
|
||||
{full && <button className="ghost" onClick={addToken}>+ Token</button>}
|
||||
{full && <button className={npcPick ? 'on' : 'ghost'} onClick={() => setNpcPick(!npcPick)}>+ NPC</button>}
|
||||
</div>
|
||||
{full && (rv || mapState.reveals?.length > 0) && (
|
||||
<div className="tokenpanel mapui revealpanel">
|
||||
<b className="small">Reveal an area</b>
|
||||
{rv && <>
|
||||
<p className="small muted">{!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.'}</p>
|
||||
<label className="small">Cone width {rv.fov >= 359 ? '360° (all around)' : `${rv.fov}°`}
|
||||
<input type="range" min="20" max="360" step="10" value={rv.fov} onChange={(e) => setRv({ ...rv, fov: Number(e.target.value) })} /></label>
|
||||
<div className="small">Shown to:
|
||||
<label className="chk"><input type="checkbox" checked={!rv.to} onChange={() => setRv({ ...rv, to: null })} /> everyone</label>
|
||||
{lobby.members.map((mm) => <label key={mm.username} className="chk"><input type="checkbox" checked={!!rv.to?.includes(mm.username)}
|
||||
onChange={(e) => setRv({ ...rv, to: e.target.checked ? [...(rv.to || []), mm.username] : (rv.to || []).filter((n) => n !== mm.username) })} /> {mm.character?.name || mm.username}</label>)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<button className="on" disabled={!rv.target || (rv.to && !rv.to.length)} onClick={() => act(`/api/lobbies/${id}/reveals`, 'POST', { x: rvShape.x, y: rvShape.y, radius: rvShape.radius, dir: rvShape.dir, fov: rv.fov, to: rv.to }, () => { setRv(null); loadMap() })}>Reveal</button>
|
||||
<button className="ghost" onClick={() => setRv(null)}>Cancel</button>
|
||||
</div>
|
||||
</>}
|
||||
{mapState.reveals?.length > 0 && <>
|
||||
<b className="small">Shown now</b>
|
||||
{mapState.reveals.map((z, i) => <div key={z.id} className="row small">Area {i + 1} · {z.to ? z.to.join(', ') : 'everyone'}
|
||||
<button className="ghost mini" onClick={() => act(`/api/lobbies/${id}/reveals/${z.id}`, 'DELETE', undefined, () => loadMap())}>Hide</button></div>)}
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>}
|
||||
{full && mapSel && (() => { const st = mapState.tokens.find((t) => t.id === mapSel); return st && !selTok ? <div className="tokenpanel mapui"><b className="small">{st.name || 'Player'}</b><StatusForm t={st} conditions={rules.conditions || []} onChange={(p) => patchToken(st.id, p)} /></div> : null })()}
|
||||
{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>}
|
||||
|
||||
@@ -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 <circle key={key} className={cls} cx={z.x} cy={z.y} r={R} vectorEffect="non-scaling-stroke" />
|
||||
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 <path key={key} className={cls} d={d} vectorEffect="non-scaling-stroke" />
|
||||
}
|
||||
return (
|
||||
<svg className="revealsvg" width={map.w} height={map.h} style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}>
|
||||
{(zones || []).map((z) => shape(z, z.id, 'rz'))}
|
||||
{draft && shape(draft, 'draft', 'rz draft')}
|
||||
{draft && <circle cx={draft.x} cy={draft.y} r="6" className="rzo" vectorEffect="non-scaling-stroke" />}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 = {}) => (
|
||||
|
||||
+20
-2
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user