Compare commits
2
Commits
779978ad02
...
1405381096
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1405381096 | ||
|
|
6880b58de2 |
@@ -164,6 +164,10 @@ Players only see a short card of the other players, never their sheets. Guessing
|
|||||||
- **Visibility in the editor**: the 👁 button beside each prepared token toggles "hidden from players".
|
- **Visibility in the editor**: the 👁 button beside each prepared token toggles "hidden from players".
|
||||||
- **Props**: decor tokens (chest, barrel, table, chair, bed, door, campfire, tree, rug, stairs, ... 19 built-in drawings, plus your own PNG/WebP uploads up to 3 MB, 40 per DM). Add them in the map editor (*+ Prop*) or on the table (*+ Prop*); drag like tokens, size width/height in squares, rotate by slider, 45° or 90° steps (also in the right click menu), hide from players. They are drawn below tokens; players cannot move them.
|
- **Props**: decor tokens (chest, barrel, table, chair, bed, door, campfire, tree, rug, stairs, ... 19 built-in drawings, plus your own PNG/WebP uploads up to 3 MB, 40 per DM). Add them in the map editor (*+ Prop*) or on the table (*+ Prop*); drag like tokens, size width/height in squares, rotate by slider, 45° or 90° steps (also in the right click menu), hide from players. They are drawn below tokens; players cannot move them.
|
||||||
|
|
||||||
|
- **Prop library**: the *Maps* page lists all props (built-in and your own) and lets you upload, rename and remove your own ones.
|
||||||
|
- **DM notes**: NPC tokens and props have a "Notes (only you see these)" field (map editor and table; e.g. the contents of a chest). On the table click the token to edit; a 📝 marks tokens with notes and the text shows as tooltip. Players never receive notes.
|
||||||
|
- **Fog without walls**: sight range and fog also work on maps without walls (circle around the token). Sight needs walls or a range above 0; the explored memory is now stored at up to 1024 px.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
|
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
|
||||||
|
|||||||
+32
-14
@@ -62,7 +62,7 @@ def schema(con):
|
|||||||
("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"),
|
("lobby_members", "vgroup", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
("lobby_tokens", "prop", "TEXT"), ("lobby_tokens", "rot", "REAL NOT NULL DEFAULT 0"), ("lobby_tokens", "size_h", "REAL"),
|
("lobby_tokens", "prop", "TEXT"), ("lobby_tokens", "rot", "REAL NOT NULL DEFAULT 0"), ("lobby_tokens", "size_h", "REAL"),
|
||||||
("lobbies", "scene_id", "TEXT")):
|
("lobbies", "scene_id", "TEXT"), ("lobby_tokens", "note", "TEXT")):
|
||||||
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}")
|
||||||
|
|
||||||
@@ -107,6 +107,7 @@ def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict:
|
|||||||
tid = str(t.get("id", "")) if keep_id else ""
|
tid = str(t.get("id", "")) if keep_id else ""
|
||||||
npc = str(t.get("npc_id") or "")
|
npc = str(t.get("npc_id") or "")
|
||||||
hp_max, hp = clean_int(t.get("hp_max"), 1, 9999), clean_int(t.get("hp"), -999, 9999)
|
hp_max, hp = clean_int(t.get("hp_max"), 1, 9999), clean_int(t.get("hp"), -999, 9999)
|
||||||
|
note = str(t.get("note") or "")[:2000]
|
||||||
prop = str(t.get("prop") or "")
|
prop = str(t.get("prop") or "")
|
||||||
prop = prop if re.fullmatch(r"[bc]:[a-z0-9_]{1,24}", prop) else ""
|
prop = prop if re.fullmatch(r"[bc]:[a-z0-9_]{1,24}", prop) else ""
|
||||||
try:
|
try:
|
||||||
@@ -117,7 +118,7 @@ def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict:
|
|||||||
return {"id": tid if tid.isalnum() and len(tid) <= 16 else uuid.uuid4().hex[:12], "name": name, "x": x, "y": y,
|
return {"id": tid if tid.isalnum() and len(tid) <= 16 else uuid.uuid4().hex[:12], "name": name, "x": x, "y": y,
|
||||||
"size": size, "color": clean_color(t.get("color")), "hidden": bool(t.get("hidden")),
|
"size": size, "color": clean_color(t.get("color")), "hidden": bool(t.get("hidden")),
|
||||||
"npc_id": npc if npc.isalnum() and len(npc) <= 16 else "", "hp": hp, "hp_max": hp_max,
|
"npc_id": npc if npc.isalnum() and len(npc) <= 16 else "", "hp": hp, "hp_max": hp_max,
|
||||||
"prop": prop, "rot": rot, "size_h": size_h if prop else None}
|
"prop": prop, "rot": rot, "size_h": size_h if prop else None, "note": note}
|
||||||
|
|
||||||
|
|
||||||
def clean_int(v, lo, hi):
|
def clean_int(v, lo, hi):
|
||||||
@@ -402,6 +403,15 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
con.execute("INSERT INTO user_props (id, owner_id, name, created) VALUES (?,?,?,?)", (pid, user["id"], name.strip()[:30] or "Prop", time.time()))
|
con.execute("INSERT INTO user_props (id, owner_id, name, created) VALUES (?,?,?,?)", (pid, user["id"], name.strip()[:30] or "Prop", time.time()))
|
||||||
return {"id": pid}
|
return {"id": pid}
|
||||||
|
|
||||||
|
@api.put("/props/{pid}")
|
||||||
|
def rename_prop(pid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
|
name = str(body.get("name", "")).strip()[:30]
|
||||||
|
with db() as con:
|
||||||
|
if not name or not con.execute("SELECT 1 FROM user_props WHERE id=? AND owner_id=?", (pid, user["id"])).fetchone():
|
||||||
|
raise HTTPException(404, "Prop not found")
|
||||||
|
con.execute("UPDATE user_props SET name=? WHERE id=?", (name, pid))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
@api.delete("/props/{pid}")
|
@api.delete("/props/{pid}")
|
||||||
def delete_prop(pid: str, user: dict = Depends(current_user)):
|
def delete_prop(pid: str, user: dict = Depends(current_user)):
|
||||||
with db() as con:
|
with db() as con:
|
||||||
@@ -443,13 +453,13 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
|
|
||||||
def put_token(con, lid, t, npc=None, tid=None):
|
def put_token(con, lid, t, npc=None, tid=None):
|
||||||
"""Inserts a (cleaned or snapshot) token row for the lobby."""
|
"""Inserts a (cleaned or snapshot) token row for the lobby."""
|
||||||
con.execute("""INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id, hp, hp_max, prop, rot, size_h)
|
con.execute("""INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id, hp, hp_max, prop, rot, size_h, note)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(tid or uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(bool(t["hidden"])), npc,
|
(tid or uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(bool(t["hidden"])), npc,
|
||||||
t.get("hp") if t.get("hp") is not None else t.get("hp_max"), t.get("hp_max"), t.get("prop") or None, t.get("rot") or 0, t.get("size_h")))
|
t.get("hp") if t.get("hp") is not None else t.get("hp_max"), t.get("hp_max"), t.get("prop") or None, t.get("rot") or 0, t.get("size_h"), t.get("note") or None))
|
||||||
|
|
||||||
def mask_dims(m):
|
def mask_dims(m):
|
||||||
f = max(1, -(-max(m["w"], m["h"]) // 512))
|
f = max(1, -(-max(m["w"], m["h"]) // 1024))
|
||||||
return f, -(-m["w"] // f), -(-m["h"] // f)
|
return f, -(-m["w"] // f), -(-m["h"] // f)
|
||||||
|
|
||||||
def explore(con, lobby, viewer, m, polys, key):
|
def explore(con, lobby, viewer, m, polys, key):
|
||||||
@@ -466,7 +476,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
old = Image.open(io.BytesIO(row["data"]))
|
old = Image.open(io.BytesIO(row["data"]))
|
||||||
old.load()
|
old.load()
|
||||||
if old.size != (mw, mh):
|
if old.size != (mw, mh):
|
||||||
old = None
|
old = old.resize((mw, mh), Image.BILINEAR) # memory made at another resolution is kept
|
||||||
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)
|
||||||
for poly in polys:
|
for poly in polys:
|
||||||
@@ -487,6 +497,11 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
def lobby_map(con, lobby):
|
def lobby_map(con, lobby):
|
||||||
return con.execute("SELECT * FROM maps WHERE id=?", (lobby["map_id"],)).fetchone() if lobby["map_id"] else None
|
return con.execute("SELECT * FROM maps WHERE id=?", (lobby["map_id"],)).fetchone() if lobby["map_id"] else None
|
||||||
|
|
||||||
|
def vision_on(lobby, m) -> bool:
|
||||||
|
"""Players only see what their token sees: needs walls to block the view or a sight range to limit it."""
|
||||||
|
sight = lobby["sight"] if lobby["sight"] is not None else m["sight"]
|
||||||
|
return bool(lobby["los"] and (json.loads(m["walls"]) or sight > 0))
|
||||||
|
|
||||||
def view_ctx(con, lobby, m, user, as_name=None):
|
def view_ctx(con, lobby, m, user, as_name=None):
|
||||||
"""Whose eyes the table is shown through. Players: their own token. DM: everything, or (as=name) one player's view."""
|
"""Whose eyes the table is shown through. Players: their own token. DM: everything, or (as=name) one player's view."""
|
||||||
is_dm = lobby["owner_id"] == user["id"]
|
is_dm = lobby["owner_id"] == user["id"]
|
||||||
@@ -506,7 +521,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
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 vision_on(lobby, m):
|
||||||
return ctx
|
return ctx
|
||||||
ctx["los"] = True
|
ctx["los"] = True
|
||||||
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"])]
|
||||||
@@ -598,6 +613,8 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
status.update(show_hp=bool(r["show_hp"]), show_cond=bool(r["show_cond"]), hp_hint=health(src) if src else None)
|
status.update(show_hp=bool(r["show_hp"]), show_cond=bool(r["show_cond"]), hp_hint=health(src) if src else None)
|
||||||
if r["prop"]:
|
if r["prop"]:
|
||||||
status = {}
|
status = {}
|
||||||
|
if full and not player:
|
||||||
|
status["note"] = r["note"] or ""
|
||||||
out.append({"id": r["id"], "kind": "player" if player else "prop" if r["prop"] else "npc", "prop": r["prop"], "rot": r["rot"], "size_h": r["size_h"], "username": r["username"], "name": name, "portrait": pt, **status,
|
out.append({"id": r["id"], "kind": "player" if player else "prop" if r["prop"] else "npc", "prop": r["prop"], "rot": r["rot"], "size_h": r["size_h"], "username": r["username"], "name": name, "portrait": pt, **status,
|
||||||
"x": r["x"], "y": r["y"], "size": r["size"], "hidden": bool(r["hidden"]),
|
"x": r["x"], "y": r["y"], "size": r["size"], "hidden": bool(r["hidden"]),
|
||||||
"color": (r["mcolor"] or "#999999") if player else r["color"],
|
"color": (r["mcolor"] or "#999999") if player else r["color"],
|
||||||
@@ -774,7 +791,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
|
|
||||||
# ---- scenes: saved states of the table (map + tokens + doors + reveals + explored ground + combat)
|
# ---- scenes: saved states of the table (map + tokens + doors + reveals + explored ground + combat)
|
||||||
|
|
||||||
TOKEN_COLS = ("id", "name", "x", "y", "color", "size", "hidden", "npc_id", "hp", "hp_max", "conds", "show_hp", "show_cond", "prop", "rot", "size_h")
|
TOKEN_COLS = ("id", "name", "x", "y", "color", "size", "hidden", "npc_id", "hp", "hp_max", "conds", "show_hp", "show_cond", "prop", "rot", "size_h", "note")
|
||||||
|
|
||||||
def snapshot(con, lid) -> str:
|
def snapshot(con, lid) -> str:
|
||||||
lobby = con.execute("SELECT * FROM lobbies WHERE id=?", (lid,)).fetchone()
|
lobby = con.execute("SELECT * FROM lobbies WHERE id=?", (lid,)).fetchone()
|
||||||
@@ -983,22 +1000,23 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
t = con.execute("SELECT * FROM lobby_tokens WHERE id=? AND lobby_id=?", (tid, lid)).fetchone()
|
t = con.execute("SELECT * FROM lobby_tokens WHERE id=? AND lobby_id=?", (tid, lid)).fetchone()
|
||||||
if not t or (not is_dm and t["user_id"] != user["id"]):
|
if not t or (not is_dm and t["user_id"] != user["id"]):
|
||||||
raise HTTPException(404, "Token not found")
|
raise HTTPException(404, "Token not found")
|
||||||
if not is_dm and t["x"] is None and lobby["los"] and json.loads(m["walls"]):
|
if not is_dm and t["x"] is None and vision_on(lobby, m):
|
||||||
raise HTTPException(403, "The DM puts your token on the map") # with line of sight on you could not see where
|
raise HTTPException(403, "The DM puts your token on the map") # with line of sight on you could not see where
|
||||||
merged = {"name": t["name"], "x": t["x"], "y": t["y"], "color": t["color"], "size": t["size"], "hidden": t["hidden"],
|
merged = {"name": t["name"], "x": t["x"], "y": t["y"], "color": t["color"], "size": t["size"], "hidden": t["hidden"],
|
||||||
"prop": t["prop"], "rot": t["rot"], "size_h": t["size_h"]}
|
"prop": t["prop"], "rot": t["rot"], "size_h": t["size_h"], "note": t["note"] or ""}
|
||||||
if "x" in body or "y" in body:
|
if "x" in body or "y" in body:
|
||||||
merged["x"], merged["y"] = body.get("x"), body.get("y")
|
merged["x"], merged["y"] = body.get("x"), body.get("y")
|
||||||
if is_dm and t["user_id"] is None:
|
if is_dm and t["user_id"] is None:
|
||||||
for k in ("name", "color", "size", "hidden", "rot", "size_h"):
|
for k in ("name", "color", "size", "hidden", "rot", "size_h", "note"):
|
||||||
if k in body:
|
if k in body:
|
||||||
merged[k] = body[k]
|
merged[k] = body[k]
|
||||||
elif is_dm and "size" in body:
|
elif is_dm and "size" in body:
|
||||||
merged["size"] = body["size"]
|
merged["size"] = body["size"]
|
||||||
c = clean_token(merged, m["w"], m["h"])
|
c = clean_token(merged, m["w"], m["h"])
|
||||||
con.execute("UPDATE lobby_tokens SET name=?, x=?, y=?, color=?, size=?, hidden=?, rot=?, size_h=? WHERE id=?",
|
con.execute("UPDATE lobby_tokens SET name=?, x=?, y=?, color=?, size=?, hidden=?, rot=?, size_h=?, note=? WHERE id=?",
|
||||||
(c["name"] if t["user_id"] is None else t["name"], c["x"], c["y"], c["color"], c["size"], int(c["hidden"]),
|
(c["name"] if t["user_id"] is None else t["name"], c["x"], c["y"], c["color"], c["size"], int(c["hidden"]),
|
||||||
c["rot"] if t["user_id"] is None else 0, c["size_h"] if t["user_id"] is None else None, tid))
|
c["rot"] if t["user_id"] is None else 0, c["size_h"] if t["user_id"] is None else None,
|
||||||
|
(c["note"] or None) if t["user_id"] is None else None, tid))
|
||||||
if is_dm and any(k in body for k in ("hp", "hp_max", "conds", "show_hp", "show_cond")):
|
if is_dm and any(k in body for k in ("hp", "hp_max", "conds", "show_hp", "show_cond")):
|
||||||
hp_max, hp = t["hp_max"], t["hp"]
|
hp_max, hp = t["hp_max"], t["hp"]
|
||||||
if "hp_max" in body:
|
if "hp_max" in body:
|
||||||
|
|||||||
@@ -792,7 +792,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
const addPropToken = (p) => {
|
const addPropToken = (p) => {
|
||||||
const m = mapState.map
|
const m = mapState.map
|
||||||
setPropPick(false)
|
setPropPick(false)
|
||||||
send(`/api/lobbies/${id}/tokens`, 'POST', { name: p.name, prop: p.prop, x: m.w / 2, y: m.h / 2, size: p.w, size_h: p.h, hidden: false }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
|
send(`/api/lobbies/${id}/tokens`, 'POST', { name: p.name, prop: p.prop, x: m.w / 2, y: m.h / 2, size: p.w, size_h: p.h, hidden: true }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
|
||||||
}
|
}
|
||||||
const addNpcToken = (n) => {
|
const addNpcToken = (n) => {
|
||||||
const hp = n.hp > 0 ? n.hp : undefined
|
const hp = n.hp > 0 ? n.hp : undefined
|
||||||
@@ -916,14 +916,14 @@ function LobbyView({ id, rules, user }) {
|
|||||||
{lobby.members.map((m) => <option key={m.username} value={m.username}>View as {m.character?.name || m.username}</option>)}
|
{lobby.members.map((m) => <option key={m.username} value={m.username}>View as {m.character?.name || m.username}</option>)}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
{lobby.is_dm && mapState.wall_count > 0 && mapState.los_on && <>
|
{lobby.is_dm && mapState.los_on && <>
|
||||||
<label className="small sightrange" title="How far players see, in squares (0 = unlimited). Resets to the map's value when you switch maps."><input type="number" min="0" max="1000" key={mapState.sight} defaultValue={mapState.sight} aria-label="Sight range in squares"
|
<label className="small sightrange" title="How far players see, in squares (0 = unlimited). Resets to the map's value when you switch maps."><input type="number" min="0" max="1000" key={mapState.sight} defaultValue={mapState.sight} aria-label="Sight range in squares"
|
||||||
onBlur={(e) => e.target.value !== String(mapState.sight) && act(`/api/lobbies/${id}/sight`, 'PUT', { range: e.target.value }, () => loadMap())} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} /> sq.</label>
|
onBlur={(e) => e.target.value !== String(mapState.sight) && act(`/api/lobbies/${id}/sight`, 'PUT', { range: e.target.value }, () => loadMap())} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} /> sq.</label>
|
||||||
<button className={mapState.fog_on ? 'on' : 'ghost'} aria-pressed={!!mapState.fog_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { fog: !mapState.fog_on }, () => loadMap())} title="Fog of war: players keep a dim memory of what they have seen">Fog</button>
|
<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>}
|
||||||
</>}
|
</>}
|
||||||
{lobby.is_dm && mapState.wall_count > 0 && mapState.los_on && <button disabled={!full} className={rv ? 'on' : 'ghost'} aria-pressed={!!rv} onClick={() => setRv(rv ? null : { origin: null, target: null, hover: null, fov: 90, to: null })} title={full ? 'Show players a far away area: click the spot, then click where it ends' : 'Switch to the full view to use this'}>👁 Reveal</button>}
|
{lobby.is_dm && mapState.los_on && <button disabled={!full} className={rv ? 'on' : 'ghost'} aria-pressed={!!rv} onClick={() => setRv(rv ? null : { origin: null, target: null, hover: null, fov: 90, to: null })} title={full ? 'Show players a far away area: click the spot, then click where it ends' : 'Switch to the full view to use this'}>👁 Reveal</button>}
|
||||||
{lobby.is_dm && mapState.wall_count > 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 && <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="Players only see what their character can see: limited by walls and by the sight range (needs one of them)">Sight {mapState.los_on ? 'on' : 'off'}</button>}
|
||||||
{lobby.is_dm && mapState.wall_count > 0 && <button disabled={!full} 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>}
|
{lobby.is_dm && mapState.wall_count > 0 && <button disabled={!full} 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>
|
||||||
{lobby.is_dm && <button className="ghost" onClick={addToken}>+ Token</button>}
|
{lobby.is_dm && <button className="ghost" onClick={addToken}>+ Token</button>}
|
||||||
|
|||||||
@@ -129,8 +129,9 @@ export function MapView({ map, image, fog, portraitUrl, propSrc, onMenu, isTurn,
|
|||||||
<div key={t.id} className={`maptoken prop ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`}
|
<div key={t.id} className={`maptoken prop ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`}
|
||||||
onContextMenu={(e) => { if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }}
|
onContextMenu={(e) => { if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }}
|
||||||
style={{ left: x - w / 2, top: y - h / 2, width: w, height: h, transform: `rotate(${t.rot || 0}deg)` }}
|
style={{ left: x - w / 2, top: y - h / 2, width: w, height: h, transform: `rotate(${t.rot || 0}deg)` }}
|
||||||
onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}>
|
onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '') + (t.note ? `\n\n${t.note}` : '')}>
|
||||||
<PropArt prop={t.prop} customSrc={propSrc} />
|
<PropArt prop={t.prop} customSrc={propSrc} />
|
||||||
|
{t.note && <b className="toknote" aria-hidden="true">📝</b>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -138,7 +139,8 @@ export function MapView({ map, image, fog, portraitUrl, propSrc, onMenu, isTurn,
|
|||||||
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''} ${isTurn?.(t) ? 'turn' : ''}`}
|
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''} ${isTurn?.(t) ? 'turn' : ''}`}
|
||||||
onContextMenu={(e) => { if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }}
|
onContextMenu={(e) => { if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }}
|
||||||
style={{ left: x - d(t) / 2, top: y - d(t) / 2, width: d(t), height: d(t), '--tc': t.color, fontSize: Math.max(8, d(t) * 0.45) }}
|
style={{ left: x - d(t) / 2, top: y - d(t) / 2, width: d(t), height: d(t), '--tc': t.color, fontSize: Math.max(8, d(t) * 0.45) }}
|
||||||
onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}>
|
onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '') + (t.note ? `\n\n${t.note}` : '')}>
|
||||||
|
{t.note && <b className="toknote" aria-hidden="true">📝</b>}
|
||||||
{portraitUrl?.(t) ? <img className="tokpic" src={portraitUrl(t)} alt="" draggable={false} /> : <span>{(t.name || '?').trim().slice(0, 2)}</span>}
|
{portraitUrl?.(t) ? <img className="tokpic" src={portraitUrl(t)} alt="" draggable={false} /> : <span>{(t.name || '?').trim().slice(0, 2)}</span>}
|
||||||
<em style={{ fontSize: Math.max(9, map.grid * 0.28) }}>{t.name}</em>
|
<em style={{ fontSize: Math.max(9, map.grid * 0.28) }}>{t.name}</em>
|
||||||
{t.hp_max > 0 && t.hp != null && <i className="tokhp" title={`${t.hp} / ${t.hp_max}`}><b style={{ width: `${Math.max(0, Math.min(100, (t.hp / t.hp_max) * 100))}%`, background: t.hp / t.hp_max > 0.5 ? '#3fb950' : t.hp / t.hp_max > 0.25 ? '#e6c52e' : '#e0524d' }} /></i>}
|
{t.hp_max > 0 && t.hp != null && <i className="tokhp" title={`${t.hp} / ${t.hp_max}`}><b style={{ width: `${Math.max(0, Math.min(100, (t.hp / t.hp_max) * 100))}%`, background: t.hp / t.hp_max > 0.5 ? '#3fb950' : t.hp / t.hp_max > 0.25 ? '#e6c52e' : '#e0524d' }} /></i>}
|
||||||
@@ -191,6 +193,26 @@ export function RadialMenu({ x, y, title, items, onClose }) {
|
|||||||
</div>, document.body)
|
</div>, document.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DM-only note on a token or prop (loot of a chest, tactics of an enemy ...). Saves shortly after typing stops.
|
||||||
|
export function NoteField({ t, onChange }) {
|
||||||
|
const [v, setV] = useState(t.note || '')
|
||||||
|
const timer = useRef(null)
|
||||||
|
const last = useRef(t.note || '')
|
||||||
|
useEffect(() => { if ((t.note || '') !== last.current) { last.current = t.note || ''; setV(t.note || '') } }, [t.note])
|
||||||
|
useEffect(() => () => clearTimeout(timer.current), [])
|
||||||
|
const type = (e) => {
|
||||||
|
const val = e.target.value
|
||||||
|
setV(val); last.current = val
|
||||||
|
clearTimeout(timer.current)
|
||||||
|
timer.current = setTimeout(() => onChange({ note: val }), 500)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<label className="small notefield">Notes (only you see these)
|
||||||
|
<textarea rows="3" maxLength={2000} value={v} onChange={type} onBlur={() => { clearTimeout(timer.current); if (v !== (t.note || '')) onChange({ note: v }) }} placeholder="Contents, tactics, secrets ..." />
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Name, size, colour and visibility of one token (used in the live table and in the map editor)
|
// Name, size, colour and visibility of one token (used in the live table and in the map editor)
|
||||||
export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
|
export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
|
||||||
return (
|
return (
|
||||||
@@ -199,6 +221,7 @@ export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
|
|||||||
<label className="small chk">Size <input type="number" min="0.25" max="8" step="0.25" value={t.size} onChange={(e) => onChange({ size: Number(e.target.value) || 1 })} /> sq.</label>
|
<label className="small chk">Size <input type="number" min="0.25" max="8" step="0.25" value={t.size} onChange={(e) => onChange({ size: Number(e.target.value) || 1 })} /> sq.</label>
|
||||||
<span className="swatches">{COLORS.map((c) => <button key={c} className={`swatch ${t.color === c ? 'on' : ''}`} style={{ background: c }} aria-label={`Colour ${c}`} onClick={() => onChange({ color: c })} />)}</span>
|
<span className="swatches">{COLORS.map((c) => <button key={c} className={`swatch ${t.color === c ? 'on' : ''}`} style={{ background: c }} aria-label={`Colour ${c}`} onClick={() => onChange({ color: c })} />)}</span>
|
||||||
<label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label>
|
<label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label>
|
||||||
|
<NoteField key={t.id} t={t} onChange={onChange} />
|
||||||
<span className="row">
|
<span className="row">
|
||||||
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
|
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
|
||||||
<button className="ghost danger" onClick={onDelete}>Remove</button>
|
<button className="ghost danger" onClick={onDelete}>Remove</button>
|
||||||
@@ -321,6 +344,7 @@ export function PropForm({ t, onChange, onDelete, onDuplicate }) {
|
|||||||
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 90) % 360 })} title="Rotate right 90°">⟳ 90°</button>
|
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 90) % 360 })} title="Rotate right 90°">⟳ 90°</button>
|
||||||
</span>
|
</span>
|
||||||
<label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label>
|
<label className="small chk"><input type="checkbox" checked={!!t.hidden} onChange={(e) => onChange({ hidden: e.target.checked })} /> Hidden from players</label>
|
||||||
|
<NoteField key={t.id} t={t} onChange={onChange} />
|
||||||
<span className="row">
|
<span className="row">
|
||||||
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
|
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
|
||||||
<button className="ghost danger" onClick={onDelete}>Remove</button>
|
<button className="ghost danger" onClick={onDelete}>Remove</button>
|
||||||
@@ -457,7 +481,7 @@ export function MapEditor({ id, onClose, live, onSaved }) {
|
|||||||
setErr('')
|
setErr('')
|
||||||
const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, grid: Number(grid), sight: Number(sight) || 0, walls,
|
body: JSON.stringify({ name, grid: Number(grid), sight: Number(sight) || 0, walls,
|
||||||
tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h }) => ({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h })) }) })
|
tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h, note }) => ({ id: i, name: n, x, y, size, color, hidden, npc_id, hp_max, prop, rot, size_h, note })) }) })
|
||||||
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false }
|
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false }
|
||||||
setDirty(false); onSaved?.(); return true
|
setDirty(false); onSaved?.(); return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { MapEditor } from './MapView.jsx'
|
import { MapEditor } from './MapView.jsx'
|
||||||
|
import { BUILTIN } from './props.jsx'
|
||||||
|
|
||||||
const J = { 'Content-Type': 'application/json' }
|
const J = { 'Content-Type': 'application/json' }
|
||||||
const errText = async (r, fallback) => {
|
const errText = async (r, fallback) => {
|
||||||
@@ -48,6 +49,52 @@ function SharePanel({ m, reload }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The DM's prop library: built-in drawings (fixed) and own images (add, rename, remove). Props can be used on every map.
|
||||||
|
function PropLibrary() {
|
||||||
|
const [mine, setMine] = useState(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const load = useCallback(() => fetch('/api/props').then((r) => (r.ok ? r.json() : [])).then(setMine).catch(() => setMine([])), [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
const upload = async (e) => {
|
||||||
|
const files = [...(e.target.files || [])]
|
||||||
|
e.target.value = ''
|
||||||
|
if (!files.length) return
|
||||||
|
setErr(''); setBusy(true)
|
||||||
|
for (const f of files) {
|
||||||
|
const r = await fetch(`/api/props?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f }).catch(() => null)
|
||||||
|
if (!r || !r.ok) { setErr(r ? (r.status === 413 ? 'An image is too big (at most 3 MB, or the web server limit).' : await errText(r, 'Upload failed')) : 'Upload failed'); break }
|
||||||
|
}
|
||||||
|
setBusy(false); load()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Props</h2>
|
||||||
|
<p className="muted">Decor for your maps: chests, tables, doors and so on. Usable on every map, in the map editor and on the table. Your own images work best as PNGs with a transparent background, seen from above.</p>
|
||||||
|
<label className="uploadbtn">{busy ? 'Uploading …' : '+ Add own props (PNG, JPEG, WebP, up to 3 MB each)'}<input type="file" multiple accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
|
||||||
|
{err && <p className="warn">{err}</p>}
|
||||||
|
<h3>Your props</h3>
|
||||||
|
{mine && !mine.length && <p className="muted small">None yet.</p>}
|
||||||
|
<ul className="propmgr">
|
||||||
|
{(mine || []).map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<img src={`/api/props/${p.id}/image`} alt="" loading="lazy" />
|
||||||
|
<strong className="small">{p.name}</strong>
|
||||||
|
<span className="row">
|
||||||
|
<button className="ghost mini" onClick={() => { const n = window.prompt('New name', p.name); if (n && n.trim()) fetch(`/api/props/${p.id}`, { method: 'PUT', headers: J, body: JSON.stringify({ name: n }) }).then(load) }}>Rename</button>
|
||||||
|
<button className="ghost mini danger" onClick={() => window.confirm(`Remove the prop "${p.name}"? Places where it is used on maps lose their picture.`) && fetch(`/api/props/${p.id}`, { method: 'DELETE' }).then(load)}>Remove</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<h3>Built in</h3>
|
||||||
|
<ul className="propmgr">
|
||||||
|
{BUILTIN.map((p) => <li key={p.id}><svg viewBox="0 0 100 100" aria-hidden="true">{p.art}</svg><strong className="small">{p.name}</strong></li>)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// All maps of the user (prepare, share, copy) plus the maps other users shared with them
|
// All maps of the user (prepare, share, copy) plus the maps other users shared with them
|
||||||
export default function Maps() {
|
export default function Maps() {
|
||||||
const [list, setList] = useState(null)
|
const [list, setList] = useState(null)
|
||||||
@@ -104,6 +151,7 @@ export default function Maps() {
|
|||||||
{list && !mine.length && <p className="muted">You have no maps yet.</p>}
|
{list && !mine.length && <p className="muted">You have no maps yet.</p>}
|
||||||
<ul className="mapcards">{mine.map(card)}</ul>
|
<ul className="mapcards">{mine.map(card)}</ul>
|
||||||
{shared.length > 0 && <><h2>Shared with me</h2><ul className="mapcards">{shared.map(card)}</ul></>}
|
{shared.length > 0 && <><h2>Shared with me</h2><ul className="mapcards">{shared.map(card)}</ul></>}
|
||||||
|
<PropLibrary />
|
||||||
{editing && <MapEditor id={editing} onClose={() => setEditing(null)} />}
|
{editing && <MapEditor id={editing} onClose={() => setEditing(null)} />}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -636,3 +636,11 @@ textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
|
|||||||
.maptools button:disabled { opacity: .45; }
|
.maptools button:disabled { opacity: .45; }
|
||||||
.maptools .sightrange input { height: 1.6rem; padding: 0 .3rem; }
|
.maptools .sightrange input { height: 1.6rem; padding: 0 .3rem; }
|
||||||
.maptools .sightrange { color: #f1f1f1; }
|
.maptools .sightrange { color: #f1f1f1; }
|
||||||
|
.notefield { display: grid; gap: .2rem; }
|
||||||
|
.notefield textarea { width: 100%; resize: vertical; font: inherit; font-size: .85rem; }
|
||||||
|
.toknote { position: absolute; top: -.5em; right: -.5em; font-size: .75rem; line-height: 1; pointer-events: none; transform: none; filter: drop-shadow(0 1px 1px #000); }
|
||||||
|
.maptoken.prop .toknote { transform: none; }
|
||||||
|
.propmgr { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: .5rem; list-style: none; padding: 0; }
|
||||||
|
.propmgr li { display: grid; justify-items: center; gap: .3rem; padding: .5rem; border: 1px solid var(--line); border-radius: 8px; }
|
||||||
|
.propmgr svg, .propmgr img { width: 64px; height: 64px; object-fit: contain; }
|
||||||
|
.propmgr .row { display: flex; gap: .25rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user