Tape measure, area templates, map markers with notes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -170,6 +170,8 @@ Players only see a short card of the other players, never their sheets. Guessing
|
|||||||
|
|
||||||
- **Map ping**: everybody (players and DM) can point at a spot: double-click the map, or press *📍 Ping* and click. All see a ring in the pinger's colour with their name, and hear a short ping (rate limited to one per second and person). The DM can switch the sound off or upload an MP3 for it under *Lobby actions → Sounds → Map ping*; players can still mute sounds for themselves.
|
- **Map ping**: everybody (players and DM) can point at a spot: double-click the map, or press *📍 Ping* and click. All see a ring in the pinger's colour with their name, and hear a short ping (rate limited to one per second and person). The DM can switch the sound off or upload an MP3 for it under *Lobby actions → Sounds → Map ping*; players can still mute sounds for themselves.
|
||||||
|
|
||||||
|
- **Measure, areas, markers** (for everybody, buttons above the map): *📏 Measure* - click start and end, the distance in squares is shown to the whole table for a few seconds. *⬡ Area* - spell templates (circle, cone with adjustable angle, line with width, square) in the colour of whoever placed them, visible to all until their author or the DM removes them (list top left, DM can clear all); they disappear when the map changes. *📌 Marker* - pins with icon, title and note on the map. Markers are private to their author until "Show to everybody at the table" is ticked; the DM can remove shared markers. They belong to the map, so they are back when you return to that map.
|
||||||
|
|
||||||
## 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.
|
||||||
@@ -201,6 +203,9 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
|
|||||||
| PUT | `/api/lobbies/{id}/doors/{wall}` , `/sight` | DM: open / close a door; line of sight on / off |
|
| PUT | `/api/lobbies/{id}/doors/{wall}` , `/sight` | DM: open / close a door; line of sight on / off |
|
||||||
| GET / POST | `/api/lobbies/{id}/scenes` | DM: saved scenes / save a copy of the table (`name`) |
|
| GET / POST | `/api/lobbies/{id}/scenes` | DM: saved scenes / save a copy of the table (`name`) |
|
||||||
| PUT / DELETE | `/api/lobbies/{id}/scenes/{sid}` | DM: rename (`name`), save now (`save`) / delete |
|
| PUT / DELETE | `/api/lobbies/{id}/scenes/{sid}` | DM: rename (`name`), save now (`save`) / delete |
|
||||||
|
| POST | `/api/lobbies/{id}/measure` | Share a measured distance (`x1,y1,x2,y2`) |
|
||||||
|
| POST / DELETE | `/api/lobbies/{id}/templates[/{tid}]` | Place an area (`kind, x, y, dir, size, width`) / remove (author or DM; DELETE without id: DM clears all) |
|
||||||
|
| POST / PUT / DELETE | `/api/lobbies/{id}/marks[/{kid}]` | Marker with note (`x, y, icon, title, note, shared`) / edit (author) / remove |
|
||||||
| POST | `/api/lobbies/{id}/ping` | Ping a spot (`x`, `y`); everybody gets a push |
|
| POST | `/api/lobbies/{id}/ping` | Ping a spot (`x`, `y`); everybody gets a push |
|
||||||
| PUT | `/api/lobbies/{id}/scene` | DM: switch to a scene (`scene_id`); the current one is saved first |
|
| PUT | `/api/lobbies/{id}/scene` | DM: switch to a scene (`scene_id`); the current one is saved first |
|
||||||
| GET / POST / DELETE | `/api/props[/{id}]` | Your own prop images / upload (raw body, `?name=`) / delete |
|
| GET / POST / DELETE | `/api/props[/{id}]` | Your own prop images / upload (raw body, `?name=`) / delete |
|
||||||
|
|||||||
+1
-1
@@ -928,7 +928,7 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)):
|
|||||||
_dm_only(_lobby_for(con, lid, user), user)
|
_dm_only(_lobby_for(con, lid, user), user)
|
||||||
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
|
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
|
||||||
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
|
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
|
||||||
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_scenes"):
|
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_scenes", "lobby_templates", "lobby_marks"):
|
||||||
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
|
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
|
||||||
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
|
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
|
||||||
purge_sounds(lid)
|
purge_sounds(lid)
|
||||||
|
|||||||
+137
-1
@@ -29,6 +29,10 @@ MAX_TOKENS = 120 # per map / lobby
|
|||||||
MAX_WALLS = 400 # wall and door segments per map
|
MAX_WALLS = 400 # wall and door segments per map
|
||||||
MAX_SHARES = 30
|
MAX_SHARES = 30
|
||||||
MAX_SCENES = 12 # saved scenes per lobby
|
MAX_SCENES = 12 # saved scenes per lobby
|
||||||
|
MAX_TEMPLATES = 24 # per lobby
|
||||||
|
MAX_MARKS = 60 # per player and lobby
|
||||||
|
TEMPLATE_KINDS = ("circle", "cone", "line", "square")
|
||||||
|
MARK_ICONS = ("📌", "⚠️", "💰", "🔒", "🚪", "❓", "⭐", "☠️", "🏕️", "👁️")
|
||||||
MAX_PROPS = 40 # custom prop images per user
|
MAX_PROPS = 40 # custom prop images per user
|
||||||
COLORS = ("#c0392b", "#8e44ad", "#16a085", "#7f8c8d", "#d35400", "#2c3e50", "#27ae60", "#b9770e")
|
COLORS = ("#c0392b", "#8e44ad", "#16a085", "#7f8c8d", "#d35400", "#2c3e50", "#27ae60", "#b9770e")
|
||||||
|
|
||||||
@@ -51,6 +55,12 @@ def schema(con):
|
|||||||
fov REAL NOT NULL, targets TEXT, created REAL NOT NULL)""")
|
fov REAL NOT NULL, targets TEXT, created REAL NOT NULL)""")
|
||||||
con.execute("""CREATE TABLE IF NOT EXISTS lobby_scenes (
|
con.execute("""CREATE TABLE IF NOT EXISTS lobby_scenes (
|
||||||
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, name TEXT NOT NULL, map_id TEXT, state TEXT NOT NULL DEFAULT '{}', updated REAL NOT NULL)""")
|
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, name TEXT NOT NULL, map_id TEXT, state TEXT NOT NULL DEFAULT '{}', updated REAL NOT NULL)""")
|
||||||
|
con.execute("""CREATE TABLE IF NOT EXISTS lobby_templates (
|
||||||
|
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, user_id INTEGER NOT NULL, kind TEXT NOT NULL, x REAL NOT NULL, y REAL NOT NULL,
|
||||||
|
dir REAL NOT NULL, size REAL NOT NULL, width REAL NOT NULL, created REAL NOT NULL)""")
|
||||||
|
con.execute("""CREATE TABLE IF NOT EXISTS lobby_marks (
|
||||||
|
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, map_id TEXT NOT NULL, user_id INTEGER NOT NULL, x REAL NOT NULL, y REAL NOT NULL,
|
||||||
|
icon TEXT NOT NULL, title TEXT NOT NULL, note TEXT NOT NULL, shared INTEGER NOT NULL DEFAULT 0, created REAL NOT NULL)""")
|
||||||
con.execute("""CREATE TABLE IF NOT EXISTS user_props (
|
con.execute("""CREATE TABLE IF NOT EXISTS user_props (
|
||||||
id TEXT PRIMARY KEY, owner_id INTEGER NOT NULL, name TEXT NOT NULL, created REAL NOT NULL)""")
|
id TEXT PRIMARY KEY, owner_id INTEGER NOT NULL, name TEXT NOT NULL, 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))")
|
||||||
@@ -290,6 +300,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
for lid in lobbies:
|
for lid in lobbies:
|
||||||
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"):
|
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"):
|
||||||
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
|
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
|
||||||
|
con.execute("DELETE FROM lobby_marks WHERE map_id=?", (mid,))
|
||||||
con.execute("UPDATE lobbies SET scene_id=NULL WHERE scene_id IN (SELECT id FROM lobby_scenes WHERE map_id=?)", (mid,))
|
con.execute("UPDATE lobbies SET scene_id=NULL WHERE scene_id IN (SELECT id FROM lobby_scenes WHERE map_id=?)", (mid,))
|
||||||
con.execute("DELETE FROM lobby_scenes WHERE map_id=?", (mid,))
|
con.execute("DELETE FROM lobby_scenes WHERE map_id=?", (mid,))
|
||||||
con.execute("DELETE FROM maps WHERE id=?", (mid,))
|
con.execute("DELETE FROM maps WHERE id=?", (mid,))
|
||||||
@@ -633,6 +644,14 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
ctx = view_ctx(con, lobby, m, user, as_)
|
ctx = view_ctx(con, lobby, m, user, as_)
|
||||||
res = {"map": meta(m), "tokens": tokens_for(con, lobby, user, ctx),
|
res = {"map": meta(m), "tokens": tokens_for(con, lobby, user, ctx),
|
||||||
"vision": {"los": ctx["los"], "empty": ctx["empty"], "rev": ctx["rev"], "fog": ctx.get("fog")}}
|
"vision": {"los": ctx["los"], "empty": ctx["empty"], "rev": ctx["rev"], "fog": ctx.get("fog")}}
|
||||||
|
res["templates"] = [{"id": t["id"], "kind": t["kind"], "x": t["x"], "y": t["y"], "dir": t["dir"], "size": t["size"], "width": t["width"],
|
||||||
|
"user": t["username"], "dm": t["user_id"] == lobby["owner_id"], "mine": t["user_id"] == user["id"]}
|
||||||
|
for t in con.execute("""SELECT t.*, u.username FROM lobby_templates t JOIN users u ON u.id = t.user_id
|
||||||
|
WHERE t.lobby_id=? ORDER BY t.created""", (lid,))]
|
||||||
|
res["marks"] = [{"id": k["id"], "x": k["x"], "y": k["y"], "icon": k["icon"], "title": k["title"], "note": k["note"], "shared": bool(k["shared"]),
|
||||||
|
"user": k["username"], "mine": k["user_id"] == user["id"]}
|
||||||
|
for k in con.execute("""SELECT k.*, u.username FROM lobby_marks k JOIN users u ON u.id = k.user_id
|
||||||
|
WHERE k.lobby_id=? AND k.map_id=? AND (k.user_id=? OR k.shared=1) ORDER BY k.created""", (lid, m["id"], user["id"]))]
|
||||||
if ctx["is_dm"]:
|
if ctx["is_dm"]:
|
||||||
res["los_on"] = bool(lobby["los"])
|
res["los_on"] = bool(lobby["los"])
|
||||||
res["wall_count"] = len(ctx["walls"])
|
res["wall_count"] = len(ctx["walls"])
|
||||||
@@ -797,6 +816,121 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
hub.notify(lid, "ping", delay=0, x=round(x, 1), y=round(y, 1), user=user["username"], dm=lobby["owner_id"] == user["id"])
|
hub.notify(lid, "ping", delay=0, x=round(x, 1), y=round(y, 1), user=user["username"], dm=lobby["owner_id"] == user["id"])
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
def throttle(lid, uid, gap=0.8):
|
||||||
|
now = time.time()
|
||||||
|
if now - pinged.get((lid, uid), 0) < gap:
|
||||||
|
raise HTTPException(429, "Slow down")
|
||||||
|
pinged[(lid, uid)] = now
|
||||||
|
|
||||||
|
def num(body, key, lo, hi, default=None):
|
||||||
|
try:
|
||||||
|
v = float(body.get(key, default))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(422, f"Invalid {key}")
|
||||||
|
if not lo <= v <= hi:
|
||||||
|
raise HTTPException(422, f"{key} is out of range")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@api.post("/lobbies/{lid}/measure")
|
||||||
|
def share_measure(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
|
"""A measured distance, shown to everybody for a few seconds."""
|
||||||
|
with db() as con:
|
||||||
|
lobby = lobby_for(con, lid, user)
|
||||||
|
m = need_map(con, lobby)
|
||||||
|
pts = [num(body, k, 0, m["w"] if k[0] == "x" else m["h"]) for k in ("x1", "y1", "x2", "y2")]
|
||||||
|
throttle(lid, user["id"], 0.4)
|
||||||
|
hub.notify(lid, "measure", delay=0, x1=pts[0], y1=pts[1], x2=pts[2], y2=pts[3], user=user["username"], dm=lobby["owner_id"] == user["id"])
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@api.post("/lobbies/{lid}/templates")
|
||||||
|
def add_template(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
|
"""An area (spell template) on the map, visible to everybody until it is removed."""
|
||||||
|
kind = body.get("kind")
|
||||||
|
if kind not in TEMPLATE_KINDS:
|
||||||
|
raise HTTPException(422, "Unknown template")
|
||||||
|
with db() as con:
|
||||||
|
lobby = lobby_for(con, lid, user)
|
||||||
|
m = need_map(con, lobby)
|
||||||
|
x, y = num(body, "x", 0, m["w"]), num(body, "y", 0, m["h"])
|
||||||
|
d, size = num(body, "dir", -720, 720, 0) % 360, num(body, "size", 0.25, 60)
|
||||||
|
width = num(body, "width", 1, 180, 1)
|
||||||
|
if kind == "line":
|
||||||
|
width = min(width, 20)
|
||||||
|
if kind == "cone":
|
||||||
|
width = min(max(width, 20), 180)
|
||||||
|
if con.execute("SELECT COUNT(*) FROM lobby_templates WHERE lobby_id=?", (lid,)).fetchone()[0] >= MAX_TEMPLATES:
|
||||||
|
raise HTTPException(409, "Too many templates on the map - remove some first")
|
||||||
|
tid = uuid.uuid4().hex[:12]
|
||||||
|
con.execute("INSERT INTO lobby_templates (id, lobby_id, user_id, kind, x, y, dir, size, width, created) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(tid, lid, user["id"], kind, x, y, d, size, width, time.time()))
|
||||||
|
hub.notify(lid, "map")
|
||||||
|
return {"id": tid}
|
||||||
|
|
||||||
|
@api.delete("/lobbies/{lid}/templates/{tid}")
|
||||||
|
def remove_template(lid: str, tid: str, user: dict = Depends(current_user)):
|
||||||
|
with db() as con:
|
||||||
|
lobby = lobby_for(con, lid, user)
|
||||||
|
if lobby["owner_id"] == user["id"]:
|
||||||
|
con.execute("DELETE FROM lobby_templates WHERE id=? AND lobby_id=?", (tid, lid))
|
||||||
|
else:
|
||||||
|
con.execute("DELETE FROM lobby_templates WHERE id=? AND lobby_id=? AND user_id=?", (tid, lid, user["id"]))
|
||||||
|
hub.notify(lid, "map")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@api.delete("/lobbies/{lid}/templates")
|
||||||
|
def clear_templates(lid: str, user: dict = Depends(current_user)):
|
||||||
|
with db() as con:
|
||||||
|
dm_only(lobby_for(con, lid, user), user)
|
||||||
|
con.execute("DELETE FROM lobby_templates WHERE lobby_id=?", (lid,))
|
||||||
|
hub.notify(lid, "map")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def clean_mark(body, m):
|
||||||
|
icon = body.get("icon") if body.get("icon") in MARK_ICONS else MARK_ICONS[0]
|
||||||
|
return (num(body, "x", 0, m["w"]), num(body, "y", 0, m["h"]), icon, str(body.get("title", "")).strip()[:40] or "Marker",
|
||||||
|
str(body.get("note", ""))[:1000], int(bool(body.get("shared"))))
|
||||||
|
|
||||||
|
@api.post("/lobbies/{lid}/marks")
|
||||||
|
def add_mark(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
|
"""A pin with a note on the current map. Private to its author unless shared with the table."""
|
||||||
|
with db() as con:
|
||||||
|
lobby = lobby_for(con, lid, user)
|
||||||
|
m = need_map(con, lobby)
|
||||||
|
x, y, icon, title, note, shared = clean_mark(body, m)
|
||||||
|
if con.execute("SELECT COUNT(*) FROM lobby_marks WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone()[0] >= MAX_MARKS:
|
||||||
|
raise HTTPException(409, "Too many markers - remove some first")
|
||||||
|
kid = uuid.uuid4().hex[:12]
|
||||||
|
con.execute("INSERT INTO lobby_marks (id, lobby_id, map_id, user_id, x, y, icon, title, note, shared, created) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(kid, lid, m["id"], user["id"], x, y, icon, title, note, shared, time.time()))
|
||||||
|
hub.notify(lid, "map")
|
||||||
|
return {"id": kid}
|
||||||
|
|
||||||
|
@api.put("/lobbies/{lid}/marks/{kid}")
|
||||||
|
def edit_mark(lid: str, kid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
|
with db() as con:
|
||||||
|
lobby = lobby_for(con, lid, user)
|
||||||
|
m = need_map(con, lobby)
|
||||||
|
k = con.execute("SELECT * FROM lobby_marks WHERE id=? AND lobby_id=? AND user_id=?", (kid, lid, user["id"])).fetchone()
|
||||||
|
if not k:
|
||||||
|
raise HTTPException(404, "Marker not found")
|
||||||
|
merged = {"x": k["x"], "y": k["y"], "icon": k["icon"], "title": k["title"], "note": k["note"], "shared": k["shared"], **body}
|
||||||
|
x, y, icon, title, note, shared = clean_mark(merged, m)
|
||||||
|
con.execute("UPDATE lobby_marks SET x=?, y=?, icon=?, title=?, note=?, shared=? WHERE id=?", (x, y, icon, title, note, shared, kid))
|
||||||
|
hub.notify(lid, "map")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@api.delete("/lobbies/{lid}/marks/{kid}")
|
||||||
|
def remove_mark(lid: str, kid: str, user: dict = Depends(current_user)):
|
||||||
|
"""Authors remove their own markers; the DM can also remove markers that were shared with the table."""
|
||||||
|
with db() as con:
|
||||||
|
lobby = lobby_for(con, lid, user)
|
||||||
|
if lobby["owner_id"] == user["id"]:
|
||||||
|
con.execute("DELETE FROM lobby_marks WHERE id=? AND lobby_id=? AND (user_id=? OR shared=1)", (kid, lid, user["id"]))
|
||||||
|
else:
|
||||||
|
con.execute("DELETE FROM lobby_marks WHERE id=? AND lobby_id=? AND user_id=?", (kid, lid, user["id"]))
|
||||||
|
hub.notify(lid, "map")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
@api.put("/lobbies/{lid}/sight")
|
@api.put("/lobbies/{lid}/sight")
|
||||||
def set_sight(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
def set_sight(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
"""DM: switch line of sight on or off for the table (off: players see the whole map)."""
|
"""DM: switch line of sight on or off for the table (off: players see the whole map)."""
|
||||||
@@ -835,6 +969,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
"fog": lobby["fog"], "los": lobby["los"], "combat": lobby["combat"]})
|
"fog": lobby["fog"], "los": lobby["los"], "combat": lobby["combat"]})
|
||||||
|
|
||||||
def restore(con, lid, state: dict):
|
def restore(con, lid, state: dict):
|
||||||
|
con.execute("DELETE FROM lobby_templates WHERE lobby_id=?", (lid,))
|
||||||
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("DELETE FROM lobby_reveals WHERE lobby_id=?", (lid,))
|
||||||
@@ -860,6 +995,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
|
|
||||||
def fresh_state(con, lid, r, user):
|
def fresh_state(con, lid, r, user):
|
||||||
"""The state of a map that was just put on the table: its prepared tokens, nothing explored."""
|
"""The state of a map that was just put on the table: its prepared tokens, nothing explored."""
|
||||||
|
con.execute("DELETE FROM lobby_templates WHERE lobby_id=?", (lid,))
|
||||||
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("DELETE FROM lobby_reveals WHERE lobby_id=?", (lid,))
|
||||||
@@ -910,7 +1046,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
|||||||
new_scene(con, lid, r["id"], snapshot(con, lid))
|
new_scene(con, lid, r["id"], snapshot(con, lid))
|
||||||
event(con, lid, f"The DM put the map \"{r['name']}\" on the table", user["id"])
|
event(con, lid, f"The DM put the map \"{r['name']}\" on the table", user["id"])
|
||||||
else:
|
else:
|
||||||
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"):
|
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_templates"):
|
||||||
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
|
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
|
||||||
con.execute("UPDATE lobbies SET map_id=NULL, sight=NULL, doors='{}', combat=NULL WHERE id=?", (lid,))
|
con.execute("UPDATE lobbies SET map_id=NULL, sight=NULL, doors='{}', combat=NULL WHERE id=?", (lid,))
|
||||||
event(con, lid, "The DM cleared the table", user["id"])
|
event(con, lid, "The DM cleared the table", user["id"])
|
||||||
|
|||||||
@@ -1,6 +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, playPing, preload, DEFAULT_ROLL } from './sound.js'
|
import { sound, playRoll, playPing, preload, DEFAULT_ROLL } from './sound.js'
|
||||||
|
import { useTableTools } from './Tools.jsx'
|
||||||
import { MapView, MapLibrary, NpcPicker, PropForm, PropPicker, RadialMenu, RevealLayer, StatusForm, TokenForm, WallLayer } from './MapView.jsx'
|
import { MapView, MapLibrary, NpcPicker, PropForm, PropPicker, RadialMenu, RevealLayer, StatusForm, TokenForm, WallLayer } from './MapView.jsx'
|
||||||
|
|
||||||
const J = { 'Content-Type': 'application/json' }
|
const J = { 'Content-Type': 'application/json' }
|
||||||
@@ -725,8 +726,10 @@ function LobbyView({ id, rules, user }) {
|
|||||||
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
|
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
|
||||||
const [pings, setPings] = useState([])
|
const [pings, setPings] = useState([])
|
||||||
const [pingMode, setPingMode] = useState(false)
|
const [pingMode, setPingMode] = useState(false)
|
||||||
|
const tools = useTableTools({ id, lobby, mapState })
|
||||||
const up = useSocket(id, {
|
const up = useSocket(id, {
|
||||||
ping: (n) => sock.current.ping?.(n),
|
ping: (n) => sock.current.ping?.(n),
|
||||||
|
measure: (n) => sock.current.measure?.(n),
|
||||||
open: () => { sock.current.load?.(); sock.current.poll?.(); sock.current.loadMap?.() },
|
open: () => { sock.current.load?.(); sock.current.poll?.(); sock.current.loadMap?.() },
|
||||||
map: () => sock.current.loadMap?.(),
|
map: () => sock.current.loadMap?.(),
|
||||||
messages: () => sock.current.poll?.(),
|
messages: () => sock.current.poll?.(),
|
||||||
@@ -764,7 +767,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
setTimeout(() => setPings((l) => l.filter((p) => p.key !== key)), 3500)
|
setTimeout(() => setPings((l) => l.filter((p) => p.key !== key)), 3500)
|
||||||
playPing(sfx.current)
|
playPing(sfx.current)
|
||||||
}
|
}
|
||||||
sock.current = { load, poll: feed.poll, loadMap, ping: showPing }
|
sock.current = { load, poll: feed.poll, loadMap, ping: showPing, measure: tools.receive }
|
||||||
|
|
||||||
const act = async (url, method, body, done) => {
|
const act = async (url, method, body, done) => {
|
||||||
if (!url) return load()
|
if (!url) return load()
|
||||||
@@ -790,6 +793,8 @@ function LobbyView({ id, rules, user }) {
|
|||||||
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 [rv, setRv] = useState(null) // DM "show an area" tool: {origin, target, hover, fov, to}
|
||||||
|
useEffect(() => { if (rv || pingMode) tools.stop() }, [rv, pingMode]) // eslint-disable-line
|
||||||
|
useEffect(() => { if (tools.mode) { setRv(null); setPingMode(false) } }, [tools.mode])
|
||||||
const rvShape = (() => {
|
const rvShape = (() => {
|
||||||
if (!rv?.origin) return null
|
if (!rv?.origin) return null
|
||||||
const t = rv.target || rv.hover
|
const t = rv.target || rv.hover
|
||||||
@@ -917,11 +922,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) }}
|
||||||
tool={rv ? 'reveal' : pingMode ? 'ping' : undefined}
|
tool={rv ? 'reveal' : pingMode ? 'ping' : tools.tool}
|
||||||
onClick={rv ? (p) => setRv((s) => (s.origin && !s.target ? { ...s, target: p } : s.origin ? { ...s, target: p } : { ...s, origin: p })) : pingMode ? (p) => { setPingMode(false); send(`/api/lobbies/${id}/ping`, 'POST', { x: p.x, y: p.y }).catch(() => null) } : undefined}
|
onClick={rv ? (p) => setRv((s) => (s.origin && !s.target ? { ...s, target: p } : s.origin ? { ...s, target: p } : { ...s, origin: p })) : pingMode ? (p) => { setPingMode(false); send(`/api/lobbies/${id}/ping`, 'POST', { x: p.x, y: p.y }).catch(() => null) } : tools.active ? tools.onClick : undefined}
|
||||||
onHover={rv && rv.origin && !rv.target ? (p) => setRv((s) => (s ? { ...s, hover: p } : s)) : undefined}
|
onHover={rv && rv.origin && !rv.target ? (p) => setRv((s) => (s ? { ...s, hover: p } : s)) : tools.active ? tools.onHover : 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}
|
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} />}</>}>
|
{full && <RevealLayer map={mapState.map} zones={mapState.reveals} draft={rv && rvShape && rv.origin ? rvShape : null} />}{tools.overlay}</>}>
|
||||||
{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 && (
|
||||||
@@ -940,6 +945,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
{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 && <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>
|
||||||
|
{tools.buttons}
|
||||||
<button className={pingMode ? 'on' : 'ghost'} aria-pressed={pingMode} onClick={() => { setPingMode(!pingMode); setRv(null) }} title="Point at a spot for everybody: click here, then the map (or just double-click the map)">📍 Ping</button>
|
<button className={pingMode ? 'on' : 'ghost'} aria-pressed={pingMode} onClick={() => { setPingMode(!pingMode); setRv(null) }} title="Point at a spot for everybody: click here, then the map (or just double-click the map)">📍 Ping</button>
|
||||||
{lobby.is_dm && <button className="ghost" onClick={addToken}>+ Token</button>}
|
{lobby.is_dm && <button className="ghost" onClick={addToken}>+ Token</button>}
|
||||||
{lobby.is_dm && <button className={npcPick ? 'on' : 'ghost'} onClick={() => { setNpcPick(!npcPick); setPropPick(false) }}>+ NPC</button>}
|
{lobby.is_dm && <button className={npcPick ? 'on' : 'ghost'} onClick={() => { setNpcPick(!npcPick); setPropPick(false) }}>+ NPC</button>}
|
||||||
@@ -969,6 +975,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{tools.panel}
|
||||||
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>}
|
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>}
|
||||||
{propPick && <div className="mapui npcpickwrap"><PropPicker onPick={addPropToken} onClose={() => setPropPick(false)} /></div>}
|
{propPick && <div className="mapui npcpickwrap"><PropPicker onPick={addPropToken} onClose={() => setPropPick(false)} /></div>}
|
||||||
{selProp && <div className="tokenpanel mapui"><PropForm t={selProp} onChange={(p) => patchToken(selProp.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selProp.id}`, 'DELETE'); setMapSel(null) }} /></div>}
|
{selProp && <div className="tokenpanel mapui"><PropForm t={selProp} onChange={(p) => patchToken(selProp.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selProp.id}`, 'DELETE'); setMapSel(null) }} /></div>}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function MapView({ map, image, fog, portraitUrl, propSrc, pings, onPing,
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const down = (e) => {
|
const down = (e) => {
|
||||||
if (e.target.closest('.maptoken, .mapui, .mapdoor')) return
|
if (e.target.closest('.maptoken, .mapui, .mapdoor, .mappin')) return
|
||||||
box.current.setPointerCapture(e.pointerId)
|
box.current.setPointerCapture(e.pointerId)
|
||||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY })
|
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY })
|
||||||
if (pointers.current.size === 1) onSelect?.(null)
|
if (pointers.current.size === 1) onSelect?.(null)
|
||||||
@@ -114,7 +114,7 @@ export function MapView({ map, image, fog, portraitUrl, propSrc, pings, onPing,
|
|||||||
const d = (t) => t.size * map.grid
|
const d = (t) => t.size * map.grid
|
||||||
return (
|
return (
|
||||||
<div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}
|
<div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}
|
||||||
onDoubleClick={(e) => { if (onPing && !e.target.closest('.maptoken, .mapui, .mapdoor')) { const p = toWorld(e); if (p.inside) onPing(p.x, p.y) } }}>
|
onDoubleClick={(e) => { if (onPing && !e.target.closest('.maptoken, .mapui, .mapdoor, .mappin')) { const p = toWorld(e); if (p.inside) onPing(p.x, p.y) } }}>
|
||||||
<div className="mapworld" style={{ width: map.w, height: map.h, transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.s})` }}>
|
<div className="mapworld" style={{ width: map.w, height: map.h, transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.s})` }}>
|
||||||
{fog && <img className="mapfog" src={fog} width={map.w} height={map.h} alt="" draggable={false} />}
|
{fog && <img className="mapfog" src={fog} width={map.w} height={map.h} alt="" draggable={false} />}
|
||||||
{image && <img src={image.src} style={{ position: 'absolute', left: image.x, top: image.y }} width={image.w} height={image.h} alt="" draggable={false} />}
|
{image && <img src={image.src} style={{ position: 'absolute', left: image.x, top: image.y }} width={image.w} height={image.h} alt="" draggable={false} />}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// Table tools for everybody: tape measure, area templates (spells) and map markers with notes.
|
||||||
|
const J = { 'Content-Type': 'application/json' }
|
||||||
|
const post = (url, body) => fetch(url, { method: 'POST', headers: J, body: JSON.stringify(body) }).catch(() => null)
|
||||||
|
const del = (url) => fetch(url, { method: 'DELETE' }).catch(() => null)
|
||||||
|
const rad = (d) => (d * Math.PI) / 180
|
||||||
|
const half = (v) => Math.round(v * 2) / 2
|
||||||
|
const KINDS = [['circle', '◯ Circle'], ['cone', '◔ Cone'], ['line', '▭ Line'], ['square', '▢ Square']]
|
||||||
|
const ICONS = ['📌', '⚠️', '💰', '🔒', '🚪', '❓', '⭐', '☠️', '🏕️', '👁️']
|
||||||
|
|
||||||
|
// shape (in map pixels) of a template: {kind, x, y, dir, size (squares), width}
|
||||||
|
function Shape({ t, g, color, ghost }) {
|
||||||
|
const L = t.size * g
|
||||||
|
const common = { fill: color, fillOpacity: ghost ? 0.18 : 0.3, stroke: color, strokeWidth: 3, strokeDasharray: ghost ? '8 6' : undefined }
|
||||||
|
if (t.kind === 'circle') return <circle cx={t.x} cy={t.y} r={L} {...common} />
|
||||||
|
if (t.kind === 'cone') {
|
||||||
|
const a1 = rad(t.dir - t.width / 2), a2 = rad(t.dir + t.width / 2)
|
||||||
|
return <path d={`M${t.x} ${t.y} L${t.x + L * Math.cos(a1)} ${t.y + L * Math.sin(a1)} A${L} ${L} 0 0 1 ${t.x + L * Math.cos(a2)} ${t.y + L * Math.sin(a2)} Z`} {...common} />
|
||||||
|
}
|
||||||
|
if (t.kind === 'line') return <rect x={t.x} y={t.y - (t.width * g) / 2} width={L} height={t.width * g} transform={`rotate(${t.dir} ${t.x} ${t.y})`} {...common} />
|
||||||
|
return <rect x={t.x - L / 2} y={t.y - L / 2} width={L} height={L} transform={`rotate(${t.dir} ${t.x} ${t.y})`} {...common} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// where the label of a shape goes (its far end)
|
||||||
|
const tip = (t, g) => {
|
||||||
|
const L = t.size * g
|
||||||
|
if (t.kind === 'circle' || t.kind === 'square') return { x: t.x, y: t.y }
|
||||||
|
return { x: t.x + (L / 2) * Math.cos(rad(t.dir)), y: t.y + (L / 2) * Math.sin(rad(t.dir)) }
|
||||||
|
}
|
||||||
|
const sizeLabel = (t) => (t.kind === 'circle' ? `r ${t.size}` : t.kind === 'square' ? `${t.size}×${t.size}` : `${t.size}`) + ' sq'
|
||||||
|
|
||||||
|
export function useTableTools({ id, lobby: lobbyIn, mapState }) {
|
||||||
|
const lobby = lobbyIn || { members: [], palette: [], dm_color: '#d4af37' }
|
||||||
|
const [mode, setMode] = useState(null) // 'ruler' | 'tpl' | 'mark'
|
||||||
|
const [kind, setKind] = useState('cone')
|
||||||
|
const [angle, setAngle] = useState(60)
|
||||||
|
const [lineW, setLineW] = useState(1)
|
||||||
|
const [origin, setOrigin] = useState(null)
|
||||||
|
const [hover, setHover] = useState(null)
|
||||||
|
const [draft, setDraft] = useState(null) // new marker being written
|
||||||
|
const [selMark, setSelMark] = useState(null)
|
||||||
|
const [measures, setMeasures] = useState([])
|
||||||
|
const map = mapState.map
|
||||||
|
const g = map?.grid || 50
|
||||||
|
const colorOf = (user, dm) => (dm ? lobby.dm_color : lobby.palette.find((p) => p.id === lobby.members.find((m) => m.username === user)?.color)?.hex || '#ffffff')
|
||||||
|
const me = lobby.members.find((m) => m.me)
|
||||||
|
const myColor = lobby.is_dm ? lobby.dm_color : lobby.palette.find((p) => p.id === me?.color)?.hex || '#ffffff'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const k = (e) => { if (e.key === 'Escape') { setOrigin(null); setDraft(null); setMode(null) } }
|
||||||
|
window.addEventListener('keydown', k)
|
||||||
|
return () => window.removeEventListener('keydown', k)
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { setOrigin(null); setHover(null); if (mode !== 'mark') setDraft(null) }, [mode, kind])
|
||||||
|
|
||||||
|
const shapeFrom = (p) => {
|
||||||
|
const dx = p.x - origin.x, dy = p.y - origin.y
|
||||||
|
const dist = Math.hypot(dx, dy) / g
|
||||||
|
const size = Math.max(0.5, half(kind === 'square' ? dist * 2 : dist))
|
||||||
|
return { kind, x: origin.x, y: origin.y, dir: (Math.atan2(dy, dx) * 180) / Math.PI, size, width: kind === 'cone' ? angle : kind === 'line' ? lineW : 1 }
|
||||||
|
}
|
||||||
|
const onClick = (raw) => {
|
||||||
|
const p = { x: Math.min(Math.max(raw.x, 0), map.w), y: Math.min(Math.max(raw.y, 0), map.h) } // clicks next to the map count as its edge
|
||||||
|
if (mode === 'ruler') {
|
||||||
|
if (!origin) return setOrigin(p)
|
||||||
|
post(`/api/lobbies/${id}/measure`, { x1: origin.x, y1: origin.y, x2: p.x, y2: p.y })
|
||||||
|
setOrigin(null)
|
||||||
|
} else if (mode === 'tpl') {
|
||||||
|
if (!origin) return setOrigin(p)
|
||||||
|
post(`/api/lobbies/${id}/templates`, shapeFrom(p))
|
||||||
|
setOrigin(null); setHover(null)
|
||||||
|
} else if (mode === 'mark') {
|
||||||
|
setSelMark(null)
|
||||||
|
setDraft({ x: p.x, y: p.y, icon: '📌', title: '', note: '', shared: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onHover = origin ? (p) => setHover({ x: Math.min(Math.max(p.x, 0), map.w), y: Math.min(Math.max(p.y, 0), map.h) }) : undefined
|
||||||
|
|
||||||
|
const receive = (n) => { // a measurement somebody shared
|
||||||
|
const key = `${Date.now()}${Math.random()}`
|
||||||
|
setMeasures((l) => [...l.slice(-4), { key, ...n, color: colorOf(n.user, n.dm) }])
|
||||||
|
setTimeout(() => setMeasures((l) => l.filter((x) => x.key !== key)), 6000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = mapState.templates || []
|
||||||
|
const marks = mapState.marks || []
|
||||||
|
const dist = (a, b) => half(Math.hypot(b.x - a.x, b.y - a.y) / g)
|
||||||
|
const overlay = !map ? null : (
|
||||||
|
<>
|
||||||
|
<svg className="toollayer" width={map.w} height={map.h} aria-hidden="true">
|
||||||
|
{templates.map((t) => {
|
||||||
|
const c = colorOf(t.user, t.dm), e = tip(t, g)
|
||||||
|
return <g key={t.id}><Shape t={t} g={g} color={c} /><circle cx={t.x} cy={t.y} r="5" fill={c} />
|
||||||
|
<text x={e.x} y={e.y} className="toolabel" fontSize={Math.max(12, g * 0.32)} textAnchor="middle">{sizeLabel(t)}</text></g>
|
||||||
|
})}
|
||||||
|
{mode === 'tpl' && origin && hover && (() => { const t = shapeFrom(hover); const e = tip(t, g)
|
||||||
|
return <g><Shape t={t} g={g} color={myColor} ghost /><text x={e.x} y={e.y} className="toolabel" fontSize={Math.max(12, g * 0.32)} textAnchor="middle">{sizeLabel(t)}</text></g> })()}
|
||||||
|
{mode === 'ruler' && origin && hover && <Line a={origin} b={hover} g={g} color={myColor} label={`${dist(origin, hover)} sq`} />}
|
||||||
|
{measures.map((m) => <Line key={m.key} a={{ x: m.x1, y: m.y1 }} b={{ x: m.x2, y: m.y2 }} g={g} color={m.color} label={`${dist({ x: m.x1, y: m.y1 }, { x: m.x2, y: m.y2 })} sq${m.dm ? '' : ` · ${m.user}`}`} />)}
|
||||||
|
</svg>
|
||||||
|
{marks.map((k) => (
|
||||||
|
<button key={k.id} className={`mappin ${selMark === k.id ? 'sel' : ''} ${k.shared ? 'shared' : ''}`} style={{ left: k.x, top: k.y, fontSize: Math.max(18, g * 0.7) }}
|
||||||
|
title={`${k.title}${k.note ? `\n${k.note}` : ''}${k.mine ? '' : `\n(${k.user})`}`}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); setDraft(null); setSelMark(selMark === k.id ? null : k.id) }}>{k.icon}</button>
|
||||||
|
))}
|
||||||
|
{draft && <span className="mappin draft" style={{ left: draft.x, top: draft.y, fontSize: Math.max(18, g * 0.7) }}>{draft.icon}</span>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const buttons = (
|
||||||
|
<>
|
||||||
|
<button className={mode === 'ruler' ? 'on' : 'ghost'} aria-pressed={mode === 'ruler'} onClick={() => setMode(mode === 'ruler' ? null : 'ruler')} title="Tape measure: click the start, then the end. Everybody sees the distance for a moment.">📏 Measure</button>
|
||||||
|
<button className={mode === 'tpl' ? 'on' : 'ghost'} aria-pressed={mode === 'tpl'} onClick={() => setMode(mode === 'tpl' ? null : 'tpl')} title="Area templates for spells: circle, cone, line, square">⬡ Area</button>
|
||||||
|
<button className={mode === 'mark' ? 'on' : 'ghost'} aria-pressed={mode === 'mark'} onClick={() => setMode(mode === 'mark' ? null : 'mark')} title="Place a marker with a note">📌 Marker</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const cur = marks.find((k) => k.id === selMark)
|
||||||
|
const panel = !map ? null : (
|
||||||
|
<>
|
||||||
|
{mode === 'ruler' && <div className="tokenpanel toolspanel mapui"><b className="small">Tape measure</b><p className="small muted">{origin ? 'Click the end point. Esc cancels.' : 'Click the start point.'} 1 square = 1 grid cell. The result is shown to everybody for a few seconds.</p></div>}
|
||||||
|
{mode === 'tpl' && (
|
||||||
|
<div className="tokenpanel toolspanel mapui">
|
||||||
|
<b className="small">Area template</b>
|
||||||
|
<div className="toolrow" role="group" aria-label="Shape">{KINDS.map(([k, l]) => <button key={k} className={kind === k ? 'on mini' : 'ghost mini'} onClick={() => setKind(k)}>{l}</button>)}</div>
|
||||||
|
{kind === 'cone' && <label className="small">Angle {angle}°<input type="range" min="30" max="180" step="10" value={angle} onChange={(e) => setAngle(Number(e.target.value))} /></label>}
|
||||||
|
{kind === 'line' && <label className="small">Width {lineW} sq<input type="range" min="1" max="6" step="1" value={lineW} onChange={(e) => setLineW(Number(e.target.value))} /></label>}
|
||||||
|
<p className="small muted">{!origin ? (kind === 'cone' || kind === 'line' ? 'Click where it starts.' : 'Click the centre.') : 'Click to set direction and size.'} It stays for everybody until removed.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(mode === 'tpl' || templates.length > 0) && templates.length > 0 && (
|
||||||
|
<div className={`tokenpanel toolspanel tpllist mapui ${mode ? 'low' : ''}`}>
|
||||||
|
<b className="small">Areas on the map</b>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<div key={t.id} className="row small"><i className="dot" style={{ background: colorOf(t.user, t.dm) }} /> {KINDS.find((k) => k[0] === t.kind)?.[1]} {sizeLabel(t)} · {t.dm ? 'DM' : t.user}
|
||||||
|
{(t.mine || lobby.is_dm) && <button className="ghost mini danger" aria-label="Remove area" onClick={() => del(`/api/lobbies/${id}/templates/${t.id}`)}>✕</button>}</div>
|
||||||
|
))}
|
||||||
|
{lobby.is_dm && templates.length > 1 && <button className="ghost mini" onClick={() => del(`/api/lobbies/${id}/templates`)}>Remove all</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === 'mark' && !draft && <div className="tokenpanel toolspanel mapui"><b className="small">Marker</b><p className="small muted">Click the map to place a marker with a note. Markers are private unless you share them with the table.</p></div>}
|
||||||
|
{draft && <MarkForm key="new" init={draft} title="New marker" onSave={async (v) => { await post(`/api/lobbies/${id}/marks`, { ...draft, ...v }); setDraft(null); setMode(null) }} onCancel={() => setDraft(null)} />}
|
||||||
|
{cur && !draft && (cur.mine
|
||||||
|
? <MarkForm key={cur.id} init={cur} title="Your marker" onSave={async (v) => { await fetch(`/api/lobbies/${id}/marks/${cur.id}`, { method: 'PUT', headers: J, body: JSON.stringify(v) }).catch(() => null); setSelMark(null) }}
|
||||||
|
onCancel={() => setSelMark(null)} onDelete={() => { del(`/api/lobbies/${id}/marks/${cur.id}`); setSelMark(null) }} />
|
||||||
|
: <div className="tokenpanel toolspanel mapui"><div className="row"><b>{cur.icon} {cur.title}</b><button className="ghost mini" onClick={() => setSelMark(null)} aria-label="Close">×</button></div>
|
||||||
|
<p className="small notetext">{cur.note || <span className="muted">No note.</span>}</p><p className="small muted">Shared by {cur.user}</p>
|
||||||
|
{lobby.is_dm && <button className="ghost mini danger" onClick={() => { del(`/api/lobbies/${id}/marks/${cur.id}`); setSelMark(null) }}>Remove</button>}</div>)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return { mode, active: !!mode, tool: mode ? 'draw' : undefined, onClick, onHover, overlay, buttons, panel, receive, stop: () => setMode(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Line({ a, b, g, color, label }) {
|
||||||
|
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke="#000" strokeWidth="6" strokeLinecap="round" opacity=".5" />
|
||||||
|
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={color} strokeWidth="3" strokeLinecap="round" strokeDasharray="10 7" />
|
||||||
|
<circle cx={a.x} cy={a.y} r="6" fill={color} /><circle cx={b.x} cy={b.y} r="6" fill={color} />
|
||||||
|
<text x={mx} y={my - 8} className="toolabel" fontSize={Math.max(13, g * 0.34)} textAnchor="middle">{label}</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkForm({ init, title, onSave, onCancel, onDelete }) {
|
||||||
|
const [v, setV] = useState({ icon: init.icon, title: init.title, note: init.note, shared: !!init.shared })
|
||||||
|
const busy = useRef(false)
|
||||||
|
const save = async () => { if (busy.current) return; busy.current = true; await onSave(v) }
|
||||||
|
return (
|
||||||
|
<div className="tokenpanel toolspanel mapui">
|
||||||
|
<div className="row"><b className="small">{title}</b><button className="ghost mini" onClick={onCancel} aria-label="Close">×</button></div>
|
||||||
|
<div className="iconrow" role="group" aria-label="Icon">{ICONS.map((i) => <button key={i} className={v.icon === i ? 'on mini' : 'ghost mini'} onClick={() => setV({ ...v, icon: i })}>{i}</button>)}</div>
|
||||||
|
<input value={v.title} maxLength={40} placeholder="Title" aria-label="Marker title" onChange={(e) => setV({ ...v, title: e.target.value })} autoFocus />
|
||||||
|
<textarea rows="4" maxLength={1000} placeholder="Your note ..." aria-label="Marker note" value={v.note} onChange={(e) => setV({ ...v, note: e.target.value })} />
|
||||||
|
<label className="small chk"><input type="checkbox" checked={v.shared} onChange={(e) => setV({ ...v, shared: e.target.checked })} /> Show to everybody at the table</label>
|
||||||
|
<div className="row"><button className="on" onClick={save}>Save</button>{onDelete && <button className="ghost danger" onClick={onDelete}>Delete</button>}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -652,3 +652,22 @@ textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
|
|||||||
@keyframes pingring { 0% { transform: scale(.1); opacity: .95; } 100% { transform: scale(1.4); opacity: 0; } }
|
@keyframes pingring { 0% { transform: scale(.1); opacity: .95; } 100% { transform: scale(1.4); opacity: 0; } }
|
||||||
@keyframes pingname { 0%, 80% { opacity: 1; } 100% { opacity: 0; } }
|
@keyframes pingname { 0%, 80% { opacity: 1; } 100% { opacity: 0; } }
|
||||||
@media (prefers-reduced-motion: reduce) { .mapping i { animation-duration: 3s; animation-iteration-count: 1; } }
|
@media (prefers-reduced-motion: reduce) { .mapping i { animation-duration: 3s; animation-iteration-count: 1; } }
|
||||||
|
/* table tools: measure, areas, markers */
|
||||||
|
.toollayer { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; }
|
||||||
|
.toolabel { fill: #fff; stroke: #000; stroke-width: 3px; paint-order: stroke; font-weight: 700; }
|
||||||
|
.mappin { position: absolute; transform: translate(-50%, -92%); padding: 0; margin: 0; min-height: 0; background: none; border: 0; line-height: 1; cursor: pointer; filter: drop-shadow(0 2px 2px rgba(0,0,0,.8)); z-index: 4; }
|
||||||
|
.mappin.shared { filter: drop-shadow(0 0 4px #fff) drop-shadow(0 2px 2px rgba(0,0,0,.8)); }
|
||||||
|
.mappin.sel { outline: 2px solid var(--accent, gold); border-radius: 6px; }
|
||||||
|
.mappin.draft { opacity: .6; pointer-events: none; }
|
||||||
|
.toolspanel { right: auto; left: .5rem; top: .5rem; bottom: auto; display: grid; gap: .4rem; max-width: 18rem; }
|
||||||
|
.toolspanel.tpllist { top: .5rem; left: .5rem; }
|
||||||
|
.toolspanel.tpllist.low { left: 15.8rem; }
|
||||||
|
.toolspanel textarea, .toolspanel input:not([type=range]):not([type=checkbox]) { width: 100%; font: inherit; }
|
||||||
|
.toolspanel .toolrow, .toolspanel .iconrow { display: flex; flex-wrap: wrap; gap: .25rem; }
|
||||||
|
.toolspanel .dot { display: inline-block; width: .7rem; height: .7rem; border-radius: 50%; }
|
||||||
|
.toolspanel .notetext { white-space: pre-wrap; margin: 0; }
|
||||||
|
.toolspanel { padding: .45rem .55rem; max-width: 15rem; gap: .3rem; font-size: .8rem; }
|
||||||
|
.toolspanel button { padding: .15rem .5rem; font-size: .78rem; min-height: 0; width: auto; }
|
||||||
|
.toolspanel p { margin: 0; }
|
||||||
|
.toolspanel input[type=range] { width: 100%; }
|
||||||
|
.toolspanel input:not([type=range]):not([type=checkbox]), .toolspanel textarea { padding: .25rem .4rem; font-size: .82rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user