Compare commits

...
16 Commits
Author SHA1 Message Date
ottoandClaude Sonnet 5.5 b31936235d 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
2026-10-25 15:43:26 +00:00
ottoandClaude Sonnet 5.5 0f97129243 Map ping with sound (custom MP3 per lobby)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-25 09:18:46 +00:00
ottoandClaude Sonnet 5.5 2087ce9960 Fog: current view is transparent outside its shape so explored ground stays visible
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 20:38:04 +00:00
ottoandClaude Sonnet 5.5 1405381096 Prop library page, DM notes on tokens and props, fog/sight without walls, finer explored mask
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 20:23:23 +00:00
ottoandClaude Sonnet 5.5 6880b58de2 Props added in the lobby start hidden
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 20:15:28 +00:00
ottoandClaude Sonnet 5.5 779978ad02 Lower, readable table buttons; keep DM buttons in player view
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 19:50:56 +00:00
ottoandClaude Sonnet 5.5 c1fdb7cc76 Scenes (switch maps without losing state), decor props with rotation, editor visibility toggle
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 19:37:55 +00:00
Claude 928e018fcd Fix map editor crash, readable radial menu, new lobby tokens start hidden
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 19:09:15 +00:00
Claude ab3c35aab7 Sight groups, DM reveal areas (cones), client-side image shrink for map upload
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 18:33:08 +00:00
Claude 6aac03f112 Token HP and conditions with DM visibility toggles, fog of war, live sight range
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 18:21:39 +00:00
Claude a2103566a0 Sheet: large portrait slot, writable notes in summary and lobby
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 18:10:12 +00:00
Claude 7940fcfa3f Compact lobby top bar, scrollable initiative cards, hidden NPC names, resizable panels
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 17:05:38 +00:00
Claude 72d8113ece Initiative tracker, radial token menu, slimmer lobby header
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 15:55:23 +00:00
Claude f6448aff85 Lobby: online presence indicator
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-24 14:36:25 +00:00
Claude 1db8a45cae NPC editor, NPC tokens from library, admin management
- NPC library (own + shared) built on the character wizard with a free NPC preset
- NPC tokens in map editor and lobby, DM sheet on token click, DM checks for NPCs
- Admins can promote/demote other users

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 21:36:09 +00:00
Claude 57f262b6aa Lobby: live refresh after saving a character, slimmer player bar
A saved character now refreshes the player bar and the map tokens right
away (new portrait or name). The player bar shows only the character name.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 20:50:55 +00:00
22 changed files with 2374 additions and 203 deletions
+61 -2
View File
@@ -90,9 +90,11 @@ The Summary page and the sheet tabs in the lobbies show the character as **A4 pa
On the **Concept** page *Add picture* takes a PNG, JPEG or WebP: move and zoom it inside the square window, *Use this* stores a small 256 px picture inside the character (so it is saved, shared, exported as JSON and loaded with it). The server checks and re-encodes it. The picture appears in the header of the character sheet (also when printing), as icon in the player bar of the lobby and as the token of the character on the map (in the player's colour ring). Only members of the lobby get the picture through `/api/lobbies/{id}/portrait/{username}`. The PDF / Word export from the server does not contain it yet. On the **Concept** page *Add picture* takes a PNG, JPEG or WebP: move and zoom it inside the square window, *Use this* stores a small 256 px picture inside the character (so it is saved, shared, exported as JSON and loaded with it). The server checks and re-encodes it. The picture appears in the header of the character sheet (also when printing), as icon in the player bar of the lobby and as the token of the character on the map (in the player's colour ring). Only members of the lobby get the picture through `/api/lobbies/{id}/portrait/{username}`. The PDF / Word export from the server does not contain it yet.
The portrait sits next to the name and identity fields on page 1 of the sheet. The two ruled note areas ("Conditions & notes" on page 1, "Notes" on page 2) can be typed into on the Summary page and, for your own character, in the lobby (saved automatically through `PUT /api/characters/{id}/notes`, which only touches those two fields). They are included in print, PDF and Word.
### Rules editor ### Rules editor
Admins get an **Editor** tab to add, change and delete **items, item categories, skills, skill groups and professions** with forms. Admins are the usernames in `AW_ADMINS` (comma separated, set in `.env`); without that setting only the first account ever created is admin. Admins get an **Editor** tab to add, change and delete **items, item categories, skills, skill groups and professions** with forms. Admins are the usernames in `AW_ADMINS` (comma separated, set in `.env`); without that setting only the first account ever created is admin. Admins can also promote other users: the **Users and admins** card above the tabs lists all accounts with *Make admin* / *Remove admin*. Names in `AW_ADMINS` stay admin and can only be changed in `.env`; the last admin cannot be removed.
- Changes are saved in `db/rules_overrides.json` (next to the database, so it is part of your backup). For each edited list it **replaces** the list from `rules.json`; the shipped file is not touched. A previous copy is kept as `rules_overrides.json.bak`. "Reset to shipped" removes the override of one list. - Changes are saved in `db/rules_overrides.json` (next to the database, so it is part of your backup). For each edited list it **replaces** the list from `rules.json`; the shipped file is not touched. A previous copy is kept as `rules_overrides.json.bak`. "Reset to shipped" removes the override of one list.
- New entries get their id from the name (lowercase, `_`). Ids of existing entries cannot be changed, because characters refer to them. - New entries get their id from the name (lowercase, `_`). Ids of existing entries cannot be changed, because characters refer to them.
@@ -116,6 +118,35 @@ The lobby page is built for desktop with three columns: the **character sheet**
- **Colours**: every player has a different colour (lobby menu -> Your colour); the DM is gold. - **Colours**: every player has a different colour (lobby menu -> Your colour); the DM is gold.
- **Lobby menu** (top right): join code, rename, remove players, colour, switch character, leave or delete the lobby. - **Lobby menu** (top right): join code, rename, remove players, colour, switch character, leave or delete the lobby.
### NPCs, enemies and creatures
The **NPCs** tab is a library of rules-based NPCs. It uses the same wizard as the character creator (attributes, skills, talents, mutations, equipment, portrait), with a free "NPC / creature" preset that has large pools and no profession requirement, plus a type (NPC, enemy, monster, creature, animal, robot), a default token size, a description, special abilities and attacks, and loot.
- NPCs are stored like characters (flag `npc`) and are listed separately; they can be duplicated and shared with other DMs (read-only).
- In the map editor and at the table, **+ From NPC library** / **+ NPC** places a token that takes the NPC's name, size and portrait. Plain **+ Token** still works.
- The DM clicks an NPC token to see its sheet in the left column and can click attributes and skills to roll checks for it (shown in the chat as "NPC: Skill"). Players never see the NPC link or sheet.
Players and the DM show a green dot in the lobby while they have it open (online presence over the WebSocket; without WebSockets everyone appears offline).
### Initiative and fights
The DM presses **⚔ Start fight** in the lobby. Every player with a character joins automatically and rolls initiative (1d20 + the character's Initiative value, as in the rules). **Right click** a token (DM, full view) for a radial menu: add it to the fight, open its sheet, hide/reveal or remove an NPC token. Players who join later can be added with the **+ name** buttons in the initiative bar.
The bar shows the order, the round and whose turn it is (the token pulses on the map). The DM can overwrite every number, reroll, remove entries, step **◀ / Next turn ▶** and **End fight**. Hidden NPCs are not shown to players. Fight start, joins and the end are logged in the chat feed.
The top of the lobby is one compact row: small buttons on the left (start fight, previous/next turn, end fight, ▾ to expand players and difficulty), the player bar – replaced by a scrollable initiative bar during a fight – and the lobby settings button. Players only see the name of whoever's turn it is; NPC names are not sent to players otherwise, and token names show on hover, selection or turn. The sheet and the chat can be resized by dragging the thin lines next to them (remembered per browser).
### HP, conditions and fog of war
- **HP and conditions:** select a token (DM, full view) to edit its HP (−5/−1/+1/+5, max) and conditions from the rules (click raises the level 1 → 3 → off). NPC tokens from the library start with the NPC's Health value. Right click the token and use the **HP** and **Cond.** toggles of the radial menu to decide when players may see them; by default players see their own and other players' values but not NPCs'.
- **Fog of war:** with line of sight on, ground a player has already seen stays visible as a dimmed memory (**Fog** button; **↺** forgets it for everybody). The memory is a small mask per player and lobby, the dim picture is made on the server, so players never get pixels they have not seen.
- **Sight range:** the map's value is the default (map editor); the number next to the Sight button changes it live for the lobby (reset when you switch maps).
### Sight groups and revealing far away areas
- **Sight groups** (Lobby actions, DM): players in the same group (A–D) share what they see; "Everyone shares sight" / "Everyone alone" are shortcuts. Tokens and ground seen by one member are visible to the whole group.
- **👁 Reveal** (DM, line of sight on): click the spot the view starts from, then click where it ends (distance = range, direction = where it looks), pick the cone width and who sees it (everyone or chosen players), then **Reveal**. Walls block it like any other view. The area stays visible until you press **Hide**; afterwards it remains as dimmed memory (fog of war). Everything is computed on the server, so players only receive the pixels you showed them.
### Maps, tokens and line of sight ### Maps, 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.
@@ -126,6 +157,21 @@ The lobby page is built for desktop with three columns: the **character sheet**
Players only see a short card of the other players, never their sheets. Guessing join codes is rate limited. Deleting a character takes it out of the lobbies (the player can pick another); deleting a lobby does not delete characters. Players only see a short card of the other players, never their sheets. Guessing join codes is rate limited. Deleting a character takes it out of the lobbies (the player can pick another); deleting a lobby does not delete characters.
### Scenes and props
- **Scenes**: every map you put on the table becomes a *scene* of the lobby. Switching to another map (or back, in *Lobby actions → Scenes*) first saves the current one - token positions, HP and conditions, doors, explored ground, revealed areas, sight settings and the running fight - and restores the other exactly as you left it. State also survives pauses, since the table lives in the database. Per lobby up to 12 scenes; rename, delete, *Save now* and *Save a copy of the current table* (checkpoint) are available. Only one scene runs at a time (no simultaneous split party).
- **Edit live**: the map editor can be opened for the running map. Walls, grid and sight range apply to the game at once; the prepared tokens are only used the next time the map is put on the table.
- **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.
- **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.
- **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.
@@ -135,7 +181,11 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
| GET | `/api/auth/me` | Current user (or `null`) | | GET | `/api/auth/me` | Current user (or `null`) |
| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts | | POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts |
| GET | `/api/rules` | Rule data including presets | | GET | `/api/rules` | Rule data including presets |
| GET / POST | `/api/characters` | Own and shared-with-me characters / create one | | GET / POST | `/api/characters[?npc=1]` | Own and shared-with-me characters (or the NPC library) / create one |
| GET | `/api/characters/{id}/portrait` | Portrait image (readers of the character) |
| POST / PUT / DELETE | `/api/lobbies/{id}/combat` | Start a fight / add, remove, set, reroll, next, prev / end (DM) |
| GET | `/api/lobbies/{id}/token-portrait/{token}` | Portrait of an NPC token (lobby members) |
| GET / PUT | `/api/admin/users[/{username}]` | List accounts / make or remove an admin (admins) |
| GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) | | GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) |
| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) | | POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) |
| PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) | | PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) |
@@ -151,6 +201,15 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
| GET / PUT | `/api/lobbies/{id}/map` | Map and tokens as the viewer may see them (`?as=<player>` for the DM) / DM: show map (`map_id`, null clears) | | GET / PUT | `/api/lobbies/{id}/map` | Map and tokens as the viewer may see them (`?as=<player>` for the DM) / DM: show map (`map_id`, null clears) |
| GET | `/api/lobbies/{id}/map/view` | The map image for this viewer, cut to their line of sight (`X-Crop` = x,y,w,h) | | GET | `/api/lobbies/{id}/map/view` | The map image for this viewer, cut to their line of sight (`X-Crop` = x,y,w,h) |
| 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`) |
| 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 |
| 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 | `/api/props/{id}/image`, `/api/lobbies/{id}/prop-image/{pid}` | Prop image (owner / lobby members, DM's props) |
| POST / PUT / DELETE | `/api/lobbies/{id}/tokens[/{tid}]` | Add NPC token (DM) / move (own token or DM) and edit (DM) / remove (DM) | | POST / PUT / DELETE | `/api/lobbies/{id}/tokens[/{tid}]` | Add NPC token (DM) / move (own token or DM) and edit (DM) / remove (DM) |
| WebSocket | `/api/lobbies/{id}/ws` | Live notes `{t: messages / lobby / sheet / map}` for lobby members (cookie login, same origin) | | WebSocket | `/api/lobbies/{id}/ws` | Live notes `{t: messages / lobby / sheet / map}` for lobby members (cookie login, same origin) |
| PUT | `/api/lobbies/{id}/sounds` | DM: switch result / critical sounds on or off | | PUT | `/api/lobbies/{id}/sounds` | DM: switch result / critical sounds on or off |
+172
View File
@@ -0,0 +1,172 @@
"""Initiative tracker. One fight per lobby, stored as JSON on the lobby row.
{"round": 1, "current": "<entry id>", "entries": [{"id", "kind": "player"|"npc", "user": username, "token": token id,
"name", "init": number, "base": initiative modifier, "roll": d20}]}
The DM starts and ends the fight; players are added automatically at the start. Initiative is 1d20 + the character's
Initiative value (Agility Mod + Perception Mod), the DM can overwrite every number."""
import json
import secrets
import uuid
from fastapi import Body, Depends, HTTPException
from .derive import compute
MAX_ENTRIES = 60
def schema(con):
if "combat" not in [r["name"] for r in con.execute("PRAGMA table_info(lobbies)")]:
con.execute("ALTER TABLE lobbies ADD COLUMN combat TEXT")
def _sorted(c: dict):
c["entries"].sort(key=lambda e: (-(e["init"] if e["init"] is not None else -999), e.get("seq", 0)))
def install(api, db, current_user, lobby_for, dm_only, hub, event, load_rules):
def base_of(data, rules) -> int:
try:
return int(compute(json.loads(data), rules)["derived"].get("initiative", 0))
except Exception:
return 0
def new_entry(c, kind, name, base, **kw):
roll = secrets.randbelow(20) + 1
c["seq"] = c.get("seq", 0) + 1
return {"id": uuid.uuid4().hex[:10], "kind": kind, "name": name, "base": base, "roll": roll, "init": roll + base, "seq": c["seq"], **kw}
def load(lobby):
return json.loads(lobby["combat"]) if lobby["combat"] else None
def save(con, lid, c, log=None, uid=None):
con.execute("UPDATE lobbies SET combat=? WHERE id=?", (json.dumps(c) if c else None, lid))
if log:
event(con, lid, log, uid)
hub.notify(lid, "lobby")
def view(con, lobby, is_dm):
"""What the viewer gets: hidden NPCs are left out for players; NPCs whose token is gone disappear."""
c = load(lobby)
if not c:
return None
toks = {r["id"]: r["hidden"] for r in con.execute("SELECT id, hidden FROM lobby_tokens WHERE lobby_id=?", (lobby["id"],))}
out = []
for e in c["entries"]:
if e["kind"] == "npc":
if e["token"] not in toks:
continue
if toks[e["token"]] and not is_dm:
continue
if e["kind"] == "npc" and not is_dm and e["id"] != c.get("current"):
e = {**e, "name": ""} # players only learn an NPC's name when it is its turn
out.append({k: e[k] for k in ("id", "kind", "name", "init", "base", "roll") if k in e} | {k: e[k] for k in ("user", "token") if k in e})
cur = c.get("current")
return {"round": c["round"], "current": cur if any(e["id"] == cur for e in out) else None, "entries": out}
@api.post("/lobbies/{lid}/combat")
def start(lid: str, user: dict = Depends(current_user)):
rules = load_rules()
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
c = {"round": 1, "current": None, "seq": 0, "entries": []}
for r in con.execute("""SELECT u.username, ch.data FROM lobby_members m JOIN users u ON u.id = m.user_id
JOIN characters ch ON ch.id = m.character_id WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)):
nm = json.loads(r["data"]).get("name") or r["username"]
c["entries"].append(new_entry(c, "player", nm, base_of(r["data"], rules), user=r["username"]))
_sorted(c)
c["current"] = c["entries"][0]["id"] if c["entries"] else None
save(con, lid, c, "⚔ A fight begins. Initiative: " + ", ".join(f"{e['name']} {e['init']}" for e in c["entries"]), user["id"])
return {"ok": True}
@api.put("/lobbies/{lid}/combat")
def change(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
rules = load_rules()
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
c = load(lobby)
if not c:
raise HTTPException(409, "There is no fight")
op, eid = body.get("op"), str(body.get("id") or "")
ents = c["entries"]
ent = next((e for e in ents if e["id"] == eid), None)
log = None
if op == "add":
if len(ents) >= MAX_ENTRIES:
raise HTTPException(409, "Too many entries")
if body.get("token"):
t = con.execute("""SELECT t.id, t.name, t.user_id, n.data FROM lobby_tokens t LEFT JOIN characters n ON n.id = t.npc_id
WHERE t.id=? AND t.lobby_id=?""", (str(body["token"]), lid)).fetchone()
if not t or t["user_id"]:
raise HTTPException(404, "No NPC token")
if any(e.get("token") == t["id"] for e in ents):
raise HTTPException(409, "Already in the fight")
e = new_entry(c, "npc", t["name"], base_of(t["data"], rules) if t["data"] else 0, token=t["id"])
elif body.get("user"):
r = con.execute("""SELECT u.username, ch.data FROM lobby_members m JOIN users u ON u.id = m.user_id
JOIN characters ch ON ch.id = m.character_id WHERE m.lobby_id=? AND u.username=?""", (lid, str(body["user"]))).fetchone()
if not r:
raise HTTPException(404, "No such player with a character")
if any(e.get("user") == r["username"] for e in ents):
raise HTTPException(409, "Already in the fight")
e = new_entry(c, "player", json.loads(r["data"]).get("name") or r["username"], base_of(r["data"], rules), user=r["username"])
else:
raise HTTPException(422, "Say which token or player to add")
ents.append(e)
log = f"{e['name']} joins the fight with initiative {e['init']}"
elif op == "remove":
if not ent:
raise HTTPException(404, "No such entry")
if c["current"] == eid:
i = ents.index(ent)
nxt = ents[(i + 1) % len(ents)] if len(ents) > 1 else None
c["current"] = nxt["id"] if nxt else None
ents.remove(ent)
elif op == "init":
if not ent:
raise HTTPException(404, "No such entry")
try:
ent["init"] = max(-99, min(999, int(body.get("value"))))
except (TypeError, ValueError):
raise HTTPException(422, "Initiative must be a whole number")
elif op == "reroll":
if not ent:
raise HTTPException(404, "No such entry")
ent["roll"] = secrets.randbelow(20) + 1
ent["init"] = ent["roll"] + ent["base"]
log = f"{ent['name']} rerolls initiative: {ent['init']}"
elif op in ("next", "prev"):
_sorted(c)
if ents:
i = next((k for k, e in enumerate(ents) if e["id"] == c.get("current")), -1)
if op == "next":
if i + 1 >= len(ents):
c["round"] += 1
i = (i + 1) % len(ents)
else:
if i <= 0:
c["round"] = max(1, c["round"] - 1)
i = (i - 1) % len(ents)
c["current"] = ents[i]["id"]
else:
raise HTTPException(422, "Unknown action")
cur = c.get("current")
_sorted(c)
if cur and not any(e["id"] == cur for e in c["entries"]):
c["current"] = c["entries"][0]["id"] if c["entries"] else None
save(con, lid, c, log, user["id"])
return {"ok": True}
@api.delete("/lobbies/{lid}/combat")
def end(lid: str, user: dict = Depends(current_user)):
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
c = load(lobby)
if c:
save(con, lid, None, f"The fight is over after {c['round']} round{'s' if c['round'] != 1 else ''}.", user["id"])
return {"ok": True}
return view
+2 -2
View File
@@ -99,7 +99,7 @@ def to_docx(ch: dict, rules: dict) -> bytes:
p = doc.add_paragraph() p = doc.add_paragraph()
p.add_run("Other: ").bold = True p.add_run("Other: ").bold = True
p.add_run(s["other"]) p.add_run(s["other"])
for key, head in (("background", "Background"), ("notes", "Notes")): for key, head in (("background", "Background"), ("conditions", "Conditions"), ("notes", "Notes")):
if ch.get(key): if ch.get(key):
doc.add_heading(head, 1) doc.add_heading(head, 1)
doc.add_paragraph(ch[key]) doc.add_paragraph(ch[key])
@@ -171,7 +171,7 @@ def to_pdf(ch: dict, rules: dict) -> bytes:
el.append(Paragraph("-", body)) el.append(Paragraph("-", body))
if s["other"]: if s["other"]:
el.append(Paragraph(f"<b>Other:</b> {esc(s['other'])}", body)) el.append(Paragraph(f"<b>Other:</b> {esc(s['other'])}", body))
for key, head in (("background", "Background"), ("notes", "Notes")): for key, head in (("background", "Background"), ("conditions", "Conditions"), ("notes", "Notes")):
if ch.get(key): if ch.get(key):
el += [Paragraph(head, h2), Paragraph(esc(ch[key]), body)] el += [Paragraph(head, h2), Paragraph(esc(ch[key]), body)]
+153 -31
View File
@@ -18,6 +18,7 @@ from . import rules_edit
from . import maps as maps_mod from . import maps as maps_mod
from . import sounds as sounds_mod from . import sounds as sounds_mod
from . import portrait as portrait_mod from . import portrait as portrait_mod
from . import combat as combat_mod
from .realtime import hub from .realtime import hub
from .export import to_docx, to_pdf from .export import to_docx, to_pdf
@@ -103,8 +104,13 @@ def db() -> sqlite3.Connection:
("lobby_members", "char_mod", "INTEGER NOT NULL DEFAULT 0")): ("lobby_members", "char_mod", "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}")
if "is_admin" not in [r["name"] for r in con.execute("PRAGMA table_info(users)")]:
con.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
if "npc" not in [r["name"] for r in con.execute("PRAGMA table_info(characters)")]:
con.execute("ALTER TABLE characters ADD COLUMN npc INTEGER NOT NULL DEFAULT 0")
maps_mod.schema(con) maps_mod.schema(con)
sounds_mod.schema(con) sounds_mod.schema(con)
combat_mod.schema(con)
for table in ("characters", "presets"): # older databases: add the owner column for table in ("characters", "presets"): # older databases: add the owner column
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]: if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER") con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
@@ -188,14 +194,21 @@ async def security(request: Request, call_next):
return resp return resp
def is_admin(user: dict) -> bool: def admin_ids(con) -> set:
"""Admins (rules editor): the names in AW_ADMINS, or without that setting the very first account.""" """Admins: the names in AW_ADMINS, accounts an admin promoted, and (when there is neither) the very first account."""
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()} names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
if names: ids = {r["id"] for r in con.execute("SELECT id, username FROM users") if r["username"].lower() in names}
return user["username"].lower() in names ids |= {r["id"] for r in con.execute("SELECT id FROM users WHERE is_admin=1")}
with db() as con: if not ids:
first = con.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone() first = con.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
return bool(first) and first["id"] == user["id"] if first:
ids.add(first["id"])
return ids
def is_admin(user: dict) -> bool:
with db() as con:
return user["id"] in admin_ids(con)
def current_user(request: Request) -> dict: def current_user(request: Request) -> dict:
@@ -366,23 +379,36 @@ def _name(data: str) -> str:
return json.loads(data).get("name") or "Nameless" return json.loads(data).get("name") or "Nameless"
def _entry(r, shares=None, owner=None) -> dict:
ch = json.loads(r["data"])
e = {"id": r["id"], "name": ch.get("name") or "Nameless", "updated": r["updated"], "portrait": portrait_mod.tag(ch.get("portrait"))}
if r["npc"]:
e["kind"] = str(ch.get("npc_kind") or "")
e["size"] = ch.get("token_size", 1)
e["hp"] = _health(r["data"])
if shares is not None:
e["shared_with"] = shares.get(r["id"], [])
if owner is not None:
e["owner"] = owner or "?"
return e
@api.get("/characters") @api.get("/characters")
def my_characters(user: dict = Depends(current_user)): def my_characters(npc: int = 0, user: dict = Depends(current_user)):
"""Your characters and the ones shared with you. With ?npc=1 the NPC library (NPCs, enemies, creatures) instead."""
flag = 1 if npc else 0
with db() as con: with db() as con:
rows = con.execute("SELECT id, data, updated FROM characters WHERE owner_id=? ORDER BY updated DESC", rows = con.execute("SELECT id, data, updated, npc FROM characters WHERE owner_id=? AND npc=? ORDER BY updated DESC",
(user["id"],)).fetchall() (user["id"], flag)).fetchall()
shares: dict = {} shares: dict = {}
for r in con.execute("""SELECT s.character_id, u.username FROM shares s JOIN users u ON u.id = s.user_id for r in con.execute("""SELECT s.character_id, u.username FROM shares s JOIN users u ON u.id = s.user_id
JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? ORDER BY u.username""", JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? AND c.npc=? ORDER BY u.username""",
(user["id"],)): (user["id"], flag)):
shares.setdefault(r["character_id"], []).append(r["username"]) shares.setdefault(r["character_id"], []).append(r["username"])
shared = con.execute("""SELECT c.id, c.data, c.updated, o.username AS owner FROM shares s shared = con.execute("""SELECT c.id, c.data, c.updated, c.npc, o.username AS owner FROM shares s
JOIN characters c ON c.id = s.character_id LEFT JOIN users o ON o.id = c.owner_id JOIN characters c ON c.id = s.character_id LEFT JOIN users o ON o.id = c.owner_id
WHERE s.user_id=? ORDER BY c.updated DESC""", (user["id"],)).fetchall() WHERE s.user_id=? AND c.npc=? ORDER BY c.updated DESC""", (user["id"], flag)).fetchall()
return {"mine": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "shared_with": shares.get(r["id"], [])} return {"mine": [_entry(r, shares=shares) for r in rows], "shared": [_entry(r, owner=r["owner"]) for r in shared]}
for r in rows],
"shared": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "owner": r["owner"] or "?"}
for r in shared]}
@api.post("/characters") @api.post("/characters")
@@ -393,20 +419,39 @@ def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS: if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
raise HTTPException(409, "Character limit reached") raise HTTPException(409, "Character limit reached")
cid, now = uuid.uuid4().hex[:12], time.time() cid, now = uuid.uuid4().hex[:12], time.time()
con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id) VALUES (?,?,?,?,?,?)", con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id, npc) VALUES (?,?,?,?,?,?,?)",
(cid, "", json.dumps(ch), now, now, user["id"])) (cid, "", json.dumps(ch), now, now, user["id"], 1 if ch.get("npc") else 0))
return {"id": cid} return {"id": cid}
def _can_read_character(con, cid: str, user: dict) -> bool:
"""The owner, users the character is shared with, the DM of a lobby it plays in, and the DM of a lobby with a token of this NPC."""
row = con.execute("SELECT owner_id FROM characters WHERE id=?", (cid,)).fetchone()
return bool(row) and bool(row["owner_id"] == user["id"] or con.execute(
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone() or con.execute(
"""SELECT 1 FROM lobby_members m JOIN lobbies l ON l.id = m.lobby_id
WHERE m.character_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone() or con.execute(
"""SELECT 1 FROM lobby_tokens t JOIN lobbies l ON l.id = t.lobby_id
WHERE t.npc_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone())
@api.get("/characters/{cid}/portrait")
def character_portrait(cid: str, user: dict = Depends(current_user)):
with db() as con:
row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()
allowed = _can_read_character(con, cid, user)
img = portrait_mod.image(json.loads(row["data"]).get("portrait")) if row and allowed else None
if not img:
raise HTTPException(404, "No portrait")
return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})
@api.get("/characters/{cid}") @api.get("/characters/{cid}")
def read_character(cid: str, user: dict = Depends(current_user)): def read_character(cid: str, user: dict = Depends(current_user)):
"""Readable by the owner, by users the character is shared with, and by the DM of a lobby it plays in.""" """Readable by the owner, by users the character is shared with, and by the DM of a lobby it plays in."""
with db() as con: with db() as con:
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone() row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
allowed = bool(row) and (row["owner_id"] == user["id"] or con.execute( allowed = _can_read_character(con, cid, user)
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone() or con.execute(
"""SELECT 1 FROM lobby_members m JOIN lobbies l ON l.id = m.lobby_id
WHERE m.character_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone())
if not allowed: if not allowed:
raise HTTPException(404, "Character not found") raise HTTPException(404, "Character not found")
return json.loads(row["data"]) return json.loads(row["data"])
@@ -443,6 +488,22 @@ def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(curren
check_size(ch) check_size(ch)
with db() as con: with db() as con:
_owned(con, "characters", cid, user) _owned(con, "characters", cid, user)
con.execute("UPDATE characters SET data=?, updated=?, npc=? WHERE id=?", (json.dumps(ch), time.time(), 1 if ch.get("npc") else 0, cid))
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
for lid in lobbies:
hub.notify(lid, "sheet", cid=cid)
return {"ok": True}
@api.put("/characters/{cid}/notes")
def update_notes(cid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""Only the two note fields, so typing in the lobby never overwrites other changes made elsewhere."""
with db() as con:
_owned(con, "characters", cid, user)
ch = json.loads(con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()["data"])
for k in ("notes", "conditions"):
if k in body:
ch[k] = str(body[k] or "")[:5000]
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid)) con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))] lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
for lid in lobbies: for lid in lobbies:
@@ -485,7 +546,7 @@ def _new_code(con) -> str:
def _own_character(con, cid, user: dict): def _own_character(con, cid, user: dict):
row = con.execute("SELECT id FROM characters WHERE id=? AND owner_id=?", (cid, user["id"])).fetchone() row = con.execute("SELECT id FROM characters WHERE id=? AND owner_id=? AND npc=0", (cid, user["id"])).fetchone()
if not row: if not row:
raise HTTPException(422, "Pick one of your own characters") raise HTTPException(422, "Pick one of your own characters")
@@ -613,23 +674,25 @@ 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)
members = [] members = []
online = hub.online(lid)
for r in rows: for r in rows:
mine = r["user_id"] == user["id"] mine = r["user_id"] == user["id"]
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, "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}
if hidden and not is_dm: if hidden and not is_dm:
diff = {"label": "", "mod": None, "hidden": True} diff = {"label": "", "mod": None, "hidden": True}
return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm, return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm,
"code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff, "dm_online": lobby["owner_id"] in online, "combat": combat, "code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff,
"sounds": sounds_mod.settings(lobby["sounds"]), "levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR} "sounds": sounds_mod.settings(lobby["sounds"]), "levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR}
@@ -810,6 +873,14 @@ def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_us
lobby = _lobby_for(con, lid, user) lobby = _lobby_for(con, lid, user)
member = con.execute("""SELECT m.char_mod, c.data FROM lobby_members m LEFT JOIN characters c ON c.id = m.character_id member = con.execute("""SELECT m.char_mod, c.data FROM lobby_members m LEFT JOIN characters c ON c.id = m.character_id
WHERE m.lobby_id=? AND m.user_id=?""", (lid, user["id"])).fetchone() WHERE m.lobby_id=? AND m.user_id=?""", (lid, user["id"])).fetchone()
npc_name = ""
if body.get("npc_token"): # the DM rolls for an NPC on the table
_dm_only(lobby, user)
t = con.execute("""SELECT t.name, c.data FROM lobby_tokens t JOIN characters c ON c.id = t.npc_id
WHERE t.id=? AND t.lobby_id=?""", (str(body["npc_token"]), lid)).fetchone()
if not t:
raise HTTPException(404, "This token has no NPC sheet")
member, npc_name = {"char_mod": 0, "data": t["data"]}, t["name"]
if not member or not member["data"]: if not member or not member["data"]:
raise HTTPException(422, "Only players with a character in this lobby can roll checks") raise HTTPException(422, "Only players with a character in this lobby can roll checks")
c = compute(json.loads(member["data"]), rules) c = compute(json.loads(member["data"]), rules)
@@ -819,12 +890,12 @@ def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_us
raise HTTPException(404, "Unknown skill") raise HTTPException(404, "Unknown skill")
if not s["usable"]: if not s["usable"]:
raise HTTPException(422, "This skill is still locked") raise HTTPException(422, "This skill is still locked")
what, base = s["name"], s["total"] what, base = (f"{npc_name}: " if npc_name else "") + s["name"], s["total"]
elif kind == "attr": elif kind == "attr":
a = next((x for x in rules["attributes"] if x["id"] == key), None) a = next((x for x in rules["attributes"] if x["id"] == key), None)
if not a: if not a:
raise HTTPException(404, "Unknown attribute") raise HTTPException(404, "Unknown attribute")
what, base = a["name"], c["attrs"][key] what, base = (f"{npc_name}: " if npc_name else "") + a["name"], c["attrs"][key]
else: else:
raise HTTPException(422, "Roll a skill or an attribute") raise HTTPException(422, "Roll a skill or an attribute")
manual = _manual_results(None if body.get("manual") in (None, "") else [body.get("manual")], 100, 1) manual = _manual_results(None if body.get("manual") in (None, "") else [body.get("manual")], 100, 1)
@@ -857,7 +928,8 @@ 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,))
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,)) 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("DELETE FROM lobbies WHERE id=?", (lid,)) con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
purge_sounds(lid) purge_sounds(lid)
hub.close_lobby(lid) hub.close_lobby(lid)
@@ -975,6 +1047,43 @@ def admin_user(user: dict = Depends(current_user)) -> dict:
return user return user
@api.get("/admin/users")
def admin_users(user: dict = Depends(admin_user)):
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
with db() as con:
ids = admin_ids(con)
rows = con.execute("SELECT id, username, created FROM users ORDER BY username COLLATE NOCASE").fetchall()
return [{"username": r["username"], "admin": r["id"] in ids, "fixed": r["username"].lower() in names, "me": r["id"] == user["id"],
"created": r["created"]} for r in rows]
@api.put("/admin/users/{username}")
def set_admin(username: str, body: dict = Body(...), user: dict = Depends(admin_user)):
"""Make a user an admin or take it away again. Names listed in AW_ADMINS can only be changed in the server settings."""
want = bool(body.get("admin"))
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
with db() as con:
target = con.execute("SELECT id, username FROM users WHERE username=?", (username,)).fetchone()
if not target:
raise HTTPException(404, "No user with this name")
ids = admin_ids(con)
if want:
if not any(r["is_admin"] for r in con.execute("SELECT is_admin FROM users")) and not names: # the implicit first-account admin becomes explicit
con.execute("UPDATE users SET is_admin=1 WHERE id IN (%s)" % ",".join(str(i) for i in ids))
con.execute("UPDATE users SET is_admin=1 WHERE id=?", (target["id"],))
else:
if target["username"].lower() in names:
raise HTTPException(409, "This admin is set in AW_ADMINS on the server and can only be removed there")
if ids - {target["id"]} == set():
raise HTTPException(409, "There must be at least one admin")
if target["id"] not in ids:
return {"ok": True}
if not any(r["is_admin"] for r in con.execute("SELECT is_admin FROM users")): # implicit first account: make it explicit first
con.execute("UPDATE users SET is_admin=1 WHERE id IN (%s)" % ",".join(str(i) for i in ids))
con.execute("UPDATE users SET is_admin=0 WHERE id=?", (target["id"],))
return {"ok": True}
@api.get("/admin/rules") @api.get("/admin/rules")
def editor_state(user: dict = Depends(admin_user)): def editor_state(user: dict = Depends(admin_user)):
"""The editable sections as they are now, and which of them differ from the shipped rules.json.""" """The editable sections as they are now, and which of them differ from the shipped rules.json."""
@@ -1036,7 +1145,15 @@ def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin
return {"ok": True} return {"ok": True}
maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps") def _health(data) -> int | None:
try:
return int(compute(json.loads(data), load_rules())["derived"].get("health"))
except Exception:
return None
maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps", _can_read_character, _health)
combat_view = combat_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, load_rules)
purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds") purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds")
@@ -1066,9 +1183,12 @@ async def lobby_socket(websocket: WebSocket, lid: str):
await websocket.close(code=4401) await websocket.close(code=4401)
return return
await websocket.accept() await websocket.accept()
was_online = user["id"] in hub.online(lid)
if not hub.add(lid, websocket, user["id"]): if not hub.add(lid, websocket, user["id"]):
await websocket.close(code=4429) await websocket.close(code=4429)
return return
if not was_online:
hub.notify(lid, "lobby") # presence changed
try: try:
last_check = time.time() last_check = time.time()
while True: while True:
@@ -1086,6 +1206,8 @@ async def lobby_socket(websocket: WebSocket, lid: str):
pass pass
finally: finally:
hub.remove(lid, websocket) hub.remove(lid, websocket)
if user["id"] not in hub.online(lid):
hub.notify(lid, "lobby")
try: try:
await websocket.close() await websocket.close()
except Exception: except Exception:
+759 -43
View File
@@ -2,9 +2,12 @@
Coordinates are pixels of the stored image. A token's size is in grid squares (the map's `grid` is pixels per square). Coordinates are pixels of the stored image. A token's size is in grid squares (the map's `grid` is pixels per square).
Images are re-encoded with Pillow on upload, so only real images get through and metadata is gone.""" Images are re-encoded with Pillow on upload, so only real images get through and metadata is gone."""
import base64
import hashlib import hashlib
import io import io
import json import json
import math
import re
import shutil import shutil
import time import time
import uuid import uuid
@@ -13,10 +16,10 @@ from pathlib import Path
from fastapi import Body, Depends, HTTPException, Query, Request from fastapi import Body, Depends, HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from fastapi.responses import Response from fastapi.responses import Response
from PIL import Image, ImageOps from PIL import Image, ImageChops, ImageDraw, ImageOps
from . import vision from . import vision
from .portrait import tag as portrait_tag from .portrait import image as portrait_image, tag as portrait_tag
MAX_UPLOAD = 12_000_000 # bytes of the uploaded file MAX_UPLOAD = 12_000_000 # bytes of the uploaded file
MAX_SIDE = 4096 # larger images are scaled down MAX_SIDE = 4096 # larger images are scaled down
@@ -25,6 +28,12 @@ MAX_MAPS = 30 # per DM
MAX_TOKENS = 120 # per map / lobby 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_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
COLORS = ("#c0392b", "#8e44ad", "#16a085", "#7f8c8d", "#d35400", "#2c3e50", "#27ae60", "#b9770e") COLORS = ("#c0392b", "#8e44ad", "#16a085", "#7f8c8d", "#d35400", "#2c3e50", "#27ae60", "#b9770e")
@@ -36,9 +45,34 @@ def schema(con):
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, user_id INTEGER, name TEXT NOT NULL, x REAL, y REAL, id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, user_id INTEGER, name TEXT NOT NULL, x REAL, y REAL,
color TEXT, size REAL NOT NULL DEFAULT 1, hidden INTEGER NOT NULL DEFAULT 0)""") color TEXT, size REAL NOT NULL DEFAULT 1, hidden INTEGER NOT NULL DEFAULT 0)""")
con.execute("CREATE INDEX IF NOT EXISTS lobby_tokens_lobby ON lobby_tokens (lobby_id)") con.execute("CREATE INDEX IF NOT EXISTS lobby_tokens_lobby ON lobby_tokens (lobby_id)")
if "npc_id" not in [r["name"] for r in con.execute("PRAGMA table_info(lobby_tokens)")]:
con.execute("ALTER TABLE lobby_tokens ADD COLUMN npc_id TEXT")
con.execute("""CREATE TABLE IF NOT EXISTS lobby_explored (
lobby_id TEXT NOT NULL, user_id INTEGER NOT NULL, map_id TEXT NOT NULL, data BLOB NOT NULL, rev TEXT NOT NULL, last TEXT,
PRIMARY KEY (lobby_id, user_id))""")
con.execute("""CREATE TABLE IF NOT EXISTS lobby_reveals (
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, x REAL NOT NULL, y REAL NOT NULL, radius REAL NOT NULL, dir REAL NOT NULL,
fov REAL NOT NULL, targets TEXT, created REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS 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)""")
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 (
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))")
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"),
("lobby_tokens", "hp", "INTEGER"), ("lobby_tokens", "hp_max", "INTEGER"),
("lobby_tokens", "conds", "TEXT NOT NULL DEFAULT '{}'"), ("lobby_tokens", "show_hp", "INTEGER NOT NULL DEFAULT 0"),
("lobby_tokens", "show_cond", "INTEGER NOT NULL DEFAULT 0"),
("lobby_members", "vgroup", "INTEGER NOT NULL DEFAULT 0"),
("lobby_tokens", "prop", "TEXT"), ("lobby_tokens", "rot", "REAL NOT NULL DEFAULT 0"), ("lobby_tokens", "size_h", "REAL"),
("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}")
@@ -81,8 +115,47 @@ def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict:
if (x is None) != (y is None): if (x is None) != (y is None):
x = y = None x = y = None
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 "")
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 = prop if re.fullmatch(r"[bc]:[a-z0-9_]{1,24}", prop) else ""
try:
rot = float(t.get("rot") or 0) % 360
size_h = None if t.get("size_h") in (None, "") else min(max(float(t["size_h"]), 0.25), 8)
except (TypeError, ValueError):
raise HTTPException(422, "Invalid rotation or size")
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,
"prop": prop, "rot": rot, "size_h": size_h if prop else None, "note": note}
def clean_int(v, lo, hi):
if v is None or v == "":
return None
try:
return max(lo, min(hi, int(float(v))))
except (TypeError, ValueError):
raise HTTPException(422, "Hit points must be whole numbers")
def clean_conds(raw) -> dict:
"""{condition id: level 1-3}"""
if not isinstance(raw, dict) or len(raw) > 20:
raise HTTPException(422, "Invalid conditions")
out = {}
for k, v in raw.items():
k = str(k)
if not (k.replace("_", "").isalnum() and len(k) <= 24):
continue
try:
lv = int(v)
except (TypeError, ValueError):
continue
if 1 <= lv <= 3:
out[k] = lv
return out
def clean_walls(raw, w: int, h: int) -> list: def clean_walls(raw, w: int, h: int) -> list:
@@ -105,7 +178,7 @@ def clean_walls(raw, w: int, h: int) -> list:
return out return out
def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Path): def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Path, can_read, health=lambda data: None):
store_dir.mkdir(parents=True, exist_ok=True) store_dir.mkdir(parents=True, exist_ok=True)
def path(mid: str) -> Path: def path(mid: str) -> Path:
@@ -171,7 +244,18 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
def read_map(mid: str, user: dict = Depends(current_user)): def read_map(mid: str, user: dict = Depends(current_user)):
with db() as con: with db() as con:
r = own_map(con, mid, user) r = own_map(con, mid, user)
return {**meta(r), "tokens": json.loads(r["tokens"]), "walls": json.loads(r["walls"])} tokens = json.loads(r["tokens"])
for t in tokens: # NPC tokens: tell the editor whether the NPC has a portrait
t["portrait"] = ""
if t.get("npc_id") and can_read(con, t["npc_id"], user):
row = con.execute("SELECT data FROM characters WHERE id=?", (t["npc_id"],)).fetchone()
try:
t["portrait"] = portrait_tag(json.loads(row["data"]).get("portrait")) if row else ""
except ValueError:
pass
else:
t["npc_id"] = ""
return {**meta(r), "tokens": tokens, "walls": json.loads(r["walls"])}
@api.put("/maps/{mid}") @api.put("/maps/{mid}")
def update_map(mid: str, body: dict = Body(...), user: dict = Depends(current_user)): def update_map(mid: str, body: dict = Body(...), user: dict = Depends(current_user)):
@@ -214,7 +298,11 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
con.execute("UPDATE lobbies SET map_id=NULL WHERE map_id=?", (mid,)) con.execute("UPDATE lobbies SET map_id=NULL WHERE map_id=?", (mid,))
con.execute("DELETE FROM map_shares WHERE map_id=?", (mid,)) con.execute("DELETE FROM map_shares WHERE map_id=?", (mid,))
for lid in lobbies: for lid in lobbies:
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,)) for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"):
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("DELETE FROM lobby_scenes WHERE map_id=?", (mid,))
con.execute("DELETE FROM maps WHERE id=?", (mid,)) con.execute("DELETE FROM maps WHERE id=?", (mid,))
path(mid).unlink(missing_ok=True) path(mid).unlink(missing_ok=True)
(store_dir / f"{mid}.thumb.webp").unlink(missing_ok=True) (store_dir / f"{mid}.thumb.webp").unlink(missing_ok=True)
@@ -279,6 +367,89 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
raise HTTPException(404, "Image missing") raise HTTPException(404, "Image missing")
return Response(p.read_bytes(), media_type="image/webp", headers={"Cache-Control": "private, max-age=3600"}) return Response(p.read_bytes(), media_type="image/webp", headers={"Cache-Control": "private, max-age=3600"})
# ---- custom prop images (decor tokens such as chests or tables); the built-in ones are drawn by the frontend
props_dir = store_dir.parent / "props"
props_dir.mkdir(parents=True, exist_ok=True)
def encode_prop(raw: bytes) -> bytes:
try:
im = Image.open(io.BytesIO(raw))
if im.format not in ("PNG", "JPEG", "WEBP"):
raise ValueError("format")
im.load()
except Exception:
raise HTTPException(422, "Please upload a PNG, JPEG or WebP image")
im = ImageOps.exif_transpose(im)
im.thumbnail((512, 512))
im = im.convert("RGBA")
out = io.BytesIO()
im.save(out, "WEBP", quality=88, method=4)
return out.getvalue()
@api.get("/props")
def list_props(user: dict = Depends(current_user)):
with db() as con:
return [{"id": r["id"], "name": r["name"]} for r in
con.execute("SELECT id, name FROM user_props WHERE owner_id=? ORDER BY created", (user["id"],))]
@api.post("/props")
async def upload_prop(request: Request, name: str = "", user: dict = Depends(current_user)):
"""Raw image bytes in the body, the name as ?name=. Transparent PNGs work best (top view)."""
if int(request.headers.get("content-length") or 0) > 3_000_000:
raise HTTPException(413, "The file is too large (at most 3 MB)")
chunks, size = [], 0
async for c in request.stream():
size += len(c)
if size > 3_000_000:
raise HTTPException(413, "The file is too large (at most 3 MB)")
chunks.append(c)
with db() as con:
if con.execute("SELECT COUNT(*) FROM user_props WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_PROPS:
raise HTTPException(409, "Prop limit reached, delete one first")
data = await run_in_threadpool(encode_prop, b"".join(chunks))
pid = uuid.uuid4().hex[:12]
(props_dir / f"{pid}.webp").write_bytes(data)
with db() as con:
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}
@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}")
def delete_prop(pid: str, user: dict = Depends(current_user)):
with db() as con:
if 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("DELETE FROM user_props WHERE id=?", (pid,))
(props_dir / f"{pid}.webp").unlink(missing_ok=True)
return {"ok": True}
@api.get("/props/{pid}/image")
def prop_image(pid: str, user: dict = Depends(current_user)):
with db() as con:
ok = con.execute("SELECT 1 FROM user_props WHERE id=? AND owner_id=?", (pid, user["id"])).fetchone()
if not ok:
raise HTTPException(404, "Prop not found")
return image_response(props_dir / f"{pid}.webp")
@api.get("/lobbies/{lid}/prop-image/{pid}")
def lobby_prop_image(lid: str, pid: str, user: dict = Depends(current_user)):
"""Members of a lobby may load the prop images of the lobby's DM."""
with db() as con:
lobby = lobby_for(con, lid, user)
ok = con.execute("SELECT 1 FROM user_props WHERE id=? AND owner_id=?", (pid, lobby["owner_id"])).fetchone()
if not ok or not re.fullmatch(r"[0-9a-f]{12}", pid):
raise HTTPException(404, "Prop not found")
return image_response(props_dir / f"{pid}.webp")
# ---- the map on a lobby's table # ---- the map on a lobby's table
def sync_players(con, lid: str): def sync_players(con, lid: str):
@@ -287,13 +458,61 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
have = {r["user_id"] for r in con.execute("SELECT user_id FROM lobby_tokens WHERE lobby_id=? AND user_id IS NOT NULL", (lid,))} have = {r["user_id"] for r in con.execute("SELECT user_id FROM lobby_tokens WHERE lobby_id=? AND user_id IS NOT NULL", (lid,))}
ids = {m["user_id"] for m in members} ids = {m["user_id"] for m in members}
for uid in ids - have: for uid in ids - have:
con.execute("INSERT INTO lobby_tokens (id, lobby_id, user_id, name, size) VALUES (?,?,?,?,1)", (uuid.uuid4().hex[:12], lid, uid, "")) con.execute("INSERT INTO lobby_tokens (id, lobby_id, user_id, name, size, show_hp, show_cond) VALUES (?,?,?,?,1,1,1)", (uuid.uuid4().hex[:12], lid, uid, ""))
for uid in have - ids: for uid in have - ids:
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=? AND user_id=?", (lid, uid)) con.execute("DELETE FROM lobby_tokens WHERE lobby_id=? AND user_id=?", (lid, uid))
def put_token(con, lid, t, npc=None, tid=None):
"""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, note)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(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("note") or None))
def mask_dims(m):
f = max(1, -(-max(m["w"], m["h"]) // 1024))
return f, -(-m["w"] // f), -(-m["h"] // f)
def explore(con, lobby, viewer, m, polys, key):
"""Adds what the player sees right now to the lobby's memory of explored ground (a small mask, 1 cell = f pixels)."""
lid = lobby["id"]
f, mw, mh = mask_dims(m)
row = con.execute("SELECT * FROM lobby_explored WHERE lobby_id=? AND user_id=?", (lid, viewer)).fetchone()
if row and row["map_id"] != m["id"]:
row = None
if row and row["last"] == key:
return row["rev"]
old = None
if row:
old = Image.open(io.BytesIO(row["data"]))
old.load()
if old.size != (mw, mh):
old = old.resize((mw, mh), Image.BILINEAR) # memory made at another resolution is kept
base = old or Image.new("L", (mw, mh), 0)
cur = Image.new("L", (mw, mh), 0)
for poly in polys:
if len(poly) > 2:
ImageDraw.Draw(cur).polygon([(x / f, y / f) for x, y in poly], fill=255)
new = ImageChops.lighter(base, cur)
rev = hashlib.md5(new.tobytes()).hexdigest()[:10]
if row and row["rev"] == rev:
con.execute("UPDATE lobby_explored SET last=? WHERE lobby_id=? AND user_id=?", (key, lid, viewer))
return rev
out = io.BytesIO()
new.save(out, "PNG", optimize=True)
con.execute("""INSERT INTO lobby_explored (lobby_id, user_id, map_id, data, rev, last) VALUES (?,?,?,?,?,?)
ON CONFLICT(lobby_id, user_id) DO UPDATE SET map_id=excluded.map_id, data=excluded.data, rev=excluded.rev, last=excluded.last""",
(lid, viewer, m["id"], out.getvalue(), rev, key))
return rev
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"]
@@ -308,34 +527,70 @@ 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 vision_on(lobby, m):
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 blocking = [(w["x1"], w["y1"], w["x2"], w["y2"]) for w in walls if w["kind"] == "wall" or not doors.get(w["id"])]
sight = lobby["sight"] if lobby["sight"] is not None else m["sight"]
radius = round(sight * m["grid"], 1)
me = con.execute("""SELECT u.username, mm.vgroup FROM lobby_members mm JOIN users u ON u.id = mm.user_id
WHERE mm.lobby_id=? AND mm.user_id=?""", (lobby["id"], viewer)).fetchone()
if me and me["vgroup"]: # a sight group: everybody in it sees what any of them sees
toks = con.execute("""SELECT t.x, t.y FROM lobby_tokens t JOIN lobby_members mm ON mm.lobby_id = t.lobby_id AND mm.user_id = t.user_id
WHERE t.lobby_id=? AND mm.vgroup=? AND t.x IS NOT NULL ORDER BY t.rowid""", (lobby["id"], me["vgroup"])).fetchall()
else:
toks = con.execute("SELECT x, y FROM lobby_tokens WHERE lobby_id=? AND user_id=? AND x IS NOT NULL", (lobby["id"], viewer)).fetchall()
origins = [(round(t["x"]), round(t["y"])) for t in toks]
zones = []
for z in con.execute("SELECT * FROM lobby_reveals WHERE lobby_id=? ORDER BY created", (lobby["id"],)):
tg = json.loads(z["targets"]) if z["targets"] else None
if tg is None or (me and me["username"] in tg):
zones.append(z)
if not origins and not zones: # not on the map yet and nothing revealed: sees nothing
ctx.update(empty=True, rev="empty") ctx.update(empty=True, rev="empty")
return ctx return ctx
x, y = round(tok["x"]), round(tok["y"]) polys = []
blocking = [(w["x1"], w["y1"], w["x2"], w["y2"]) for w in walls if w["kind"] == "wall" or not doors.get(w["id"])] for (x, y) in origins:
radius = round(m["sight"] * m["grid"], 1) k = hashlib.sha1(json.dumps([m["id"], blocking, radius, x, y]).encode()).hexdigest()
key = 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)))
poly = vision.cached_polygon(key, lambda: vision.polygon((x, y), blocking, m["w"], m["h"], radius)) zkeys = []
ctx.update(poly=poly, key=key, rev=key[:12]) for z in zones:
ctx_poly, zk = zone_polygon(m, blocking, z)
polys.append(ctx_poly)
zkeys.append(zk)
key = hashlib.sha1(json.dumps([m["id"], blocking, radius, origins, zkeys]).encode()).hexdigest()
ctx.update(polys=polys, key=key, rev=key[:12])
if lobby["fog"]:
ctx["fog"] = explore(con, lobby, viewer, m, polys, key)
return ctx 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 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
LEFT JOIN characters n ON n.id = t.npc_id
LEFT JOIN users u ON u.id = t.user_id LEFT JOIN users u ON u.id = t.user_id
LEFT JOIN lobby_members m ON m.lobby_id = t.lobby_id AND m.user_id = t.user_id LEFT JOIN lobby_members m ON m.lobby_id = t.lobby_id AND m.user_id = t.user_id
LEFT JOIN characters c ON c.id = m.character_id LEFT JOIN characters c ON c.id = m.character_id
WHERE t.lobby_id=? ORDER BY t.rowid""", (lobby["id"],)).fetchall() WHERE t.lobby_id=? ORDER BY t.rowid""", (lobby["id"],)).fetchall()
full = ctx["viewer"] is None full = ctx["viewer"] is None
turn_tok = "" # players only learn an NPC's name while it is that NPC's turn
try:
cb = json.loads(lobby["combat"]) if lobby["combat"] else None
if cb:
turn_tok = next((e.get("token", "") for e in cb["entries"] if e["id"] == cb.get("current")), "")
except (ValueError, KeyError, TypeError):
pass
out = [] out = []
for r in rows: for r in rows:
player = r["user_id"] is not None player = r["user_id"] is not None
@@ -343,7 +598,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:
@@ -351,16 +606,31 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
name = (json.loads(r["data"]).get("name") if r["data"] else "") or r["username"] name = (json.loads(r["data"]).get("name") if r["data"] else "") or r["username"]
except ValueError: except ValueError:
name = r["username"] name = r["username"]
if not player and not full and r["id"] != turn_tok and not r["prop"]:
name = ""
pt = "" pt = ""
if player and r["data"]: src = r["data"] if player else r["ndata"]
if src:
try: try:
pt = portrait_tag(json.loads(r["data"]).get("portrait")) pt = portrait_tag(json.loads(src).get("portrait"))
except ValueError: except ValueError:
pass pass
out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name, "portrait": pt, status = {}
if full or own or r["show_hp"]:
status.update(hp=r["hp"], hp_max=r["hp_max"])
if full or own or r["show_cond"]:
status["conds"] = json.loads(r["conds"] or "{}")
if full:
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"]:
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,
"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"],
"can_move": ctx["is_dm"] or (r["user_id"] == user["id"] and not (ctx["los"] and r["x"] is None))}) "can_move": ctx["is_dm"] or (r["user_id"] == user["id"] and not (ctx["los"] and r["x"] is None)),
**({"npc_id": r["npc_id"]} if full and r["npc_id"] else {})})
return out return out
@api.get("/lobbies/{lid}/map") @api.get("/lobbies/{lid}/map")
@@ -373,9 +643,24 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
return {"map": None, "tokens": []} return {"map": None, "tokens": []}
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"]}} "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["sight"] = lobby["sight"] if lobby["sight"] is not None else m["sight"]
res["sight_map"] = m["sight"]
res["fog_on"] = bool(lobby["fog"])
res["reveals"] = [{"id": z["id"], "x": z["x"], "y": z["y"], "radius": z["radius"], "dir": z["dir"], "fov": z["fov"],
"to": json.loads(z["targets"]) if z["targets"] else None}
for z in con.execute("SELECT * FROM lobby_reveals WHERE lobby_id=? ORDER BY created", (lobby["id"],))]
if ctx["viewer"] is None: 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
@@ -393,11 +678,104 @@ 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"})
@api.get("/lobbies/{lid}/map/explored")
def lobby_map_explored(lid: str, as_: str = Query("", alias="as"), user: dict = Depends(current_user)):
"""Ground the player has already seen, as a (smaller) picture: black where unexplored. The client dims it."""
with db() as con:
lobby = lobby_for(con, lid, user)
m = lobby_map(con, lobby)
if not m:
raise HTTPException(404, "No map")
ctx = view_ctx(con, lobby, m, user, as_)
row = con.execute("SELECT data, rev FROM lobby_explored WHERE lobby_id=? AND user_id=? AND map_id=?",
(lid, ctx["viewer"], m["id"])).fetchone() if ctx["viewer"] is not None and lobby["fog"] else None
if not row:
raise HTTPException(404, "Nothing explored")
hit = vision.fog_cache.fetch((m["id"], row["rev"]))
if not hit:
im = vision.source(path(m["id"]))
k = min(1.0, 2048 / max(im.size))
small = im.resize((max(1, round(im.width * k)), max(1, round(im.height * k)))) if k < 1 else im
mask = Image.open(io.BytesIO(row["data"])).resize(small.size, Image.BILINEAR)
piece = Image.composite(small, Image.new("RGB", small.size, (0, 0, 0)), mask)
out = io.BytesIO()
piece.save(out, "WEBP", quality=70, method=2)
hit = out.getvalue()
vision.fog_cache.put((m["id"], row["rev"]), hit)
return Response(hit, media_type="image/webp", headers={"Cache-Control": "private, max-age=3600"})
@api.put("/lobbies/{lid}/vision-groups")
def set_vision_groups(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""DM: players in the same group (1-4) share what they see. 0 = sees only for themselves."""
with db() as con:
dm_only(lobby_for(con, lid, user), user)
if body.get("preset") in ("shared", "separate"):
con.execute("UPDATE lobby_members SET vgroup=? WHERE lobby_id=?", (1 if body["preset"] == "shared" else 0, lid))
for name, g in (body.get("groups") or {}).items():
con.execute("""UPDATE lobby_members SET vgroup=? WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)""",
(clean_int(g, 0, 4) or 0, lid, str(name)))
hub.notify(lid, "map")
hub.notify(lid, "lobby")
return {"ok": True}
@api.post("/lobbies/{lid}/reveals")
def add_reveal(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""DM: show an area (circle or cone, blocked by walls) to all players or some of them. Stays until removed;
what was shown is also added to the players' memory of explored ground."""
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
m = need_map(con, lobby)
if con.execute("SELECT COUNT(*) FROM lobby_reveals WHERE lobby_id=?", (lid,)).fetchone()[0] >= 12:
raise HTTPException(409, "Too many revealed areas, hide some first")
try:
x, y = min(max(float(body["x"]), 0), m["w"]), min(max(float(body["y"]), 0), m["h"])
radius = min(max(float(body.get("radius", 8)), 0.5), 200)
dr = float(body.get("dir", 0)) % 360
fov = min(max(float(body.get("fov", 360)), 10), 360)
except (KeyError, TypeError, ValueError):
raise HTTPException(422, "Invalid area")
names = body.get("to")
targets = None
members = {r["username"]: r["user_id"] for r in con.execute("""SELECT u.username, mm.user_id FROM lobby_members mm
JOIN users u ON u.id = mm.user_id WHERE mm.lobby_id=?""", (lid,))}
if isinstance(names, list):
targets = [n for n in names if n in members]
if not targets:
raise HTTPException(422, "Pick at least one player")
rid = uuid.uuid4().hex[:12]
con.execute("INSERT INTO lobby_reveals (id, lobby_id, x, y, radius, dir, fov, targets, created) VALUES (?,?,?,?,?,?,?,?,?)",
(rid, lid, x, y, radius, dr, fov, json.dumps(targets) if targets is not None else None, time.time()))
if lobby["fog"]: # the memory keeps it after the area is hidden again
walls, doors = json.loads(m["walls"]), json.loads(lobby["doors"] or "{}")
blocking = [(w["x1"], w["y1"], w["x2"], w["y2"]) for w in walls if w["kind"] == "wall" or not doors.get(w["id"])]
poly, _ = zone_polygon(m, blocking, {"radius": radius, "fov": fov, "dir": dr, "x": x, "y": y})
for n in (targets if targets is not None else members):
explore(con, lobby, members[n], m, [poly], "reveal:" + rid)
hub.notify(lid, "map")
return {"id": rid}
@api.delete("/lobbies/{lid}/reveals/{rid}")
def remove_reveal(lid: str, rid: str, user: dict = Depends(current_user)):
with db() as con:
dm_only(lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_reveals WHERE id=? AND lobby_id=?", (rid, lid))
hub.notify(lid, "map")
return {"ok": True}
@api.delete("/lobbies/{lid}/explored")
def forget_explored(lid: str, user: dict = Depends(current_user)):
with db() as con:
dm_only(lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_explored WHERE lobby_id=?", (lid,))
hub.notify(lid, "map")
return {"ok": True}
@api.put("/lobbies/{lid}/doors/{wid}") @api.put("/lobbies/{lid}/doors/{wid}")
def set_door(lid: str, wid: str, body: dict = Body(...), user: dict = Depends(current_user)): def set_door(lid: str, wid: str, body: dict = Body(...), user: dict = Depends(current_user)):
with db() as con: with db() as con:
@@ -415,33 +793,340 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
hub.notify(lid, "map") hub.notify(lid, "map")
return {"ok": True} return {"ok": True}
pinged: dict = {}
@api.post("/lobbies/{lid}/ping")
def ping(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""Anybody at the table can point at a spot of the map; everybody sees a ring there (and hears a ping)."""
with db() as con:
lobby = lobby_for(con, lid, user)
m = need_map(con, lobby)
try:
x, y = float(body.get("x")), float(body.get("y"))
except (TypeError, ValueError):
raise HTTPException(422, "Invalid position")
if not (0 <= x <= m["w"] and 0 <= y <= m["h"]):
raise HTTPException(422, "Outside of the map")
now = time.time()
if now - pinged.get((lid, user["id"]), 0) < 0.8:
raise HTTPException(429, "Slow down")
pinged[(lid, user["id"])] = now
if len(pinged) > 2000:
pinged.clear()
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}
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)."""
with db() as con: with db() as con:
dm_only(lobby_for(con, lid, user), user) dm_only(lobby_for(con, lid, user), user)
con.execute("UPDATE lobbies SET los=? WHERE id=?", (1 if body.get("on") else 0, lid)) if "on" in body:
con.execute("UPDATE lobbies SET los=? WHERE id=?", (1 if body.get("on") else 0, lid))
if "range" in body: # squares; null = the map's own value
v = None if body["range"] in (None, "") else clean_int(body["range"], 0, 1000)
con.execute("UPDATE lobbies SET sight=? WHERE id=?", (v, lid))
if "fog" in body:
con.execute("UPDATE lobbies SET fog=? WHERE id=?", (1 if body["fog"] else 0, lid))
hub.notify(lid, "map") hub.notify(lid, "map")
return {"ok": True} return {"ok": True}
# ---- 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", "note")
def snapshot(con, lid) -> str:
lobby = con.execute("SELECT * FROM lobbies WHERE id=?", (lid,)).fetchone()
names = {r["id"]: r["username"] for r in con.execute("SELECT id, username FROM users WHERE id IN (SELECT user_id FROM lobby_members WHERE lobby_id=?)", (lid,))}
toks = []
for r in con.execute("SELECT * FROM lobby_tokens WHERE lobby_id=? ORDER BY rowid", (lid,)):
d = {k: r[k] for k in TOKEN_COLS}
if r["user_id"] is not None:
if r["user_id"] not in names:
continue
d["username"] = names[r["user_id"]]
toks.append(d)
reveals = [{k: z[k] for k in ("id", "x", "y", "radius", "dir", "fov", "targets", "created")}
for z in con.execute("SELECT * FROM lobby_reveals WHERE lobby_id=?", (lid,))]
explored = [{"username": names[z["user_id"]], "map_id": z["map_id"], "rev": z["rev"], "data": base64.b64encode(z["data"]).decode()}
for z in con.execute("SELECT * FROM lobby_explored WHERE lobby_id=?", (lid,)) if z["user_id"] in names]
return json.dumps({"tokens": toks, "reveals": reveals, "explored": explored, "doors": lobby["doors"], "sight": lobby["sight"],
"fog": lobby["fog"], "los": lobby["los"], "combat": lobby["combat"]})
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_explored WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_reveals WHERE lobby_id=?", (lid,))
ids = {r["username"]: r["user_id"] for r in con.execute("SELECT u.username, m.user_id FROM lobby_members m JOIN users u ON u.id = m.user_id WHERE m.lobby_id=?", (lid,))}
sync_players(con, lid)
for d in state.get("tokens", []):
if d.get("username"):
uid = ids.get(d["username"])
if uid is not None:
con.execute("""UPDATE lobby_tokens SET x=?, y=?, hidden=?, hp=?, hp_max=?, conds=?, show_hp=?, show_cond=?, size=? WHERE lobby_id=? AND user_id=?""",
(d["x"], d["y"], d["hidden"], d["hp"], d["hp_max"], d["conds"], d["show_hp"], d["show_cond"], d["size"], lid, uid))
else:
con.execute(f"INSERT INTO lobby_tokens (lobby_id, {', '.join(TOKEN_COLS)}) VALUES (?{',?' * len(TOKEN_COLS)})", (lid, *[d[k] for k in TOKEN_COLS]))
for z in state.get("reveals", []):
con.execute("INSERT INTO lobby_reveals (id, lobby_id, x, y, radius, dir, fov, targets, created) VALUES (?,?,?,?,?,?,?,?,?)",
(z["id"], lid, z["x"], z["y"], z["radius"], z["dir"], z["fov"], z["targets"], z["created"]))
for z in state.get("explored", []):
if z["username"] in ids:
con.execute("INSERT INTO lobby_explored (lobby_id, user_id, map_id, data, rev, last) VALUES (?,?,?,?,?,NULL)",
(lid, ids[z["username"]], z["map_id"], base64.b64decode(z["data"]), z["rev"]))
con.execute("UPDATE lobbies SET doors=?, sight=?, fog=?, los=?, combat=? WHERE id=?",
(state.get("doors", "{}"), state.get("sight"), state.get("fog", 1), state.get("los", 1), state.get("combat"), lid))
def fresh_state(con, lid, r, user):
"""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_explored WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_reveals WHERE lobby_id=?", (lid,))
con.execute("UPDATE lobbies SET sight=NULL, doors='{}', combat=NULL WHERE id=?", (lid,))
for t in json.loads(r["tokens"]):
npc = t.get("npc_id") if t.get("npc_id") and can_read(con, t["npc_id"], user) else None # only NPCs this DM may open
put_token(con, lid, t, npc)
sync_players(con, lid)
def autosave(con, lid):
"""Stores the table's current state in its scene (creating the scene for tables that predate scenes)."""
lobby = con.execute("SELECT * FROM lobbies WHERE id=?", (lid,)).fetchone()
if not lobby["map_id"]:
return
now = time.time()
if lobby["scene_id"] and con.execute("SELECT 1 FROM lobby_scenes WHERE id=? AND lobby_id=?", (lobby["scene_id"], lid)).fetchone():
con.execute("UPDATE lobby_scenes SET state=?, updated=? WHERE id=?", (snapshot(con, lid), now, lobby["scene_id"]))
else:
new_scene(con, lid, lobby["map_id"], snapshot(con, lid))
def new_scene(con, lid, mid, state, name=None) -> str:
if con.execute("SELECT COUNT(*) FROM lobby_scenes WHERE lobby_id=?", (lid,)).fetchone()[0] >= MAX_SCENES:
raise HTTPException(409, f"Too many saved scenes (max {MAX_SCENES}) - delete one first")
m = con.execute("SELECT name FROM maps WHERE id=?", (mid,)).fetchone()
base = (name or (m["name"] if m else "Scene")).strip()[:50] or "Scene"
taken = {r["name"] for r in con.execute("SELECT name FROM lobby_scenes WHERE lobby_id=?", (lid,))}
nm, i = base, 2
while nm in taken:
nm, i = f"{base} ({i})", i + 1
sid = uuid.uuid4().hex[:12]
con.execute("INSERT INTO lobby_scenes (id, lobby_id, name, map_id, state, updated) VALUES (?,?,?,?,?,?)", (sid, lid, nm, mid, state, time.time()))
con.execute("UPDATE lobbies SET scene_id=? WHERE id=?", (sid, lid))
return sid
@api.put("/lobbies/{lid}/map") @api.put("/lobbies/{lid}/map")
def set_lobby_map(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): def set_lobby_map(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""DM: show a map from the library (or null to clear the table). The map's prepared tokens are copied to the lobby.""" """DM: show a map from the library (or null to clear the table). The table's current state is kept as a scene;
the new map starts fresh with its prepared tokens as a scene of its own."""
with db() as con: with db() as con:
dm_only(lobby_for(con, lid, user), user) dm_only(lobby_for(con, lid, user), user)
mid = body.get("map_id") mid = body.get("map_id")
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,)) r = usable_map(con, str(mid), user) if mid else None
if mid: autosave(con, lid)
r = usable_map(con, str(mid), user) con.execute("UPDATE lobbies SET scene_id=NULL WHERE id=?", (lid,))
for t in json.loads(r["tokens"]): if r:
con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden) VALUES (?,?,?,?,?,?,?,?)", fresh_state(con, lid, r, user)
(uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]))) con.execute("UPDATE lobbies SET map_id=? WHERE id=?", (r["id"], lid))
sync_players(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", "lobby_templates"):
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,))
event(con, lid, "The DM cleared the table", user["id"]) event(con, lid, "The DM cleared the table", user["id"])
con.execute("UPDATE lobbies SET map_id=?, doors='{}' WHERE id=?", (str(mid) if mid else None, lid))
hub.notify(lid, "map") hub.notify(lid, "map")
hub.notify(lid, "lobby")
return {"ok": True}
@api.get("/lobbies/{lid}/scenes")
def list_scenes(lid: str, user: dict = Depends(current_user)):
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
if lobby["map_id"] and not con.execute("SELECT 1 FROM lobby_scenes WHERE id=? AND lobby_id=?", (lobby["scene_id"] or "", lid)).fetchone():
try:
autosave(con, lid) # a table that predates scenes becomes one
except HTTPException:
pass
lobby = lobby_for(con, lid, user)
rows = con.execute("""SELECT s.id, s.name, s.map_id, s.updated, m.name AS map_name, LENGTH(s.state) AS size FROM lobby_scenes s
LEFT JOIN maps m ON m.id = s.map_id WHERE s.lobby_id=? ORDER BY s.updated DESC""", (lid,)).fetchall()
return [{"id": r["id"], "name": r["name"], "map_id": r["map_id"], "map_name": r["map_name"] or "", "updated": r["updated"],
"current": r["id"] == lobby["scene_id"]} for r in rows]
@api.put("/lobbies/{lid}/scene")
def switch_scene(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""DM: continue another saved scene. The current table is saved first, so nothing is lost when switching back."""
with db() as con:
dm_only(lobby_for(con, lid, user), user)
sc = con.execute("SELECT * FROM lobby_scenes WHERE id=? AND lobby_id=?", (str(body.get("scene_id", "")), lid)).fetchone()
if not sc:
raise HTTPException(404, "Scene not found")
r = usable_map(con, sc["map_id"], user) if sc["map_id"] else None
if not r:
raise HTTPException(409, "The map of this scene is no longer available")
autosave(con, lid)
restore(con, lid, json.loads(sc["state"]))
con.execute("UPDATE lobbies SET map_id=?, scene_id=? WHERE id=?", (r["id"], sc["id"], lid))
event(con, lid, f"The DM switched to the scene \"{sc['name']}\"", user["id"])
hub.notify(lid, "map")
hub.notify(lid, "lobby")
return {"ok": True}
@api.post("/lobbies/{lid}/scenes")
def save_scene_copy(lid: str, body: dict = Body(default={}), user: dict = Depends(current_user)):
"""DM: store a copy of the current table (a checkpoint to come back to). The table keeps running on its own scene."""
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
if not lobby["map_id"]:
raise HTTPException(409, "There is no map on the table")
autosave(con, lid)
current = con.execute("SELECT scene_id FROM lobbies WHERE id=?", (lid,)).fetchone()["scene_id"]
sid = new_scene(con, lid, lobby["map_id"], snapshot(con, lid), str(body.get("name", "")) or None)
con.execute("UPDATE lobbies SET scene_id=? WHERE id=?", (current, lid))
hub.notify(lid, "lobby")
return {"id": sid}
@api.put("/lobbies/{lid}/scenes/{sid}")
def edit_scene(lid: str, sid: str, body: dict = Body(...), user: dict = Depends(current_user)):
with db() as con:
dm_only(lobby_for(con, lid, user), user)
if not con.execute("SELECT 1 FROM lobby_scenes WHERE id=? AND lobby_id=?", (sid, lid)).fetchone():
raise HTTPException(404, "Scene not found")
name = str(body.get("name", "")).strip()[:50]
if name:
if con.execute("SELECT 1 FROM lobby_scenes WHERE lobby_id=? AND name=? AND id<>?", (lid, name, sid)).fetchone():
raise HTTPException(409, "There is already a scene with this name")
con.execute("UPDATE lobby_scenes SET name=? WHERE id=?", (name, sid))
if body.get("save"):
autosave(con, lid)
hub.notify(lid, "lobby")
return {"ok": True}
@api.delete("/lobbies/{lid}/scenes/{sid}")
def delete_scene(lid: str, sid: str, user: dict = Depends(current_user)):
with db() as con:
dm_only(lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_scenes WHERE id=? AND lobby_id=?", (sid, lid))
con.execute("UPDATE lobbies SET scene_id=NULL WHERE id=? AND scene_id=?", (lid, sid))
hub.notify(lid, "lobby")
return {"ok": True} return {"ok": True}
def need_map(con, lobby): def need_map(con, lobby):
@@ -459,8 +1144,8 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
if con.execute("SELECT COUNT(*) FROM lobby_tokens WHERE lobby_id=?", (lid,)).fetchone()[0] >= MAX_TOKENS: if con.execute("SELECT COUNT(*) FROM lobby_tokens WHERE lobby_id=?", (lid,)).fetchone()[0] >= MAX_TOKENS:
raise HTTPException(409, "Too many tokens") raise HTTPException(409, "Too many tokens")
t = clean_token(body, m["w"], m["h"], keep_id=False) t = clean_token(body, m["w"], m["h"], keep_id=False)
con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden) VALUES (?,?,?,?,?,?,?,?)", npc = t["npc_id"] if t["npc_id"] and can_read(con, t["npc_id"], user) else None
(t["id"], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]))) put_token(con, lid, t, npc, t["id"])
hub.notify(lid, "map") hub.notify(lid, "map")
return {"id": t["id"]} return {"id": t["id"]}
@@ -474,20 +1159,38 @@ 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"], "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"): 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=? 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"]), tid)) (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,
(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")):
hp_max, hp = t["hp_max"], t["hp"]
if "hp_max" in body:
hp_max = clean_int(body["hp_max"], 1, 9999)
if "hp" in body:
hp = clean_int(body["hp"], -999, 9999)
if hp_max is None:
hp = None
elif hp is None:
hp = hp_max
elif hp > hp_max:
hp = hp_max
conds = clean_conds(body["conds"]) if "conds" in body else json.loads(t["conds"] or "{}")
con.execute("UPDATE lobby_tokens SET hp=?, hp_max=?, conds=?, show_hp=?, show_cond=? WHERE id=?",
(hp, hp_max, json.dumps(conds), int(bool(body.get("show_hp", t["show_hp"]))), int(bool(body.get("show_cond", t["show_cond"]))), tid))
hub.notify(lid, "map") hub.notify(lid, "map")
return {"ok": True} return {"ok": True}
@@ -499,4 +1202,17 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
hub.notify(lid, "map") hub.notify(lid, "map")
return {"ok": True} return {"ok": True}
@api.get("/lobbies/{lid}/token-portrait/{tid}")
def token_portrait(lid: str, tid: str, user: dict = Depends(current_user)):
"""Portrait of an NPC token for the lobby's members (hidden tokens only for the DM)."""
with db() as con:
lobby = lobby_for(con, lid, user)
t = con.execute("SELECT t.hidden, n.data FROM lobby_tokens t JOIN characters n ON n.id = t.npc_id WHERE t.id=? AND t.lobby_id=?", (tid, lid)).fetchone()
if not t or (t["hidden"] and lobby["owner_id"] != user["id"]):
raise HTTPException(404, "No portrait")
img = portrait_image(json.loads(t["data"]).get("portrait"))
if not img:
raise HTTPException(404, "No portrait")
return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})
return sync_players return sync_players
+3 -3
View File
@@ -6,8 +6,8 @@ from fastapi import Body, Depends, HTTPException, Request
from fastapi.responses import Response from fastapi.responses import Response
MAX_SOUND = 1_500_000 # bytes per file MAX_SOUND = 1_500_000 # bytes per file
SLOTS = ("roll", "crit_success", "crit_failure") SLOTS = ("roll", "crit_success", "crit_failure", "ping")
FLAGS = ("result", "crit_success", "crit_failure") # result = the short tones for success / failure of a check FLAGS = ("result", "crit_success", "crit_failure", "ping") # result = the short tones for success / failure of a check
def schema(con): def schema(con):
@@ -20,7 +20,7 @@ def settings(raw) -> dict:
s = json.loads(raw or "{}") s = json.loads(raw or "{}")
except ValueError: except ValueError:
s = {} s = {}
return {"result": s.get("result", True), "crit_success": s.get("crit_success", True), "crit_failure": s.get("crit_failure", True), return {"result": s.get("result", True), "crit_success": s.get("crit_success", True), "crit_failure": s.get("crit_failure", True), "ping": s.get("ping", True),
"custom": {k: bool(s.get("custom", {}).get(k)) for k in SLOTS}, "v": int(s.get("v", 0))} "custom": {k: bool(s.get("custom", {}).get(k)) for k in SLOTS}, "v": int(s.get("v", 0))}
+25 -9
View File
@@ -11,8 +11,9 @@ from PIL import Image, ImageDraw
EPS = 1e-5 EPS = 1e-5
def polygon(origin, segs, w, h, radius=0.0): def polygon(origin, segs, w, h, radius=0.0, sector=None):
"""Points of the area visible from origin. segs: [(x1, y1, x2, y2)] that block sight; radius 0 means unlimited.""" """Points of the area visible from origin. segs: [(x1, y1, x2, y2)] that block sight; radius 0 means unlimited.
sector = (direction, half angle) in radians limits the view to a cone."""
ox, oy = origin ox, oy = origin
border = [(0, 0, w, 0), (w, 0, w, h), (w, h, 0, h), (0, h, 0, 0)] border = [(0, 0, w, 0), (w, 0, w, h), (w, h, 0, h), (0, h, 0, 0)]
if radius: if radius:
@@ -34,7 +35,13 @@ def polygon(origin, segs, w, h, radius=0.0):
angles += (a - EPS, a, a + EPS) angles += (a - EPS, a, a + EPS)
if radius: if radius:
angles += [i * (2 * math.pi / 120) for i in range(120)] angles += [i * (2 * math.pi / 120) for i in range(120)]
angles.sort() if sector:
d, half = sector
rel = [(a - d + math.pi) % (2 * math.pi) - math.pi for a in angles]
rel = [a for a in rel if abs(a) <= half] + [-half, half] + [-half + i * (2 * half / 48) for i in range(49)]
angles = [d + a for a in sorted(rel)]
else:
angles.sort()
pts = [] pts = []
for a in angles: for a in angles:
dx, dy = math.cos(a), math.sin(a) dx, dy = math.cos(a), math.sin(a)
@@ -54,7 +61,7 @@ def polygon(origin, segs, w, h, radius=0.0):
if best == float("inf"): if best == float("inf"):
continue continue
pts.append((ox + dx * best, oy + dy * best)) pts.append((ox + dx * best, oy + dy * best))
return pts return [(ox, oy)] + pts if sector else pts
def inside(pt, poly) -> bool: def inside(pt, poly) -> bool:
@@ -90,6 +97,7 @@ class Lru(OrderedDict):
_images = Lru(2) _images = Lru(2)
_polys = Lru(128) _polys = Lru(128)
_cuts = Lru(24) _cuts = Lru(24)
fog_cache = Lru(12)
def cached_polygon(key, make): def cached_polygon(key, make):
@@ -111,20 +119,28 @@ def source(path):
return im return im
def cut(path, poly, key): def inside_any(pt, polys) -> bool:
"""The part of the image inside the polygon (everything else black), cropped to its bounding box. return any(inside(pt, p) for p in polys)
def cut(path, polys, key):
"""The part of the image inside the polygon (everything else transparent), 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)
piece = Image.composite(im.crop((x0, y0, x0 + w, y0 + h)), Image.new("RGB", (w, h), (0, 0, 0)), mask) for poly in polys:
if len(poly) > 2:
draw.polygon([(x - x0, y - y0) for x, y in poly], fill=255)
piece = im.crop((x0, y0, x0 + w, y0 + h)).convert("RGBA")
piece.putalpha(mask) # transparent outside the view, so remembered (dimmed) ground underneath stays visible
out = io.BytesIO() out = io.BytesIO()
piece.save(out, "WEBP", quality=82, method=1) piece.save(out, "WEBP", quality=82, method=1)
res = (out.getvalue(), (x0, y0, w, h)) res = (out.getvalue(), (x0, y0, w, h))
+1
View File
@@ -31,6 +31,7 @@
{"id": "standard", "name": "Standard", "desc": "The numbers from the brainstorm document.", "creation": {}}, {"id": "standard", "name": "Standard", "desc": "The numbers from the brainstorm document.", "creation": {}},
{"id": "gritty", "name": "Gritty survival", "desc": "Example preset: fewer points, one starting talent, harder to learn advanced skills.", "creation": {"attr_points": 70, "skill_points_default": 100, "talents_max": 1, "free_proficiencies": 1, "advanced_unlock_cost": 15}}, {"id": "gritty", "name": "Gritty survival", "desc": "Example preset: fewer points, one starting talent, harder to learn advanced skills.", "creation": {"attr_points": 70, "skill_points_default": 100, "talents_max": 1, "free_proficiencies": 1, "advanced_unlock_cost": 15}},
{"id": "heroic", "name": "Heroic", "desc": "Example preset: more points, higher attribute cap, three starting talents.", "creation": {"attr_points": 110, "attr_max_raise": 25, "attr_max": 50, "skill_points_default": 200, "talents_max": 3, "free_proficiencies": 3, "advanced_unlock_cost": 5}}, {"id": "heroic", "name": "Heroic", "desc": "Example preset: more points, higher attribute cap, three starting talents.", "creation": {"attr_points": 110, "attr_max_raise": 25, "attr_max": 50, "skill_points_default": 200, "talents_max": 3, "free_proficiencies": 3, "advanced_unlock_cost": 5}},
{"id": "npc", "name": "NPC / creature (free)", "desc": "Used by the NPC editor: huge pools, no limits.", "npc": true, "creation": {"attr_points": 500, "attr_max_raise": 100, "attr_max": 150, "skill_points_default": 1000, "talents_min": 0, "talents_max": 10, "free_proficiencies": 20, "advanced_unlock_cost": 0}},
{"id": "oneshot", "name": "One-shot", "desc": "Example preset: quick, strong characters for a single session.", "creation": {"attr_points": 100, "skill_points_default": 120, "talents_max": 2, "free_proficiencies": 3, "advanced_unlock_cost": 0}} {"id": "oneshot", "name": "One-shot", "desc": "Example preset: quick, strong characters for a single session.", "creation": {"attr_points": 100, "skill_points_default": 120, "talents_max": 2, "free_proficiencies": 3, "advanced_unlock_cost": 0}}
], ],
+7 -1
View File
@@ -27,6 +27,7 @@ export default function App() {
const [error, setError] = useState('') const [error, setError] = useState('')
// Used to load a saved character into the creator // Used to load a saved character into the creator
const [loadRequest, setLoadRequest] = useState(null) const [loadRequest, setLoadRequest] = useState(null)
const [npcLoad, setNpcLoad] = useState(null)
const [charName, setCharName] = useState('') // name of the character being worked on, shown in the nav const [charName, setCharName] = useState('') // name of the character being worked on, shown in the nav
// Ask the server who we are; any later 401 (session expired) sends us back to the login // Ask the server who we are; any later 401 (session expired) sends us back to the login
@@ -55,7 +56,7 @@ export default function App() {
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main> if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
const page = hash.split('/')[0] const page = hash.split('/')[0]
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['maps', 'Maps'], ['characters', 'My characters'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])] const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['maps', 'Maps'], ['characters', 'My characters'], ['npcs', 'NPCs'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])]
return ( return (
<> <>
@@ -81,6 +82,11 @@ export default function App() {
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} <Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} /> onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
)} )}
{rules && page === 'npcs' && (
<Characters npc onOpen={(entry) => { setNpcLoad(entry); window.location.hash = 'npc' }}
onNew={() => { setNpcLoad({ new: true }); window.location.hash = 'npc' }} />
)}
{rules && page === 'npc' && <Creator npc rules={rules} user={username} loadRequest={npcLoad} clearLoad={() => setNpcLoad(null)} onName={() => {}} />}
{rules && page === 'lobbies' && ( {rules && page === 'lobbies' && (
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} /> <Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
)} )}
+29 -12
View File
@@ -19,7 +19,7 @@ function SharePanel({ c, reload }) {
return ( return (
<div className="sharepanel"> <div className="sharepanel">
<p className="small muted">These users can view this character (read-only), for example your DM:</p> <p className="small muted">These users can view this entry (read-only), for example another DM:</p>
<div className="chips"> <div className="chips">
{c.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>} {c.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
{c.shared_with.map((u) => ( {c.shared_with.map((u) => (
@@ -35,12 +35,13 @@ function SharePanel({ c, reload }) {
) )
} }
export default function Characters({ onOpen, onNew }) { export default function Characters({ onOpen, onNew, npc = false }) {
const what = npc ? 'NPC' : 'character'
const [data, setData] = useState(null) const [data, setData] = useState(null)
const [open, setOpen] = useState(null) // id of the character whose share panel is open const [open, setOpen] = useState(null) // id of the character whose share panel is open
const [err, setErr] = useState('') const [err, setErr] = useState('')
const load = () => fetch('/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your characters.')) const load = () => fetch(npc ? '/api/characters?npc=1' : '/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load the list.'))
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
const remove = async (c) => { const remove = async (c) => {
@@ -49,32 +50,46 @@ export default function Characters({ onOpen, onNew }) {
if (!r || !r.ok) return setErr('Deleting failed.') if (!r || !r.ok) return setErr('Deleting failed.')
load() load()
} }
const copy = async (c) => {
const r = await fetch(`/api/characters/${c.id}`).catch(() => null)
if (!r || !r.ok) return setErr('Duplicating failed.')
const data = await r.json()
const p = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, name: `${data.name || 'NPC'} (copy)` }) }).catch(() => null)
if (!p || !p.ok) return setErr('Duplicating failed.')
load()
}
const kindName = (k) => ({ npc: 'NPC', enemy: 'Enemy', monster: 'Monster', creature: 'Creature', animal: 'Animal', robot: 'Robot / machine' })[k] || ''
const thumb = (c) => c.portrait ? <img className="npcthumb" src={`/api/characters/${c.id}/portrait?v=${c.portrait}`} alt="" /> : <span className="npcthumb none" aria-hidden="true">{npc ? '?' : ''}</span>
const when = (t) => new Date(t * 1000).toLocaleString('en-GB') const when = (t) => new Date(t * 1000).toLocaleString('en-GB')
return ( return (
<section className="page narrow"> <section className="page narrow">
<div className="row"> <div className="row">
<h1>My characters</h1> <h1>{npc ? 'NPCs and creatures' : 'My characters'}</h1>
<button onClick={onNew}>+ New character</button> <button onClick={onNew}>{npc ? '+ New NPC' : '+ New character'}</button>
</div> </div>
<p className="muted"> <p className="muted">
These characters are saved in your account. Only you and the users you share a character with can see it. {npc ? 'Your library of NPCs, enemies, monsters and creatures. Place them as tokens in the map editor or at the table; a DM sees their sheet by clicking the token. You can share NPCs with other DMs.'
Shared users can look at it but not change it. : 'These characters are saved in your account. Only you and the users you share a character with can see it. Shared users can look at it but not change it.'}
</p> </p>
{err && <p className="warn">{err}</p>} {err && <p className="warn">{err}</p>}
{data && data.mine.length === 0 && <p className="card">Nothing saved yet. Use "New character", then click "Save" on the Summary page.</p>} {data && data.mine.length === 0 && <p className="card">Nothing saved yet. Use "{npc ? 'New NPC' : 'New character'}", then click "{npc ? 'Save NPC' : 'Save'}" on the Summary page.</p>}
<ul className="charlist"> <ul className="charlist">
{(data?.mine || []).map((c) => ( {(data?.mine || []).map((c) => (
<li key={c.id} className="card"> <li key={c.id} className="card">
<div className="row"> <div className="row">
<div> <div className="npchead">
<strong>{c.name}</strong> {thumb(c)}
<div>
<strong>{c.name}</strong>{npc && c.kind && <span className="badge">{kindName(c.kind)}</span>}
<div className="muted small"> <div className="muted small">
ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`} ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`}
</div> </div>
</div>
</div> </div>
<div className="actions"> <div className="actions">
<button onClick={() => onOpen(c)}>Open</button> <button onClick={() => onOpen(c)}>Open</button>
{npc && <button className="ghost" onClick={() => copy(c)}>Duplicate</button>}
<button className="ghost" onClick={() => setOpen(open === c.id ? null : c.id)}>Share</button> <button className="ghost" onClick={() => setOpen(open === c.id ? null : c.id)}>Share</button>
<button className="ghost danger" onClick={() => remove(c)}>Delete</button> <button className="ghost danger" onClick={() => remove(c)}>Delete</button>
</div> </div>
@@ -90,9 +105,11 @@ export default function Characters({ onOpen, onNew }) {
<ul className="charlist"> <ul className="charlist">
{data.shared.map((c) => ( {data.shared.map((c) => (
<li key={c.id} className="card row"> <li key={c.id} className="card row">
<div> <div className="npchead">
<strong>{c.name}</strong> {thumb(c)}
<div><strong>{c.name}</strong>
<div className="muted small">from {c.owner} · {when(c.updated)}</div> <div className="muted small">from {c.owner} · {when(c.updated)}</div>
</div>
</div> </div>
<div className="actions"><button onClick={() => onOpen(c)}>View</button></div> <div className="actions"><button onClick={() => onOpen(c)}>View</button></div>
</li> </li>
+41 -18
View File
@@ -14,9 +14,13 @@ function readDraft(key) {
try { return JSON.parse(localStorage.getItem(key)) } catch { return null } try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
} }
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName }) { const NPC_KINDS = [['npc', 'NPC'], ['enemy', 'Enemy'], ['monster', 'Monster'], ['creature', 'Creature'], ['animal', 'Animal'], ['robot', 'Robot / machine']]
const DRAFT = `aw_draft:${user}` // drafts are kept per account
const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard') export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName, npc = false }) {
const DRAFT = `${npc ? 'aw_draft_npc' : 'aw_draft'}:${user}` // drafts are kept per account
const freshCharacter = () => npc
? { ...applyPreset(emptyCharacter(baseRules), baseRules, 'npc'), npc: true, npc_kind: 'enemy', token_size: 1, abilities: '', loot: '' }
: applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
const [ch, setCh] = useState(() => { const [ch, setCh] = useState(() => {
const d = readDraft(DRAFT) const d = readDraft(DRAFT)
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter() return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
@@ -58,7 +62,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
if (!loadRequest) return if (!loadRequest) return
if (loadRequest.new) { // "New character" button on the My characters page if (loadRequest.new) { // "New character" button on the My characters page
const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved
if (untouched || window.confirm('Start a new character? Your current draft is discarded unless you saved it.')) { if (untouched || window.confirm('Start a new ' + (npc ? 'NPC' : 'character') + '? Your current draft is discarded unless you saved it.')) {
setBoth(freshCharacter()); setSaved(null); setStep(0) setBoth(freshCharacter()); setSaved(null); setStep(0)
} }
clearLoad() clearLoad()
@@ -74,7 +78,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
// Direct link #creator/<id> (read only; saving creates a copy) // Direct link #creator/<id> (read only; saving creates a copy)
useEffect(() => { useEffect(() => {
const id = window.location.hash.split('/')[1] const id = window.location.hash.split('/')[1]
if (!id || loadRequest) return if (!id || loadRequest || npc) return
fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }) fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') }) .then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') })
.catch(() => setMsg('Character not found, or it is not shared with you.')) .catch(() => setMsg('Character not found, or it is not shared with you.'))
@@ -94,7 +98,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
const resetAll = () => { const resetAll = () => {
if (!window.confirm('Discard all input and start over?')) return if (!window.confirm('Discard all input and start over?')) return
setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation }); setSaved(null); setStep(0) setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation, ...(npc ? { npc: true, npc_kind: ch.npc_kind, token_size: ch.token_size, abilities: '', loot: '' } : {}) }); setSaved(null); setStep(0)
} }
const resetThisStep = () => { const resetThisStep = () => {
if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return
@@ -154,10 +158,25 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
const stepConcept = ( const stepConcept = (
<> <>
<h2>Concept</h2> <h2>Concept</h2>
<div className="card presetbox"> {npc && (
<div className="card presetbox">
<div className="grid2">
<label>Type
<select value={ch.npc_kind || 'npc'} onChange={(e) => set({ npc_kind: e.target.value })}>
{NPC_KINDS.map(([id, label]) => <option key={id} value={id}>{label}</option>)}
</select>
</label>
<label>Token size (grid squares)
<input type="number" min="1" max="8" value={ch.token_size || 1} onChange={(e) => set({ token_size: Math.max(1, Math.min(8, Number(e.target.value) || 1)) })} />
</label>
</div>
<p className="small muted">NPCs use generous pools (attributes, skills, talents), so you can build anything from a rat to a boss.</p>
</div>
)}
{!npc && <div className="card presetbox">
<label>Rules preset <label>Rules preset
<select value={ch.preset} onChange={(e) => e.target.value !== 'custom' && setCh((x) => applyPreset(x, baseRules, e.target.value))}> <select value={ch.preset} onChange={(e) => e.target.value !== 'custom' && setCh((x) => applyPreset(x, baseRules, e.target.value))}>
{(baseRules.presets || []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)} {(baseRules.presets || []).filter((p) => !p.npc).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
{ch.preset === 'custom' && <option value="custom">Custom</option>} {ch.preset === 'custom' && <option value="custom">Custom</option>}
</select> </select>
</label> </label>
@@ -167,11 +186,11 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
return diff.length > 0 && <p className="small muted">Differs from Standard: {diff.map((x) => `${x.label} ${cr[x.key]}`).join(' · ')}</p> return diff.length > 0 && <p className="small muted">Differs from Standard: {diff.map((x) => `${x.label} ${cr[x.key]}`).join(' · ')}</p>
})()} })()}
<p className="small">Create or edit presets on the <a href="#presets">Presets</a> page.</p> <p className="small">Create or edit presets on the <a href="#presets">Presets</a> page.</p>
</div> </div>}
<div className="card portraitbox"><PortraitEditor value={ch.portrait} onChange={(p) => set({ portrait: p })} /></div> <div className="card portraitbox"><PortraitEditor value={ch.portrait} onChange={(p) => set({ portrait: p })} /></div>
<div className="grid2"> <div className="grid2">
<label>Character name<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label> <label>{npc ? 'NPC name' : 'Character name'}<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
<label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label> {!npc && <label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>}
</div> </div>
<div className="grid2"> <div className="grid2">
<label>Class (optional) <label>Class (optional)
@@ -189,7 +208,9 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
</div> </div>
{sub && <p className="small muted">{sub.desc}</p>} {sub && <p className="small muted">{sub.desc}</p>}
<label>Concept in one sentence<input value={ch.concept} onChange={(e) => set({ concept: e.target.value })} placeholder="e.g. Former militia fighter searching for his family" /></label> <label>Concept in one sentence<input value={ch.concept} onChange={(e) => set({ concept: e.target.value })} placeholder="e.g. Former militia fighter searching for his family" /></label>
<label>Background<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label> <label>{npc ? 'Description' : 'Background'}<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
{npc && <label>Special abilities and attacks<textarea rows="5" value={ch.abilities || ''} onChange={(e) => set({ abilities: e.target.value })} placeholder="e.g. Bite 2d6, poison spit (range 10, VIT check or poisoned), regenerates 2 HP per round" /></label>}
{npc && <label>Loot and drops<textarea rows="3" value={ch.loot || ''} onChange={(e) => set({ loot: e.target.value })} placeholder="e.g. 2 scrap, hide, rusty knife" /></label>}
</> </>
) )
@@ -419,6 +440,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
) )
const warnings = [] const warnings = []
if (npc) { if (c.attrLeft < 0) warnings.push('Too many attribute points spent.'); if (c.skillLeft < 0) warnings.push('More skill points spent than the pool allows.') } else {
if (c.attrLeft < 0) warnings.push('Too many attribute points spent.') if (c.attrLeft < 0) warnings.push('Too many attribute points spent.')
if (c.attrLeft > 0) warnings.push(`${c.attrLeft} attribute points left.`) if (c.attrLeft > 0) warnings.push(`${c.attrLeft} attribute points left.`)
if (!ch.profession) warnings.push('No profession chosen.') if (!ch.profession) warnings.push('No profession chosen.')
@@ -432,22 +454,23 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
if (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.') if (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
if (over) warnings.push('Augmentation Capacity exceeded.') if (over) warnings.push('Augmentation Capacity exceeded.')
if (overWeight) warnings.push('Carrying more than the carry capacity.') if (overWeight) warnings.push('Carrying more than the carry capacity.')
}
const gearLines = kitItems(ch, rules) const gearLines = kitItems(ch, rules)
const stepSummary = ( const stepSummary = (
<> <>
<h2>Summary</h2> <h2>Summary</h2>
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>} {warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
<PaperSheet data={ch} baseRules={baseRules} /> <PaperSheet data={ch} baseRules={baseRules} onNotes={(p) => setBoth({ ...ch, ...p })} />
<div className="actions wrap"> <div className="actions wrap">
<button onClick={() => download('pdf')}>Download as PDF</button> {!npc && <button onClick={() => download('pdf')}>Download as PDF</button>}
<button onClick={() => download('docx')}>Download as Word</button> {!npc && <button onClick={() => download('docx')}>Download as Word</button>}
<button className="ghost" onClick={save}>{saved ? 'Save changes' : 'Save on server'}</button> <button className="ghost" onClick={save}>{saved ? 'Save changes' : npc ? 'Save NPC' : 'Save on server'}</button>
<button className="ghost" onClick={exportJson}>JSON backup</button> <button className="ghost" onClick={exportJson}>JSON backup</button>
<button className="ghost" onClick={() => fileRef.current.click()}>Import JSON</button> <button className="ghost" onClick={() => fileRef.current.click()}>Import JSON</button>
<input ref={fileRef} type="file" accept="application/json" hidden onChange={importJson} /> <input ref={fileRef} type="file" accept="application/json" hidden onChange={importJson} />
</div> </div>
{saved && ( {saved && !npc && (
<p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p> <p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p>
)} )}
</> </>
@@ -457,7 +480,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
return ( return (
<div className="page creator"> <div className="page creator">
<p className="small muted presetchip">Rules: <button className="linklike" onClick={() => go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}</button></p> {!npc && <p className="small muted presetchip">Rules: <button className="linklike" onClick={() => go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}</button></p>}
<ol className="steps"> <ol className="steps">
{STEPS.map((s, i) => ( {STEPS.map((s, i) => (
<li key={s}><button className={`${i === step ? 'on' : ''} ${i < step ? 'done' : ''}`} onClick={() => go(i)}> <li key={s}><button className={`${i === step ? 'on' : ''} ${i < step ? 'done' : ''}`} onClick={() => go(i)}>
+37
View File
@@ -65,6 +65,42 @@ function Field({ f, value, onChange }) {
) )
} }
// Admins can make other accounts admins (and take it away again)
function AdminUsers() {
const [users, setUsers] = useState(null)
const [err, setErr] = useState('')
const [open, setOpen] = useState(false)
const load = () => fetch('/api/admin/users').then((r) => (r.ok ? r.json() : Promise.reject())).then(setUsers).catch(() => setErr('Could not load the users.'))
useEffect(() => { if (open && !users) load() }, [open]) // eslint-disable-line
const set = async (u, admin) => {
setErr('')
const r = await fetch(`/api/admin/users/${encodeURIComponent(u.username)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ admin }) })
if (!r.ok) setErr((await r.json().catch(() => ({}))).detail || 'That did not work.')
load()
}
return (
<div className="card adminusers">
<button className="linklike" aria-expanded={open} onClick={() => setOpen(!open)}>{open ? '▾' : '▸'} Users and admins</button>
{open && (
<>
<p className="small muted">Admins can use this editor and make other users admins. Admins named in <code>AW_ADMINS</code> on the server can only be changed there.</p>
{err && <p className="warn small">{err}</p>}
<ul className="userlist">
{(users || []).map((u) => (
<li key={u.username} className="row">
<span><strong>{u.username}</strong>{u.me ? ' (you)' : ''} {u.admin && <span className="chip on">admin</span>}{u.fixed && <span className="muted small"> set on the server</span>}</span>
{u.admin
? <button className="ghost" disabled={u.fixed} onClick={() => window.confirm(`Take admin rights from ${u.username}?`) && set(u, false)}>Remove admin</button>
: <button className="ghost" onClick={() => window.confirm(`Make ${u.username} an admin? Admins can change the rules and make others admins.`) && set(u, true)}>Make admin</button>}
</li>
))}
</ul>
</>
)}
</div>
)
}
export default function Editor({ rules, onReload }) { export default function Editor({ rules, onReload }) {
const [state, setState] = useState(null) const [state, setState] = useState(null)
const [tab, setTab] = useState('equipment') const [tab, setTab] = useState('equipment')
@@ -139,6 +175,7 @@ export default function Editor({ rules, onReload }) {
<h1>Rules editor</h1> <h1>Rules editor</h1>
<p className="muted small">Changes are stored next to the database and replace the entries of rules.json for that list; the shipped file stays as it is. <p className="muted small">Changes are stored next to the database and replace the entries of rules.json for that list; the shipped file stays as it is.
Existing characters keep working. Talents, mutations and implants are still edited in rules.json.</p> Existing characters keep working. Talents, mutations and implants are still edited in rules.json.</p>
<AdminUsers />
<div className="tabs" role="tablist"> <div className="tabs" role="tablist">
{Object.entries(D).map(([id, x]) => ( {Object.entries(D).map(([id, x]) => (
<button key={id} role="tab" aria-selected={tab === id} className={tab === id ? 'on' : ''} onClick={() => { setTab(id); setEditing(''); setQ(''); setMsg({ text: '', bad: false }) }}> <button key={id} role="tab" aria-selected={tab === id} className={tab === id ? 'on' : ''} onClick={() => { setTab(id); setEditing(''); setQ(''); setMsg({ text: '', bad: false }) }}>
+263 -38
View File
@@ -1,7 +1,8 @@
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, playPing, preload, DEFAULT_ROLL } from './sound.js'
import { MapView, MapLibrary, TokenForm, WallLayer } from './MapView.jsx' import { useTableTools } from './Tools.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' }
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) })
@@ -168,6 +169,17 @@ function useMapImage(id, vision, as) {
return img return img
} }
// The ground this player has already seen (dimmed under the live view). Reloaded a moment after the picture changes.
function useFogImage(id, rev, as) {
const [src, setSrc] = useState(null)
useEffect(() => {
if (!rev) { setSrc(null); return }
const t = setTimeout(() => setSrc(`/api/lobbies/${id}/map/explored?v=${rev}${as ? `&as=${encodeURIComponent(as)}` : ''}`), 400)
return () => clearTimeout(t)
}, [id, rev, as])
return src
}
// Chat, whispers, event log, dice rolls and checks arrive through one feed that is polled while the page is open. // Chat, whispers, event log, dice rolls and checks arrive through one feed that is polled while the page is open.
function useFeed(id, onEvent, onRoll, socketUp) { function useFeed(id, onEvent, onRoll, socketUp) {
const [msgs, setMsgs] = useState([]) const [msgs, setMsgs] = useState([])
@@ -427,15 +439,18 @@ function DiceTray({ rolls }) {
function CharBar({ lobby, sel, onSel }) { function CharBar({ lobby, sel, onSel }) {
return ( return (
<div className="charbar" role="list"> <div className="charbar" role="list">
<div role="listitem" className={`pcard dmcard${lobby.dm_online ? '' : ' off'}`} title={`Dungeon master${lobby.dm_online ? ' (online)' : ' (offline)'}`}>
<span className="pavatar dm"><i className={`presence ${lobby.dm_online ? 'on' : ''}`} aria-label={lobby.dm_online ? 'online' : 'offline'} />DM</span>
<span className="pinfo"><b>{lobby.dm}{lobby.is_dm ? ' (you)' : ''}</b></span>
</div>
{lobby.members.map((m) => { {lobby.members.map((m) => {
const c = m.character, can = !!m.character_id const c = m.character, can = !!m.character_id
return ( return (
<div key={m.username} role="listitem" className={`pcard${sel === m.username ? ' sel' : ''}${can ? ' can' : ''}`} style={{ '--pc': m.color || 'var(--line)' }} <div key={m.username} role="listitem" className={`pcard${sel === m.username ? ' sel' : ''}${can ? ' can' : ''}${m.online ? '' : ' off'}`} title={m.online ? `${m.username} is online` : `${m.username} is offline`} style={{ '--pc': m.color || 'var(--line)' }}
onClick={() => can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}> onClick={() => can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}>
<span className="pavatar">{c?.portrait ? <img src={`/api/lobbies/${lobby.id}/portrait/${encodeURIComponent(m.username)}?v=${c.portrait}`} alt="" /> : (c?.name || m.username).slice(0, 1).toUpperCase()}</span> <span className="pavatar"><i className={`presence ${m.online ? 'on' : ''}`} aria-label={m.online ? 'online' : 'offline'} />{c?.portrait ? <img src={`/api/lobbies/${lobby.id}/portrait/${encodeURIComponent(m.username)}?v=${c.portrait}`} alt="" /> : (c?.name || m.username).slice(0, 1).toUpperCase()}</span>
<span className="pinfo"> <span className="pinfo">
<b>{c ? c.name : 'No character'}</b> <b title={m.username}>{c ? c.name : 'No character'}</b>
<span className="muted small">{m.username}{m.me ? ' (you)' : ''}{c?.class ? ` · ${c.class}` : ''}</span>
</span> </span>
{lobby.is_dm {lobby.is_dm
? <input type="number" className="modin" key={`${m.username}:${m.mod}`} defaultValue={m.mod || 0} min={-100} max={100} title={`Bonus / malus for ${m.username}`} aria-label={`Modifier for ${m.username}`} ? <input type="number" className="modin" key={`${m.username}:${m.mod}`} defaultValue={m.mod || 0} min={-100} max={100} title={`Bonus / malus for ${m.username}`} aria-label={`Modifier for ${m.username}`}
@@ -449,6 +464,51 @@ function CharBar({ lobby, sel, onSel }) {
) )
} }
// Drag handle that resizes a panel. Reports the new size through onChange, keeps it within min/max. dir = 1: dragging right grows, -1: shrinks.
function Splitter({ value, onChange, min, max, dir = 1, className = '', label }) {
const down = (e) => {
e.preventDefault()
const x0 = e.clientX, v0 = value
document.body.classList.add('resizing')
const mv = (ev) => onChange(Math.round(Math.min(max, Math.max(min, v0 + dir * (ev.clientX - x0)))))
const up = () => { document.body.classList.remove('resizing'); window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', up) }
window.addEventListener('pointermove', mv); window.addEventListener('pointerup', up)
}
return <div className={`splitter ${className}`} role="separator" aria-orientation="vertical" aria-label={label} title={`${label} (drag, double click to reset)`} onPointerDown={down} />
}
// ---- initiative tracker: compact cards (portrait + number). Players only see the name of whoever's turn it is.
function InitiativeCards({ lobby, tokens, onOp, onPick }) {
const c = lobby.combat, dm = lobby.is_dm
const cardPic = (e) => {
if (e.kind === 'player') { const m = lobby.members.find((x) => x.username === e.user); return m?.character?.portrait ? `/api/lobbies/${lobby.id}/portrait/${encodeURIComponent(e.user)}?v=${m.character.portrait}` : null }
const t = tokens.find((x) => x.id === e.token)
return t?.portrait ? `/api/lobbies/${lobby.id}/token-portrait/${t.id}?v=${t.portrait}` : null
}
const missing = dm ? lobby.members.filter((m) => m.character && !c.entries.some((e) => e.user === m.username)) : []
return (
<div className="initcards" role="list" aria-label="Initiative order">
<span className="initround" title="Round"><b>R{c.round}</b></span>
{c.entries.map((e) => {
const cur = c.current === e.id, pic = cardPic(e), showName = dm || cur
return (
<div key={e.id} role="listitem" className={`initcard ${e.kind}${cur ? ' cur' : ''}`} onClick={() => onPick(e)} title={showName ? `${e.name}: d20 ${e.roll} + ${e.base}` : 'Initiative'}>
<span className="initpic">{pic ? <img src={pic} alt="" /> : (e.name || '?').slice(0, 1).toUpperCase()}</span>
{dm
? <input type="number" className="modin" defaultValue={e.init} key={`${e.id}:${e.init}`} aria-label={`Initiative of ${e.name}`}
onClick={(ev) => ev.stopPropagation()} onBlur={(ev) => ev.target.value !== String(e.init) && onOp({ op: 'init', id: e.id, value: ev.target.value })} onKeyDown={(ev) => ev.key === 'Enter' && ev.currentTarget.blur()} />
: <span className="initnum">{e.init}</span>}
{showName && <b className="initname">{e.name}</b>}
{dm && <button className="linklike" onClick={(ev) => { ev.stopPropagation(); onOp({ op: 'reroll', id: e.id }) }} title="Roll again" aria-label={`Reroll ${e.name}`}>🎲</button>}
{dm && <button className="linklike" onClick={(ev) => { ev.stopPropagation(); onOp({ op: 'remove', id: e.id }) }} title="Remove from the fight" aria-label={`Remove ${e.name}`}>×</button>}
</div>
)
})}
{missing.map((m) => <button key={m.username} className="ghost small" onClick={() => onOp({ op: 'add', user: m.username })}>+ {m.character.name}</button>)}
</div>
)
}
// ---- the DM sets the difficulty for all checks, from the rules table or a custom number // ---- the DM sets the difficulty for all checks, from the rules table or a custom number
function DifficultyBar({ lobby, onSet }) { function DifficultyBar({ lobby, onSet }) {
const d = lobby.difficulty const d = lobby.difficulty
@@ -476,23 +536,51 @@ function DifficultyBar({ lobby, onSet }) {
} }
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves // ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
function CharTab({ cid, rules, onRoll, tick }) { function CharTab({ cid, rules, onRoll, tick, editable }) {
const [data, setData] = useState(null) const [data, setData] = useState(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const typing = useRef(0) // time of the last keystroke: refreshes must not overwrite what is being typed
const timer = useRef(null)
const saveNotes = (patch) => {
typing.current = Date.now()
setData((d) => ({ ...d, ...patch }))
clearTimeout(timer.current)
timer.current = setTimeout(() => { send(`/api/characters/${cid}/notes`, 'PUT', patch).catch(() => setErr('Could not save the notes.')) }, 700)
}
useEffect(() => () => clearTimeout(timer.current), [])
useEffect(() => { useEffect(() => {
setData(null); setErr('') setData(null); setErr('')
const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then(setData).catch(() => setErr('Could not load this character.')) const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then((d) => { if (Date.now() - typing.current > 4000) setData(d) }).catch(() => setErr('Could not load this character.'))
load() load()
const t = setInterval(() => !document.hidden && load(), 15000) const t = setInterval(() => !document.hidden && load(), 15000)
return () => clearInterval(t) return () => clearInterval(t)
}, [cid]) }, [cid])
useEffect(() => { // pushed by the server when the player saved the character useEffect(() => { // pushed by the server when the player saved the character
if (tick) fetch(`/api/characters/${cid}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setData(d)).catch(() => {}) if (tick) fetch(`/api/characters/${cid}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && Date.now() - typing.current > 4000 && setData(d)).catch(() => {})
}, [tick]) // eslint-disable-line }, [tick]) // eslint-disable-line
return ( return (
<div> <div>
{err && <p className="warn">{err}</p>} {err && <p className="warn">{err}</p>}
{data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} />} {data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} onNotes={editable ? saveNotes : undefined} />}
</div>
)
}
// 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> </div>
) )
} }
@@ -521,7 +609,7 @@ function SoundSettings({ lobby, id, act, sfx }) {
<div className="row small"> <div className="row small">
<span className="muted">{sn.custom[slot] ? 'Your file' : defaultText}</span> <span className="muted">{sn.custom[slot] ? 'Your file' : defaultText}</span>
<span className="row"> <span className="row">
<button className="ghost" onClick={() => playRoll(slot === 'roll' ? 20 : 100, 1, slot === 'roll' ? undefined : slot, { ...sfx, [`${slot}_on`]: true })}>▶ Test</button> <button className="ghost" onClick={() => (slot === 'ping' ? playPing({ ...sfx, ping_on: true }) : playRoll(slot === 'roll' ? 20 : 100, 1, slot === 'roll' ? undefined : slot, { ...sfx, [`${slot}_on`]: true }))}>▶ Test</button>
<label className="uploadbtn small">Upload MP3<input type="file" accept="audio/mpeg,.mp3" hidden onChange={(e) => upload(slot, e)} /></label> <label className="uploadbtn small">Upload MP3<input type="file" accept="audio/mpeg,.mp3" hidden onChange={(e) => upload(slot, e)} /></label>
{sn.custom[slot] && <button className="ghost" onClick={() => act(`/api/lobbies/${id}/sounds/${slot}`, 'DELETE')}>Reset</button>} {sn.custom[slot] && <button className="ghost" onClick={() => act(`/api/lobbies/${id}/sounds/${slot}`, 'DELETE')}>Reset</button>}
</span> </span>
@@ -536,6 +624,7 @@ function SoundSettings({ lobby, id, act, sfx }) {
<label className="small chk"><input type="checkbox" checked={!!sn.result} onChange={(e) => flag('result', e.target.checked)} /> Short tones for success / failure of a check</label> <label className="small chk"><input type="checkbox" checked={!!sn.result} onChange={(e) => flag('result', e.target.checked)} /> Short tones for success / failure of a check</label>
{row('crit_success', 'Critical success', 'Built-in tone', 'crit_success')} {row('crit_success', 'Critical success', 'Built-in tone', 'crit_success')}
{row('crit_failure', 'Critical failure', 'Built-in tone', 'crit_failure')} {row('crit_failure', 'Critical failure', 'Built-in tone', 'crit_failure')}
{row('ping', 'Map ping', 'Built-in tone', 'ping')}
{err && <p className="warn small">{err}</p>} {err && <p className="warn small">{err}</p>}
</div> </div>
) )
@@ -632,14 +721,20 @@ function LobbyView({ id, rules, user }) {
const [showWalls, setShowWalls] = useState(true) const [showWalls, setShowWalls] = useState(true)
const loadMap = useCallback(() => fetch(`/api/lobbies/${id}/map${viewAs ? `?as=${encodeURIComponent(viewAs)}` : ''}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setMapState(d)).catch(() => {}), [id, viewAs]) const loadMap = useCallback(() => fetch(`/api/lobbies/${id}/map${viewAs ? `?as=${encodeURIComponent(viewAs)}` : ''}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setMapState(d)).catch(() => {}), [id, viewAs])
const mapImg = useMapImage(id, mapState.vision, viewAs) const mapImg = useMapImage(id, mapState.vision, viewAs)
const fogImg = useFogImage(id, mapState.vision?.fog, viewAs)
const sock = useRef({}) const sock = useRef({})
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 }) const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
const [pings, setPings] = useState([])
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),
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?.(),
lobby: () => sock.current.load?.(), lobby: () => sock.current.load?.(),
sheet: (n) => setSheetTick((t) => ({ cid: n.cid, n: t.n + 1 })), sheet: (n) => { setSheetTick((t) => ({ cid: n.cid, n: t.n + 1 })); sock.current.load?.(); sock.current.loadMap?.() }, // a saved character can bring a new name or portrait
}) })
useEffect(() => { useEffect(() => {
load(); loadMap() load(); loadMap()
@@ -652,6 +747,7 @@ function LobbyView({ id, rules, user }) {
sfx.current = sn ? { roll: sn.custom.roll ? `/api/lobbies/${id}/sounds/roll?v=${sn.v}` : DEFAULT_ROLL, sfx.current = sn ? { roll: sn.custom.roll ? `/api/lobbies/${id}/sounds/roll?v=${sn.v}` : DEFAULT_ROLL,
crit_success: sn.custom.crit_success ? `/api/lobbies/${id}/sounds/crit_success?v=${sn.v}` : null, crit_success: sn.custom.crit_success ? `/api/lobbies/${id}/sounds/crit_success?v=${sn.v}` : null,
crit_failure: sn.custom.crit_failure ? `/api/lobbies/${id}/sounds/crit_failure?v=${sn.v}` : null, crit_failure: sn.custom.crit_failure ? `/api/lobbies/${id}/sounds/crit_failure?v=${sn.v}` : null,
ping: sn.custom.ping ? `/api/lobbies/${id}/sounds/ping?v=${sn.v}` : null, ping_on: sn.ping,
result: sn.result, crit_success_on: sn.crit_success, crit_failure_on: sn.crit_failure } : { roll: DEFAULT_ROLL } result: sn.result, crit_success_on: sn.crit_success, crit_failure_on: sn.crit_failure } : { roll: DEFAULT_ROLL }
useEffect(() => { preload(sfx.current) }, [sn?.v]) // eslint-disable-line useEffect(() => { preload(sfx.current) }, [sn?.v]) // eslint-disable-line
const onRoll = useCallback((m) => { const onRoll = useCallback((m) => {
@@ -662,7 +758,16 @@ function LobbyView({ id, rules, user }) {
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000) setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
}, []) }, [])
const feed = useFeed(id, load, onRoll, up) const feed = useFeed(id, load, onRoll, up)
sock.current = { load, poll: feed.poll, loadMap } const showPing = (n) => {
if (!lobby) return
const mem = lobby.members.find((m) => m.username === n.user)
const color = n.dm ? lobby.dm_color : lobby.palette.find((p) => p.id === mem?.color)?.hex || '#ffffff'
const key = `${Date.now()}${Math.random()}`
setPings((l) => [...l.slice(-5), { key, x: n.x, y: n.y, color, name: n.dm ? 'DM' : mem?.character?.name || n.user }])
setTimeout(() => setPings((l) => l.filter((p) => p.key !== key)), 3500)
playPing(sfx.current)
}
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()
@@ -681,9 +786,42 @@ function LobbyView({ id, rules, user }) {
setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, ...p } : t)) })) setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, ...p } : t)) }))
return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', p).catch(() => null) return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', p).catch(() => null)
} }
const defSheet = () => Math.round(Math.min(800, Math.max(480, window.innerWidth * 0.42)))
const [sheetW, setSheetW] = useState(() => Number(store('aw_sheetw')) || defSheet())
const [chatW, setChatW] = useState(() => Number(store('aw_chatw')) || 380)
useEffect(() => { document.documentElement.style.setProperty('--chatw', `${chatW}px`); store('aw_chatw', String(chatW)) }, [chatW])
useEffect(() => () => document.documentElement.style.removeProperty('--chatw'), [])
useEffect(() => { store('aw_sheetw', String(sheetW)) }, [sheetW])
const [rv, setRv] = useState(null) // DM "show an area" tool: {origin, target, hover, fov, to}
useEffect(() => { if (rv || pingMode) tools.stop() }, [rv, pingMode]) // eslint-disable-line
useEffect(() => { if (tools.mode) { setRv(null); setPingMode(false) } }, [tools.mode])
const rvShape = (() => {
if (!rv?.origin) return null
const t = rv.target || rv.hover
if (!t) return { x: rv.origin.x, y: rv.origin.y, radius: 0.01, dir: 0, fov: rv.fov }
const dx = t.x - rv.origin.x, dy = t.y - rv.origin.y
return { x: rv.origin.x, y: rv.origin.y, radius: Math.max(0.5, Math.hypot(dx, dy) / (mapState.map?.grid || 50)), dir: (Math.atan2(dy, dx) * 180) / Math.PI, fov: rv.fov }
})()
const [menu, setMenu] = useState(null) // right click menu on a token
const [topOpen, setTopOpen] = useState(() => store('aw_topopen') === '1') // difficulty and player bar during a fight
const toggleTop = () => { setTopOpen((v) => { store('aw_topopen', v ? '0' : '1'); return !v }) }
const combatOp = (b) => act(`/api/lobbies/${id}/combat`, 'PUT', b)
const [npcPick, setNpcPick] = useState(false)
const [propPick, setPropPick] = useState(false)
const addPropToken = (p) => {
const m = mapState.map
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: true }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
}
const addNpcToken = (n) => {
const hp = n.hp > 0 ? n.hp : undefined
const m = mapState.map
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, 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>
@@ -693,6 +831,7 @@ function LobbyView({ id, rules, user }) {
const people = [lobby.dm, ...lobby.members.map((m) => m.username)] const people = [lobby.dm, ...lobby.members.map((m) => m.username)]
const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open
const cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0] const cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0]
const npcTok = lobby.is_dm && !viewAs ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc' && t.npc_id) : null
const dest = people.includes(to) ? to : '' const dest = people.includes(to) ? to : ''
const canCheck = !!(me && me.character_id) const canCheck = !!(me && me.character_id)
lobby.setMod = (u, v) => act(`/api/lobbies/${id}/members/${encodeURIComponent(u)}/mod`, 'PUT', { mod: parseInt(v, 10) || 0 }) lobby.setMod = (u, v) => act(`/api/lobbies/${id}/members/${encodeURIComponent(u)}/mod`, 'PUT', { mod: parseInt(v, 10) || 0 })
@@ -709,62 +848,85 @@ function LobbyView({ id, rules, user }) {
if (own) return setPending({ type: 'dice', sides, count }) if (own) return setPending({ type: 'dice', sides, count })
post(`/api/lobbies/${id}/rolls`, { sides, count, to: dest, ...(lobby.is_dm ? { mode } : {}) }) post(`/api/lobbies/${id}/rolls`, { sides, count, to: dest, ...(lobby.is_dm ? { mode } : {}) })
} }
const check = (kind, cid, name) => { const check = (kind, cid, name, npcToken) => {
if (busy) return if (busy) return
if (own) return setPending({ type: 'check', kind, id: cid, name }) if (own) return setPending({ type: 'check', kind, id: cid, name, npcToken })
post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '' }) post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '', ...(npcToken ? { npc_token: npcToken } : {}) })
} }
const submitManual = (vals) => { const submitManual = (vals) => {
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0], to: dest === user ? user : '' }, () => setPending(null)) if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0], to: dest === user ? user : '', ...(pending.npcToken ? { npc_token: pending.npcToken } : {}) }, () => setPending(null))
else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null)) else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null))
} }
return ( return (
<section className="page lobbypage"> <section className="page lobbypage">
<div className="lobbyhead">
<a className="small" href="#lobbies">← Lobbies</a>
<h1>{lobby.name}</h1>
<span className="muted small">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
<span className="tabright">
<button className="ghost" onClick={() => (lobby.is_dm ? (setSheetOpen(true), setLeftTab('lobby')) : setShowSettings((v) => !v))} aria-expanded={lobby.is_dm ? sheetOpen && leftTab === 'lobby' : showSettings}>⚙ Lobby</button>
</span>
</div>
{err && <p className="warn">{err}</p>} {err && <p className="warn">{err}</p>}
{info && <p className="small">{info}</p>} {info && <p className="small">{info}</p>}
<div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`}> <div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`} style={{ '--sheetw': `${sheetW}px` }}>
{sheetOpen && ( {sheetOpen && (
<div className="sheetcol"> <div className="sheetcol">
<div className="sheetbar"> <div className="sheetbar">
{lobby.is_dm ? ( {lobby.is_dm ? (
<span className="seg" role="tablist"> <span className="seg" role="tablist">
<button role="tab" className={leftTab === 'sheet' ? 'on' : 'ghost'} aria-selected={leftTab === 'sheet'} onClick={() => setLeftTab('sheet')}>Sheet{cur ? `: ${cur.character.name}` : ''}</button> <button role="tab" className={leftTab === 'sheet' ? 'on' : 'ghost'} aria-selected={leftTab === 'sheet'} onClick={() => setLeftTab('sheet')}>Sheet{npcTok ? `: ${npcTok.name}` : cur ? `: ${cur.character.name}` : ''}</button>
<button role="tab" className={leftTab === 'lobby' ? 'on' : 'ghost'} aria-selected={leftTab === 'lobby'} onClick={() => setLeftTab('lobby')}>Lobby actions</button> <button role="tab" className={leftTab === 'lobby' ? 'on' : 'ghost'} aria-selected={leftTab === 'lobby'} onClick={() => setLeftTab('lobby')}>Lobby actions</button>
</span> </span>
) : <b>{cur ? cur.character.name : 'Character sheet'}</b>} ) : <b>{cur ? cur.character.name : 'Character sheet'}</b>}
<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} /></>
: 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} /> : npcTok ? <CharTab key={`npc${npcTok.npc_id}`} cid={npcTok.npc_id} rules={rules} tick={sheetTick.cid === npcTok.npc_id ? sheetTick.n : 0} onRoll={(k, i, n) => check(k, i, n, npcTok.id)} />
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} editable={cur.me} />
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>} : <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>}
</div> </div>
)} )}
{sheetOpen && <Splitter value={sheetW} onChange={setSheetW} min={320} max={Math.max(400, window.innerWidth - chatW - 360)} label="Resize the sheet" className="sheetsplit" />}
<div className="playarea"> <div className="playarea">
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>} {!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
<CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setLeftTab('sheet') }} /> <div className="topbar">
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} /> <div className="topctl" role="group" aria-label="Fight controls">
{lobby.is_dm && !lobby.combat && <button className="ghost mini wide" onClick={() => act(`/api/lobbies/${id}/combat`, 'POST')} title="Start a fight">⚔</button>}
{lobby.is_dm && lobby.combat && <>
<button className="ghost mini" onClick={() => combatOp({ op: 'prev' })} title="Previous turn" aria-label="Previous turn">◀</button>
<button className="on mini" onClick={() => combatOp({ op: 'next' })} title="Next turn" aria-label="Next turn">▶</button>
<button className="ghost mini danger" onClick={() => window.confirm('End the fight?') && act(`/api/lobbies/${id}/combat`, 'DELETE')} title="End the fight" aria-label="End the fight">■</button>
</>}
<button className={`mini ${topOpen ? 'on' : 'ghost'}`} onClick={toggleTop} aria-expanded={topOpen} title="Players, difficulty and details">{topOpen ? '▴' : '▾'}</button>
</div>
<div className="topscroll">
{lobby.combat
? <InitiativeCards lobby={lobby} tokens={mapState.tokens} onOp={combatOp}
onPick={(e) => { if (e.kind === 'player') { setSel(e.user); setMapSel(null); setLeftTab('sheet') } else if (lobby.is_dm) { setMapSel(e.token); setLeftTab('sheet') } }} />
: <CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setMapSel(null); setLeftTab('sheet') }} />}
</div>
<button className="ghost lobbycfg" title="Lobby settings" onClick={() => (lobby.is_dm ? (setSheetOpen(true), setLeftTab('lobby')) : setShowSettings((v) => !v))} aria-expanded={lobby.is_dm ? sheetOpen && leftTab === 'lobby' : showSettings}>⚙ {lobby.name}</button>
</div>
{topOpen && (
<div className="topmore">
{lobby.combat && <CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setMapSel(null); setLeftTab('sheet') }} />}
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
</div>
)}
<div className="table"> <div className="table">
{showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />} {showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
{mapState.map && (() => { {mapState.map && (() => {
const full = lobby.is_dm && !viewAs const full = lobby.is_dm && !viewAs
const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null
const selProp = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'prop') : null
const walls = full && showWalls ? mapState.walls || [] : [] const walls = full && showWalls ? mapState.walls || [] : []
return ( return (
<MapView map={mapState.map} image={mapImg} portraitUrl={(t) => t.portrait ? `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}` : null} tokens={mapState.tokens} grid={showGrid} <MapView map={mapState.map} image={mapImg} fog={fogImg} pings={pings} onPing={(x, y) => send(`/api/lobbies/${id}/ping`, 'POST', { x, y }).catch(() => null)} propSrc={(pid) => `/api/lobbies/${id}/prop-image/${pid}`} portraitUrl={(t) => t.portrait ? (t.kind === 'npc' ? `/api/lobbies/${id}/token-portrait/${t.id}?v=${t.portrait}` : `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}`) : null} tokens={mapState.tokens} grid={showGrid}
onMove={moveToken} selected={mapSel} onSelect={setMapSel} onMove={moveToken} selected={mapSel} onSelect={setMapSel}
overlay={walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}> 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) }}
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) } : tools.active ? tools.onClick : 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}
{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 && (
@@ -773,15 +935,77 @@ 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.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.los_on && <>
{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>} <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>
<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>}
</>}
{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 && <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>}
<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}>+ NPC</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>
{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={propPick ? 'on' : 'ghost'} onClick={() => { setPropPick(!propPick); setNpcPick(false) }} title="Decor such as chests, tables or doors">+ Prop</button>}
</div> </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) }} /></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>
)}
{tools.panel}
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(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>}
{full && mapSel && (() => { const st = mapState.tokens.find((t) => t.id === mapSel); return st && !selTok && !selProp ? <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>}
</MapView> </MapView>
) )
})()} })()}
{menu && (() => {
const t = menu.t
if (t.kind === 'prop') {
const rot = t.rot || 0
return <RadialMenu x={menu.x} y={menu.y} title={t.name} onClose={() => setMenu(null)} items={[
{ icon: '⟲', label: 'Turn left', onClick: () => patchToken(t.id, { rot: (rot + 315) % 360 }) },
{ icon: '⟳', label: 'Turn right', onClick: () => patchToken(t.id, { rot: (rot + 45) % 360 }) },
{ icon: t.hidden ? '👁' : '🚫', label: t.hidden ? 'Reveal' : 'Hide', onClick: () => patchToken(t.id, { hidden: !t.hidden }) },
{ icon: '🗑', label: 'Remove', onClick: () => { send(`/api/lobbies/${id}/tokens/${t.id}`, 'DELETE'); setMapSel(null) } }]} />
}
const e = lobby.combat?.entries.find((x) => (t.kind === 'npc' ? x.token === t.id : x.user === t.username))
const fight = !!lobby.combat
return <RadialMenu x={menu.x} y={menu.y} title={t.name} onClose={() => setMenu(null)} items={[
{ icon: '⚔', label: e ? 'In fight' : fight ? 'Add to fight' : 'No fight', disabled: !fight || !!e, onClick: () => combatOp(t.kind === 'npc' ? { op: 'add', token: t.id } : { op: 'add', user: t.username }) },
{ icon: '📄', label: 'Sheet', disabled: t.kind === 'npc' && !t.npc_id, onClick: () => { if (t.kind === 'npc') setMapSel(t.id); else { setSel(t.username); setMapSel(null) } setLeftTab('sheet'); setSheetOpen(true) } },
{ icon: '❤', label: t.show_hp ? 'HP shown' : 'HP hidden', disabled: !(t.hp_max > 0), onClick: () => patchToken(t.id, { show_hp: !t.show_hp }) },
{ icon: '☣', label: t.show_cond ? 'Cond. shown' : 'Cond. hidden', onClick: () => patchToken(t.id, { show_cond: !t.show_cond }) },
...(t.kind === 'npc' ? [
{ icon: t.hidden ? '👁' : '🚫', label: t.hidden ? 'Reveal' : 'Hide', onClick: () => patchToken(t.id, { hidden: !t.hidden }) },
{ icon: '🗑', label: 'Remove', onClick: () => { send(`/api/lobbies/${id}/tokens/${t.id}`, 'DELETE'); setMapSel(null) } }] : []),
]} />
})()}
<DiceTray rolls={tray} /> <DiceTray rolls={tray} />
{canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>} {canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>}
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />} {pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
@@ -791,6 +1015,7 @@ function LobbyView({ id, rules, user }) {
</div> </div>
<aside className="lobbychat card" aria-label="Chat"> <aside className="lobbychat card" aria-label="Chat">
<Splitter value={chatW} onChange={setChatW} min={240} max={Math.max(300, Math.min(900, window.innerWidth - 700))} dir={-1} label="Resize the chat" className="chatsplit" />
<Chat id={id} me={user} people={people} msgs={feed.msgs} poll={feed.poll} to={dest} setTo={setTo} /> <Chat id={id} me={user} people={people} msgs={feed.msgs} poll={feed.poll} to={dest} setTo={setTo} />
</aside> </aside>
</div> </div>
+282 -23
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { BUILTIN, PropArt } from './props.jsx'
const COLORS = ['#c0392b', '#8e44ad', '#16a085', '#7f8c8d', '#d35400', '#2c3e50', '#27ae60', '#b9770e'] const COLORS = ['#c0392b', '#8e44ad', '#16a085', '#7f8c8d', '#d35400', '#2c3e50', '#27ae60', '#b9770e']
const clamp = (v, a, b) => Math.min(b, Math.max(a, v)) const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
// Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels. // Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels.
// Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise. // Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise.
export function MapView({ map, image, portraitUrl, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) { export function MapView({ map, image, fog, portraitUrl, propSrc, pings, onPing, onMenu, isTurn, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
const box = useRef(null) const box = useRef(null)
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 }) const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
const [drag, setDrag] = useState(null) const [drag, setDrag] = useState(null)
@@ -46,7 +47,7 @@ export function MapView({ map, image, portraitUrl, tokens, onMove, selected, onS
}, []) }, [])
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)
@@ -109,24 +110,47 @@ export function MapView({ map, image, portraitUrl, tokens, onMove, selected, onS
window.addEventListener('pointermove', mv); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end) window.addEventListener('pointermove', mv); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end)
} }
const unplaced = tokens.filter((t) => t.x == null && t.can_move) const unplaced = tokens.filter((t) => t.x == null && t.can_move && t.kind !== 'prop')
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, .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} />}
{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} />}
{showGrid && <div className="mapgrid" style={{ backgroundSize: `${map.grid}px ${map.grid}px` }} />} {showGrid && <div className="mapgrid" style={{ backgroundSize: `${map.grid}px ${map.grid}px` }} />}
{overlay} {overlay}
{tokens.map((t) => { {pings?.map((p) => (
<div key={p.key} className="mapping" style={{ left: p.x, top: p.y, '--pc': p.color, '--ps': `${map.grid * 1.6}px` }}>
<i /><i /><b style={{ fontSize: Math.max(11, map.grid * 0.3) }}>{p.name}</b>
</div>
))}
{[...tokens].sort((a, b) => (b.kind === 'prop') - (a.kind === 'prop')).map((t) => {
const dr = drag?.id === t.id ? drag : null const dr = drag?.id === t.id ? drag : null
const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y
if (x == null || (dr && dr.out)) return null if (x == null || (dr && dr.out)) return null
if (t.kind === 'prop') { // decor: below the tokens, no label, rotated
const w = t.size * map.grid, h = (t.size_h || t.size) * map.grid
return (
<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) } }}
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)' : '') + (t.note ? `\n\n${t.note}` : '')}>
<PropArt prop={t.prop} customSrc={propSrc} />
{t.note && <b className="toknote" aria-hidden="true">📝</b>}
</div>
)
}
return ( return (
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`} <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) } }}
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.conds && Object.keys(t.conds).length > 0 && <span className="tokconds">{Object.entries(t.conds).map(([k, lv]) => <u key={k} title={`${k} ${lv}`}>{k.slice(0, 2)}{lv}</u>)}</span>}
</div> </div>
) )
})} })}
@@ -149,6 +173,52 @@ export function MapView({ map, image, portraitUrl, tokens, onMove, selected, onS
) )
} }
// Small ring of round buttons around a point (right click on a token). items: [{icon, label, onClick, disabled}]
export function RadialMenu({ x, y, title, items, onClose }) {
useEffect(() => {
const k = (e) => e.key === 'Escape' && onClose()
window.addEventListener('keydown', k)
return () => window.removeEventListener('keydown', k)
}, [onClose])
const R = items.length > 4 ? 74 : 62
return createPortal(
<div className="radialback" onPointerDown={onClose} onContextMenu={(e) => { e.preventDefault(); onClose() }}>
<div className="radial" style={{ left: x, top: y }} onPointerDown={(e) => e.stopPropagation()}>
<span className="radialtitle">{title}</span>
{items.map((it, i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / items.length
return (
<button key={it.label} className="radialbtn" disabled={it.disabled} title={it.label}
style={{ transform: `translate(${Math.cos(a) * R}px, ${Math.sin(a) * R}px)` }}
onClick={() => { it.onClick(); onClose() }}>
<span className="ri">{it.icon}</span><span className="rl">{it.label}</span>
</button>
)
})}
</div>
</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 (
@@ -157,6 +227,130 @@ 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">
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
<button className="ghost danger" onClick={onDelete}>Remove</button>
</span>
</div>
)
}
// HP and conditions of one token (DM). conditions = rules.conditions
export function StatusForm({ t, conditions, onChange }) {
const conds = t.conds || {}
const cycle = (id) => { const lv = ((conds[id] || 0) + 1) % 4; const n = { ...conds }; if (lv) n[id] = lv; else delete n[id]; onChange({ conds: n }) }
const tracking = t.hp_max > 0 && t.hp != null
const hint = t.hp_hint > 0 ? t.hp_hint : 10
return (
<div className="statusform">
<div className="row hprow">
<b className="small">HP</b>
{tracking ? <>
<button className="ghost mini" onClick={() => onChange({ hp: t.hp - 5 })}>−5</button>
<button className="ghost mini" onClick={() => onChange({ hp: t.hp - 1 })}>−1</button>
<input type="number" value={t.hp} aria-label="Current HP" onChange={(e) => onChange({ hp: e.target.value })} />
<span className="small">/</span>
<input type="number" min="1" value={t.hp_max} aria-label="Maximum HP" onChange={(e) => onChange({ hp_max: e.target.value })} />
<button className="ghost mini" onClick={() => onChange({ hp: t.hp + 1 })}>+1</button>
<button className="ghost mini" onClick={() => onChange({ hp: t.hp + 5 })}>+5</button>
<button className="ghost mini" onClick={() => onChange({ hp_max: null })} title="Stop tracking HP">×</button>
</> : <button className="ghost" onClick={() => onChange({ hp_max: hint, hp: hint })}>Track HP ({hint})</button>}
</div>
<div className="condrow">
{conditions.map((c) => (
<button key={c.id} className={conds[c.id] ? 'on mini' : 'ghost mini'} onClick={() => cycle(c.id)} title={`${c.levels?.[(conds[c.id] || 1) - 1] || ''} (click to raise the level, 3 → off)`}>
{c.name}{conds[c.id] ? ` ${conds[c.id]}` : ''}
</button>
))}
</div>
<p className="small muted">Players see HP and conditions only where you switched them on (right click the token).</p>
</div>
)
}
// Picks an NPC from the library (own and shared). onPick(npc) gets {id, name, kind, size, portrait}.
export function NpcPicker({ onPick, onClose }) {
const [list, setList] = useState(null)
const [q, setQ] = useState('')
useEffect(() => { fetch('/api/characters?npc=1').then((r) => r.json()).then((d) => setList([...d.mine, ...d.shared])).catch(() => setList([])) }, [])
const shown = (list || []).filter((n) => n.name.toLowerCase().includes(q.toLowerCase()))
return (
<div className="npcpicker card" role="dialog" aria-label="NPC library">
<div className="row"><strong>NPC library</strong><button className="ghost" onClick={onClose} aria-label="Close">×</button></div>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search" aria-label="Search NPCs" />
<ul className="npcpick">
{list === null && <li className="muted small">Loading …</li>}
{list && !list.length && <li className="muted small">No NPCs yet. Create some on the <a href="#npcs">NPCs</a> page.</li>}
{shown.map((n) => (
<li key={n.id}><button className="ghost" onClick={() => onPick(n)}>
{n.portrait ? <img className="npcthumb" src={`/api/characters/${n.id}/portrait?v=${n.portrait}`} alt="" /> : <span className="npcthumb none" aria-hidden="true">?</span>}
<span>{n.name}{n.owner ? <span className="muted small"> · {n.owner}</span> : null}</span>
</button></li>
))}
</ul>
</div>
)
}
// Library of decor tokens: built-in drawings and the DM's own uploaded images. onPick({prop, name, w, h}).
export function PropPicker({ onPick, onClose }) {
const [mine, setMine] = useState([])
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(() => fetch('/api/props').then((r) => (r.ok ? r.json() : [])).then(setMine).catch(() => {}), [])
useEffect(() => { load() }, [load])
const upload = async (e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr(''); setBusy(true)
const r = await fetch(`/api/props?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f })
setBusy(false)
if (!r.ok) { setErr(r.status === 413 ? 'The image is too large (at most 3 MB).' : (await r.json().catch(() => ({}))).detail || 'Upload failed'); return }
load()
}
return (
<div className="npcpicker card proppicker" role="dialog" aria-label="Prop library">
<div className="row"><strong>Props</strong><button className="ghost" onClick={onClose} aria-label="Close">×</button></div>
<div className="propgrid">
{BUILTIN.map((p) => (
<button key={p.id} className="ghost propbtn" title={p.name} onClick={() => onPick({ prop: `b:${p.id}`, name: p.name, w: p.w, h: p.h })}>
<svg viewBox="0 0 100 100" aria-hidden="true">{p.art}</svg><span>{p.name}</span>
</button>
))}
{mine.map((p) => (
<span key={p.id} className="propbtn own">
<button className="ghost" title={p.name} onClick={() => onPick({ prop: `c:${p.id}`, name: p.name, w: 1, h: 1 })}><img src={`/api/props/${p.id}/image`} alt="" /><span>{p.name}</span></button>
<button className="ghost mini danger" aria-label={`Delete ${p.name}`} onClick={() => window.confirm(`Delete the prop "${p.name}"? It disappears from all maps.`) && fetch(`/api/props/${p.id}`, { method: 'DELETE' }).then(load)}>×</button>
</span>
))}
</div>
<label className="uploadbtn">{busy ? 'Uploading…' : '+ Own image (PNG with transparency)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
{err && <p className="warn small">{err}</p>}
</div>
)
}
// Name, size, rotation and visibility of a decor token
export function PropForm({ t, onChange, onDelete, onDuplicate }) {
const rot = Math.round(t.rot || 0)
return (
<div className="tokenform">
<input value={t.name} maxLength={40} aria-label="Prop name" onChange={(e) => onChange({ name: e.target.value })} />
<span className="row">
<label className="small chk">Width <input type="number" min="0.25" max="8" step="0.25" value={t.size} onChange={(e) => onChange({ size: Number(e.target.value) || 1 })} /></label>
<label className="small chk">Height <input type="number" min="0.25" max="8" step="0.25" value={t.size_h || t.size} onChange={(e) => onChange({ size_h: Number(e.target.value) || 1 })} /></label>
</span>
<label className="small">Rotation {rot}°
<input type="range" min="0" max="359" step="5" value={rot} onChange={(e) => onChange({ rot: Number(e.target.value) })} /></label>
<span className="row">
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 315) % 360 })} title="Rotate left 45°">⟲ 45°</button>
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 45) % 360 })} title="Rotate right 45°">⟳ 45°</button>
<button className="ghost mini" onClick={() => onChange({ rot: (rot + 90) % 360 })} title="Rotate right 90°">⟳ 90°</button>
</span>
<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>
@@ -167,6 +361,24 @@ export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
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 = {}) => (
@@ -196,7 +408,7 @@ const distToSeg = (p, w) => {
} }
// Full-screen editor for a map in the DM's library: grid, enemies and NPCs, walls and doors for line of sight. // Full-screen editor for a map in the DM's library: grid, enemies and NPCs, walls and doors for line of sight.
export function MapEditor({ id, onClose }) { export function MapEditor({ id, onClose, live, onSaved }) {
const [m, setM] = useState(null) const [m, setM] = useState(null)
const [tokens, setTokens] = useState([]) const [tokens, setTokens] = useState([])
const [walls, setWalls] = useState([]) const [walls, setWalls] = useState([])
@@ -213,7 +425,7 @@ export function MapEditor({ id, onClose }) {
const [dirty, setDirty] = useState(false) const [dirty, setDirty] = useState(false)
useEffect(() => { useEffect(() => {
fetch(`/api/maps/${id}`).then((r) => r.json()).then((d) => { fetch(`/api/maps/${id}`).then((r) => r.json()).then((d) => {
setM(d); setTokens(d.tokens.map((t) => ({ ...t, kind: 'npc', can_move: true }))); setWalls(d.walls); setName(d.name); setGrid(d.grid); setSight(d.sight || 0) setM(d); setTokens(d.tokens.map((t) => ({ ...t, kind: t.prop ? 'prop' : 'npc', can_move: true }))); setWalls(d.walls); setName(d.name); setGrid(d.grid); setSight(d.sight || 0)
}) })
}, [id]) }, [id])
useEffect(() => { useEffect(() => {
@@ -224,11 +436,23 @@ 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)
const [propPick, setPropPick] = 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 addNpc = (n) => {
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 }
setTokens([...tokens, t]); setSel(t.id); setDirty(true); setPicker(false)
}
const addProp = (p) => {
const k = tokens.length + 1
const t = { id: uid(), kind: 'prop', can_move: true, name: p.name, prop: p.prop, rot: 0, x: m.w / 2 + (k % 5) * gridNum, y: m.h / 2 + (k % 3) * gridNum, size: p.w, size_h: p.h, color: COLORS[0], hidden: false }
setTokens([...tokens, t]); setSel(t.id); setDirty(true); setPropPick(false)
}
const add = () => { const add = () => {
const n = tokens.length + 1 const n = tokens.length + 1
const t = { id: uid(), kind: 'npc', can_move: true, name: `Enemy ${n}`, x: m.w / 2 + (n % 5) * gridNum, y: m.h / 2 + (n % 3) * gridNum, size: 1, color: COLORS[0], hidden: false } const t = { id: uid(), kind: 'npc', can_move: true, name: `Enemy ${n}`, x: m.w / 2 + (n % 5) * gridNum, y: m.h / 2 + (n % 3) * gridNum, size: 1, color: COLORS[0], hidden: false }
@@ -263,9 +487,9 @@ export function MapEditor({ id, onClose }) {
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 }) => ({ id: i, name: n, x, y, size, color, hidden })) }) }) 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); return true setDirty(false); onSaved?.(); return true
} }
const close = async () => { if (!dirty || window.confirm('Close without saving your changes?')) onClose() } const close = async () => { if (!dirty || window.confirm('Close without saving your changes?')) onClose() }
const tools = [['select', 'Move tokens'], ['wall', 'Wall'], ['rect', 'Room'], ['door', 'Door'], ['erase', 'Erase']] const tools = [['select', 'Move tokens'], ['wall', 'Wall'], ['rect', 'Room'], ['door', 'Door'], ['erase', 'Erase']]
@@ -295,20 +519,31 @@ export function MapEditor({ id, onClose }) {
</div> </div>
<label className="small">Sight range in squares (0 = unlimited) <label className="small">Sight range in squares (0 = unlimited)
<input type="number" min="0" max="1000" value={sight} onChange={(e) => { setSight(e.target.value); setDirty(true) }} /></label> <input type="number" min="0" max="1000" value={sight} onChange={(e) => { setSight(e.target.value); setDirty(true) }} /></label>
<h4>Enemies and NPCs</h4> <h4>{live ? 'Prepared tokens (used when the map is put on the table again)' : 'Enemies, NPCs and props'}</h4>
<button className="on" onClick={add}>+ Add token</button> <div className="row"><button className="on" onClick={add}>+ Add token</button><button className="ghost" onClick={() => { setPicker(!picker); setPropPick(false) }}>+ From NPC library</button><button className="ghost" onClick={() => { setPropPick(!propPick); setPicker(false) }}>+ Prop</button></div>
{picker && <NpcPicker onPick={addNpc} onClose={() => setPicker(false)} />}
{propPick && <PropPicker onPick={addProp} onClose={() => setPropPick(false)} />}
<ul className="tokenlist"> <ul className="tokenlist">
{tokens.map((t) => <li key={t.id}><button className={sel === t.id ? 'on' : 'ghost'} onClick={() => setSel(t.id)}><i style={{ background: t.color }} /> {t.name}{t.hidden ? ' (hidden)' : ''}</button></li>)} {tokens.map((t) => (
{!tokens.length && <li className="muted small">None yet. Add a token, then drag it onto the map.</li>} <li key={t.id} className="tokrow">
<button className={sel === t.id ? 'on' : 'ghost'} onClick={() => setSel(t.id)}>{t.kind === 'prop' ? <i className="propdot" /> : <i style={{ background: t.color }} />} {t.name}{t.npc_id ? ' ★' : ''}{t.hidden ? ' (hidden)' : ''}</button>
<button className="ghost mini" aria-pressed={!t.hidden} aria-label={t.hidden ? `Show ${t.name} to players` : `Hide ${t.name} from players`} title={t.hidden ? 'Hidden from players - click to show' : 'Visible to players - click to hide'}
onClick={() => patch(t.id, { hidden: !t.hidden })}>{t.hidden ? '🚫' : '👁'}</button>
</li>
))}
{!tokens.length && <li className="muted small">None yet. Add a token or a prop, then drag it onto the map.</li>}
</ul> </ul>
{cur && <TokenForm t={cur} onChange={(p) => patch(cur.id, p)} {cur && (() => {
onDelete={() => { setTokens(tokens.filter((t) => t.id !== cur.id)); setSel(null); setDirty(true) }} const props = { t: cur, onChange: (p) => patch(cur.id, p),
onDuplicate={() => { const c = { ...cur, id: uid(), name: cur.name.replace(/\s*\d*$/, '') + ' ' + (tokens.length + 1), x: cur.x + gridNum, y: cur.y + gridNum }; setTokens([...tokens, c]); setSel(c.id); setDirty(true) }} />} onDelete: () => { setTokens(tokens.filter((t) => t.id !== cur.id)); setSel(null); setDirty(true) },
onDuplicate: () => { const c = { ...cur, id: uid(), name: cur.name.replace(/\s*\d*$/, '') + ' ' + (tokens.length + 1), x: cur.x + gridNum, y: cur.y + gridNum }; setTokens([...tokens, c]); setSel(c.id); setDirty(true) } }
return cur.kind === 'prop' ? <PropForm {...props} /> : <TokenForm {...props} />
})()}
{err && <p className="warn small">{err}</p>} {err && <p className="warn small">{err}</p>}
<div className="row editactions"><button className="on" disabled={!dirty} onClick={save}>Save</button><button className="ghost" onClick={close}>Close</button></div> <div className="row editactions"><button className="on" disabled={!dirty} onClick={save}>Save</button><button className="ghost" onClick={close}>Close</button></div>
</aside> </aside>
<div className="mapstage"> <div className="mapstage">
<MapView map={{ ...m, grid: gridNum }} image={image} tokens={tokens} grid={showGrid} selected={sel} onSelect={setSel} tool={tool} <MapView map={{ ...m, grid: gridNum }} image={image} tokens={tokens} propSrc={(pid) => `/api/props/${pid}/image`} portraitUrl={(t) => (t.npc_id && t.portrait ? `/api/characters/${t.npc_id}/portrait?v=${t.portrait}` : null)} grid={showGrid} selected={sel} onSelect={setSel} tool={tool}
onClick={click} onHover={draft ? (p, s) => setHover(p) : undefined} onClick={click} onHover={draft ? (p, s) => setHover(p) : undefined}
overlay={<WallLayer map={m} walls={walls} draft={draft} hover={draft ? hover : null} />} overlay={<WallLayer map={m} walls={walls} draft={draft} hover={draft ? hover : null} />}
onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} /> onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
@@ -322,6 +557,9 @@ export function MapLibrary({ activeId, lobbyId, act }) {
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [scenes, setScenes] = useState([])
const loadScenes = useCallback(() => (lobbyId ? fetch(`/api/lobbies/${lobbyId}/scenes`).then((r) => (r.ok ? r.json() : [])).then(setScenes).catch(() => {}) : null), [lobbyId])
useEffect(() => { loadScenes() }, [loadScenes, activeId])
const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => {}), []) const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => {}), [])
useEffect(() => { load() }, [load, editing]) useEffect(() => { load() }, [load, editing])
const upload = async (e) => { const upload = async (e) => {
@@ -345,16 +583,37 @@ export function MapLibrary({ activeId, lobbyId, act }) {
<strong>{m.name}</strong> <span className="muted small">{m.w}×{m.h}, {m.tokens} token{m.tokens === 1 ? '' : 's'}, {m.walls} wall{m.walls === 1 ? '' : 's'}{!m.mine && ` · from ${m.author}`}</span> <strong>{m.name}</strong> <span className="muted small">{m.w}×{m.h}, {m.tokens} token{m.tokens === 1 ? '' : 's'}, {m.walls} wall{m.walls === 1 ? '' : 's'}{!m.mine && ` · from ${m.author}`}</span>
<span className="row"> <span className="row">
{m.id === activeId {m.id === activeId
? <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null })}>Clear table</button> ? <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null }, () => { loadScenes() })}>Clear table</button>
: <button className="on" onClick={() => (!activeId || window.confirm('Replace the current map? Tokens on it are reset to the prepared ones.')) && act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: m.id })}>Show on table</button>} : <button className="on" onClick={() => (!activeId || window.confirm('Put this map on the table? The current table is saved as a scene first - you can switch back to it below.')) && act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: m.id }, () => { loadScenes() })}>Show on table</button>}
{m.mine && <button className="ghost" onClick={() => setEditing(m.id)}>Edit</button>} {m.mine && <button className="ghost" onClick={() => setEditing(m.id)} title={m.id === activeId ? 'Walls, grid and sight apply to the running game at once' : ''}>{m.id === activeId ? 'Edit live' : 'Edit'}</button>}
{m.mine && <button className="ghost danger" onClick={() => window.confirm(`Delete the map "${m.name}"?`) && fetch(`/api/maps/${m.id}`, { method: 'DELETE' }).then(load)}>Delete</button>} {m.mine && <button className="ghost danger" onClick={() => window.confirm(`Delete the map "${m.name}"?`) && fetch(`/api/maps/${m.id}`, { method: 'DELETE' }).then(load)}>Delete</button>}
</span> </span>
</li> </li>
))} ))}
{!list.length && <li className="muted small">No maps yet. Upload an image, then prepare enemies in the editor.</li>} {!list.length && <li className="muted small">No maps yet. Upload an image, then prepare enemies in the editor.</li>}
</ul> </ul>
{editing && <MapEditor id={editing} onClose={() => setEditing(null)} />} {lobbyId && (
<>
<h4>Scenes</h4>
<p className="small muted">Every map you put on the table is a scene. Switching saves the current one (tokens, doors, explored ground, revealed areas, fight) and loads the other exactly as you left it.</p>
<ul className="maplist">
{scenes.map((sc) => (
<li key={sc.id} className={sc.current ? 'active' : ''}>
<strong>{sc.name}</strong> <span className="muted small">{sc.map_name ? `Map: ${sc.map_name}` : 'map deleted'} · saved {new Date(sc.updated * 1000).toLocaleString()}{sc.current ? ' · running' : ''}</span>
<span className="row">
{!sc.current && <button className="on" disabled={!sc.map_name} onClick={() => act(`/api/lobbies/${lobbyId}/scene`, 'PUT', { scene_id: sc.id }, () => { loadScenes(); load() })}>Switch to</button>}
{sc.current && <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'PUT', { save: true }, () => loadScenes())}>Save now</button>}
<button className="ghost" onClick={() => { const n = window.prompt('New name', sc.name); if (n) act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'PUT', { name: n }, () => loadScenes()) }}>Rename</button>
<button className="ghost danger" onClick={() => window.confirm(`Delete the scene "${sc.name}"?${sc.current ? ' The table keeps running, but this state is no longer stored.' : ''}`) && act(`/api/lobbies/${lobbyId}/scenes/${sc.id}`, 'DELETE', undefined, () => loadScenes())}>Delete</button>
</span>
</li>
))}
{!scenes.length && <li className="muted small">No scenes yet. Put a map on the table.</li>}
</ul>
{activeId && <button className="ghost" onClick={() => { const n = window.prompt('Name for the checkpoint', ''); if (n !== null) act(`/api/lobbies/${lobbyId}/scenes`, 'POST', { name: n }, () => loadScenes()) }}>Save a copy of the current table</button>}
</>
)}
{editing && <MapEditor id={editing} live={editing === activeId} onClose={() => setEditing(null)} />}
</div> </div>
) )
} }
+68 -2
View File
@@ -1,8 +1,26 @@
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) => (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('')
@@ -31,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)
@@ -45,7 +109,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()
@@ -86,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>
) )
+43 -15
View File
@@ -31,6 +31,8 @@ export function printSheet(root, title) {
const win = window.open('', '_blank') const win = window.open('', '_blank')
if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return } if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return }
const clone = root.cloneNode(true) const clone = root.cloneNode(true)
const src = root.querySelectorAll('textarea'), dst = clone.querySelectorAll('textarea')
dst.forEach((t, i) => { t.textContent = src[i].value }) // typed text does not survive cloning on its own
clone.style.zoom = '' clone.style.zoom = ''
clone.classList.remove('dark') // printed pages are always light clone.classList.remove('dark') // printed pages are always light
const head = [...document.querySelectorAll('link[rel=stylesheet], style')].map((n) => n.outerHTML).join('') const head = [...document.querySelectorAll('link[rel=stylesheet], style')].map((n) => n.outerHTML).join('')
@@ -47,6 +49,17 @@ export function printSheet(root, title) {
links.forEach((l) => { l.addEventListener('load', () => { if (--pending === 0) setTimeout(go, 150) }); l.addEventListener('error', () => { if (--pending === 0) go() }) }) links.forEach((l) => { l.addEventListener('load', () => { if (--pending === 0) setTimeout(go, 150) }); l.addEventListener('error', () => { if (--pending === 0) go() }) })
} }
// Ruled note area. With onChange (own sheet in the summary or the lobby) it can be typed into, otherwise it just shows the text.
function Notes({ title, value, onChange }) {
return (
<div className="psec pnotes"><h3>{title}</h3>
{onChange
? <textarea className="plines pwrite" value={value || ''} maxLength={5000} onChange={(e) => onChange(e.target.value)} placeholder="Click here to write …" aria-label={title} />
: <div className="plines pwrite">{value}</div>}
</div>
)
}
// Scales the A4 pages down when the space is narrower than the paper // Scales the A4 pages down when the space is narrower than the paper
function Fit({ children, rootRef, dark }) { function Fit({ children, rootRef, dark }) {
const wrap = useRef(null) const wrap = useRef(null)
@@ -68,7 +81,7 @@ const Pips = ({ n, locked }) => (
) )
// The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes). // The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes).
export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) { export default function PaperSheet({ data, baseRules, tools, bare, onRoll, onNotes }) {
const ch = useMemo(() => normalize(data, baseRules), [data, baseRules]) const ch = useMemo(() => normalize(data, baseRules), [data, baseRules])
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch]) const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
const c = useMemo(() => compute(ch, rules), [ch, rules]) const c = useMemo(() => compute(ch, rules), [ch, rules])
@@ -108,18 +121,26 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
)} )}
<Fit rootRef={root} dark={dark}> <Fit rootRef={root} dark={dark}>
<section className="paper"> <section className="paper">
<header className="phead"> {(() => {
<div className="pname">{name}</div> const head = (
{ch.portrait && <img className="pportrait" src={ch.portrait} alt="" />} <header className="phead">
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div> <div className="pname">{name}</div>
</header> <div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
<div className="pfields"> </header>
<Field label="Player">{ch.player}</Field> )
<Field label="Concept">{ch.concept}</Field> const fields = (
<Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field> <div className="pfields">
<Field label="Profession">{prof?.name}</Field> <Field label="Player">{ch.player}</Field>
<Field label="Rules">{preset}</Field> <Field label="Concept">{ch.concept}</Field>
</div> <Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field>
<Field label="Profession">{prof?.name}</Field>
<Field label="Rules">{preset}</Field>
</div>
)
return ch.portrait
? <div className="pidentity"><img className="pportrait" src={ch.portrait} alt="" /><div className="pidright">{head}{fields}</div></div>
: <>{head}{fields}</>
})()}
<div className="pattrs"> <div className="pattrs">
{rules.attributes.map((a) => ( {rules.attributes.map((a) => (
@@ -158,7 +179,14 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
))} ))}
</div> </div>
</div> </div>
<div className="psec pnotes"><h3>Conditions &amp; notes</h3><div className="plines" /></div> {data.npc && (data.abilities || data.loot || data.background) && (
<div className="psec pnpc">
{data.background && <><h3>Description</h3><p className="pre">{data.background}</p></>}
{data.abilities && <><h3>Abilities and attacks</h3><p className="pre">{data.abilities}</p></>}
{data.loot && <><h3>Loot</h3><p className="pre">{data.loot}</p></>}
</div>
)}
<Notes title="Conditions &amp; notes" value={ch.conditions} onChange={onNotes && ((v) => onNotes({ conditions: v }))} />
<Foot n={1} /> <Foot n={1} />
</section> </section>
@@ -201,7 +229,7 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
{ch.background && <div className="psec"><h3>Background</h3><p className="pbg">{ch.background}</p></div>} {ch.background && <div className="psec"><h3>Background</h3><p className="pbg">{ch.background}</p></div>}
<div className="psec pnotes"><h3>Notes</h3><div className="plines" /></div> <Notes title="Notes" value={ch.notes} onChange={onNotes && ((v) => onNotes({ notes: v }))} />
<Foot n={2} /> <Foot n={2} />
</section> </section>
</Fit> </Fit>
+1 -1
View File
@@ -6,7 +6,7 @@ export default function Presets({ rules, reload }) {
const [sel, setSel] = useState(rules.default_preset || 'standard') const [sel, setSel] = useState(rules.default_preset || 'standard')
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
const presets = rules.presets || [] const presets = (rules.presets || []).filter((p) => !p.npc)
const current = draft || presets.find((p) => p.id === sel) || presets[0] const current = draft || presets.find((p) => p.id === sel) || presets[0]
const editable = !!draft const editable = !!draft
const own = !!current?.mine const own = !!current?.mine
+182
View File
@@ -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>
)
}
+1 -1
View File
@@ -14,7 +14,7 @@ export function emptyCharacter(rules) {
talents: [], augments: [], talents: [], augments: [],
kit: { extras: {} }, // item id -> quantity kit: { extras: {} }, // item id -> quantity
gear: { other: '' }, gear: { other: '' },
notes: '', notes: '', conditions: '',
} }
} }
+37
View File
@@ -0,0 +1,37 @@
// Decor tokens ("props"): drawn from above, purely visual. Built-in ones are SVG (id "b:<name>"), own uploads are images (id "c:<id>").
// w / h are the default size in grid squares.
const wood = '#8a5a2b', dark = '#5b3a18', light = '#b98545', metal = '#8d949b', stone = '#7d7f84', green = '#3f7d3a'
export const BUILTIN = [
{ id: 'chest', name: 'Chest', w: 1, h: 0.7, art: <><rect x="4" y="12" width="92" height="76" rx="8" fill={wood} stroke={dark} strokeWidth="5" /><rect x="4" y="40" width="92" height="8" fill={dark} /><rect x="42" y="34" width="16" height="20" rx="3" fill="#e0b84a" stroke={dark} strokeWidth="3" /><path d="M24 12v76M76 12v76" stroke={dark} strokeWidth="5" /></> },
{ id: 'barrel', name: 'Barrel', w: 1, h: 1, art: <><circle cx="50" cy="50" r="45" fill={wood} stroke={dark} strokeWidth="5" /><circle cx="50" cy="50" r="33" fill="none" stroke={dark} strokeWidth="4" /><circle cx="50" cy="50" r="20" fill={light} stroke={dark} strokeWidth="3" /><path d="M30 50h40" stroke={dark} strokeWidth="3" /></> },
{ id: 'crate', name: 'Crate', w: 1, h: 1, art: <><rect x="5" y="5" width="90" height="90" fill={light} stroke={dark} strokeWidth="5" /><path d="M5 5l90 90M95 5L5 95" stroke={dark} strokeWidth="5" /><rect x="5" y="5" width="90" height="90" fill="none" stroke={dark} strokeWidth="10" opacity=".4" /></> },
{ id: 'table', name: 'Table', w: 2, h: 1, art: <><rect x="3" y="12" width="94" height="76" rx="6" fill={light} stroke={dark} strokeWidth="5" /><path d="M12 30h76M12 50h76M12 70h76" stroke={wood} strokeWidth="2" opacity=".7" /></> },
{ id: 'roundtable', name: 'Round table', w: 2, h: 2, art: <><circle cx="50" cy="50" r="46" fill={light} stroke={dark} strokeWidth="5" /><circle cx="50" cy="50" r="30" fill="none" stroke={wood} strokeWidth="2" opacity=".7" /></> },
{ id: 'chair', name: 'Chair', w: 0.6, h: 0.6, art: <><rect x="12" y="25" width="76" height="68" rx="8" fill={wood} stroke={dark} strokeWidth="6" /><rect x="8" y="6" width="84" height="22" rx="6" fill={dark} /></> },
{ id: 'bed', name: 'Bed', w: 1, h: 2, art: <><rect x="4" y="3" width="92" height="94" rx="8" fill={dark} /><rect x="9" y="8" width="82" height="84" rx="6" fill="#a8423b" /><rect x="14" y="12" width="72" height="22" rx="8" fill="#eee" stroke="#bbb" strokeWidth="2" /><path d="M9 46h82" stroke="#7b2d28" strokeWidth="4" /></> },
{ id: 'door', name: 'Door (decor)', w: 1, h: 0.25, art: <><rect x="2" y="15" width="96" height="70" rx="6" fill={wood} stroke={dark} strokeWidth="8" /><circle cx="80" cy="50" r="7" fill="#e0b84a" /></> },
{ id: 'campfire', name: 'Campfire', w: 1, h: 1, art: <><circle cx="50" cy="50" r="46" fill="#3b2f2a" /><path d="M20 70L80 30M20 30L80 70" stroke={dark} strokeWidth="9" strokeLinecap="round" /><path d="M50 18c14 16 22 26 14 42-5 9-23 9-28 0-8-16 6-26 14-42z" fill="#f08a24" /><path d="M50 40c8 9 10 14 6 21-3 5-10 5-12 0-3-7 2-12 6-21z" fill="#ffd24a" /></> },
{ id: 'torch', name: 'Torch', w: 0.5, h: 0.5, art: <><circle cx="50" cy="50" r="46" fill="#ffcf5a" opacity=".35" /><circle cx="50" cy="50" r="22" fill="#f08a24" /><circle cx="50" cy="50" r="11" fill="#ffd24a" /></> },
{ id: 'tree', name: 'Tree', w: 3, h: 3, art: <><circle cx="50" cy="50" r="48" fill="#2f6a2c" /><circle cx="36" cy="40" r="26" fill={green} /><circle cx="62" cy="56" r="28" fill="#47903f" /><circle cx="50" cy="50" r="10" fill="#6b4a25" opacity=".8" /></> },
{ id: 'bush', name: 'Bush', w: 1.2, h: 1.2, art: <><circle cx="35" cy="55" r="28" fill="#2f6a2c" /><circle cx="62" cy="42" r="28" fill={green} /><circle cx="64" cy="66" r="24" fill="#47903f" /></> },
{ id: 'rock', name: 'Rock', w: 1.2, h: 1, art: <path d="M10 62L22 24l32-14 30 12 8 34-18 22-34 4z" fill={stone} stroke="#4c4e52" strokeWidth="5" strokeLinejoin="round" /> },
{ id: 'rug', name: 'Rug', w: 3, h: 2, art: <><rect x="2" y="6" width="96" height="88" rx="4" fill="#8e2f3a" stroke="#d9b45a" strokeWidth="5" /><rect x="12" y="16" width="76" height="68" fill="none" stroke="#d9b45a" strokeWidth="3" /><circle cx="50" cy="50" r="14" fill="#d9b45a" opacity=".8" /></> },
{ id: 'stairs', name: 'Stairs', w: 1, h: 2, art: <><rect x="3" y="3" width="94" height="94" fill="#9b9ea3" stroke="#555" strokeWidth="4" />{[18, 33, 48, 63, 78].map((y) => <path key={y} d={`M3 ${y}h94`} stroke="#555" strokeWidth="4" />)}</> },
{ id: 'pillar', name: 'Pillar', w: 1, h: 1, art: <><circle cx="50" cy="50" r="44" fill="#a7a9ae" stroke="#5c5e63" strokeWidth="6" /><circle cx="50" cy="50" r="28" fill="#c4c6cb" stroke="#5c5e63" strokeWidth="3" /></> },
{ id: 'altar', name: 'Altar', w: 2, h: 1, art: <><rect x="3" y="12" width="94" height="76" rx="4" fill="#6c6f77" stroke="#3d3f45" strokeWidth="5" /><path d="M50 26v48M30 44h40" stroke="#d9b45a" strokeWidth="7" strokeLinecap="round" /></> },
{ id: 'bones', name: 'Bones', w: 1, h: 1, art: <><g stroke="#4a4338" strokeWidth="3" fill="#e8e1cf"><circle cx="40" cy="42" r="17" /><rect x="30" y="52" width="20" height="12" rx="3" /><path d="M58 70l30-28" strokeWidth="9" stroke="#e8e1cf" strokeLinecap="round" /><path d="M12 82l34-14" strokeWidth="9" stroke="#e8e1cf" strokeLinecap="round" /></g><circle cx="34" cy="40" r="4" fill="#222" /><circle cx="47" cy="40" r="4" fill="#222" /></> },
{ id: 'cart', name: 'Wreck / cart', w: 2, h: 1, art: <><rect x="6" y="14" width="88" height="72" rx="5" fill="#6d5a45" stroke="#3b2f22" strokeWidth="5" /><path d="M6 36h88M6 62h88" stroke="#3b2f22" strokeWidth="3" /><rect x="14" y="2" width="14" height="12" fill="#222" /><rect x="72" y="86" width="14" height="12" fill="#222" /></> },
]
const byId = Object.fromEntries(BUILTIN.map((p) => [p.id, p]))
export const isCustom = (id) => typeof id === 'string' && id.startsWith('c:')
export const builtin = (id) => byId[String(id).replace(/^b:/, '')]
// Picture of a prop filling its box. customSrc(id) gives the image URL for own uploads.
export function PropArt({ prop, customSrc }) {
if (isCustom(prop)) return <img src={customSrc?.(prop.slice(2))} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }} />
const b = builtin(prop)
return <svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: '100%', display: 'block', pointerEvents: 'none' }} aria-hidden="true">{b ? b.art : <rect width="100" height="100" fill="#888" />}</svg>
}
+15 -1
View File
@@ -36,7 +36,7 @@ const buffer = (url) => {
return buffers.get(url) return buffers.get(url)
} }
// load everything of a lobby up front, so the first roll does not wait for the download // load everything of a lobby up front, so the first roll does not wait for the download
export const preload = (cfg) => { if (cfg) [cfg.roll, cfg.crit_success, cfg.crit_failure].forEach(buffer) } export const preload = (cfg) => { if (cfg) [cfg.roll, cfg.crit_success, cfg.crit_failure, cfg.ping].forEach(buffer) }
const clip = (c, out, buf, t, rate, vol) => { const clip = (c, out, buf, t, rate, vol) => {
const s = c.createBufferSource() const s = c.createBufferSource()
@@ -98,3 +98,17 @@ export async function playRoll(sides, n, outcome, cfg = {}) {
else cue[outcome](c, out, t1) else cue[outcome](c, out, t1)
} else if (cfg.result !== false) cue[outcome](c, out, t1) } else if (cfg.result !== false) cue[outcome](c, out, t1)
} }
// A ping on the map: the DM's MP3 if there is one, otherwise a short two-tone "blip". cfg.ping_on === false switches it off.
export async function playPing(cfg = {}) {
if (!sound.enabled || sound.volume <= 0 || document.hidden || cfg.ping_on === false) return
const c = audio()
if (!c) return
const custom = await buffer(cfg.ping)
const out = c.createGain()
out.gain.value = sound.volume
out.connect(c.destination)
const t = c.currentTime + 0.02
if (custom) clip(c, out, custom, t, 1, 1)
else { tone(c, out, t, 880, 0.18, 'sine', 0.3); tone(c, out, t + 0.11, 1320, 0.35, 'sine', 0.25) }
}
+192 -1
View File
@@ -476,7 +476,198 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
.cropwin { position: relative; overflow: hidden; border-radius: 10px; border: 2px solid var(--noxium, #7aa63c); background: #000; cursor: move; touch-action: none; flex: none; } .cropwin { position: relative; overflow: hidden; border-radius: 10px; border: 2px solid var(--noxium, #7aa63c); background: #000; cursor: move; touch-action: none; flex: none; }
.cropwin img { position: absolute; max-width: none; user-select: none; } .cropwin img { position: absolute; max-width: none; user-select: none; }
.cropctl { display: grid; gap: .5rem; } .cropctl { display: grid; gap: .5rem; }
.pportrait { width: 21mm; height: 21mm; object-fit: cover; border: 1.5px solid var(--pg); border-radius: 1.5mm; flex: none; margin-left: auto; }
.pavatar { overflow: hidden; border: 2px solid var(--pc); } .pavatar { overflow: hidden; border: 2px solid var(--pc); }
.pavatar img { width: 100%; height: 100%; object-fit: cover; display: block; } .pavatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.maptoken .tokpic { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; } .maptoken .tokpic { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; }
.adminusers { margin: .8rem 0; }
.userlist { list-style: none; padding: 0; margin: .5rem 0 0; display: grid; gap: .4rem; }
.userlist li { justify-content: space-between; align-items: center; }
.chip.on { display: inline-block; }
.npchead { display: flex; align-items: center; gap: .7rem; }
.npcthumb { width: 44px; height: 44px; border-radius: 8px; object-fit: cover; border: 1px solid var(--line, #444); flex: none; }
.npcthumb.none { display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,.05); color: var(--muted, #888); }
.npchead .badge { margin-left: .5rem; }
.npcpicker { display: grid; gap: .5rem; max-width: 320px; }
.npcpick { list-style: none; margin: 0; padding: 0; max-height: 260px; overflow: auto; display: grid; gap: .25rem; }
.npcpick button { display: flex; align-items: center; gap: .6rem; width: 100%; text-align: left; }
.npcpickwrap { position: absolute; top: 3rem; left: .5rem; z-index: 5; }
.pnpc .pre { white-space: pre-wrap; margin: .1rem 0 .6rem; font-size: .85rem; }
.pavatar { position: relative; }
.presence { position: absolute; right: -3px; bottom: -3px; width: 11px; height: 11px; border-radius: 50%; background: #777; border: 2px solid var(--panel, #222); }
.presence.on { background: #3fb950; }
.presence.inline { position: static; display: inline-block; width: 9px; height: 9px; border: 0; margin-right: .2rem; }
.pcard.off { opacity: .6; }
/* lobby header removed: DM chip and settings button live in the player bar */
.playarea { height: calc(100vh - var(--hdr) - 1.2rem); }
.sheetcol { max-height: calc(100vh - var(--hdr) - 1.2rem); }
.dmcard { min-width: 0; border-left-color: var(--accent, gold); }
.pavatar.dm { font-size: .7rem; font-weight: 700; }
.lobbycfg { margin-left: auto; align-self: center; max-width: 16rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fightrow { display: flex; justify-content: flex-end; margin-top: -.3rem; }
.initbar { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; padding: .35rem .5rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
.initround { padding-right: .4rem; border-right: 1px solid var(--line); }
.initcard { display: flex; align-items: center; gap: .35rem; padding: .2rem .5rem; border: 2px solid var(--line); border-radius: var(--radius); cursor: pointer; background: var(--panel2); }
.initcard.player { border-left: 5px solid #4f93e6; } .initcard.npc { border-left: 5px solid #c0392b; }
.initcard.cur { border-color: var(--accent, gold); box-shadow: 0 0 0 1px var(--accent, gold); background: color-mix(in srgb, var(--accent, gold) 18%, var(--panel2)); }
.initnum { font-weight: 700; min-width: 1.6rem; text-align: center; }
.initcard .modin { width: 3.2rem; }
.initctl { margin-left: auto; display: flex; gap: .3rem; }
.maptoken.turn { animation: turnpulse 1.4s ease-in-out infinite; }
@keyframes turnpulse { 0%, 100% { box-shadow: 0 0 0 3px var(--accent, gold), 0 0 6px 3px rgba(255,215,0,.5); } 50% { box-shadow: 0 0 0 3px var(--accent, gold), 0 0 16px 8px rgba(255,215,0,.8); } }
.radialback { position: fixed; inset: 0; z-index: 60; }
.radial { position: fixed; width: 0; height: 0; }
.radialtitle { position: absolute; transform: translate(-50%, -50%); background: rgba(0,0,0,.75); color: #fff; padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; white-space: nowrap; max-width: 9rem; overflow: hidden; text-overflow: ellipsis; }
.radialbtn { position: absolute; left: -26px; top: -26px; width: 52px; height: 52px; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 0; gap: 0; line-height: 1; box-shadow: 0 2px 8px rgba(0,0,0,.6); background: var(--panel); border: 2px solid var(--line); }
.radialbtn:hover:not(:disabled) { border-color: var(--accent, gold); background: var(--panel2); }
.radialbtn .ri { font-size: 1.2rem; } .radialbtn .rl { font-size: .55rem; margin-top: 2px; }
.initcard .modin { width: 2.8rem; padding: .1rem .25rem; min-height: 0; }
.initcard { padding: .1rem .4rem; }
/* compact top area of the lobby */
.topbar { display: flex; align-items: stretch; gap: .4rem; }
.topctl { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; align-content: center; }
.topctl button.mini { padding: 0; min-width: 28px; height: 26px; font-size: .8rem; line-height: 1; }
.topctl button.mini.wide { grid-column: span 1; }
.topscroll { flex: 1; min-width: 0; overflow-x: auto; overflow-y: hidden; padding-bottom: 4px; scrollbar-width: thin; }
.topscroll .charbar, .topscroll .initcards { flex-wrap: nowrap; width: max-content; }
.topbar .lobbycfg { margin-left: 0; align-self: center; flex: none; max-width: 11rem; }
.topmore { display: grid; gap: .5rem; padding: .4rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
.topmore .charbar { flex-wrap: wrap; }
.topbar + .topmore, .topbar + .diffbar { margin-top: -.2rem; }
.initcards { display: flex; gap: .35rem; align-items: center; }
.initcard { padding: .15rem .45rem .15rem .15rem; border-radius: 999px; border-left-width: 2px; flex: none; }
.initcard.player { border-left: 2px solid #4f93e6; border-color: #4f93e6; } .initcard.npc { border-left: 2px solid #c0392b; border-color: #c0392b; }
.initcard.cur { border-color: var(--accent, gold); }
.initpic { width: 30px; height: 30px; border-radius: 50%; overflow: hidden; display: grid; place-items: center; background: var(--panel); font-weight: 700; flex: none; }
.initpic img { width: 100%; height: 100%; object-fit: cover; }
.initname { white-space: nowrap; }
.initround { padding: 0 .4rem; flex: none; }
.pcard { flex: none; }
.playarea { gap: .4rem; }
.maptoken em { display: none; }
.maptoken:hover em, .maptoken.sel em, .maptoken.turn em { display: block; }
/* resizing */
.splitter { width: 10px; cursor: col-resize; align-self: stretch; position: relative; touch-action: none; }
.splitter::after { content: ''; position: absolute; left: 4px; top: 0; bottom: 0; width: 2px; border-radius: 2px; background: var(--line); transition: background .15s; }
.splitter:hover::after, body.resizing .splitter::after { background: var(--accent, gold); }
body.resizing { cursor: col-resize; user-select: none; }
.lobbygrid:not(.nosheet) { grid-template-columns: var(--sheetw) 10px minmax(0, 1fr); gap: .3rem; }
.chatsplit { position: absolute; left: -5px; top: 0; bottom: 0; z-index: 12; }
.chatsplit::after { left: 4px; }
@media (max-width: 1100px) {
.lobbygrid, .lobbygrid:not(.nosheet) { grid-template-columns: minmax(0, 1fr); }
.sheetsplit, .chatsplit { display: none; }
}
/* portrait next to name and identity fields */
.pidentity { display: flex; gap: 5mm; align-items: stretch; }
.pidright { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3mm; justify-content: flex-end; }
.pportrait { width: 36mm; height: 42mm; object-fit: cover; border: 1.5px solid var(--pg); border-radius: 1.5mm; flex: none; align-self: flex-start; }
.pidright .pfields { grid-template-columns: 1fr 1fr 1fr; }
.pidright .pfield:nth-child(2) { grid-column: span 2; }
.pwrite { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; line-height: calc(6.4mm + 1px); padding: 0; color: var(--pi); border: 0; resize: none; width: 100%; box-sizing: border-box; background-attachment: local; min-height: 18mm; }
textarea.pwrite { background-color: transparent; outline: none; field-sizing: content; }
textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
.pwrite::placeholder { color: var(--pm); opacity: .6; }
/* token status, fog */
.mapfog { position: absolute; left: 0; top: 0; filter: brightness(.42) saturate(.55); pointer-events: none; max-width: none; }
.tokhp { position: absolute; left: 8%; right: 8%; bottom: -7px; height: 6px; background: rgba(0,0,0,.75); border-radius: 3px; border: 1px solid rgba(255,255,255,.7); overflow: hidden; pointer-events: none; font-style: normal; }
.tokhp b { display: block; height: 100%; }
.tokconds { position: absolute; left: 50%; top: -9px; transform: translateX(-50%); display: flex; gap: 2px; pointer-events: none; }
.tokconds u { text-decoration: none; font-size: 9px; font-weight: 700; text-transform: none; background: #6a1b9a; border: 1px solid #fff; border-radius: 4px; padding: 0 3px; line-height: 12px; white-space: nowrap; color: #fff; }
.statusform { display: grid; gap: .4rem; margin-top: .5rem; padding-top: .5rem; border-top: 1px solid var(--line); }
.hprow { gap: .3rem; flex-wrap: wrap; align-items: center; }
.hprow input { width: 3.6rem; }
.condrow { display: flex; flex-wrap: wrap; gap: .25rem; }
.sightrange input { width: 3.4rem; }
.tokenpanel { max-height: calc(100% - 4rem); overflow-y: auto; }
/* reveal areas, sight groups */
.rz { fill: rgba(255, 215, 0, .14); stroke: rgba(255, 215, 0, .9); stroke-width: 2; stroke-dasharray: 6 4; }
.rz.draft { fill: rgba(255, 215, 0, .25); }
.rzo { fill: gold; stroke: #000; stroke-width: 1; }
.revealpanel { right: auto; left: .5rem; bottom: .5rem; top: auto; max-width: 18rem; display: grid; gap: .4rem; }
.visiongroups { display: grid; gap: .4rem; }
.vgrow { justify-content: space-between; }
/* 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; }
/* decor tokens (props) */
.maptoken.prop { border: 0; border-radius: 0; background: none; box-shadow: none; display: block; transform-origin: center; }
.maptoken.prop:not(.mov) { pointer-events: none; }
.maptoken.prop.hid { opacity: .5; outline: 2px dashed rgba(255,255,255,.5); border-style: none; }
.maptoken.prop.sel { outline: 3px solid var(--accent, gold); outline-offset: 2px; }
.maptoken.prop em { display: none !important; }
.proppicker { max-width: 360px; }
.propgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(78px, 1fr)); gap: .3rem; max-height: 300px; overflow: auto; }
.propbtn { display: grid; justify-items: center; gap: .15rem; padding: .3rem; font-size: .72rem; position: relative; }
.propbtn svg, .propbtn img { width: 46px; height: 46px; object-fit: contain; }
.propbtn.own { padding: 0; }
.propbtn.own > .ghost:first-child { display: grid; justify-items: center; gap: .15rem; width: 100%; }
.propbtn.own .mini { position: absolute; top: 0; right: 0; padding: 0 .3rem; min-height: 0; }
.tokrow { display: flex; gap: .25rem; }
.tokrow > button:first-child { flex: 1; min-width: 0; }
.propdot { display: inline-block; width: .8rem; height: .8rem; border-radius: 2px; background: #b98545; vertical-align: middle; }
.tokenform input[type=range] { width: 100%; }
.tokenform .row { display: flex; flex-wrap: wrap; gap: .35rem; justify-content: flex-start; align-items: center; }
.tokenform label.small { display: flex; align-items: center; gap: .3rem; flex-wrap: wrap; }
.tokenform label.small input[type=number] { width: 4rem; }
.tokenform .mini { min-height: 0; padding: .15rem .5rem; }
.tokenlist .tokrow > button:first-child { width: auto; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.tokenlist .tokrow > button.mini { width: auto; flex: none; }
/* table tool buttons: low and readable on the dark map */
.maptools button, .maptools select { padding: .1rem .5rem; font-size: .76rem; line-height: 1.2; height: 1.6rem; min-height: 0; }
.maptools button.ghost, .maptools select { background: rgba(20,22,26,.85); color: #f1f1f1; border: 1px solid rgba(255,255,255,.35); }
.maptools button.on { background: var(--accent, #2f7d32); color: #fff; border-color: transparent; }
.maptools button:disabled { opacity: .45; }
.maptools .sightrange input { height: 1.6rem; padding: 0 .3rem; }
.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; }
/* map pings */
.mapping { position: absolute; width: 0; height: 0; pointer-events: none; z-index: 6; }
.mapping i { position: absolute; left: calc(var(--ps) / -2); top: calc(var(--ps) / -2); width: var(--ps); height: var(--ps); border-radius: 50%; border: 4px solid var(--pc); box-shadow: 0 0 10px var(--pc), inset 0 0 10px var(--pc); opacity: 0; animation: pingring 1.6s ease-out 2; }
.mapping i + i { animation-delay: .35s; }
.mapping b { position: absolute; left: 0; top: calc(var(--ps) / 2 + 2px); transform: translateX(-50%); white-space: nowrap; color: #fff; text-shadow: 0 0 3px #000, 0 0 3px #000; animation: pingname 3.4s forwards; }
@keyframes pingring { 0% { transform: scale(.1); opacity: .95; } 100% { transform: scale(1.4); 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; } }
/* 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; }