Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
928e018fcd | ||
|
|
ab3c35aab7 |
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user