Compare commits

..
2 Commits
8 changed files with 256 additions and 32 deletions
+5
View File
@@ -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. - **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 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, 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. - **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
View File
@@ -674,7 +674,7 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
lobby = _lobby_for(con, lid, user) lobby = _lobby_for(con, lid, user)
is_dm = lobby["owner_id"] == user["id"] is_dm = lobby["owner_id"] == user["id"]
_ensure_colors(con, lid) _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 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() WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
combat = combat_view(con, lobby, is_dm) 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 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) # 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"], 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}) "character_id": r["character_id"] if (is_dm or mine) and card else None})
hidden = bool(lobby["diff_hidden"]) hidden = bool(lobby["diff_hidden"])
diff = {"label": lobby["diff_label"], "mod": lobby["diff_mod"], "hidden": hidden} diff = {"label": lobby["diff_label"], "mod": lobby["diff_mod"], "hidden": hidden}
+110 -15
View File
@@ -5,6 +5,7 @@ Images are re-encoded with Pillow on upload, so only real images get through and
import hashlib import hashlib
import io import io
import json import json
import math
import shutil import shutil
import time import time
import uuid import uuid
@@ -41,13 +42,17 @@ def schema(con):
con.execute("""CREATE TABLE IF NOT EXISTS lobby_explored ( 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, 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))""") 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))") 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 '{}'"), 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"), ("lobbies", "sight", "REAL"), ("lobbies", "fog", "INTEGER NOT NULL DEFAULT 1"),
("lobby_tokens", "hp", "INTEGER"), ("lobby_tokens", "hp_max", "INTEGER"), ("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", "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})")]: 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}") 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)) f = max(1, -(-max(m["w"], m["h"]) // 512))
return f, -(-m["w"] // f), -(-m["h"] // f) 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).""" """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"] lid = lobby["id"]
f, mw, mh = mask_dims(m) 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 old = None
base = old or Image.new("L", (mw, mh), 0) base = old or Image.new("L", (mw, mh), 0)
cur = 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) new = ImageChops.lighter(base, cur)
rev = hashlib.md5(new.tobytes()).hexdigest()[:10] rev = hashlib.md5(new.tobytes()).hexdigest()[:10]
if row and row["rev"] == rev: 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"] viewer = user["id"]
walls = json.loads(m["walls"]) walls = json.loads(m["walls"])
doors = json.loads(lobby["doors"] or "{}") 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} "rev": "full:" + m["id"], "key": None}
if viewer is None: if viewer is None:
return ctx return ctx
ctx["rev"] = "open:" + m["id"] ctx["rev"] = "open:" + m["id"]
if not (lobby["los"] and walls): if not (lobby["los"] and walls):
return ctx return ctx
tok = con.execute("SELECT x, y FROM lobby_tokens WHERE lobby_id=? AND user_id=?", (lobby["id"], viewer)).fetchone()
ctx["los"] = True 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"])] 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"] sight = lobby["sight"] if lobby["sight"] is not None else m["sight"]
radius = round(sight * m["grid"], 1) radius = round(sight * m["grid"], 1)
key = hashlib.sha1(json.dumps([m["id"], blocking, radius, x, y]).encode()).hexdigest() me = con.execute("""SELECT u.username, mm.vgroup FROM lobby_members mm JOIN users u ON u.id = mm.user_id
poly = vision.cached_polygon(key, lambda: vision.polygon((x, y), blocking, m["w"], m["h"], radius)) WHERE mm.lobby_id=? AND mm.user_id=?""", (lobby["id"], viewer)).fetchone()
ctx.update(poly=poly, key=key, rev=key[:12]) 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"]: if lobby["fog"]:
ctx["fog"] = explore(con, lobby, viewer, m, poly, key) ctx["fog"] = explore(con, lobby, viewer, m, polys, key)
return ctx 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: def tokens_for(con, lobby, user, ctx) -> list:
sync_players(con, lobby["id"]) 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 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 not full and not own:
if r["hidden"] or r["x"] is None: if r["hidden"] or r["x"] is None:
continue 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 continue
name = r["name"] name = r["name"]
if player: 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"] = lobby["sight"] if lobby["sight"] is not None else m["sight"]
res["sight_map"] = m["sight"] res["sight_map"] = m["sight"]
res["fog_on"] = bool(lobby["fog"]) 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: if ctx["viewer"] is None:
res["walls"] = [{**w, "open": bool(ctx["doors"].get(w["id"]))} for w in ctx["walls"]] res["walls"] = [{**w, "open": bool(ctx["doors"].get(w["id"]))} for w in ctx["walls"]]
return res return res
@@ -502,7 +537,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
if ctx["empty"]: if ctx["empty"]:
raise HTTPException(404, "Nothing to see yet") raise HTTPException(404, "Nothing to see yet")
if ctx["los"]: 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(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", 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"}) 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) vision.fog_cache.put((m["id"], row["rev"]), hit)
return Response(hit, media_type="image/webp", headers={"Cache-Control": "private, max-age=3600"}) 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") @api.delete("/lobbies/{lid}/explored")
def forget_explored(lid: str, user: dict = Depends(current_user)): def forget_explored(lid: str, user: dict = Depends(current_user)):
with db() as con: 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") mid = body.get("map_id")
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,)) 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_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,)) con.execute("UPDATE lobbies SET sight=NULL WHERE id=?", (lid,))
if mid: if mid:
r = usable_map(con, str(mid), user) r = usable_map(con, str(mid), user)
+21 -7
View File
@@ -11,8 +11,9 @@ from PIL import Image, ImageDraw
EPS = 1e-5 EPS = 1e-5
def polygon(origin, segs, w, h, radius=0.0): 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.""" """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 ox, oy = origin
border = [(0, 0, w, 0), (w, 0, w, h), (w, h, 0, h), (0, h, 0, 0)] border = [(0, 0, w, 0), (w, 0, w, h), (w, h, 0, h), (0, h, 0, 0)]
if radius: if radius:
@@ -34,7 +35,13 @@ def polygon(origin, segs, w, h, radius=0.0):
angles += (a - EPS, a, a + EPS) angles += (a - EPS, a, a + EPS)
if radius: if radius:
angles += [i * (2 * math.pi / 120) for i in range(120)] 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 = [] pts = []
for a in angles: for a in angles:
dx, dy = math.cos(a), math.sin(a) 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"): if best == float("inf"):
continue continue
pts.append((ox + dx * best, oy + dy * best)) pts.append((ox + dx * best, oy + dy * best))
return pts return [(ox, oy)] + pts if sector else pts
def inside(pt, poly) -> bool: def inside(pt, poly) -> bool:
@@ -112,19 +119,26 @@ def source(path):
return im 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. """The part of the image inside the polygon (everything else black), cropped to its bounding box.
Returns (webp bytes, (x, y, w, h)).""" Returns (webp bytes, (x, y, w, h))."""
hit = _cuts.fetch(key) hit = _cuts.fetch(key)
if hit: if hit:
return hit return hit
im = source(path) 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)))) 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)))) 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) w, h = max(1, x1 - x0), max(1, y1 - y0)
mask = Image.new("L", (w, h), 0) 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) piece = Image.composite(im.crop((x0, y0, x0 + w, y0 + h)), Image.new("RGB", (w, h), (0, 0, 0)), mask)
out = io.BytesIO() out = io.BytesIO()
piece.save(out, "WEBP", quality=82, method=1) piece.save(out, "WEBP", quality=82, method=1)
+61 -5
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx' import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js' 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 J = { 'Content-Type': 'application/json' }
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) }) 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 // 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 // DM: replace the dice sound, add sounds for critical results, switch cues on or off
function SoundSettings({ lobby, id, act, sfx }) { 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.setProperty('--chatw', `${chatW}px`); store('aw_chatw', String(chatW)) }, [chatW])
useEffect(() => () => document.documentElement.style.removeProperty('--chatw'), []) useEffect(() => () => document.documentElement.style.removeProperty('--chatw'), [])
useEffect(() => { store('aw_sheetw', String(sheetW)) }, [sheetW]) 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 [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 [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 }) } const toggleTop = () => { setTopOpen((v) => { store('aw_topopen', v ? '0' : '1'); return !v }) }
@@ -765,11 +792,11 @@ function LobbyView({ id, rules, user }) {
const hp = n.hp > 0 ? n.hp : undefined const hp = n.hp > 0 ? n.hp : undefined
const m = mapState.map const m = mapState.map
setNpcPick(false) 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', hp_max: hp }).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, hidden: true }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
} }
const addToken = () => { const addToken = () => {
const m = mapState.map, n = mapState.tokens.filter((t) => t.kind === 'npc').length + 1 const m = mapState.map, n = mapState.tokens.filter((t) => t.kind === 'npc').length + 1
send(`/api/lobbies/${id}/tokens`, 'POST', { name: `Enemy ${n}`, x: m.w / 2, y: m.h / 2, size: 1, color: '#c0392b' }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {}) send(`/api/lobbies/${id}/tokens`, 'POST', { name: `Enemy ${n}`, x: m.w / 2, y: m.h / 2, size: 1, color: '#c0392b', hidden: true }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
} }
if (err && !lobby) return <section className="page lobbypage"><p className="warn">{err}</p><a href="#lobbies">Back to all lobbies</a></section> if (err && !lobby) return <section className="page lobbypage"><p className="warn">{err}</p><a href="#lobbies">Back to all lobbies</a></section>
@@ -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> <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> </div>
{lobby.is_dm && leftTab === 'lobby' {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)} /> : 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} /> : 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>} : <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} onMove={moveToken} selected={mapSel} onSelect={setMapSel}
onMenu={lobby.is_dm && !viewAs ? (t, x, y) => setMenu({ t, x, y }) : undefined} 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) }} 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>} {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"> <div className="maptools mapui">
{lobby.is_dm && ( {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> <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>} {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>} {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>} {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> <button className={showGrid ? 'on' : 'ghost'} aria-pressed={showGrid} onClick={() => setShowGrid(!showGrid)}>Grid</button>
{full && <button className="ghost" onClick={addToken}>+ Token</button>} {full && <button className="ghost" onClick={addToken}>+ Token</button>}
{full && <button className={npcPick ? 'on' : 'ghost'} onClick={() => setNpcPick(!npcPick)}>+ NPC</button>} {full && <button className={npcPick ? 'on' : 'ghost'} onClick={() => setNpcPick(!npcPick)}>+ NPC</button>}
</div> </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>} {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 })()} {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>} {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>}
+19 -1
View File
@@ -254,6 +254,24 @@ export function NpcPicker({ onPick, onClose }) {
const uid = () => Math.random().toString(36).slice(2, 12) 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. // 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 }) { export function WallLayer({ map, walls, draft, hover, onDoor }) {
const line = (w, extra = {}) => ( const line = (w, extra = {}) => (
@@ -311,12 +329,12 @@ export function MapEditor({ id, onClose }) {
// the full-size image, loaded once for the editor // the full-size image, loaded once for the editor
const [image, setImage] = useState(null) const [image, setImage] = useState(null)
useEffect(() => { if (m) setImage({ src: `/api/maps/${id}/image`, x: 0, y: 0, w: m.w, h: m.h }) }, [m?.id]) // eslint-disable-line useEffect(() => { if (m) setImage({ src: `/api/maps/${id}/image`, x: 0, y: 0, w: m.w, h: m.h }) }, [m?.id]) // eslint-disable-line
const [picker, setPicker] = useState(false)
if (!m) return null if (!m) return null
const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid
const cur = tokens.find((t) => t.id === sel) const cur = tokens.find((t) => t.id === sel)
const patch = (tid, p) => { setTokens((ts) => ts.map((t) => (t.id === tid ? { ...t, ...p } : t))); setDirty(true) } const patch = (tid, p) => { setTokens((ts) => ts.map((t) => (t.id === tid ? { ...t, ...p } : t))); setDirty(true) }
const addWalls = (list) => { setWalls((w) => [...w, ...list.map((x) => ({ id: uid(), ...x }))]); setDirty(true) } const addWalls = (list) => { setWalls((w) => [...w, ...list.map((x) => ({ id: uid(), ...x }))]); setDirty(true) }
const [picker, setPicker] = useState(false)
const addNpc = (n) => { const addNpc = (n) => {
const k = tokens.length + 1 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, hp_max: n.hp > 0 ? n.hp : null, hp: n.hp > 0 ? n.hp : null } 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 }
+20 -2
View File
@@ -2,7 +2,24 @@ import { useCallback, useEffect, useState } from 'react'
import { MapEditor } from './MapView.jsx' import { MapEditor } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' } 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 }) { function SharePanel({ m, reload }) {
const [name, setName] = useState('') const [name, setName] = useState('')
@@ -45,7 +62,8 @@ export default function Maps() {
e.target.value = '' e.target.value = ''
if (!f) return if (!f) return
setErr(''); setBusy(true) 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) setBusy(false)
if (!r || !r.ok) return setErr(r ? await errText(r, 'Upload failed') : 'Upload failed') if (!r || !r.ok) return setErr(r ? await errText(r, 'Upload failed') : 'Upload failed')
load() load()
+18
View File
@@ -587,3 +587,21 @@ textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
.condrow { display: flex; flex-wrap: wrap; gap: .25rem; } .condrow { display: flex; flex-wrap: wrap; gap: .25rem; }
.sightrange input { width: 3.4rem; } .sightrange input { width: 3.4rem; }
.tokenpanel { max-height: calc(100% - 4rem); overflow-y: auto; } .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; }
/* radial menu: always a dark disc with light text, readable on both themes */
.radialbtn { background: #1b2116 !important; border: 2px solid #8fd44c !important; color: #fff !important; }
.radialbtn:hover:not(:disabled) { background: #2b3a1c !important; border-color: gold !important; }
.radialbtn:disabled { opacity: .55; }
.radialbtn .ri { font-size: 1.35rem; color: #fff; }
.radialbtn .rl { font-size: .62rem; font-weight: 700; color: #fff; text-shadow: 0 1px 1px #000; }
.radialbtn { width: 58px; height: 58px; left: -29px; top: -29px; }
.radialtitle { background: #000; color: #fff; font-weight: 700; font-size: .8rem; border: 1px solid #8fd44c; }
.radialbtn:disabled { opacity: .78; background: #262b22 !important; border-color: #5a634f !important; cursor: not-allowed; }