Compare commits

...
26 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
Claude d4d7b4d22e Character portrait
Crop-and-zoom picture editor on the Concept page, stored (re-encoded to a
256 px WebP data URL) inside the character; shown on the sheet header, in
the lobby player bar and as token icon on the map.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 19:33:28 +00:00
Claude 03ebc3a138 Lobby: MP3 dice sound with pitch per die, DM sound settings
Default dice sound is public/dice.mp3 with a small random pitch and a
base pitch per die size (d4 lighter ... d100 heavier). The DM can upload
an own dice sound and sounds for critical success / failure per lobby,
reset them, and switch result and critical sounds on or off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 19:04:12 +00:00
Claude ce24a0ba6f Lobby: heavier dice sound
Low thump with a dull knock per bounce, fewer bounces, darker filter and
lower result cues.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 18:47:35 +00:00
Claude 2195ecce94 Lobby: dice sounds
Clatter per die and a result cue for checks, synthesized with the Web
Audio API; mute button and volume slider next to the dice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 18:42:46 +00:00
Claude 82e4ac9458 Maps tab with sharing, walls/doors and strict line of sight
Maps page (list, upload, edit, share, copy), wall/room/door tools in the
map editor, server-side visibility polygon: the image is cut per player
and other tokens are filtered, DM can view as a player, open doors and
switch sight on/off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 14:23:17 +00:00
Claude e8755582d5 Lobby: maps, map editor and tokens
DM map library (upload re-encoded with Pillow), map editor with prepared
enemies/NPCs, map on the lobby table with personal pan/zoom, draggable
tokens (players move their own, DM all), full/player view, live sync via
the WebSocket.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 13:59:21 +00:00
Claude 162d8c9224 Lobby: WebSockets for real-time sync
Server pushes small change notes (messages / lobby / sheet) per lobby;
clients fetch data through the normal API so visibility rules stay in one
place. Polling stays as slow fallback. nginx WebSocket headers documented.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 13:41:02 +00:00
Claude 1c672f0ab8 Lobby: private rolls and checks for players, DM tab with lobby actions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 13:32:57 +00:00
Claude 78f0de5333 Rules editor: admins can edit items, skills, professions (and categories/groups) in the app
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-22 14:48:55 +00:00
Claude a8c9740a05 Skills: +-5 buttons, advanced skills use 50% of the attribute (creation.advanced_attr_percent)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-22 14:37:52 +00:00
33 changed files with 4677 additions and 139 deletions
+2
View File
@@ -5,3 +5,5 @@ AW_REGISTRATION_CODE=
AW_TRUST_PROXY=0
# Port on the host (bound to 127.0.0.1 only). nginx proxies to it. Pick a free one (check: ss -tlnp | grep :8090):
AW_PORT=8090
# Who may use the rules editor (comma separated usernames). Empty = only the first account that was ever created:
AW_ADMINS=
+19 -2
View File
@@ -51,7 +51,7 @@ docker compose up -d --build
curl -sI http://127.0.0.1:8090 | head -1 # should print HTTP/1.1 200 OK
```
The container only listens on `127.0.0.1:8090` (change it with `AW_PORT` in `.env` if the port is taken, e.g. AMP uses 8080), so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`.
The container only listens on `127.0.0.1:8090` (change it with `AW_PORT` in `.env` if the port is taken, e.g. AMP uses 8080), so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`; changes from the rules editor are in `./db/rules_overrides.json` (back up the whole `db` folder). Set `AW_ADMINS` in `.env` to choose who may use the editor (default: the first account).
## 4. nginx + HTTPS
@@ -62,7 +62,7 @@ server {
listen 80;
server_name aw.yourdomain.tld;
client_max_body_size 1m;
client_max_body_size 13m; # map uploads (the app itself limits them to 12 MB and keeps characters small)
location / {
proxy_pass http://127.0.0.1:8090; # same as AW_PORT in .env
@@ -70,10 +70,17 @@ server {
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, so clients cannot fake their address
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# the lobby uses a WebSocket for live updates (without these three lines it falls back to polling)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
```
Already running an older config? Add the last four lines inside `location /` and change `client_max_body_size` to `13m` (needed for map uploads, otherwise nginx answers 413), then `sudo nginx -t && sudo systemctl reload nginx`. Nothing else changes; the lobby still works without them, just with a few seconds of delay.
```bash
sudo ln -s /etc/nginx/sites-available/aw /etc/nginx/sites-enabled/aw
sudo nginx -t && sudo systemctl reload nginx
@@ -118,3 +125,13 @@ docker compose down # stop
```
If `docker` says it cannot connect to the daemon, check the two `export` lines from step 1 and `systemctl --user status docker`.
## Updating
```bash
cd ~/AWCharacterCreator
git pull
docker compose up -d --build
```
The `db` folder (database and rules editor changes) is kept. New settings from `.env.example` have to be added to your `.env` by hand.
+97 -2
View File
@@ -52,6 +52,7 @@ In the creator, the Concept page only has a "Rules preset" selector. Presets are
### Skills, proficiency and expertise
- `creation.advanced_attr_percent`: share of the attribute that counts for **advanced skills** (default 50, rounded down; 100 = full attribute like basic skills).
- `creation.advanced_unlock_cost`: pool points needed to unlock an advanced skill (free if the character's profession grants proficiency in it; proficiency picks do not unlock a skill).
- `creation.free_proficiencies`: free proficiency picks every character gets. Talents can add more with `"proficiency_picks": N` (see "Versatile Training").
- Proficiency is +`proficiency_bonus`; two picks on one skill (a profession proficiency counts as one) give Expertise, +`expertise_bonus`.
@@ -85,23 +86,92 @@ Each wizard page has a **Reset this page** button that clears only that page's d
The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits above the sheet) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout.
### Character portrait
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
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.
- New entries get their id from the name (lowercase, `_`). Ids of existing entries cannot be changed, because characters refer to them.
- The server checks every reference: an item needs an existing category, a skill a group and attributes, a profession existing skills. Removing a category, group or skill that is still used is refused. Removing an item, skill or profession that saved characters use asks for confirmation first (their data stays, the entry disappears from the sheets).
- Talents, mutations, implants, classes and the rules text are still edited in `rules.json`. Players see changes after reloading the page.
### Lobbies
The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave).
The lobby page is built for desktop with three columns: the **character sheet** on the left (collapsible with the arrow), the **Player Area** in the middle and the **chat** at the right edge of the window.
- **Chat** (right): live chat for everyone, plus **whispers** to a single person (dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes, can be hidden), dice rolls and skill checks. Names are shown in the player's colour. The chat polls every few seconds, keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages/rolls per minute.
- **Chat** (right): live chat for everyone, plus **whispers** to a single person (dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes, can be hidden), dice rolls and skill checks. Names are shown in the player's colour. Everything in the lobby (chat, dice, difficulty, colours, sheets) updates live over a **WebSocket** (`/api/lobbies/{id}/ws`); it only sends a short "something changed" note and the data is fetched through the normal API, so the visibility rules (whispers, hidden numbers) stay in one place. If the socket is blocked or drops, the page reconnects and meanwhile polls every few seconds. The chat keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages/rolls per minute.
- **Character bar** (top of the Player Area): one card per player with the character, class and the player's colour. The DM clicks a card to show that character's sheet on the left (always the current saved version, refreshed every 15 s) and can enter a personal **bonus / malus** per character in the small number field. Players see their own sheet only and just a short card of the others. (The bar is also meant for initiative and enemies later.)
- **Skill and attribute checks**: players click an attribute or an unlocked skill on their own sheet. The server rolls a d100 (roll under): target = attribute or skill total + the DM's difficulty + the character's personal modifier. 01-05 is always a critical success, 96-100 always a critical failure. The chat and the table show the roll, the target and by how much it succeeded or failed. Combat is not handled specially: roll the normal skill and let the DM adjust with the modifiers.
- **Difficulty** (bar under the characters): the DM picks a level from the rules table (Trivial +60 ... Hellish -60) or a custom number. It applies to all checks until changed. With **Hide numbers from players** the players only see their roll and success / failure, not the target, difficulty or margin.
- **Dice** (below the table): d4 ... d100 with a count (1 to 10 dice). The server rolls, the dice tumble into the middle of the table in the colour of whoever threw them, and the result goes into the chat. With a person selected as recipient in the chat dropdown it is a private roll. **DM rolls** have three modes: *Mystery* (default: players see grey dice with "?" but not the result), *Hidden* (only the DM sees it) and *Public*.
- **Sound**: rolling plays `frontend/public/dice.mp3` (the default dice sound) with a slight random pitch; every die has its own base pitch, from a little higher and lighter (d4) to a little lower and heavier (d100). Many dice layer the sound up to three times. Checks add a short cue after the dice: built-in tones for success and failure, and for a critical success or failure either a built-in tone or a sound the DM uploaded. The speaker button next to the dice mutes everything for yourself, the slider sets the volume (remembered per browser). Browsers only allow sound after the page was clicked once, and nothing plays while the tab is in the background.
- **DM sound settings** (*Lobby actions* -> *Sounds*): replace the dice sound with an own MP3 (up to 1.5 MB, per lobby) or reset it to the default, upload sounds for *Critical success* and *Critical failure* (reset = built-in tone), and switch the critical sounds and the short success / failure tones on or off for the whole lobby. *Test* plays a sound locally.
- **Own dice**: with this switch on, a click on a die or a skill asks for the result of your real dice instead of rolling. The server only checks the range and marks the roll as "own dice" in the chat.
- **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.
### 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 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.
- **Map editor**: grid (pixels per square, token sizes are counted in squares), **enemies and NPCs** placed in advance (name, size, colour, *Hidden from players*, duplicate), and **walls and doors** for line of sight: tools *Wall* (click points, chains lines), *Room* (two corners), *Door* (two ends), *Erase*, *Undo last*; new lines snap to existing corners, optionally to grid corners. An optional **sight range** in squares limits how far characters see (0 = unlimited).
- **Show on table** (lobby, DM, *Lobby actions*): shows one of your or a shared map; the prepared tokens are copied into the lobby, so moving them during play never changes the map. Doors start closed. Every player has a token of their own. Without walls the players drag it onto the map themselves; with walls (line of sight) the DM places it from the tray.
- **At the table**: pan (drag the background), zoom (wheel, pinch, buttons) and *Grid* are personal. Players move only their own token; the DM moves everything, edits NPC tokens (click one), adds NPCs (*+ NPC*) and clicks doors to open or close them (*Walls* shows the layout in the full view).
- **Line of sight is strict**: the server calculates what each character can see from the walls and closed doors, cuts the map image to that area (everything else is black, only that piece is ever sent) and leaves out all other tokens. Walls are never sent to players. The DM chooses **Full view** or **View as <player>** (exactly what that player gets) and can switch **Sight on/off** for the table.
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
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
@@ -111,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`) |
| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts |
| 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) |
| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) |
| PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) |
@@ -119,6 +193,27 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
| POST | `/api/lobbies/join` | Join with `{code, character_id}` |
| GET / PUT / DELETE | `/api/lobbies/{id}` | Members and cards (DM sees all ids) / rename / delete (DM only) |
| POST | `/api/lobbies/{id}/code` | New join code (DM only) |
| GET / POST | `/api/maps` | Your maps and the ones shared with you / upload (raw image body, `?name=`) |
| GET / PUT / DELETE | `/api/maps/{id}` | Map with tokens and walls / change (name, grid, sight, tokens, walls) / delete |
| POST / DELETE | `/api/maps/{id}/shares[/{username}]` | Share with a user / stop sharing |
| POST | `/api/maps/{id}/copy` | Your own editable copy |
| GET | `/api/maps/{id}/thumb` | Preview image |
| 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) |
| 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) |
| 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 |
| GET / POST / DELETE | `/api/lobbies/{id}/sounds/{roll\|crit_success\|crit_failure}` | Custom lobby sound (MP3): everyone gets it / DM uploads (raw body) / DM resets |
| GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper |
| POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?, manual?, mode?}` (d4-d20, d100; up to 10 dice). `manual` = results of real dice, `mode` (DM only) = `hidden`/`mystery`/`public` |
| POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character |
+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 -1
View File
@@ -39,8 +39,9 @@ def compute(ch: dict, rules: dict) -> dict:
unlocked = s["id"] in (ch.get("unlocked") or [])
usable = s["type"] == "basic" or unlocked or prof_pip > 0 # advanced skills must be unlocked (a profession proficiency does it for free)
best = max(attrs[a] for a in s["attrs"]) # with several attributes the higher one counts
base = best * cr.get("advanced_attr_percent", 100) // 100 if s["type"] == "advanced" else best # advanced skills only use part of the attribute
skills.append({**s, "points": pts, "prof": prof, "pips": pips, "usable": usable, "unlocked": unlocked,
"total": best + pts + prof})
"base": base, "total": base + pts + prof})
aug_by_id = {a["id"]: a for a in rules["augments"]}
chosen = [aug_by_id[i] for i in ch.get("augments", []) if i in aug_by_id]
+2 -2
View File
@@ -99,7 +99,7 @@ def to_docx(ch: dict, rules: dict) -> bytes:
p = doc.add_paragraph()
p.add_run("Other: ").bold = True
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):
doc.add_heading(head, 1)
doc.add_paragraph(ch[key])
@@ -171,7 +171,7 @@ def to_pdf(ch: dict, rules: dict) -> bytes:
el.append(Paragraph("-", body))
if s["other"]:
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):
el += [Paragraph(head, h2), Paragraph(esc(ch[key]), body)]
+367 -36
View File
@@ -7,12 +7,19 @@ import time
import uuid
from pathlib import Path
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse
import asyncio
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from . import auth
from .derive import compute
from . import rules_edit
from . import maps as maps_mod
from . import sounds as sounds_mod
from . import portrait as portrait_mod
from . import combat as combat_mod
from .realtime import hub
from .export import to_docx, to_pdf
BASE = Path(__file__).resolve().parent.parent
@@ -37,15 +44,39 @@ CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I
# No public API docs: everything about the API stays behind the login
app = FastAPI(title="Arcane Wasteland Character Creator", docs_url=None, redoc_url=None, openapi_url=None)
limiter = auth.Limiter()
@app.on_event("startup")
async def _attach_hub():
hub.attach(asyncio.get_running_loop())
TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse proxy that sets X-Forwarded-For / -Proto
def load_rules() -> dict:
# Re-read on every request so rules.json can be edited without a restart.
# Changes made in the rules editor live next to the database (writable), the shipped rules.json stays untouched.
OVERRIDES_PATH = Path(DB_PATH).parent / "rules_overrides.json"
def base_rules() -> dict:
return json.loads(RULES_PATH.read_text(encoding="utf-8"))
def read_overrides() -> dict:
try:
return json.loads(OVERRIDES_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def load_rules() -> dict:
# Re-read on every request so rules.json can be edited without a restart; editor changes replace whole sections.
r = base_rules()
for sec, items in read_overrides().items():
if sec in rules_edit.SECTIONS:
r[sec] = items
return r
def db() -> sqlite3.Connection:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB_PATH)
@@ -73,6 +104,13 @@ def db() -> sqlite3.Connection:
("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})")]:
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)
sounds_mod.schema(con)
combat_mod.schema(con)
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})")]:
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
@@ -147,13 +185,32 @@ async def security(request: Request, call_next):
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "same-origin"
host = request.headers.get("x-forwarded-host", "") if TRUST_PROXY else ""
host = re.sub(r"[^A-Za-z0-9.:\-\[\]]", "", host or request.headers.get("host", ""))
resp.headers["Content-Security-Policy"] = ("default-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; frame-ancestors 'none'")
f"img-src 'self' data: blob:; connect-src 'self' ws://{host} wss://{host}; frame-ancestors 'none'")
if request.url.path.startswith("/api/"):
resp.headers["Cache-Control"] = "no-store"
return resp
def admin_ids(con) -> set:
"""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()}
ids = {r["id"] for r in con.execute("SELECT id, username FROM users") if r["username"].lower() in names}
ids |= {r["id"] for r in con.execute("SELECT id FROM users WHERE is_admin=1")}
if not ids:
first = con.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
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:
with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
@@ -187,6 +244,8 @@ def check_password_rules(pw: str):
def me(request: Request):
with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
if user:
user = {**user, "admin": is_admin(user)}
return {"user": user, "registration_open": bool(auth.registration_code())}
@@ -213,7 +272,8 @@ def register(request: Request, response: Response, body: dict = Body(...)):
(username, auth.hash_password(password), time.time()))
token = auth.create_session(con, cur.lastrowid)
set_cookie(request, response, token)
return {"user": {"id": cur.lastrowid, "username": username}}
u = {"id": cur.lastrowid, "username": username}
return {"user": {**u, "admin": is_admin(u)}}
@app.post("/api/auth/login")
@@ -235,7 +295,8 @@ def login(request: Request, response: Response, body: dict = Body(...)):
token = auth.create_session(con, row["id"])
limiter.reset(keys[1])
set_cookie(request, response, token)
return {"user": {"id": row["id"], "username": row["username"]}}
u = {"id": row["id"], "username": row["username"]}
return {"user": {**u, "admin": is_admin(u)}}
@app.post("/api/auth/logout")
@@ -318,46 +379,79 @@ def _name(data: str) -> str:
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")
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:
rows = con.execute("SELECT id, data, updated FROM characters WHERE owner_id=? ORDER BY updated DESC",
(user["id"],)).fetchall()
rows = con.execute("SELECT id, data, updated, npc FROM characters WHERE owner_id=? AND npc=? ORDER BY updated DESC",
(user["id"], flag)).fetchall()
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
JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? ORDER BY u.username""",
(user["id"],)):
JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? AND c.npc=? ORDER BY u.username""",
(user["id"], flag)):
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
WHERE s.user_id=? ORDER BY c.updated DESC""", (user["id"],)).fetchall()
return {"mine": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "shared_with": shares.get(r["id"], [])}
for r in rows],
"shared": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "owner": r["owner"] or "?"}
for r in shared]}
WHERE s.user_id=? AND c.npc=? ORDER BY c.updated DESC""", (user["id"], flag)).fetchall()
return {"mine": [_entry(r, shares=shares) for r in rows], "shared": [_entry(r, owner=r["owner"]) for r in shared]}
@api.post("/characters")
def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
portrait_mod.clean(ch)
check_size(ch)
with db() as con:
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
raise HTTPException(409, "Character limit reached")
cid, now = uuid.uuid4().hex[:12], time.time()
con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id) VALUES (?,?,?,?,?,?)",
(cid, "", json.dumps(ch), now, now, user["id"]))
con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id, npc) VALUES (?,?,?,?,?,?,?)",
(cid, "", json.dumps(ch), now, now, user["id"], 1 if ch.get("npc") else 0))
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}")
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."""
with db() as con:
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(
"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())
allowed = _can_read_character(con, cid, user)
if not allowed:
raise HTTPException(404, "Character not found")
return json.loads(row["data"])
@@ -390,10 +484,30 @@ def remove_share(cid: str, username: str, user: dict = Depends(current_user)):
@api.put("/characters/{cid}")
def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(current_user)):
portrait_mod.clean(ch)
check_size(ch)
with db() as con:
_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))
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}
@@ -432,7 +546,7 @@ def _new_code(con) -> str:
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:
raise HTTPException(422, "Pick one of your own characters")
@@ -467,6 +581,7 @@ def _event(con, lid: str, text: str, uid=None):
"""Writes a line into the lobby's event log (shown in the chat feed to everybody)."""
con.execute("INSERT INTO lobby_messages (lobby_id, user_id, kind, text, created) VALUES (?,?, 'event', ?, ?)",
(lid, uid, text, time.time()))
hub.notify(lid, "messages")
def _char_name(con, cid) -> str:
@@ -482,7 +597,7 @@ def _card(data: str, rules: dict) -> dict:
prof = next((p for p in rules["professions"] if p["id"] == ch.get("profession")), None)
return {"name": ch.get("name") or "Nameless", "concept": str(ch.get("concept") or "")[:120],
"class": (cls or {}).get("name", ""), "subclass": (sub or {}).get("name", ""),
"profession": (prof or {}).get("name", "")}
"profession": (prof or {}).get("name", ""), "portrait": portrait_mod.tag(ch.get("portrait"))}
@api.get("/lobbies")
@@ -534,10 +649,24 @@ def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(cu
con.execute("""INSERT INTO lobby_members (lobby_id, user_id, character_id, joined, color) VALUES (?,?,?,?,?)
ON CONFLICT(lobby_id, user_id) DO UPDATE SET character_id=excluded.character_id""",
(lobby["id"], user["id"], body["character_id"], time.time(), _free_color(con, lobby["id"])))
hub.notify(lobby["id"], "map")
_event(con, lobby["id"], f"{user['username']} {'switched to' if known else 'joined as'} {_char_name(con, body['character_id'])}", user["id"])
return {"id": lobby["id"]}
@api.get("/lobbies/{lid}/portrait/{username}")
def lobby_portrait(lid: str, username: str, user: dict = Depends(current_user)):
"""The portrait of a player's character in this lobby (icon on the map and in the player bar). Members only."""
with db() as con:
_lobby_for(con, lid, user)
row = con.execute("""SELECT c.data FROM lobby_members m JOIN users u ON u.id = m.user_id
JOIN characters c ON c.id = m.character_id WHERE m.lobby_id=? AND u.username=?""", (lid, username)).fetchone()
img = portrait_mod.image(json.loads(row["data"]).get("portrait")) if row 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("/lobbies/{lid}")
def read_lobby(lid: str, user: dict = Depends(current_user)):
rules = load_rules()
@@ -545,24 +674,26 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
lobby = _lobby_for(con, lid, user)
is_dm = lobby["owner_id"] == user["id"]
_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
WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
combat = combat_view(con, lobby, is_dm)
members = []
online = hub.online(lid)
for r in rows:
mine = r["user_id"] == user["id"]
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)
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})
hidden = bool(lobby["diff_hidden"])
diff = {"label": lobby["diff_label"], "mod": lobby["diff_mod"], "hidden": hidden}
if hidden and not is_dm:
diff = {"label": "", "mod": None, "hidden": True}
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,
"levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR}
"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}
@api.put("/lobbies/{lid}/difficulty")
@@ -578,6 +709,7 @@ def set_difficulty(lid: str, body: dict = Body(...), user: dict = Depends(curren
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
con.execute("UPDATE lobbies SET diff_mod=?, diff_label=?, diff_hidden=? WHERE id=?", (mod, label, 1 if body.get("hidden") else 0, lid))
hub.notify(lid, "lobby")
return {"ok": True}
@@ -596,19 +728,20 @@ def set_member_mod(lid: str, username: str, body: dict = Body(...), user: dict =
(mod, lid, username))
if not cur.rowcount:
raise HTTPException(404, "No such player in this lobby")
hub.notify(lid, "lobby")
return {"ok": True}
def _recipient(con, lobby, user: dict, to):
"""User id to whisper to (the DM or a member, not yourself), None for everybody."""
"""User id to whisper to (the DM, a member or yourself for private rolls and notes), None for everybody."""
to = str(to or "").strip()
if not to:
return None
target = con.execute("""SELECT u.id FROM users u WHERE u.username=? AND (u.id=? OR EXISTS
(SELECT 1 FROM lobby_members m WHERE m.lobby_id=? AND m.user_id=u.id))""",
(to, lobby["owner_id"], lobby["id"])).fetchone()
if not target or target["id"] == user["id"]:
raise HTTPException(422, "You can only whisper to other people in this lobby")
if not target:
raise HTTPException(422, "You can only whisper to people in this lobby")
return target["id"]
@@ -667,6 +800,7 @@ def send_message(lid: str, body: dict = Body(...), user: dict = Depends(current_
(lid, user["id"], to_id, "whisper" if to_id else "chat", text, time.time()))
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
hub.notify(lid, "messages", None if to_id is None else {user["id"], to_id})
return {"id": cur.lastrowid}
@@ -692,6 +826,7 @@ def _post(con, lid: str, uid: int, to_id, kind: str, payload: dict) -> int:
(lid, uid, to_id, kind, json.dumps(payload), time.time()))
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
hub.notify(lid, "messages", None if to_id is None else {uid, to_id})
return cur.lastrowid
@@ -738,6 +873,14 @@ def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_us
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
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"]:
raise HTTPException(422, "Only players with a character in this lobby can roll checks")
c = compute(json.loads(member["data"]), rules)
@@ -747,12 +890,12 @@ def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_us
raise HTTPException(404, "Unknown skill")
if not s["usable"]:
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":
a = next((x for x in rules["attributes"] if x["id"] == key), None)
if not a:
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:
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)
@@ -762,7 +905,8 @@ def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_us
payload = {"kind": kind, "what": what, "base": base, "mod": lobby["diff_mod"], "cmod": member["char_mod"], "diff": lobby["diff_label"],
"target": target, "roll": roll, "margin": target - roll, "outcome": outcome, "hidden": bool(lobby["diff_hidden"]),
"manual": bool(manual)}
mid = _post(con, lid, user["id"], None, "check", payload)
private = str(body.get("to") or "") == user["username"] # a private check is only seen by the roller
mid = _post(con, lid, user["id"], user["id"] if private else None, "check", payload)
return {"id": mid, "roll": roll, "outcome": outcome}
@@ -784,7 +928,11 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)):
_dm_only(_lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_scenes", "lobby_templates", "lobby_marks"):
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
purge_sounds(lid)
hub.close_lobby(lid)
return {"ok": True}
@@ -815,6 +963,7 @@ def update_my_membership(lid: str, body: dict = Body(...), user: dict = Depends(
if con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND color=? AND user_id!=?", (lid, color, user["id"])).fetchone():
raise HTTPException(409, "Another player already uses this colour")
con.execute("UPDATE lobby_members SET color=? WHERE lobby_id=? AND user_id=?", (color, lid, user["id"]))
hub.notify(lid, "lobby")
if "character_id" in body:
_own_character(con, body.get("character_id"), user)
con.execute("UPDATE lobby_members SET character_id=? WHERE lobby_id=? AND user_id=?", (body["character_id"], lid, user["id"]))
@@ -829,6 +978,9 @@ def leave_lobby(lid: str, user: dict = Depends(current_user)):
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).rowcount
if gone:
_event(con, lid, f"{user['username']} left the lobby", user["id"])
if gone:
hub.notify(lid, "map")
hub.close_user(lid, user["id"])
return {"ok": True}
@@ -840,6 +992,10 @@ def kick_member(lid: str, username: str, user: dict = Depends(current_user)):
(lid, username)).rowcount
if gone:
_event(con, lid, f"{username} was removed by the DM", user["id"])
gone_id = con.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"]
if gone:
hub.notify(lid, "map")
hub.close_user(lid, gone_id)
return {"ok": True}
@@ -883,6 +1039,181 @@ def export(fmt: str, ch: dict = Body(...), user: dict = Depends(current_user)):
headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'})
# ---- rules editor (admins only)
def admin_user(user: dict = Depends(current_user)) -> dict:
if not is_admin(user):
raise HTTPException(403, "Only admins can edit the rules")
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")
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."""
r = load_rules()
return {"sections": {k: r[k] for k in rules_edit.SECTIONS}, "customized": [k for k in read_overrides() if k in rules_edit.SECTIONS]}
def _all_characters(con) -> list:
return [json.loads(x["data"]) for x in con.execute("SELECT data FROM characters")]
def _save_overrides(data: dict):
OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True)
if OVERRIDES_PATH.exists():
OVERRIDES_PATH.with_suffix(".json.bak").write_bytes(OVERRIDES_PATH.read_bytes())
tmp = OVERRIDES_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, OVERRIDES_PATH)
def _apply(section: str, new: list, confirm: bool):
r = load_rules()
old_ids = {x["id"] for x in r[section]}
removed = old_ids - {x["id"] for x in new}
bad = rules_edit.dangling(section, new, r)
if bad:
raise HTTPException(422, "Cannot save: " + "; ".join(bad[:5]) + (" ..." if len(bad) > 5 else ""))
with db() as con:
used = rules_edit.usage(_all_characters(con), section, removed)
if used and not confirm:
raise HTTPException(409, f"{used} saved character{'s' if used != 1 else ''} use{'' if used != 1 else 's'} entries you are removing. "
"Their data stays in the characters but the removed entries disappear from the sheets.")
return used
@api.put("/admin/rules/{section}")
def editor_save(section: str, body: dict = Body(...), user: dict = Depends(admin_user)):
try:
new = rules_edit.clean(section, body.get("items"), load_rules())
except rules_edit.RuleError as e:
raise HTTPException(422, str(e))
_apply(section, new, bool(body.get("confirm")))
data = read_overrides()
data[section] = new
_save_overrides(data)
return {"ok": True, "count": len(new)}
@api.delete("/admin/rules/{section}")
def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin_user)):
"""Back to the shipped rules.json for this section."""
if section not in rules_edit.SECTIONS:
raise HTTPException(404, "Unknown section")
data = read_overrides()
if section in data:
_apply(section, base_rules()[section], confirm)
del data[section]
_save_overrides(data)
return {"ok": True}
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")
@app.websocket("/api/lobbies/{lid}/ws")
async def lobby_socket(websocket: WebSocket, lid: str):
"""One socket per open lobby page. Only notes go out ("messages", "lobby", "sheet"), the data is fetched through the API."""
origin = websocket.headers.get("origin")
hosts = {websocket.headers.get("host", "")}
if TRUST_PROXY:
hosts.add(websocket.headers.get("x-forwarded-host", ""))
if origin and origin.split("://", 1)[-1] not in hosts:
await websocket.close(code=4403)
return
def check():
with db() as con:
user = auth.user_for_token(con, websocket.cookies.get(auth.SESSION_COOKIE))
if not user:
return None
lobby = con.execute("SELECT owner_id FROM lobbies WHERE id=?", (lid,)).fetchone()
ok = lobby and (lobby["owner_id"] == user["id"] or con.execute(
"SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone())
return user if ok else None
user = await asyncio.to_thread(check)
if not user:
await websocket.close(code=4401)
return
await websocket.accept()
was_online = user["id"] in hub.online(lid)
if not hub.add(lid, websocket, user["id"]):
await websocket.close(code=4429)
return
if not was_online:
hub.notify(lid, "lobby") # presence changed
try:
last_check = time.time()
while True:
try:
text = await asyncio.wait_for(websocket.receive_text(), timeout=70)
except asyncio.TimeoutError:
break # the client pings every 25 seconds, silence means it is gone
if text == "ping":
await websocket.send_text("pong")
if time.time() - last_check > 300: # session or membership may have ended
last_check = time.time()
if not await asyncio.to_thread(check):
break
except WebSocketDisconnect:
pass
finally:
hub.remove(lid, websocket)
if user["id"] not in hub.online(lid):
hub.notify(lid, "lobby")
try:
await websocket.close()
except Exception:
pass
app.include_router(api)
+1218
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
"""Character portraits: a small square picture stored inside the character as a data URL (so it travels with saving, sharing and
loading). The browser crops it to a square; the server checks and re-encodes it (256 px, WebP) so only real images get stored."""
import base64
import binascii
import hashlib
import io
from fastapi import HTTPException
from PIL import Image, ImageOps
SIZE = 256
MAX_RAW = 400_000 # bytes of the uploaded data before re-encoding
def clean(ch: dict) -> None:
"""Validates and re-encodes ch["portrait"] in place (an empty value removes it)."""
p = ch.get("portrait")
if not p:
ch["portrait"] = ""
return
if not isinstance(p, str) or not p.startswith("data:image/") or ";base64," not in p:
raise HTTPException(422, "The portrait must be an image")
try:
raw = base64.b64decode(p.split(";base64,", 1)[1], validate=True)
if len(raw) > MAX_RAW:
raise HTTPException(413, "The portrait is too large")
im = Image.open(io.BytesIO(raw))
if im.format not in ("PNG", "JPEG", "WEBP"):
raise ValueError
im.load()
except HTTPException:
raise
except (binascii.Error, ValueError, OSError, Image.DecompressionBombError):
raise HTTPException(422, "The portrait must be a PNG, JPEG or WebP image")
im = ImageOps.fit(ImageOps.exif_transpose(im).convert("RGB"), (SIZE, SIZE))
out = io.BytesIO()
im.save(out, "WEBP", quality=85)
ch["portrait"] = "data:image/webp;base64," + base64.b64encode(out.getvalue()).decode()
def tag(portrait) -> str:
"""Short fingerprint, used as cache key in URLs ("" if there is no portrait)."""
return hashlib.sha1(portrait.encode()).hexdigest()[:10] if portrait else ""
def image(portrait) -> bytes | None:
if not portrait or ";base64," not in portrait:
return None
try:
return base64.b64decode(portrait.split(";base64,", 1)[1])
except (binascii.Error, ValueError):
return None
+79
View File
@@ -0,0 +1,79 @@
"""Real-time pushes for lobbies over WebSockets.
The sockets only carry short "something changed" notes ({"t": "messages"} or {"t": "lobby"}). The browser then loads the
data through the normal API, so all visibility rules (whispers, hidden rolls, who sees which character) stay in one place.
Without a working socket the client keeps polling, so nothing breaks when a proxy blocks WebSockets."""
import asyncio
import threading
MAX_PER_USER = 6 # open sockets per user (several tabs)
MAX_PER_LOBBY = 80
class Hub:
def __init__(self):
self.rooms: dict = {} # lobby id -> {websocket: user id}
self.loop = None
self.lock = threading.Lock()
def attach(self, loop):
self.loop = loop
def add(self, lid: str, ws, uid: int) -> bool:
with self.lock:
room = self.rooms.setdefault(lid, {})
if len(room) >= MAX_PER_LOBBY or sum(1 for u in room.values() if u == uid) >= MAX_PER_USER:
return False
room[ws] = uid
return True
def remove(self, lid: str, ws):
with self.lock:
room = self.rooms.get(lid)
if room:
room.pop(ws, None)
if not room:
del self.rooms[lid]
def online(self, lid: str) -> set:
with self.lock:
return set(self.rooms.get(lid, {}).values())
async def _send(self, lid: str, note: dict, users):
with self.lock:
targets = [ws for ws, uid in self.rooms.get(lid, {}).items() if users is None or uid in users]
for ws in targets:
try:
await ws.send_json(note)
except Exception:
self.remove(lid, ws)
def notify(self, lid: str, kind: str, users=None, delay: float = 0.08, **extra):
"""Callable from normal (threadpool) endpoints. The short delay lets the database transaction finish first."""
if not self.loop or lid not in self.rooms:
return
async def go():
await asyncio.sleep(delay)
await self._send(lid, {"t": kind, **extra}, users)
asyncio.run_coroutine_threadsafe(go(), self.loop)
def close_user(self, lid: str, uid: int):
"""Drops the sockets of someone who left or was removed."""
if not self.loop:
return
with self.lock:
targets = [ws for ws, u in self.rooms.get(lid, {}).items() if u == uid]
for ws in targets:
asyncio.run_coroutine_threadsafe(ws.close(code=4403), self.loop)
def close_lobby(self, lid: str):
if not self.loop:
return
with self.lock:
targets = list(self.rooms.get(lid, {}))
for ws in targets:
asyncio.run_coroutine_threadsafe(ws.close(code=4404), self.loop)
hub = Hub()
+125
View File
@@ -0,0 +1,125 @@
"""Validation for the rules editor (items, skills, professions and their categories).
Only whitelisted fields are kept and every reference (category, group, attribute, skill) has to exist."""
import re
SECTIONS = ("equipment", "equipment_categories", "skills", "skill_groups", "professions")
MAX_ENTRIES = 400
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
class RuleError(ValueError):
pass
def _text(v, limit, label, required=False):
v = str(v if v is not None else "").strip()
if required and not v:
raise RuleError(f"{label} must not be empty")
if len(v) > limit:
raise RuleError(f"{label} is too long (max {limit} characters)")
return v
def _num(v, lo, hi, label, integer=False):
try:
n = float(v)
except (TypeError, ValueError):
raise RuleError(f"{label} must be a number")
if not lo <= n <= hi:
raise RuleError(f"{label} must be between {lo} and {hi}")
return int(n) if integer else round(n, 2)
def _ids(v, allowed, label, lo=0, hi=50):
if not isinstance(v, list) or not lo <= len(v) <= hi:
raise RuleError(f"{label}: choose {lo} to {hi}" if hi < 50 else f"{label} must be a list")
out = []
for x in v:
if x not in allowed:
raise RuleError(f"{label}: unknown entry '{x}'")
if x not in out:
out.append(x)
return out
def clean(section: str, items, rules: dict) -> list:
"""Returns the cleaned list for one section. `rules` is the effective rules (for the other sections' ids)."""
if section not in SECTIONS:
raise RuleError("Unknown section")
if not isinstance(items, list) or len(items) > MAX_ENTRIES:
raise RuleError(f"A list of at most {MAX_ENTRIES} entries is expected")
attrs = {a["id"] for a in rules["attributes"]}
out, seen = [], set()
cats = {c["id"] for c in (items if section == "equipment_categories" else rules["equipment_categories"])}
groups = {g["id"] for g in (items if section == "skill_groups" else rules["skill_groups"])}
skills = {s["id"] for s in (items if section == "skills" else rules["skills"])}
for it in items:
if not isinstance(it, dict):
raise RuleError("Every entry has to be an object")
iid = str(it.get("id", ""))
if not ID_RE.match(iid):
raise RuleError(f"Invalid id '{iid}' (lowercase letters, digits and _ only, up to 40)")
if iid in seen:
raise RuleError(f"The id '{iid}' is used twice")
seen.add(iid)
name = _text(it.get("name"), 60, f"Name of '{iid}'", True)
e = {"id": iid, "name": name}
label = f"'{name}'"
if section == "equipment":
if it.get("cat") not in cats:
raise RuleError(f"{label}: unknown category")
e.update(cat=it["cat"], stat=_text(it.get("stat"), 40, f"{label} stat"), kg=_num(it.get("kg", 0), 0, 1000, f"{label} weight"),
note=_text(it.get("note"), 200, f"{label} note"))
if it.get("tier"):
e["tier"] = _text(it["tier"], 20, f"{label} tier")
elif section == "equipment_categories":
e["stat"] = _text(it.get("stat"), 40, f"{label} stat column")
elif section == "skills":
if it.get("group") not in groups:
raise RuleError(f"{label}: unknown group")
if it.get("type") not in ("basic", "advanced"):
raise RuleError(f"{label}: type must be basic or advanced")
a = _ids(it.get("attrs"), attrs, f"{label} attributes", 1, 3)
e.update(group=it["group"], attrs=a, type=it["type"], desc=_text(it.get("desc"), 300, f"{label} description"))
elif section == "professions":
e.update(desc=_text(it.get("desc"), 500, f"{label} description"), bonus_attrs=_ids(it.get("bonus_attrs"), attrs, f"{label} bonus attributes", 0, 7),
proficiencies=_ids(it.get("proficiencies"), skills, f"{label} proficiencies", 0, 12),
picks=_num(it.get("picks", 0), 0, 10, f"{label} free picks", True), note=_text(it.get("note"), 200, f"{label} note"))
if not e["note"]:
del e["note"]
if it.get("placeholder"):
e["placeholder"] = True
out.append(e)
return out
def dangling(section: str, new: list, rules: dict) -> list:
"""Things in the other sections that would point to an entry that no longer exists."""
ids = {x["id"] for x in new}
msgs = []
if section == "equipment_categories":
msgs += [f"Item '{e['name']}' uses the removed category" for e in rules["equipment"] if e["cat"] not in ids]
if section == "skill_groups":
msgs += [f"Skill '{s['name']}' uses the removed group" for s in rules["skills"] if s["group"] not in ids]
if section == "skills":
for p in rules["professions"]:
gone = [i for i in p["proficiencies"] if i not in ids]
if gone:
msgs.append(f"Profession '{p['name']}' grants proficiency in a removed skill ({', '.join(gone)})")
return msgs
def character_refs(ch: dict) -> dict:
"""Ids a saved character points to, per section."""
kit = ch.get("kit") or {}
items = {i for i in [kit.get("weapon"), kit.get("armor"), kit.get("tool"), *(kit.get("resources") or []), *(kit.get("extras") or {})] if i}
sk = set((ch.get("skill_points") or {}).keys()) | set((ch.get("proficiencies") or {}).keys()) | set((ch.get("proficiency_picks") or {}).keys()) \
| set(ch.get("unlocked") or []) | set(ch.get("profession_picks") or [])
return {"equipment": items, "skills": sk, "professions": {ch["profession"]} if ch.get("profession") else set()}
def usage(chars: list, section: str, removed: set) -> int:
"""How many saved characters use one of the removed ids."""
if section not in ("equipment", "skills", "professions") or not removed:
return 0
return sum(1 for ch in chars if character_refs(ch)[section] & removed)
+104
View File
@@ -0,0 +1,104 @@
"""Lobby sounds: the DM can replace the dice sound and add sounds for critical successes / failures (MP3, per lobby)."""
import json
from pathlib import Path
from fastapi import Body, Depends, HTTPException, Request
from fastapi.responses import Response
MAX_SOUND = 1_500_000 # bytes per file
SLOTS = ("roll", "crit_success", "crit_failure", "ping")
FLAGS = ("result", "crit_success", "crit_failure", "ping") # result = the short tones for success / failure of a check
def schema(con):
if "sounds" not in [r["name"] for r in con.execute("PRAGMA table_info(lobbies)")]:
con.execute("ALTER TABLE lobbies ADD COLUMN sounds TEXT NOT NULL DEFAULT '{}'")
def settings(raw) -> dict:
try:
s = json.loads(raw or "{}")
except ValueError:
s = {}
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))}
def install(api, db, current_user, lobby_for, dm_only, hub, store_dir: Path):
store_dir.mkdir(parents=True, exist_ok=True)
def path(lid: str, slot: str) -> Path:
if slot not in SLOTS:
raise HTTPException(404, "Unknown sound")
return store_dir / f"{lid}_{slot}.mp3"
def save(con, lid, s):
s["v"] += 1
con.execute("UPDATE lobbies SET sounds=? WHERE id=?", (json.dumps(s), lid))
@api.put("/lobbies/{lid}/sounds")
def set_flags(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
s = settings(lobby["sounds"])
for k in FLAGS:
if k in body:
s[k] = bool(body[k])
save(con, lid, s)
hub.notify(lid, "lobby")
return {"ok": True}
@api.post("/lobbies/{lid}/sounds/{slot}")
async def upload_sound(lid: str, slot: str, request: Request, user: dict = Depends(current_user)):
"""Raw MP3 bytes in the body."""
p = path(lid, slot)
with db() as con:
dm_only(lobby_for(con, lid, user), user)
if int(request.headers.get("content-length") or 0) > MAX_SOUND:
raise HTTPException(413, f"The file is too large (at most {MAX_SOUND // 1_000_000} MB)")
chunks, size = [], 0
async for c in request.stream():
size += len(c)
if size > MAX_SOUND:
raise HTTPException(413, f"The file is too large (at most {MAX_SOUND // 1_000_000} MB)")
chunks.append(c)
data = b"".join(chunks)
if not (data[:3] == b"ID3" or (len(data) > 4 and data[0] == 0xFF and data[1] & 0xE0 == 0xE0)):
raise HTTPException(422, "Please upload an MP3 file")
p.write_bytes(data)
with db() as con:
lobby = lobby_for(con, lid, user)
s = settings(lobby["sounds"])
s["custom"][slot] = True
save(con, lid, s)
hub.notify(lid, "lobby")
return {"ok": True}
@api.delete("/lobbies/{lid}/sounds/{slot}")
def reset_sound(lid: str, slot: str, user: dict = Depends(current_user)):
p = path(lid, slot)
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
s = settings(lobby["sounds"])
s["custom"][slot] = False
save(con, lid, s)
p.unlink(missing_ok=True)
hub.notify(lid, "lobby")
return {"ok": True}
@api.get("/lobbies/{lid}/sounds/{slot}")
def get_sound(lid: str, slot: str, v: int = 0, user: dict = Depends(current_user)):
p = path(lid, slot)
with db() as con:
lobby_for(con, lid, user)
if not p.exists():
raise HTTPException(404, "No custom sound")
return Response(p.read_bytes(), media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"})
def purge(lid: str):
for slot in SLOTS:
path(lid, slot).unlink(missing_ok=True)
return purge
+148
View File
@@ -0,0 +1,148 @@
"""Line of sight: visibility polygon from a point around wall segments, and cutting the map image to it.
The server cuts the image, so a player's browser only ever receives the pixels their character can see (no peeking through
dev tools). Pure Python on purpose (no numpy): a few hundred wall segments are fast enough, and results are cached."""
import io
import math
from collections import OrderedDict
from PIL import Image, ImageDraw
EPS = 1e-5
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.
sector = (direction, half angle) in radians limits the view to a cone."""
ox, oy = origin
border = [(0, 0, w, 0), (w, 0, w, h), (w, h, 0, h), (0, h, 0, 0)]
if radius:
r2 = (radius + 1) ** 2
def near(s):
x1, y1, x2, y2 = s
dx, dy = x2 - x1, y2 - y1
L = dx * dx + dy * dy
t = 0 if L == 0 else max(0, min(1, ((ox - x1) * dx + (oy - y1) * dy) / L))
return (x1 + t * dx - ox) ** 2 + (y1 + t * dy - oy) ** 2 <= r2
segs = [s for s in segs if near(s)]
allsegs = segs + border
angles = []
for x1, y1, x2, y2 in allsegs:
for x, y in ((x1, y1), (x2, y2)):
if radius and (x - ox) ** 2 + (y - oy) ** 2 > (radius * 3) ** 2:
continue
a = math.atan2(y - oy, x - ox)
angles += (a - EPS, a, a + EPS)
if radius:
angles += [i * (2 * math.pi / 120) for i in range(120)]
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 = []
for a in angles:
dx, dy = math.cos(a), math.sin(a)
best = radius if radius else float("inf")
for x1, y1, x2, y2 in allsegs:
ex, ey = x2 - x1, y2 - y1
den = dx * ey - dy * ex
if abs(den) < 1e-12:
continue
px, py = x1 - ox, y1 - oy
t = (px * ey - py * ex) / den
if t < 0 or t >= best:
continue
u = (px * dy - py * dx) / den
if -1e-9 <= u <= 1 + 1e-9:
best = t
if best == float("inf"):
continue
pts.append((ox + dx * best, oy + dy * best))
return [(ox, oy)] + pts if sector else pts
def inside(pt, poly) -> bool:
x, y = pt
c = False
j = len(poly) - 1
for i in range(len(poly)):
xi, yi = poly[i]
xj, yj = poly[j]
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi:
c = not c
j = i
return c
class Lru(OrderedDict):
def __init__(self, size):
super().__init__()
self.size = size
def put(self, k, v):
self[k] = v
self.move_to_end(k)
while len(self) > self.size:
self.popitem(last=False)
def fetch(self, k):
if k in self:
self.move_to_end(k)
return self[k]
_images = Lru(2)
_polys = Lru(128)
_cuts = Lru(24)
fog_cache = Lru(12)
def cached_polygon(key, make):
v = _polys.fetch(key)
if v is None:
v = make()
_polys.put(key, v)
return v
def source(path):
key = (str(path), path.stat().st_mtime_ns)
im = _images.fetch(key)
if im is None:
im = Image.open(path)
im.load()
im = im.convert("RGB")
_images.put(key, im)
return im
def inside_any(pt, polys) -> bool:
return any(inside(pt, p) for p in polys)
def cut(path, polys, key):
"""The part of the image inside the polygon (everything else transparent), cropped to its bounding box.
Returns (webp bytes, (x, y, w, h))."""
hit = _cuts.fetch(key)
if hit:
return hit
im = source(path)
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))))
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)
mask = Image.new("L", (w, h), 0)
draw = ImageDraw.Draw(mask)
for poly in polys:
if len(poly) > 2:
draw.polygon([(x - x0, y - y0) for x, y in poly], fill=255)
piece = 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()
piece.save(out, "WEBP", quality=82, method=1)
res = (out.getvalue(), (x0, y0, w, h))
_cuts.put(key, res)
return res
+4 -1
View File
@@ -15,6 +15,7 @@
"skill_points_default": 150,
"skill_max_divisor": 2,
"advanced_unlock_cost": 10,
"advanced_attr_percent": 50,
"free_proficiencies": 2,
"max_proficiency_pips": 2,
"talents_min": 1,
@@ -30,6 +31,7 @@
{"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": "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}}
],
@@ -41,6 +43,7 @@
{"key": "profession_attr_bonus", "label": "Profession attribute bonus", "group": "Profession", "min": 0, "max": 50},
{"key": "free_proficiencies", "label": "Free proficiency picks", "group": "Skills", "min": 0, "max": 20},
{"key": "skill_points_default", "label": "Skill point pool", "group": "Skills", "min": 0, "max": 1000},
{"key": "advanced_attr_percent", "label": "Advanced skills: share of the attribute that counts (%)", "group": "Skills", "min": 0, "max": 100},
{"key": "advanced_unlock_cost", "label": "Cost to unlock an advanced skill", "group": "Skills", "min": 0, "max": 100},
{"key": "skill_max_divisor", "label": "Skill maximum = attribute divided by", "group": "Skills", "min": 1, "max": 10},
{"key": "proficiency_bonus", "label": "Proficiency bonus", "group": "Skills", "min": 0, "max": 50},
@@ -434,7 +437,7 @@
"placeholder": true
},
{
"p": "The unlock cost for advanced skills (10 points) is a house rule of the app, not part of the brainstorm. It can be changed in rules.json (creation.advanced_unlock_cost).",
"p": "The unlock cost for advanced skills (10 points) and the rule that advanced skills only use half of the attribute are house rules of the app, not part of the brainstorm. They can be changed in rules.json (creation.advanced_unlock_cost, creation.advanced_attr_percent).",
"placeholder": true
},
{
+1
View File
@@ -2,3 +2,4 @@ fastapi>=0.115
uvicorn[standard]>=0.30
python-docx>=1.1
reportlab>=4.2
Pillow>=10
+2
View File
@@ -12,6 +12,8 @@ services:
# Code that new users must enter to create an account (set it in the .env file next to this file).
# Empty = registration is disabled.
AW_REGISTRATION_CODE: ${AW_REGISTRATION_CODE:-}
# Who may use the rules editor: comma separated usernames. Empty = only the first account that was ever created.
AW_ADMINS: ${AW_ADMINS:-}
# Set to 1 when a reverse proxy (nginx, Caddy, Traefik) in front sets X-Forwarded-* headers.
AW_TRUST_PROXY: ${AW_TRUST_PROXY:-0}
volumes:
Binary file not shown.
+11 -1
View File
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react'
import Creator from './Creator.jsx'
import Rules from './Rules.jsx'
import Maps from './Maps.jsx'
import Characters from './Characters.jsx'
import Presets from './Presets.jsx'
import Lobbies from './Lobbies.jsx'
import Login from './Login.jsx'
import Account from './Account.jsx'
import Editor from './Editor.jsx'
import { claimLegacy } from './legacy.js'
const useHash = () => {
@@ -25,6 +27,7 @@ export default function App() {
const [error, setError] = useState('')
// Used to load a saved character into the creator
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
// Ask the server who we are; any later 401 (session expired) sends us back to the login
@@ -53,7 +56,7 @@ export default function App() {
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
const page = hash.split('/')[0]
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies']]
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 (
<>
@@ -74,13 +77,20 @@ export default function App() {
{rules && page === 'creator' && <Creator rules={rules} user={username} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} onName={setCharName} />}
{rules && page === 'rules' && <Rules rules={rules} section={hash.split('/')[1]} />}
{rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />}
{page === 'maps' && <Maps />}
{rules && page === 'characters' && (
<Characters onOpen={(entry) => { setLoadRequest(entry); 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' && (
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
)}
{rules && page === 'editor' && auth.user.admin && <Editor rules={rules} onReload={loadRules} />}
{page === 'account' && <Account user={username} onLogout={logout} />}
</main>
</>
+29 -12
View File
@@ -19,7 +19,7 @@ function SharePanel({ c, reload }) {
return (
<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">
{c.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
{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 [open, setOpen] = useState(null) // id of the character whose share panel is open
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() }, [])
const remove = async (c) => {
@@ -49,32 +50,46 @@ export default function Characters({ onOpen, onNew }) {
if (!r || !r.ok) return setErr('Deleting failed.')
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')
return (
<section className="page narrow">
<div className="row">
<h1>My characters</h1>
<button onClick={onNew}>+ New character</button>
<h1>{npc ? 'NPCs and creatures' : 'My characters'}</h1>
<button onClick={onNew}>{npc ? '+ New NPC' : '+ New character'}</button>
</div>
<p className="muted">
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.
{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.'
: '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>
{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">
{(data?.mine || []).map((c) => (
<li key={c.id} className="card">
<div className="row">
<div>
<strong>{c.name}</strong>
<div className="npchead">
{thumb(c)}
<div>
<strong>{c.name}</strong>{npc && c.kind && <span className="badge">{kindName(c.kind)}</span>}
<div className="muted small">
ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`}
</div>
</div>
</div>
<div className="actions">
<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 danger" onClick={() => remove(c)}>Delete</button>
</div>
@@ -90,9 +105,11 @@ export default function Characters({ onOpen, onNew }) {
<ul className="charlist">
{data.shared.map((c) => (
<li key={c.id} className="card row">
<div>
<strong>{c.name}</strong>
<div className="npchead">
{thumb(c)}
<div><strong>{c.name}</strong>
<div className="muted small">from {c.owner} · {when(c.updated)}</div>
</div>
</div>
<div className="actions"><button onClick={() => onOpen(c)}>View</button></div>
</li>
+47 -20
View File
@@ -1,3 +1,4 @@
import PortraitEditor from './Portrait.jsx'
import { useEffect, useMemo, useRef, useState } from 'react'
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
import SheetPanel from './SheetPanel.jsx'
@@ -13,9 +14,13 @@ function readDraft(key) {
try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
}
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName }) {
const DRAFT = `aw_draft:${user}` // drafts are kept per account
const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
const NPC_KINDS = [['npc', 'NPC'], ['enemy', 'Enemy'], ['monster', 'Monster'], ['creature', 'Creature'], ['animal', 'Animal'], ['robot', 'Robot / machine']]
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 d = readDraft(DRAFT)
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
@@ -57,7 +62,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
if (!loadRequest) return
if (loadRequest.new) { // "New character" button on the My characters page
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)
}
clearLoad()
@@ -73,7 +78,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
// Direct link #creator/<id> (read only; saving creates a copy)
useEffect(() => {
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() })
.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.'))
@@ -93,7 +98,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
const resetAll = () => {
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 = () => {
if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return
@@ -153,10 +158,25 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
const stepConcept = (
<>
<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
<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>}
</select>
</label>
@@ -166,10 +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>
})()}
<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="grid2">
<label>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>
<label>{npc ? 'NPC name' : 'Character name'}<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
{!npc && <label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>}
</div>
<div className="grid2">
<label>Class (optional)
@@ -187,7 +208,9 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
</div>
{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>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>}
</>
)
@@ -314,9 +337,11 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
<button className="ghost small-btn" title={`Lock again: you get ${c.unlockCost} points back, its points and proficiency picks are returned`}
onClick={() => setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 }, proficiency_picks: { ...x.proficiency_picks, [s.id]: 0 } }))}>Lock</button>
)}
<button onClick={() => setPts(s.points - 1)} aria-label={`${s.name} minus`}>−</button>
<button className="step5" onClick={() => setPts(s.points - 5)} disabled={s.points <= 0} aria-label={`${s.name} minus 5`}>−5</button>
<button onClick={() => setPts(s.points - 1)} disabled={s.points <= 0} aria-label={`${s.name} minus`}>−</button>
<span className="val">{s.points}</span>
<button onClick={() => setPts(s.points + 1)} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+</button>
<button onClick={() => setPts(Math.min(s.points + 1, s.max, s.points + c.skillLeft))} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+</button>
<button className="step5" onClick={() => setPts(Math.min(s.points + 5, s.max, s.points + c.skillLeft))} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus 5`}>+5</button>
<Tip text={explainSkill(s, ctx)}><span className="mod">{s.total}</span></Tip><span className="small muted" title="Most points you can add"> max {s.max}</span>
</div>
)}
@@ -415,6 +440,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
)
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(`${c.attrLeft} attribute points left.`)
if (!ch.profession) warnings.push('No profession chosen.')
@@ -428,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 (over) warnings.push('Augmentation Capacity exceeded.')
if (overWeight) warnings.push('Carrying more than the carry capacity.')
}
const gearLines = kitItems(ch, rules)
const stepSummary = (
<>
<h2>Summary</h2>
{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">
<button onClick={() => download('pdf')}>Download as PDF</button>
<button onClick={() => download('docx')}>Download as Word</button>
<button className="ghost" onClick={save}>{saved ? 'Save changes' : 'Save on server'}</button>
{!npc && <button onClick={() => download('pdf')}>Download as PDF</button>}
{!npc && <button onClick={() => download('docx')}>Download as Word</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={() => fileRef.current.click()}>Import JSON</button>
<input ref={fileRef} type="file" accept="application/json" hidden onChange={importJson} />
</div>
{saved && (
{saved && !npc && (
<p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p>
)}
</>
@@ -453,7 +480,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
return (
<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">
{STEPS.map((s, i) => (
<li key={s}><button className={`${i === step ? 'on' : ''} ${i < step ? 'done' : ''}`} onClick={() => go(i)}>
+222
View File
@@ -0,0 +1,222 @@
import { useEffect, useMemo, useState } from 'react'
const J = { 'Content-Type': 'application/json' }
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40)
// What can be edited: fields per entry and the columns of the list
const defs = (saved, attrs) => {
const cats = (saved.equipment_categories || []).map((c) => [c.id, c.name])
const groups = (saved.skill_groups || []).map((g) => [g.id, g.name])
const skills = (saved.skills || []).map((s) => [s.id, s.name])
const name = (list, id) => (list.find(([k]) => k === id) || [])[1] || id
return {
equipment: {
label: 'Items', noun: 'item',
fields: [['name', 'Name', 'text'], ['cat', 'Category', 'select', cats], ['stat', 'Stat (damage, armor ...)', 'text'], ['tier', 'Tier (optional, e.g. light)', 'text'],
['kg', 'Weight (kg)', 'number'], ['note', 'Note', 'text'], ['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
blank: { cat: cats[0]?.[0] || '', stat: '', kg: 1, note: '' },
cols: [['Name', (e) => e.name], ['Category', (e) => name(cats, e.cat)], ['Stat', (e) => `${e.stat}${e.tier ? ` (${e.tier})` : ''}`], ['kg', (e) => e.kg]],
},
equipment_categories: {
label: 'Item categories', noun: 'category',
fields: [['name', 'Name', 'text'], ['stat', 'Name of the stat column', 'text']],
blank: { stat: '' }, cols: [['Name', (e) => e.name], ['Stat column', (e) => e.stat]],
},
skills: {
label: 'Skills', noun: 'skill',
fields: [['name', 'Name', 'text'], ['group', 'Group', 'select', groups], ['type', 'Type', 'select', [['basic', 'Basic'], ['advanced', 'Advanced']]],
['attrs', 'Attributes (the higher one counts)', 'multi', attrs], ['desc', 'Description', 'textarea'], ['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
blank: { group: groups[0]?.[0] || '', type: 'basic', attrs: [], desc: '' },
cols: [['Name', (e) => e.name], ['Group', (e) => name(groups, e.group)], ['Type', (e) => e.type], ['Attributes', (e) => e.attrs.map((a) => name(attrs, a)).join(' / ')]],
},
skill_groups: {
label: 'Skill groups', noun: 'group', fields: [['name', 'Name', 'text']], blank: {}, cols: [['Name', (e) => e.name]],
},
professions: {
label: 'Professions', noun: 'profession',
fields: [['name', 'Name', 'text'], ['desc', 'Description', 'textarea'], ['bonus_attrs', 'Attributes that can get the profession bonus', 'multi', attrs],
['proficiencies', 'Skills with fixed proficiency', 'multi', skills], ['picks', 'Free proficiency picks', 'number'], ['note', 'Note', 'text'],
['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
blank: { desc: '', bonus_attrs: [], proficiencies: [], picks: 0 },
cols: [['Name', (e) => e.name], ['Bonus', (e) => e.bonus_attrs.map((a) => name(attrs, a)).join(', ')], ['Proficiencies', (e) => e.proficiencies.map((s) => name(skills, s)).join(', ')], ['Picks', (e) => e.picks]],
},
}
}
function Field({ f, value, onChange }) {
const [key, label, type, opts] = f
if (type === 'bool') return <label className="small"><input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} style={{ width: 'auto' }} /> {label}</label>
return (
<label className="edfield">
<span className="small muted">{label}</span>
{type === 'text' && <input value={value ?? ''} onChange={(e) => onChange(e.target.value)} />}
{type === 'number' && <input type="number" step="any" value={value ?? ''} onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))} />}
{type === 'textarea' && <textarea rows={3} value={value ?? ''} onChange={(e) => onChange(e.target.value)} />}
{type === 'select' && <select value={value ?? ''} onChange={(e) => onChange(e.target.value)}>{opts.map(([k, n]) => <option key={k} value={k}>{n}</option>)}</select>}
{type === 'multi' && (
<span className="multi">
{opts.map(([k, n]) => (
<label key={k} className="small"><input type="checkbox" style={{ width: 'auto' }} checked={(value || []).includes(k)}
onChange={(e) => onChange(e.target.checked ? [...(value || []), k] : (value || []).filter((x) => x !== k))} /> {n}</label>
))}
</span>
)}
</label>
)
}
// 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 }) {
const [state, setState] = useState(null)
const [tab, setTab] = useState('equipment')
const [drafts, setDrafts] = useState({}) // section -> unsaved list
const [editing, setEditing] = useState('')
const [q, setQ] = useState('')
const [msg, setMsg] = useState({ text: '', bad: false })
const [busy, setBusy] = useState(false)
const load = () => fetch('/api/admin/rules').then(async (r) => {
if (!r.ok) throw new Error(r.status)
setState(await r.json())
}).catch((e) => setMsg({ text: String(e.message) === '403' ? 'Only admins can use the rules editor.' : 'Could not load the editor.', bad: true }))
useEffect(() => { load() }, [])
const D = useMemo(() => state && defs(state.sections, rules.attributes.map((a) => [a.id, a.name])), [state, rules.attributes])
if (!state) return <section className="page"><p className={msg.bad ? 'warn' : 'muted'}>{msg.text || 'Loading …'}</p></section>
const d = D[tab]
const saved = state.sections[tab]
const list = drafts[tab] || saved
const dirty = !!drafts[tab]
const setList = (l) => setDrafts((x) => ({ ...x, [tab]: l }))
const patch = (id, key, v) => setList(list.map((e) => {
if (e.id !== id) return e
const n = { ...e, [key]: v }
if (e._new && key === 'name') { // new entries get their id from the name
let base = slug(v) || 'entry', cand = base, i = 2
while (list.some((x) => x !== e && x.id === cand)) cand = `${base}_${i++}`
const old = e.id
n.id = cand
if (editing === old) setEditing(cand)
}
return n
}))
const add = () => {
let id = 'new_entry', i = 2
while (list.some((x) => x.id === id)) id = `new_entry_${i++}`
setList([...list, { id, name: '', ...d.blank, _new: true }])
setEditing(id); setQ('')
}
const del = (e) => { if (editing === e.id) setEditing(''); setList(list.filter((x) => x.id !== e.id)) }
const save = async (confirm = false) => {
setBusy(true); setMsg({ text: '', bad: false })
const items = list.map(({ _new, ...e }) => e)
const r = await fetch(`/api/admin/rules/${tab}`, { method: 'PUT', headers: J, body: JSON.stringify({ items, confirm }) }).catch(() => null)
const body = r ? await r.json().catch(() => ({})) : {}
setBusy(false)
if (r && r.status === 409 && window.confirm(`${body.detail}\n\nSave anyway?`)) return save(true)
if (!r || !r.ok) return setMsg({ text: typeof body.detail === 'string' ? body.detail : 'Could not save.', bad: true })
setDrafts((x) => { const n = { ...x }; delete n[tab]; return n })
setEditing(''); setMsg({ text: `Saved ${body.count} entries. Everybody sees the change after reloading the page.`, bad: false })
await load(); onReload?.()
}
const reset = async (confirm = false) => {
if (!confirm && !window.confirm(`Throw away all your changes to "${d.label}" and go back to the shipped rules?`)) return
const r = await fetch(`/api/admin/rules/${tab}?confirm=${confirm}`, { method: 'DELETE' }).catch(() => null)
const body = r ? await r.json().catch(() => ({})) : {}
if (r && r.status === 409 && window.confirm(`${body.detail}\n\nReset anyway?`)) return reset(true)
if (!r || !r.ok) return setMsg({ text: typeof body.detail === 'string' ? body.detail : 'Could not reset.', bad: true })
setDrafts((x) => { const n = { ...x }; delete n[tab]; return n })
setMsg({ text: 'Back to the shipped rules.', bad: false })
await load(); onReload?.()
}
const shown = list.filter((e) => !q || e.name.toLowerCase().includes(q.toLowerCase()) || e.id.includes(q.toLowerCase()))
const cur = list.find((e) => e.id === editing)
return (
<section className="page editor">
<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.
Existing characters keep working. Talents, mutations and implants are still edited in rules.json.</p>
<AdminUsers />
<div className="tabs" role="tablist">
{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 }) }}>
{x.label}{drafts[id] ? ' *' : ''}{state.customized.includes(id) ? ' •' : ''}
</button>
))}
</div>
<div className="row wrapform">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Search ${d.label.toLowerCase()} …`} aria-label="Search" style={{ maxWidth: '18rem' }} />
<span className="muted small">{list.length} entries{state.customized.includes(tab) ? ' · customised (•)' : ' · as shipped'}</span>
<span className="actions">
<button onClick={add}>+ Add {d.noun}</button>
<button onClick={() => save()} disabled={!dirty || busy}>Save changes</button>
<button className="ghost" onClick={() => { setDrafts((x) => { const n = { ...x }; delete n[tab]; return n }); setEditing('') }} disabled={!dirty}>Discard</button>
<button className="ghost danger" onClick={() => reset()} disabled={!state.customized.includes(tab)}>Reset to shipped</button>
</span>
</div>
{msg.text && <p className={msg.bad ? 'warn' : 'small'} role={msg.bad ? 'alert' : 'status'}>{msg.text}</p>}
{cur && (
<div className="card edform">
<div className="row"><strong>{cur.name || `New ${d.noun}`}</strong><span className="muted small">id: {cur.id}{cur._new ? ' (from the name)' : ''}</span></div>
<div className="edgrid">
{d.fields.map((f) => <Field key={f[0]} f={f} value={cur[f[0]]} onChange={(v) => patch(cur.id, f[0], v)} />)}
</div>
<div className="actions"><button className="ghost" onClick={() => setEditing('')}>Done</button></div>
</div>
)}
<div className="tablewrap"><table className="edtable">
<thead><tr>{d.cols.map(([h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
<tbody>
{shown.map((e) => (
<tr key={e.id} className={editing === e.id ? 'sel' : ''}>
{d.cols.map(([h, fn]) => <td key={h}>{fn(e)}{h === 'Name' && e.placeholder ? <span className="badge">Placeholder</span> : ''}</td>)}
<td className="actions"><button className="ghost" onClick={() => setEditing(e.id)}>Edit</button><button className="ghost danger" onClick={() => del(e)}>Delete</button></td>
</tr>
))}
{shown.length === 0 && <tr><td colSpan={d.cols.length + 1} className="muted">Nothing found.</td></tr>}
</tbody>
</table></div>
</section>
)
}
+451 -42
View File
@@ -1,5 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
import { sound, playRoll, playPing, preload, DEFAULT_ROLL } from './sound.js'
import { useTableTools } from './Tools.jsx'
import { MapView, MapLibrary, NpcPicker, PropForm, PropPicker, RadialMenu, RevealLayer, StatusForm, TokenForm, WallLayer } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' }
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
@@ -109,8 +112,76 @@ const sgn = (n) => (n > 0 ? `+${n}` : n < 0 ? `−${-n}` : '±0')
const store = (k, v) => { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v) } catch { /* storage may be blocked */ } return null }
const OUTCOME = { crit_success: 'Critical success!', success: 'Success', failure: 'Failure', crit_failure: 'Critical failure!' }
// One WebSocket per open lobby. It only says "something changed" ({t: 'messages' | 'lobby' | 'sheet'}), the data is loaded through
// the normal API. Without a working socket the page falls back to polling, so a proxy that blocks WebSockets does not break anything.
function useSocket(id, handlers) {
const [up, setUp] = useState(false)
const h = useRef(handlers)
h.current = handlers
useEffect(() => {
let ws, timer, ping, tries = 0, closed = false
const open = () => {
ws = new WebSocket(`${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/api/lobbies/${id}/ws`)
ws.onopen = () => {
tries = 0; setUp(true); h.current.open?.()
ping = setInterval(() => ws.readyState === 1 && ws.send('ping'), 25000)
}
ws.onmessage = (e) => {
if (e.data === 'pong') return
try { const n = JSON.parse(e.data); h.current[n.t]?.(n) } catch { /* ignore malformed notes */ }
}
ws.onclose = (e) => {
clearInterval(ping); setUp(false)
if (closed || (e.code >= 4400 && e.code < 4500)) return // refused or removed: the normal polling shows why
timer = setTimeout(open, Math.min(10000, 1000 * 2 ** tries++))
}
}
try { open() } catch { /* no WebSocket support: polling only */ }
const wake = () => { if (!document.hidden) h.current.open?.() } // catch up after the tab was in the background
document.addEventListener('visibilitychange', wake)
return () => { closed = true; clearTimeout(timer); clearInterval(ping); document.removeEventListener('visibilitychange', wake); ws?.close() }
}, [id])
return up
}
// The map picture for this viewer. With line of sight the server sends only the visible part (position in X-Crop), so the picture
// is fetched again whenever the server's `rev` for this viewer changes; the old one stays until the new one has arrived.
function useMapImage(id, vision, as) {
const [img, setImg] = useState(null)
const last = useRef({ t: 0, n: 0, timer: 0 })
const rev = vision?.rev
useEffect(() => {
if (!rev || vision.empty) { setImg(null); return }
const n = ++last.current.n
const go = () => fetch(`/api/lobbies/${id}/map/view${as ? `?as=${encodeURIComponent(as)}` : ''}`).then(async (r) => {
if (!r.ok) throw new Error(r.status)
const [x, y, w, h] = (r.headers.get('X-Crop') || '0,0,0,0').split(',').map(Number)
const url = URL.createObjectURL(await r.blob())
if (n !== last.current.n) { URL.revokeObjectURL(url); return } // a newer one was asked for meanwhile
setImg((old) => { if (old) URL.revokeObjectURL(old.src); return { src: url, x, y, w, h } })
}).catch(() => {})
const wait = Math.max(0, 200 - (Date.now() - last.current.t)) // at most ~5 pictures per second while a token is dragged
clearTimeout(last.current.timer)
last.current.timer = setTimeout(() => { last.current.t = Date.now(); go() }, wait)
return () => clearTimeout(last.current.timer)
}, [id, rev, as]) // eslint-disable-line
useEffect(() => () => setImg((old) => { old && URL.revokeObjectURL(old.src); return null }), [])
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.
function useFeed(id, onEvent, onRoll) {
function useFeed(id, onEvent, onRoll, socketUp) {
const [msgs, setMsgs] = useState([])
const last = useRef(0)
const live = useRef(false) // false until the history is loaded: old rolls are not animated again
@@ -134,9 +205,9 @@ function useFeed(id, onEvent, onRoll) {
}, [id])
useEffect(() => {
poll()
const t = setInterval(poll, 2500)
const t = setInterval(poll, socketUp ? 20000 : 2500) // the socket says when something is new, the timer is only a safety net
return () => clearInterval(t)
}, [poll])
}, [poll, socketUp])
return { msgs, poll }
}
@@ -177,7 +248,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
const who = (m) => (
<>
<strong style={m.color ? { color: m.color } : undefined}>{m.mine ? 'You' : m.from || '?'}</strong>
{m.to && <em className="small whispertag"> {m.to === m.from ? '(only visible to you, the DM)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>}
{m.to && <em className="small whispertag"> {m.to === m.from ? '(private, only you)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>}
</>
)
@@ -216,6 +287,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
<form className="row chatform" onSubmit={submit}>
<select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}>
<option value="">Everyone</option>
<option value={me}>Only me (private)</option>
{people.filter((p) => p !== me).map((p) => <option key={p} value={p}>Only {p} (whisper)</option>)}
</select>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder={to ? `Whisper to ${to} …` : 'Message …'} maxLength={500} aria-label="Message" autoComplete="off" />
@@ -243,7 +315,9 @@ function DieIcon({ sides }) {
const MODES = [['mystery', 'Mystery', 'Players see grey dice, but not the result'], ['hidden', 'Hidden', 'Only you see the roll'], ['public', 'Public', 'Everybody sees the roll']]
function DiceBar({ to, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy, sfx }) {
const [snd, setSnd] = useState(sound.enabled)
const [vol, setVol] = useState(sound.volume)
return (
<div className="dicebar" role="toolbar" aria-label="Dice">
<label className="small muted dicecount">Dice
@@ -259,12 +333,16 @@ function DiceBar({ to, isDm, count, setCount, mode, setMode, own, setOwn, onRoll
<label className="small ownswitch" title="You rolled real dice: type in the result instead of rolling here">
<input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} style={{ width: 'auto' }} /> Own dice
</label>
<span className="soundctl small" title="Dice sounds">
<button className="ghost" aria-pressed={snd} aria-label={snd ? 'Mute dice sounds' : 'Turn dice sounds on'} onClick={() => { sound.setEnabled(!snd); setSnd(!snd); if (!snd) playRoll(20, 1, undefined, sfx) }}>{snd ? '🔊' : '🔇'}</button>
{snd && <input type="range" min="0" max="1" step="0.05" value={vol} aria-label="Dice sound volume" onChange={(e) => { setVol(+e.target.value); sound.setVolume(+e.target.value) }} onPointerUp={() => playRoll(20, 1, undefined, sfx)} />}
</span>
{isDm && !to && (
<span className="seg small" role="group" aria-label="Who sees the DM's rolls">
{MODES.map(([k, label, tip]) => <button key={k} className={mode === k ? 'on' : 'ghost'} title={tip} onClick={() => setMode(k)}>{label}</button>)}
</span>
)}
{to && <span className="small dicedest priv">Private: {to}</span>}
{to && <span className="small dicedest priv">{to === me ? 'Private: only you' : `Private: ${to}`}</span>}
</div>
)
}
@@ -346,7 +424,7 @@ function DiceTray({ rolls }) {
const who = <b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b>
if (m.kind === 'check') {
const c = m.check
return <div key={m.key} className="trayroll">{who} – {c.what}{c.manual ? ' · own dice' : ''}: <span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>{c.target !== undefined && <small> ({c.roll} vs {c.target})</small>}</div>
return <div key={m.key} className="trayroll">{who} – {c.what}{c.manual ? ' · own dice' : ''}{m.to ? ' · only you' : ''}: <span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>{c.target !== undefined && <small> ({c.roll} vs {c.target})</small>}</div>
}
const t = rollText(m)
return <div key={m.key} className="trayroll">{who} rolled <strong>{t.dice}</strong>{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}
@@ -361,15 +439,18 @@ function DiceTray({ rolls }) {
function CharBar({ lobby, sel, onSel }) {
return (
<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) => {
const c = m.character, can = !!m.character_id
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)}>
<span className="pavatar">{(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">
<b>{c ? c.name : 'No character'}</b>
<span className="muted small">{m.username}{m.me ? ' (you)' : ''}{c?.class ? ` · ${c.class}` : ''}</span>
<b title={m.username}>{c ? c.name : 'No character'}</b>
</span>
{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}`}
@@ -383,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
function DifficultyBar({ lobby, onSet }) {
const d = lobby.difficulty
@@ -410,33 +536,108 @@ function DifficultyBar({ lobby, onSet }) {
}
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
function CharTab({ cid, rules, onRoll }) {
function CharTab({ cid, rules, onRoll, tick, editable }) {
const [data, setData] = useState(null)
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(() => {
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()
const t = setInterval(() => !document.hidden && load(), 15000)
return () => clearInterval(t)
}, [cid])
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 && Date.now() - typing.current > 4000 && setData(d)).catch(() => {})
}, [tick]) // eslint-disable-line
return (
<div>
{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>
)
}
// lobby settings: join code, rename, colour, character switch, members, leave / delete
function Settings({ lobby, id, mine, act, setInfo, onClose }) {
// DM: replace the dice sound, add sounds for critical results, switch cues on or off
function SoundSettings({ lobby, id, act, sfx }) {
const sn = lobby.sounds
const [err, setErr] = useState('')
const upload = async (slot, e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr('')
const r = await fetch(`/api/lobbies/${id}/sounds/${slot}`, { method: 'POST', headers: { 'Content-Type': 'audio/mpeg' }, body: f }).catch(() => null)
if (!r || !r.ok) return setErr(r ? ((await r.json().catch(() => ({}))).detail || 'Upload failed') : 'Upload failed')
act()
}
const flag = (k, v) => act(`/api/lobbies/${id}/sounds`, 'PUT', { [k]: v })
const row = (slot, label, defaultText, flagKey) => (
<div className="soundrow">
<div className="row">
{flagKey && <label className="small chk"><input type="checkbox" checked={!!sn[flagKey]} onChange={(e) => flag(flagKey, e.target.checked)} /> {label}</label>}
{!flagKey && <strong className="small">{label}</strong>}
</div>
<div className="row small">
<span className="muted">{sn.custom[slot] ? 'Your file' : defaultText}</span>
<span className="row">
<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>
{sn.custom[slot] && <button className="ghost" onClick={() => act(`/api/lobbies/${id}/sounds/${slot}`, 'DELETE')}>Reset</button>}
</span>
</div>
</div>
)
return (
<div className="soundsettings">
<h4>Sounds</h4>
<p className="small muted">For everyone in this lobby. MP3 up to 1.5 MB; players can still mute the sounds for themselves.</p>
{row('roll', 'Dice sound', 'Default sound', null)}
<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_failure', 'Critical failure', 'Built-in tone', 'crit_failure')}
{row('ping', 'Map ping', 'Built-in tone', 'ping')}
{err && <p className="warn small">{err}</p>}
</div>
)
}
function Settings({ lobby, id, mine, act, setInfo, onClose, inline }) {
const [rename, setRename] = useState('')
const [pick, setPick] = useState('')
const me = lobby.members.find((m) => m.me)
const taken = new Set(lobby.members.filter((m) => !m.me).map((m) => m.color))
return (
<div className="settings card">
<div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div>
<div className={inline ? 'settings inline' : 'settings card'}>
{!inline && <div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div>}
{lobby.is_dm && (
<>
<div className="row">
@@ -505,6 +706,7 @@ function LobbyView({ id, rules, user }) {
const [busy, setBusy] = useState(false)
const [mErr, setMErr] = useState('')
const [showSettings, setShowSettings] = useState(false)
const [leftTab, setLeftTab] = useState('sheet') // the DM's left column: character sheet or lobby actions
const [sheetOpen, setSheetOpen] = useState(() => store('aw_sheetopen') !== '0')
const setOwn = (v) => { setOwnState(v); store('aw_owndice', v ? '1' : '0') }
@@ -512,18 +714,60 @@ function LobbyView({ id, rules, user }) {
if (!r.ok) throw new Error(r.status)
setLobby(await r.json())
}).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') }), [id])
const [mapState, setMapState] = useState({ map: null, tokens: [] })
const [mapSel, setMapSel] = useState(null)
const [viewAs, setViewAs] = useState('') // the DM can look at the table through one player's eyes
const [showGrid, setShowGrid] = useState(false)
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 mapImg = useMapImage(id, mapState.vision, viewAs)
const fogImg = useFogImage(id, mapState.vision?.fog, viewAs)
const sock = useRef({})
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, {
ping: (n) => sock.current.ping?.(n),
measure: (n) => sock.current.measure?.(n),
open: () => { sock.current.load?.(); sock.current.poll?.(); sock.current.loadMap?.() },
map: () => sock.current.loadMap?.(),
messages: () => sock.current.poll?.(),
lobby: () => sock.current.load?.(),
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(() => {
load()
const t = setInterval(() => !document.hidden && load(), 5000) // picks up joins, colours, difficulty and character changes
load(); loadMap()
const t = setInterval(() => { if (!document.hidden) { load(); loadMap() } }, up ? 30000 : 5000) // joins, colours, difficulty: pushed, with a timer as safety net
return () => clearInterval(t)
}, [load])
}, [load, loadMap, up])
const sfx = useRef({})
const sn = lobby?.sounds
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_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 }
useEffect(() => { preload(sfx.current) }, [sn?.v]) // eslint-disable-line
const onRoll = useCallback((m) => {
const key = `${m.id}`
if (m.kind === 'check') playRoll(100, 1, m.check.outcome, sfx.current)
else if (m.kind === 'roll') playRoll(m.roll.sides, m.roll.n, undefined, sfx.current)
setTray((t) => [...t.slice(-2), { ...m, key }])
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
}, [])
const feed = useFeed(id, load, onRoll)
const feed = useFeed(id, load, onRoll, up)
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) => {
if (!url) return load()
@@ -534,6 +778,52 @@ function LobbyView({ id, rules, user }) {
done ? done(d) : load()
}
const moveToken = (tid, x, y) => {
setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, x, y } : t)) }))
return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', { x, y }).catch(() => null)
}
const patchToken = (tid, p) => {
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)
}
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 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', 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 (!lobby) return <section className="page lobbypage"><p className="muted">Loading …</p></section>
@@ -541,6 +831,7 @@ function LobbyView({ id, rules, user }) {
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 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 canCheck = !!(me && me.character_id)
lobby.setMod = (u, v) => act(`/api/lobbies/${id}/members/${encodeURIComponent(u)}/mod`, 'PUT', { mod: parseInt(v, 10) || 0 })
@@ -557,56 +848,174 @@ function LobbyView({ id, rules, user }) {
if (own) return setPending({ type: 'dice', sides, count })
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 (own) return setPending({ type: 'check', kind, id: cid, name })
post(`/api/lobbies/${id}/checks`, { kind, id: cid })
if (own) return setPending({ type: 'check', kind, id: cid, name, npcToken })
post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '', ...(npcToken ? { npc_token: npcToken } : {}) })
}
const submitManual = (vals) => {
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0] }, () => 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))
}
return (
<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={() => setShowSettings((v) => !v)} aria-expanded={showSettings}>⚙ Lobby</button>
</span>
</div>
{err && <p className="warn">{err}</p>}
{info && <p className="small">{info}</p>}
<div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`}>
<div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`} style={{ '--sheetw': `${sheetW}px` }}>
{sheetOpen && (
<div className="sheetcol">
<div className="sheetbar">
<b>{cur ? cur.character.name : 'Character sheet'}</b>
<span className="tabright"><ThemeSwitch /> <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide the sheet">◀</button></span>
{lobby.is_dm ? (
<span className="seg" role="tablist">
<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>
</span>
) : <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>
</div>
{cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} onRoll={cur.me && canCheck ? check : undefined} />
: <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>}
{lobby.is_dm && leftTab === 'lobby'
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><VisionGroups lobby={lobby} id={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
: npcTok ? <CharTab key={`npc${npcTok.npc_id}`} cid={npcTok.npc_id} rules={rules} tick={sheetTick.cid === npcTok.npc_id ? sheetTick.n : 0} onRoll={(k, i, n) => check(k, i, n, npcTok.id)} />
: 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>}
</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">
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
<CharBar lobby={lobby} sel={cur?.username} onSel={setSel} />
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
<div className="topbar">
<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">
{showSettings && <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 && (() => {
const full = lobby.is_dm && !viewAs
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 || [] : []
return (
<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}
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>}
<div className="maptools mapui">
{lobby.is_dm && (
<select value={viewAs} onChange={(e) => { setViewAs(e.target.value); setMapSel(null) }} aria-label="View the table as" title="Look at the table through a player's eyes">
<option value="">Full view</option>
{lobby.members.map((m) => <option key={m.username} value={m.username}>View as {m.character?.name || m.username}</option>)}
</select>
)}
{lobby.is_dm && mapState.los_on && <>
<label className="small sightrange" title="How far players see, in squares (0 = unlimited). Resets to the map's value when you switch maps."><input type="number" min="0" max="1000" key={mapState.sight} defaultValue={mapState.sight} aria-label="Sight range in squares"
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>
{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>
{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>
)
})()}
{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} />
{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 && mErr && <p className="warn small tableerr">{mErr}</p>}
</div>
<DiceBar to={dest} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} />
<DiceBar to={dest} me={user} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} sfx={sfx.current} />
</div>
<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} />
</aside>
</div>
+619
View File
@@ -0,0 +1,619 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { BUILTIN, PropArt } from './props.jsx'
const COLORS = ['#c0392b', '#8e44ad', '#16a085', '#7f8c8d', '#d35400', '#2c3e50', '#27ae60', '#b9770e']
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.
// Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise.
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 [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
const [drag, setDrag] = useState(null)
const viewRef = useRef(view)
viewRef.current = view
const pointers = useRef(new Map())
const fitted = useRef('')
const fit = useCallback(() => {
const r = box.current?.getBoundingClientRect()
if (!r || !r.width || !map) return
const s = Math.min(r.width / map.w, r.height / map.h) * 0.96
setView({ s, tx: (r.width - map.w * s) / 2, ty: (r.height - map.h * s) / 2 })
}, [map?.id, map?.w, map?.h]) // eslint-disable-line
useEffect(() => { // fit once per map, as soon as the box has a size
if (!map || fitted.current === map.id) return
const t = setInterval(() => { if (box.current?.getBoundingClientRect().width) { fit(); fitted.current = map.id; clearInterval(t) } }, 50)
return () => clearInterval(t)
}, [map?.id, fit]) // eslint-disable-line
const zoomAt = (mx, my, f) => setView((v) => {
const s = clamp(v.s * f, 0.05, 8)
return { s, tx: mx - (mx - v.tx) * (s / v.s), ty: my - (my - v.ty) * (s / v.s) }
})
useEffect(() => {
const el = box.current
if (!el) return
const wheel = (e) => {
e.preventDefault()
const r = el.getBoundingClientRect()
zoomAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015)))
}
el.addEventListener('wheel', wheel, { passive: false })
return () => el.removeEventListener('wheel', wheel)
}, [])
const down = (e) => {
if (e.target.closest('.maptoken, .mapui, .mapdoor, .mappin')) return
box.current.setPointerCapture(e.pointerId)
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY })
if (pointers.current.size === 1) onSelect?.(null)
else pointers.current.forEach((p, k) => pointers.current.set(k, { ...p, sx: -1e9 })) // two fingers: pinch, not a click
}
const move = (e) => {
if (onHover) onHover(toWorld(e), viewRef.current.s)
const p = pointers.current.get(e.pointerId)
if (!p) return
const r = box.current.getBoundingClientRect()
if (pointers.current.size === 2) { // pinch
const [a, b] = [...pointers.current.values()]
const before = Math.hypot(a.x - b.x, a.y - b.y)
const o = pointers.current.get(e.pointerId)
const now = { ...o, x: e.clientX, y: e.clientY, sx: -1e9 }
pointers.current.set(e.pointerId, now)
const [c, d] = [...pointers.current.values()]
const after = Math.hypot(c.x - d.x, c.y - d.y)
if (before > 0) zoomAt((c.x + d.x) / 2 - r.left, (c.y + d.y) / 2 - r.top, after / before)
return
}
const dx = e.clientX - p.x, dy = e.clientY - p.y
pointers.current.set(e.pointerId, { ...p, x: e.clientX, y: e.clientY })
setView((v) => ({ ...v, tx: v.tx + dx, ty: v.ty + dy }))
}
const up = (e) => {
const p = pointers.current.get(e.pointerId)
pointers.current.delete(e.pointerId)
// a press that barely moved is a click on the map (used by the drawing tools)
if (p && onClick && e.type === 'pointerup' && Math.hypot(e.clientX - p.sx, e.clientY - p.sy) < 5) onClick(toWorld(e), viewRef.current.s, e)
}
const toWorld = (ev) => {
const r = box.current.getBoundingClientRect(), v = viewRef.current
return { x: (ev.clientX - r.left - v.tx) / v.s, y: (ev.clientY - r.top - v.ty) / v.s,
inside: ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom }
}
const startToken = (e, t) => {
e.stopPropagation()
if (!t.can_move) { onSelect?.(t.id); return }
e.preventDefault()
const from = { x: e.clientX, y: e.clientY }
let moved = false, last = 0
const clampPos = (p) => ({ x: clamp(p.x, 0, map.w), y: clamp(p.y, 0, map.h) })
const mv = (ev) => {
if (!moved && Math.hypot(ev.clientX - from.x, ev.clientY - from.y) < 4) return
moved = true
const p = toWorld(ev)
setDrag({ id: t.id, ...clampPos(p), out: !p.inside })
if (t.x != null && p.inside && Date.now() - last > 120) { last = Date.now(); const c = clampPos(p); onMove(t.id, c.x, c.y) }
}
const end = (ev) => {
window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end)
if (!moved) { setDrag(null); onSelect?.(t.id); return }
const p = toWorld(ev)
if (!p.inside) { setDrag(null); return }
const c = clampPos(p)
Promise.resolve(onMove(t.id, c.x, c.y)).finally(() => setDrag(null)) // keep the local position until the server has it
}
window.addEventListener('pointermove', mv); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end)
}
const unplaced = tokens.filter((t) => t.x == null && t.can_move && t.kind !== 'prop')
const d = (t) => t.size * map.grid
return (
<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})` }}>
{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} />}
{showGrid && <div className="mapgrid" style={{ backgroundSize: `${map.grid}px ${map.grid}px` }} />}
{overlay}
{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 x = dr ? dr.x : t.x, y = dr ? dr.y : t.y
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 (
<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) }}
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>}
<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>
{unplaced.length > 0 && (
<div className="maptray mapui" role="group" aria-label="Tokens not on the map yet">
<span className="small muted">Drag onto the map:</span>
{unplaced.map((t) => (
<button key={t.id} className="traytoken" style={{ '--tc': t.color }} onPointerDown={(e) => startToken(e, t)} title="Drag onto the map">{t.name}</button>
))}
</div>
)}
<div className="mapzoom mapui">
<button className="ghost" onClick={() => { const r = box.current.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1.3) }} aria-label="Zoom in">+</button>
<button className="ghost" onClick={() => { const r = box.current.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1 / 1.3) }} aria-label="Zoom out">-</button>
<button className="ghost" onClick={fit} aria-label="Fit the map to the window" title="Fit">⤢</button>
</div>
{children}
</div>
)
}
// 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)
export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
return (
<div className="tokenform">
<input value={t.name} maxLength={40} aria-label="Token name" onChange={(e) => onChange({ name: e.target.value })} />
<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>
<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">
{onDuplicate && <button className="ghost" onClick={onDuplicate}>Duplicate</button>}
<button className="ghost danger" onClick={onDelete}>Remove</button>
</span>
</div>
)
}
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.
export function WallLayer({ map, walls, draft, hover, onDoor }) {
const line = (w, extra = {}) => (
<line key={w.id} x1={w.x1} y1={w.y1} x2={w.x2} y2={w.y2} vectorEffect="non-scaling-stroke"
className={`wl ${w.kind} ${w.open ? 'open' : ''}`} {...extra} />
)
return (
<svg className="walllayer" width={map.w} height={map.h} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', pointerEvents: 'none' }}>
{walls.filter((w) => !(onDoor && w.kind === 'door')).map((w) => line(w))}
{walls.filter((w) => onDoor && w.kind === 'door').map((w) => (
<g key={w.id} className="mapdoor" onClick={() => onDoor(w)} style={{ pointerEvents: 'stroke', cursor: 'pointer' }}>
<title>{w.open ? 'Open door, click to close' : 'Closed door, click to open'}</title>
<line x1={w.x1} y1={w.y1} x2={w.x2} y2={w.y2} stroke="transparent" strokeWidth="16" vectorEffect="non-scaling-stroke" style={{ pointerEvents: 'stroke' }} />
{line(w, { style: { pointerEvents: 'none' } })}
</g>
))}
{draft && hover && <line x1={draft.x} y1={draft.y} x2={hover.x} y2={hover.y} vectorEffect="non-scaling-stroke" className="wl draft" />}
{draft && <circle cx={draft.x} cy={draft.y} r="4" vectorEffect="non-scaling-stroke" className="wldot" />}
</svg>
)
}
const distToSeg = (p, w) => {
const dx = w.x2 - w.x1, dy = w.y2 - w.y1, L = dx * dx + dy * dy
const t = L === 0 ? 0 : clamp(((p.x - w.x1) * dx + (p.y - w.y1) * dy) / L, 0, 1)
return Math.hypot(w.x1 + t * dx - p.x, w.y1 + t * dy - p.y)
}
// 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, live, onSaved }) {
const [m, setM] = useState(null)
const [tokens, setTokens] = useState([])
const [walls, setWalls] = useState([])
const [name, setName] = useState('')
const [grid, setGrid] = useState(40)
const [sight, setSight] = useState(0)
const [showGrid, setShowGrid] = useState(true)
const [snap, setSnap] = useState(false)
const [tool, setTool] = useState('select')
const [draft, setDraft] = useState(null)
const [hover, setHover] = useState(null)
const [sel, setSel] = useState(null)
const [err, setErr] = useState('')
const [dirty, setDirty] = useState(false)
useEffect(() => {
fetch(`/api/maps/${id}`).then((r) => r.json()).then((d) => {
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])
useEffect(() => {
const k = (e) => { if (e.key === 'Escape') { setDraft(null); if (tool !== 'select') setTool('select') } }
window.addEventListener('keydown', k)
return () => window.removeEventListener('keydown', k)
}, [tool])
// the full-size image, loaded once for the editor
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
const [picker, setPicker] = useState(false)
const [propPick, setPropPick] = useState(false)
if (!m) return null
const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid
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 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 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 }
setTokens([...tokens, t]); setSel(t.id); setDirty(true)
}
const snapPt = (p, s) => { // join onto existing corners first, then the grid
let best = null, bd = 12 / s
for (const w of walls) for (const [x, y] of [[w.x1, w.y1], [w.x2, w.y2]]) { const d = Math.hypot(x - p.x, y - p.y); if (d < bd) { bd = d; best = { x, y } } }
if (best) return best
if (snap) return { x: clamp(Math.round(p.x / gridNum) * gridNum, 0, m.w), y: clamp(Math.round(p.y / gridNum) * gridNum, 0, m.h) }
return { x: clamp(p.x, 0, m.w), y: clamp(p.y, 0, m.h) }
}
const click = (world, s) => {
if (tool === 'select') return
if (tool === 'erase') {
let best = null, bd = 10 / s
for (const w of walls) { const d = distToSeg(world, w); if (d < bd) { bd = d; best = w } }
if (best) { setWalls(walls.filter((w) => w.id !== best.id)); setDirty(true) }
return
}
const p = snapPt(world, s)
if (!draft) { setDraft(p); return }
if (Math.hypot(p.x - draft.x, p.y - draft.y) < 1) { setDraft(null); return } // click the same point again: finish
if (tool === 'wall') { addWalls([{ x1: draft.x, y1: draft.y, x2: p.x, y2: p.y, kind: 'wall' }]); setDraft(p) }
else if (tool === 'door') { addWalls([{ x1: draft.x, y1: draft.y, x2: p.x, y2: p.y, kind: 'door' }]); setDraft(null) }
else if (tool === 'rect') {
addWalls([[draft.x, draft.y, p.x, draft.y], [p.x, draft.y, p.x, p.y], [p.x, p.y, draft.x, p.y], [draft.x, p.y, draft.x, draft.y]].map(([x1, y1, x2, y2]) => ({ x1, y1, x2, y2, kind: 'wall' })))
setDraft(null)
}
}
const save = async () => {
setErr('')
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,
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 }
setDirty(false); onSaved?.(); return true
}
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']]
return createPortal(
<div className="mapeditor" role="dialog" aria-label="Map editor">
<aside className="card">
<h3>Map editor</h3>
<label className="small">Name <input value={name} maxLength={60} onChange={(e) => { setName(e.target.value); setDirty(true) }} /></label>
<label className="small">Grid: pixels per square <input type="number" min="4" value={grid} onChange={(e) => { setGrid(e.target.value); setDirty(true) }} /></label>
<label className="small chk"><input type="checkbox" checked={showGrid} onChange={(e) => setShowGrid(e.target.checked)} /> Show grid</label>
<p className="small muted">Match the grid to the squares on your image. Token sizes are counted in squares.</p>
<h4>Walls and doors</h4>
<div className="toolrow" role="group" aria-label="Tool">
{tools.map(([k, label]) => <button key={k} className={tool === k ? 'on' : 'ghost'} aria-pressed={tool === k} onClick={() => { setTool(k); setDraft(null) }}>{label}</button>)}
</div>
<label className="small chk"><input type="checkbox" checked={snap} onChange={(e) => setSnap(e.target.checked)} /> Snap to grid corners</label>
<p className="small muted">
{tool === 'wall' && 'Click to set points, the line continues from the last one. Click the same point twice or press Esc to stop.'}
{tool === 'rect' && 'Click two opposite corners of a room.'}
{tool === 'door' && 'Click both ends of the door. The DM opens and closes it during play.'}
{tool === 'erase' && 'Click a wall or door to remove it.'}
{tool === 'select' && `${walls.length} wall segment${walls.length === 1 ? '' : 's'}. Walls end at existing corners automatically. Players only see what their token can see past them.`}
</p>
<div className="row">
<button className="ghost" disabled={!walls.length} onClick={() => { setWalls(walls.slice(0, -1)); setDirty(true) }}>Undo last</button>
<button className="ghost danger" disabled={!walls.length} onClick={() => window.confirm('Remove all walls and doors of this map?') && (setWalls([]), setDirty(true))}>Remove all</button>
</div>
<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>
<h4>{live ? 'Prepared tokens (used when the map is put on the table again)' : 'Enemies, NPCs and props'}</h4>
<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">
{tokens.map((t) => (
<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>
{cur && (() => {
const props = { t: cur, onChange: (p) => patch(cur.id, p),
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>}
<div className="row editactions"><button className="on" disabled={!dirty} onClick={save}>Save</button><button className="ghost" onClick={close}>Close</button></div>
</aside>
<div className="mapstage">
<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}
overlay={<WallLayer map={m} walls={walls} draft={draft} hover={draft ? hover : null} />}
onMove={(tid, x, y) => { patch(tid, { x, y }); return Promise.resolve() }} />
</div>
</div>, document.body)
}
// DM: the map library (in the "Lobby actions" tab)
export function MapLibrary({ activeId, lobbyId, act }) {
const [list, setList] = useState([])
const [err, setErr] = useState('')
const [editing, setEditing] = useState(null)
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(() => {}), [])
useEffect(() => { load() }, [load, editing])
const upload = async (e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
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 })
setBusy(false)
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Upload failed'); return }
load()
}
return (
<div className="maplib">
<h4>Maps</h4>
<label className="uploadbtn">{busy ? 'Uploading…' : '+ Upload map (PNG, JPEG, WebP)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
{err && <p className="warn small">{err}</p>}
<ul className="maplist">
{list.map((m) => (
<li key={m.id} className={m.id === activeId ? 'active' : ''}>
<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">
{m.id === activeId
? <button className="ghost" onClick={() => act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null }, () => { loadScenes() })}>Clear 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)} 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>}
</span>
</li>
))}
{!list.length && <li className="muted small">No maps yet. Upload an image, then prepare enemies in the editor.</li>}
</ul>
{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>
)
}
+158
View File
@@ -0,0 +1,158 @@
import { useCallback, useEffect, useState } from 'react'
import { MapEditor } from './MapView.jsx'
import { BUILTIN } from './props.jsx'
const J = { 'Content-Type': 'application/json' }
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 }) {
const [name, setName] = useState('')
const [err, setErr] = useState('')
const add = async (e) => {
e.preventDefault()
setErr('')
const r = await fetch(`/api/maps/${m.id}/shares`, { method: 'POST', headers: J, body: JSON.stringify({ username: name }) })
if (!r.ok) return setErr(await errText(r, 'Could not share.'))
setName(''); reload()
}
const remove = async (u) => { await fetch(`/api/maps/${m.id}/shares/${encodeURIComponent(u)}`, { method: 'DELETE' }).catch(() => {}); reload() }
return (
<div className="sharepanel">
<div className="small muted">Shared with</div>
<div className="chips">
{m.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
{m.shared_with.map((u) => <span key={u} className="chip">{u} <button className="ghost" aria-label={`Stop sharing with ${u}`} onClick={() => remove(u)}>×</button></span>)}
</div>
<form className="row" onSubmit={add}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Username" aria-label="Username to share with" />
<button type="submit" disabled={!name.trim()}>Share</button>
</form>
{err && <p className="warn small">{err}</p>}
</div>
)
}
// 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
export default function Maps() {
const [list, setList] = useState(null)
const [editing, setEditing] = useState(null)
const [open, setOpen] = useState(null)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => setList([])), [])
useEffect(() => { load() }, [load, editing])
const upload = async (e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr(''); setBusy(true)
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)
if (!r || !r.ok) return setErr(r ? await errText(r, 'Upload failed') : 'Upload failed')
load()
}
const copy = async (m) => {
const r = await fetch(`/api/maps/${m.id}/copy`, { method: 'POST' })
if (!r.ok) return setErr(await errText(r, 'Could not copy'))
load()
}
const del = async (m) => {
if (!window.confirm(`Delete the map "${m.name}"? Lobbies that show it get an empty table, shared users lose it.`)) return
await fetch(`/api/maps/${m.id}`, { method: 'DELETE' }); load()
}
const mine = (list || []).filter((m) => m.mine), shared = (list || []).filter((m) => !m.mine)
const card = (m) => (
<li key={m.id} className="mapcard card">
<img src={`/api/maps/${m.id}/thumb`} alt="" loading="lazy" />
<div className="mapcardbody">
<strong>{m.name}</strong>
<span className="small muted">{m.w}×{m.h} · {m.tokens} token{m.tokens === 1 ? '' : 's'} · {m.walls} wall{m.walls === 1 ? '' : 's'}{!m.mine && ` · from ${m.author}`}{m.mine && m.shared_with.length > 0 && ` · shared with ${m.shared_with.join(', ')}`}</span>
<span className="row">
{m.mine && <button className="on" onClick={() => setEditing(m.id)}>Edit</button>}
{m.mine && <button className="ghost" onClick={() => setOpen(open === m.id ? null : m.id)}>Share</button>}
<button className="ghost" onClick={() => copy(m)} title="Make your own editable copy">{m.mine ? 'Duplicate' : 'Copy to my maps'}</button>
{m.mine && <button className="ghost danger" onClick={() => del(m)}>Delete</button>}
</span>
{open === m.id && m.mine && <SharePanel m={m} reload={load} />}
</div>
</li>
)
return (
<section className="page">
<h1>Maps</h1>
<p className="muted">Prepare maps with grid, enemies, NPCs, walls and doors. The DM puts one on the table from a lobby (<em>Lobby actions</em>). Shared maps can be used on your table or copied, but not changed.</p>
<label className="uploadbtn">{busy ? 'Uploading …' : '+ Upload map (PNG, JPEG, WebP, up to 12 MB)'}<input type="file" accept="image/png,image/jpeg,image/webp" onChange={upload} disabled={busy} hidden /></label>
{err && <p className="warn">{err}</p>}
{list === null && <p className="muted">Loading …</p>}
{list && !mine.length && <p className="muted">You have no maps yet.</p>}
<ul className="mapcards">{mine.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)} />}
</section>
)
}
+43 -14
View File
@@ -31,6 +31,8 @@ export function printSheet(root, title) {
const win = window.open('', '_blank')
if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return }
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.classList.remove('dark') // printed pages are always light
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() }) })
}
// 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
function Fit({ children, rootRef, dark }) {
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).
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 rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
const c = useMemo(() => compute(ch, rules), [ch, rules])
@@ -108,17 +121,26 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
)}
<Fit rootRef={root} dark={dark}>
<section className="paper">
<header className="phead">
<div className="pname">{name}</div>
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
</header>
<div className="pfields">
<Field label="Player">{ch.player}</Field>
<Field label="Concept">{ch.concept}</Field>
<Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field>
<Field label="Profession">{prof?.name}</Field>
<Field label="Rules">{preset}</Field>
</div>
{(() => {
const head = (
<header className="phead">
<div className="pname">{name}</div>
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
</header>
)
const fields = (
<div className="pfields">
<Field label="Player">{ch.player}</Field>
<Field label="Concept">{ch.concept}</Field>
<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">
{rules.attributes.map((a) => (
@@ -157,7 +179,14 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
))}
</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} />
</section>
@@ -200,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>}
<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} />
</section>
</Fit>
+76
View File
@@ -0,0 +1,76 @@
import { useRef, useState } from 'react'
const BOX = 200 // size of the crop window on screen
const OUT = 256 // size of the stored picture
// Picture of a character: choose a file, move and zoom it inside a square window, apply. Stored as a small data URL.
export default function PortraitEditor({ value, onChange }) {
const [pic, setPic] = useState(null) // { img, w, h }
const [zoom, setZoom] = useState(1)
const [off, setOff] = useState({ x: 0, y: 0 })
const [err, setErr] = useState('')
const drag = useRef(null)
const pick = (e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr('')
if (!/^image\/(png|jpeg|webp)$/.test(f.type)) return setErr('Please choose a PNG, JPEG or WebP image.')
if (f.size > 12_000_000) return setErr('The image is too large (at most 12 MB).')
const url = URL.createObjectURL(f)
const img = new Image()
img.onload = () => { setPic({ img, w: img.naturalWidth, h: img.naturalHeight, url }); setZoom(1); setOff({ x: 0, y: 0 }) }
img.onerror = () => { URL.revokeObjectURL(url); setErr('This image could not be read.') }
img.src = url
}
const scale = pic ? (BOX / Math.min(pic.w, pic.h)) * zoom : 1
const clampOff = (o, z = zoom) => {
if (!pic) return o
const s = (BOX / Math.min(pic.w, pic.h)) * z
const mx = Math.max(0, (pic.w * s - BOX) / 2), my = Math.max(0, (pic.h * s - BOX) / 2)
return { x: Math.min(mx, Math.max(-mx, o.x)), y: Math.min(my, Math.max(-my, o.y)) }
}
const down = (e) => { e.currentTarget.setPointerCapture(e.pointerId); drag.current = { x: e.clientX, y: e.clientY, off } }
const move = (e) => { if (drag.current) setOff(clampOff({ x: drag.current.off.x + e.clientX - drag.current.x, y: drag.current.off.y + e.clientY - drag.current.y })) }
const up = () => { drag.current = null }
const close = () => { if (pic) URL.revokeObjectURL(pic.url); setPic(null) }
const apply = () => {
const size = BOX / scale // visible part of the original, in its pixels
const cx = pic.w / 2 - off.x / scale, cy = pic.h / 2 - off.y / scale
const cv = document.createElement('canvas')
cv.width = cv.height = OUT
cv.getContext('2d').drawImage(pic.img, cx - size / 2, cy - size / 2, size, size, 0, 0, OUT, OUT)
const data = cv.toDataURL('image/webp', 0.85)
onChange(data.startsWith('data:image/webp') ? data : cv.toDataURL('image/jpeg', 0.88))
close()
}
return (
<div className="portraiteditor">
{!pic && (
<div className="portraitcur">
{value ? <img src={value} alt="Character portrait" /> : <span className="portraitnone muted small">No picture</span>}
<div className="portraitbtns">
<label className="uploadbtn small">{value ? 'Change picture' : 'Add picture'}<input type="file" accept="image/png,image/jpeg,image/webp" hidden onChange={pick} /></label>
{value && <button className="ghost" onClick={() => onChange('')}>Remove</button>}
<span className="small muted">Shown on the sheet, in lobbies and on the map.</span>
</div>
</div>
)}
{pic && (
<div className="portraitcrop">
<div className="cropwin" style={{ width: BOX, height: BOX }} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
<img src={pic.url} alt="" draggable={false} style={{ width: pic.w * scale, height: pic.h * scale, left: (BOX - pic.w * scale) / 2 + off.x, top: (BOX - pic.h * scale) / 2 + off.y }} />
</div>
<div className="cropctl">
<label className="small">Zoom <input type="range" min="1" max="4" step="0.02" value={zoom} onChange={(e) => { const z = +e.target.value; setZoom(z); setOff((o) => clampOff(o, z)) }} /></label>
<p className="small muted">Drag the picture to choose the section.</p>
<div className="row"><button className="on" onClick={apply}>Use this</button><button className="ghost" onClick={close}>Cancel</button></div>
</div>
</div>
)}
{err && <p className="warn small">{err}</p>}
</div>
)
}
+1 -1
View File
@@ -6,7 +6,7 @@ export default function Presets({ rules, reload }) {
const [sel, setSel] = useState(rules.default_preset || 'standard')
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
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 editable = !!draft
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>
)
}
+5 -4
View File
@@ -6,7 +6,7 @@ export function emptyCharacter(rules) {
const attributes = {}
rules.attributes.forEach((a) => (attributes[a.id] = rules.creation.attr_start))
return {
name: '', player: '', concept: '', background: '', class_id: '', subclass_id: '',
name: '', player: '', concept: '', background: '', portrait: '', class_id: '', subclass_id: '',
attributes,
profession: '', profession_bonus_attr: '', profession_picks: [],
preset: rules.default_preset || 'standard', creation: {}, // creation = overrides of rules.creation for this character
@@ -14,7 +14,7 @@ export function emptyCharacter(rules) {
talents: [], augments: [],
kit: { extras: {} }, // item id -> quantity
gear: { other: '' },
notes: '',
notes: '', conditions: '',
}
}
@@ -124,8 +124,9 @@ export function compute(ch, rules) {
const pips = profPip + freePips
const prof = [0, cr.proficiency_bonus, cr.expertise_bonus][pips]
const best = Math.max(...s.attrs.map((a) => attrs[a])) // with several attributes the higher one counts
const base = s.type === 'advanced' ? Math.floor((best * (cr.advanced_attr_percent ?? 100)) / 100) : best // advanced skills only use part of the attribute
const unlocked = ch.unlocked.includes(s.id)
return { ...s, points, prof, profPip, freePips, pips, unlocked, total: best + points + prof,
return { ...s, points, prof, profPip, freePips, pips, unlocked, base, total: base + points + prof,
usable: s.type === 'basic' || unlocked || profPip > 0, // advanced skills must be unlocked (a profession proficiency does it for free)
free: s.type === 'advanced' && profPip > 0, // unlocked by the profession, costs nothing
max: Math.floor(best / cr.skill_max_divisor) }
@@ -181,7 +182,7 @@ export function setProfessionPicks(ch, rules, picks) {
export function resetStep(ch, rules, step) {
const e = emptyCharacter(rules)
switch (step) {
case 0: return { ...ch, name: '', player: '', concept: '', background: '', class_id: '', subclass_id: '' }
case 0: return { ...ch, name: '', player: '', concept: '', background: '', portrait: '', class_id: '', subclass_id: '' }
case 1: return { ...ch, attributes: e.attributes }
case 2: return pruneLocked({ ...ch, proficiencies: stripProfessionProfs(ch, rules), profession: '', profession_bonus_attr: '', profession_picks: [] }, rules)
case 3: return { ...ch, skill_points: {}, unlocked: [], proficiency_picks: {} }
+1 -1
View File
@@ -29,7 +29,7 @@ export function explainSkill(s, { ch, rules, c }) {
const list = s.attrs.map((id) => `${attrShort(rules, id)} ${c.attrs[id]}`).join(', ')
const training = s.pips === 2 ? `Expertise +${s.prof}` : s.pips === 1 ? `Proficient +${s.prof}` : 'no training +0'
const lines = [
`Target number = best attribute ${best} (${s.attrs.length > 1 ? `higher of ${list}` : list}) + ${s.points} skill points + ${training} = ${s.total}`,
`Target number = ${s.type === 'advanced' && s.base !== best ? `${cr.advanced_attr_percent}% of the best attribute (${best}) = ${s.base}` : `best attribute ${best}`} (${s.attrs.length > 1 ? `higher of ${list}` : list}) + ${s.points} skill points + ${training} = ${s.total}`,
`Most points you can put in: best attribute ÷ ${cr.skill_max_divisor}, rounded down = ${s.max}`,
]
if (s.type === 'advanced') {
+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>
}
+114
View File
@@ -0,0 +1,114 @@
// Dice sounds. The default is public/dice.mp3 (the DM can replace it per lobby), played with a slight random pitch and a pitch that
// depends on the die: d4 a little higher and lighter, d100 a little lower and heavier. Results of checks add a short cue; the cues for
// critical results can be custom MP3s from the DM, otherwise they are synthesized tones.
let ctx = null
const get = (k) => { try { return localStorage.getItem(k) } catch { return null } }
const put = (k, v) => { try { localStorage.setItem(k, v) } catch { /* storage may be blocked */ } }
export const sound = {
enabled: get('aw_sound') !== '0',
volume: Number(get('aw_volume') ?? 0.6),
setEnabled(v) { this.enabled = v; put('aw_sound', v ? '1' : '0') },
setVolume(v) { this.volume = v; put('aw_volume', String(v)) },
}
export const DEFAULT_ROLL = '/dice.mp3'
const PITCH = { 4: 1.08, 6: 1.05, 8: 1.025, 10: 1.0, 12: 0.98, 20: 0.955, 100: 0.92 } // playback speed per die (lighter ... heavier)
const audio = () => {
if (!ctx) {
const C = window.AudioContext || window.webkitAudioContext
if (!C) return null
ctx = new C()
}
if (ctx.state === 'suspended') ctx.resume().catch(() => {}) // browsers allow sound only after the page was clicked once
return ctx
}
const buffers = new Map() // url -> Promise<AudioBuffer | null>
const buffer = (url) => {
if (!url) return Promise.resolve(null)
if (!buffers.has(url)) {
const c = audio()
buffers.set(url, !c ? Promise.resolve(null) : fetch(url).then((r) => (r.ok ? r.arrayBuffer() : null))
.then((b) => (b ? c.decodeAudioData(b) : null)).catch(() => { buffers.delete(url); return null }))
}
return buffers.get(url)
}
// 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, cfg.ping].forEach(buffer) }
const clip = (c, out, buf, t, rate, vol) => {
const s = c.createBufferSource()
s.buffer = buf
s.playbackRate.value = rate
const g = c.createGain()
g.gain.value = vol
s.connect(g).connect(out)
s.start(t)
return buf.duration / rate
}
const tone = (c, out, t, freq, len, type = 'triangle', vol = 0.25) => {
const o = c.createOscillator()
o.type = type
o.frequency.value = freq
const g = c.createGain()
g.gain.setValueAtTime(0.0001, t)
g.gain.exponentialRampToValueAtTime(vol, t + 0.02)
g.gain.exponentialRampToValueAtTime(0.0001, t + len)
o.connect(g).connect(out)
o.start(t)
o.stop(t + len + 0.05)
}
const cue = {
crit_success: (c, out, t) => { tone(c, out, t, 196, 0.7, 'triangle', 0.3); tone(c, out, t + 0.16, 294, 0.8, 'triangle', 0.28); tone(c, out, t + 0.16, 392, 0.9, 'sine', 0.12) },
success: (c, out, t) => { tone(c, out, t, 147, 0.5, 'triangle', 0.3); tone(c, out, t, 220, 0.4, 'sine', 0.1) },
failure: (c, out, t) => tone(c, out, t, 82, 0.55, 'triangle', 0.35),
crit_failure: (c, out, t) => { tone(c, out, t, 98, 0.5, 'sawtooth', 0.14); tone(c, out, t + 0.22, 62, 1.0, 'sawtooth', 0.18) },
}
// sides: kind of die (100 for checks), n: number of dice, outcome: result of a check (optional)
// cfg: { roll: url, crit_success: url | null, crit_failure: url | null, result: bool, crit_success_on: bool, crit_failure_on: bool }
export async function playRoll(sides, n, outcome, cfg = {}) {
if (!sound.enabled || sound.volume <= 0 || document.hidden) return
const c = audio()
if (!c) return
const [buf, custom] = await Promise.all([buffer(cfg.roll || DEFAULT_ROLL), outcome ? buffer(cfg[outcome]) : null])
const out = c.createGain()
out.gain.value = sound.volume
out.connect(c.destination)
const t0 = c.currentTime + 0.02
let length = 0.9
if (buf) {
const base = PITCH[sides] || 1
const layers = Math.min(Math.max(n, 1), 3) // many dice: a few layers, each with its own pitch
for (let i = 0; i < layers; i++) {
const rate = base * (1 + (Math.random() - 0.5) * 0.05)
length = Math.max(length, i * 0.08 + clip(c, out, buf, t0 + i * 0.08, rate, 1 / Math.sqrt(layers)))
}
}
if (!outcome) return
const t1 = t0 + Math.min(1.2, length * 0.75)
const crit = outcome === 'crit_success' || outcome === 'crit_failure'
if (crit) {
if (cfg[`${outcome}_on`] === false) return
if (custom) clip(c, out, custom, t1, 1, 1)
else 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) }
}
+287
View File
@@ -384,3 +384,290 @@ body:has(.lobbychat) main { padding: .5rem calc(var(--chatw) + .5rem) 0 .5rem; }
.tablelog { position: absolute; left: .6rem; bottom: .5rem; display: grid; gap: .25rem; max-width: 70%; }
.tablelog .trayroll { padding: .2rem .6rem; border-radius: 8px; font-size: .85rem; background: color-mix(in srgb, var(--panel) 88%, transparent); border: 1px solid var(--line); border-top: 3px solid var(--line); box-shadow: none; }
@media (prefers-reduced-motion: reduce) { .thrown { animation: none; } }
.stepper .step5 { font-size: .75rem; padding: .25rem .4rem; min-width: 2.2rem; }
/* rules editor */
.editor .edform { margin: .8rem 0; display: grid; gap: .6rem; }
.edgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: .6rem .9rem; }
.edfield { display: grid; gap: .2rem; }
.edfield .multi { display: flex; flex-wrap: wrap; gap: .3rem .9rem; padding: .3rem 0; max-height: 11rem; overflow: auto; }
.edtable td.actions { text-align: right; white-space: nowrap; }
.edtable tr.sel { background: var(--panel2); }
.editor .tabs { margin-top: .6rem; }
.settings.inline { position: static; max-width: none; margin: 0; border: 0; background: transparent; padding: 0; }
/* ---- maps */
.table { background: color-mix(in srgb, var(--panel) 70%, #000); }
.mapbox { position: absolute; inset: 0; overflow: hidden; touch-action: none; cursor: grab; background: #111; z-index: 1; }
.mapbox:active { cursor: grabbing; }
.mapworld { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; }
.mapworld img { display: block; max-width: none; user-select: none; -webkit-user-drag: none; }
.mapgrid { position: absolute; inset: 0; pointer-events: none; background-image: linear-gradient(to right, rgba(255,255,255,.28) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,.28) 1px, transparent 1px); }
.maptoken { position: absolute; border-radius: 50%; background: var(--tc); border: 3px solid #fff; box-shadow: 0 0 0 2px rgba(0,0,0,.6), 0 2px 6px rgba(0,0,0,.6); display: grid; place-items: center; color: #fff; font-weight: 700; text-transform: uppercase; user-select: none; touch-action: none; cursor: default; }
.maptoken.mov { cursor: move; }
.maptoken.player { border-color: var(--tc); background: color-mix(in srgb, var(--tc) 55%, #000); box-shadow: 0 0 0 2px #fff, 0 2px 6px rgba(0,0,0,.6); }
.maptoken.hid { opacity: .55; border-style: dashed; }
.maptoken.sel { outline: 3px solid var(--accent, gold); outline-offset: 3px; }
.maptoken span { pointer-events: none; text-shadow: 0 1px 2px #000; }
.maptoken em { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); margin-top: 2px; font-style: normal; font-weight: 600; text-transform: none; white-space: nowrap; color: #fff; text-shadow: 0 0 3px #000, 0 0 3px #000; pointer-events: none; }
.maptray { position: absolute; top: .5rem; left: .5rem; display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; padding: .3rem .5rem; border-radius: 8px; background: color-mix(in srgb, var(--panel) 90%, transparent); border: 1px solid var(--line); z-index: 4; max-width: 70%; }
.traytoken { border-radius: 999px; border: 2px solid var(--tc); background: color-mix(in srgb, var(--tc) 40%, var(--panel)); color: var(--text); padding: .15rem .7rem; cursor: grab; touch-action: none; }
.mapzoom { position: absolute; right: .5rem; bottom: .5rem; display: grid; gap: .25rem; z-index: 4; }
.mapzoom button, .maptools button { background: color-mix(in srgb, var(--panel) 90%, transparent); }
.maptools { position: absolute; top: .5rem; right: .5rem; display: flex; gap: .3rem; z-index: 4; }
.tokenpanel { position: absolute; right: 3rem; bottom: .5rem; z-index: 5; width: 17rem; max-width: 60%; padding: .6rem; border-radius: 10px; background: var(--panel); border: 1px solid var(--line); cursor: default; }
.tokenform { display: grid; gap: .45rem; }
.tokenform input[type=number] { width: 4.5rem; }
.swatches { display: flex; gap: .3rem; flex-wrap: wrap; }
.swatch { width: 1.4rem; height: 1.4rem; padding: 0; border-radius: 50%; border: 2px solid transparent; }
.swatch.on { border-color: var(--text); }
.maplib { display: grid; gap: .5rem; margin-bottom: 1rem; }
.uploadbtn { display: inline-block; padding: .4rem .8rem; border: 1px dashed var(--line); border-radius: 8px; cursor: pointer; text-align: center; }
.maplist { list-style: none; padding: 0; margin: 0; display: grid; gap: .4rem; }
.maplist li { padding: .5rem; border: 1px solid var(--line); border-radius: 8px; display: grid; gap: .3rem; }
.maplist li.active { border-color: var(--accent, gold); }
.mapeditor { position: fixed; inset: 0; z-index: 100; display: grid; grid-template-columns: 20rem 1fr; background: var(--bg); }
.mapeditor aside { overflow-y: auto; border-radius: 0; display: grid; gap: .6rem; align-content: start; }
.mapstage { position: relative; }
.tokenlist { list-style: none; padding: 0; margin: 0; display: grid; gap: .25rem; }
.tokenlist button { width: 100%; text-align: left; }
.tokenlist i { display: inline-block; width: .8rem; height: .8rem; border-radius: 50%; vertical-align: middle; }
@media (max-width: 800px) { .mapeditor { grid-template-columns: 1fr; grid-template-rows: 45% 1fr; } }
.mapeditor, .mapeditor * { box-sizing: border-box; }
.mapeditor { z-index: 1000; }
.mapeditor .mapstage { min-width: 0; }
label.chk { display: flex; align-items: center; gap: .45rem; }
label.chk input[type=checkbox] { width: auto; margin: 0; }
.mapeditor label.small:not(.chk) { display: grid; gap: .2rem; }
.editactions { position: sticky; bottom: 0; background: var(--panel); padding: .5rem 0; }
.mapbox.drawing { cursor: crosshair; }
.mapworld { background: #000; }
.wl { stroke: #ff4d4d; stroke-width: 3; stroke-linecap: round; }
.wl.door { stroke: #ff9f1a; stroke-width: 5; }
.wl.door.open { stroke: #4cd964; stroke-dasharray: 6 6; stroke-width: 4; }
.wl.draft { stroke: #fff; stroke-dasharray: 5 5; stroke-width: 2; }
.wldot { fill: #fff; }
.toolrow { display: flex; flex-wrap: wrap; gap: .3rem; }
.mapeditor input[type=number] { width: 100%; }
.mapempty { position: absolute; inset: 0; display: grid; place-items: center; color: #ddd; z-index: 2; pointer-events: none; text-align: center; padding: 1rem; }
.mapcards { list-style: none; padding: 0; margin: 1rem 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr)); gap: 1rem; }
.mapcard { display: grid; grid-template-columns: 8rem 1fr; gap: .8rem; padding: .8rem; align-items: start; }
.mapcard img { width: 8rem; height: 6rem; object-fit: cover; border-radius: 6px; background: #111; }
.mapcardbody { display: grid; gap: .4rem; min-width: 0; }
.mapcardbody .row { flex-wrap: wrap; }
.maptools { flex-wrap: wrap; justify-content: flex-end; max-width: 60%; }
.maptools button, .maptools select { padding: .2rem .55rem; font-size: .8rem; min-height: 0; white-space: nowrap; width: auto; }
.maptray { max-width: 38%; }
.traytoken { font-size: .85rem; }
.soundctl { display: inline-flex; align-items: center; gap: .3rem; }
.soundctl input[type=range] { width: 5rem; padding: 0; }
.soundsettings { display: grid; gap: .5rem; margin-bottom: 1rem; }
.soundrow { display: grid; gap: .25rem; padding: .4rem; border: 1px solid var(--line); border-radius: 8px; }
.soundrow .row { justify-content: space-between; align-items: center; flex-wrap: wrap; }
.uploadbtn.small { padding: .15rem .6rem; font-size: .85rem; }
/* ---- portraits */
.portraitbox { display: block; }
.portraitcur { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
.portraitcur img, .portraitnone { width: 6.5rem; height: 6.5rem; border-radius: 10px; object-fit: cover; border: 1px solid var(--line); background: var(--bg); display: grid; place-items: center; }
.portraitbtns { display: grid; gap: .4rem; justify-items: start; }
.portraitcrop { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-start; }
.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; }
.cropctl { display: grid; gap: .5rem; }
.pavatar { overflow: hidden; border: 2px solid var(--pc); }
.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; }
.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; }