Compare commits
26
Commits
3e67aba75c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b31936235d | ||
|
|
0f97129243 | ||
|
|
2087ce9960 | ||
|
|
1405381096 | ||
|
|
6880b58de2 | ||
|
|
779978ad02 | ||
|
|
c1fdb7cc76 | ||
|
|
928e018fcd | ||
|
|
ab3c35aab7 | ||
|
|
6aac03f112 | ||
|
|
a2103566a0 | ||
|
|
7940fcfa3f | ||
|
|
72d8113ece | ||
|
|
f6448aff85 | ||
|
|
1db8a45cae | ||
|
|
57f262b6aa | ||
|
|
d4d7b4d22e | ||
|
|
03ebc3a138 | ||
|
|
ce24a0ba6f | ||
|
|
2195ecce94 | ||
|
|
82e4ac9458 | ||
|
|
e8755582d5 | ||
|
|
162d8c9224 | ||
|
|
1c672f0ab8 | ||
|
|
78f0de5333 | ||
|
|
a8c9740a05 |
@@ -5,3 +5,5 @@ AW_REGISTRATION_CODE=
|
|||||||
AW_TRUST_PROXY=0
|
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):
|
# 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
|
AW_PORT=8090
|
||||||
|
# Who may use the rules editor (comma separated usernames). Empty = only the first account that was ever created:
|
||||||
|
AW_ADMINS=
|
||||||
|
|||||||
@@ -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
|
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
|
## 4. nginx + HTTPS
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name aw.yourdomain.tld;
|
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 / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8090; # same as AW_PORT in .env
|
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-For $remote_addr; # overwrite, so clients cannot fake their address
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
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
|
```bash
|
||||||
sudo ln -s /etc/nginx/sites-available/aw /etc/nginx/sites-enabled/aw
|
sudo ln -s /etc/nginx/sites-available/aw /etc/nginx/sites-enabled/aw
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
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`.
|
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.
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ In the creator, the Concept page only has a "Rules preset" selector. Presets are
|
|||||||
|
|
||||||
### Skills, proficiency and expertise
|
### 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.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").
|
- `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`.
|
- 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.
|
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
|
### 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 **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.
|
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.)
|
- **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.
|
- **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.
|
- **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*.
|
- **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.
|
- **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.
|
- **Colours**: every player has a different colour (lobby menu -> Your colour); the DM is gold.
|
||||||
- **Lobby menu** (top right): join code, rename, remove players, colour, switch character, leave or delete the lobby.
|
- **Lobby menu** (top right): join code, rename, remove players, colour, switch character, leave or delete the lobby.
|
||||||
|
|
||||||
|
### NPCs, enemies and creatures
|
||||||
|
|
||||||
|
The **NPCs** tab is a library of rules-based NPCs. It uses the same wizard as the character creator (attributes, skills, talents, mutations, equipment, portrait), with a free "NPC / creature" preset that has large pools and no profession requirement, plus a type (NPC, enemy, monster, creature, animal, robot), a default token size, a description, special abilities and attacks, and loot.
|
||||||
|
|
||||||
|
- NPCs are stored like characters (flag `npc`) and are listed separately; they can be duplicated and shared with other DMs (read-only).
|
||||||
|
- In the map editor and at the table, **+ From NPC library** / **+ NPC** places a token that takes the NPC's name, size and portrait. Plain **+ Token** still works.
|
||||||
|
- The DM clicks an NPC token to see its sheet in the left column and can click attributes and skills to roll checks for it (shown in the chat as "NPC: Skill"). Players never see the NPC link or sheet.
|
||||||
|
|
||||||
|
Players and the DM show a green dot in the lobby while they have it open (online presence over the WebSocket; without WebSockets everyone appears offline).
|
||||||
|
|
||||||
|
### Initiative and fights
|
||||||
|
|
||||||
|
The DM presses **⚔ Start fight** in the lobby. Every player with a character joins automatically and rolls initiative (1d20 + the character's Initiative value, as in the rules). **Right click** a token (DM, full view) for a radial menu: add it to the fight, open its sheet, hide/reveal or remove an NPC token. Players who join later can be added with the **+ name** buttons in the initiative bar.
|
||||||
|
|
||||||
|
The bar shows the order, the round and whose turn it is (the token pulses on the map). The DM can overwrite every number, reroll, remove entries, step **◀ / Next turn ▶** and **End fight**. Hidden NPCs are not shown to players. Fight start, joins and the end are logged in the chat feed.
|
||||||
|
|
||||||
|
The top of the lobby is one compact row: small buttons on the left (start fight, previous/next turn, end fight, ▾ to expand players and difficulty), the player bar – replaced by a scrollable initiative bar during a fight – and the lobby settings button. Players only see the name of whoever's turn it is; NPC names are not sent to players otherwise, and token names show on hover, selection or turn. The sheet and the chat can be resized by dragging the thin lines next to them (remembered per browser).
|
||||||
|
|
||||||
|
### HP, conditions and fog of war
|
||||||
|
|
||||||
|
- **HP and conditions:** select a token (DM, full view) to edit its HP (−5/−1/+1/+5, max) and conditions from the rules (click raises the level 1 → 3 → off). NPC tokens from the library start with the NPC's Health value. Right click the token and use the **HP** and **Cond.** toggles of the radial menu to decide when players may see them; by default players see their own and other players' values but not NPCs'.
|
||||||
|
- **Fog of war:** with line of sight on, ground a player has already seen stays visible as a dimmed memory (**Fog** button; **↺** forgets it for everybody). The memory is a small mask per player and lobby, the dim picture is made on the server, so players never get pixels they have not seen.
|
||||||
|
- **Sight range:** the map's value is the default (map editor); the number next to the Sight button changes it live for the lobby (reset when you switch maps).
|
||||||
|
|
||||||
|
### Sight groups and revealing far away areas
|
||||||
|
|
||||||
|
- **Sight groups** (Lobby actions, DM): players in the same group (A–D) share what they see; "Everyone shares sight" / "Everyone alone" are shortcuts. Tokens and ground seen by one member are visible to the whole group.
|
||||||
|
- **👁 Reveal** (DM, line of sight on): click the spot the view starts from, then click where it ends (distance = range, direction = where it looks), pick the cone width and who sees it (everyone or chosen players), then **Reveal**. Walls block it like any other view. The area stays visible until you press **Hide**; afterwards it remains as dimmed memory (fog of war). Everything is computed on the server, so players only receive the pixels you showed them.
|
||||||
|
|
||||||
|
### Maps, tokens and line of sight
|
||||||
|
|
||||||
|
- **Maps 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.
|
Players only see a short card of the other players, never their sheets. Guessing join codes is rate limited. Deleting a character takes it out of the lobbies (the player can pick another); deleting a lobby does not delete characters.
|
||||||
|
|
||||||
|
### Scenes and props
|
||||||
|
|
||||||
|
- **Scenes**: every map you put on the table becomes a *scene* of the lobby. Switching to another map (or back, in *Lobby actions → Scenes*) first saves the current one - token positions, HP and conditions, doors, explored ground, revealed areas, sight settings and the running fight - and restores the other exactly as you left it. State also survives pauses, since the table lives in the database. Per lobby up to 12 scenes; rename, delete, *Save now* and *Save a copy of the current table* (checkpoint) are available. Only one scene runs at a time (no simultaneous split party).
|
||||||
|
- **Edit live**: the map editor can be opened for the running map. Walls, grid and sight range apply to the game at once; the prepared tokens are only used the next time the map is put on the table.
|
||||||
|
- **Visibility in the editor**: the 👁 button beside each prepared token toggles "hidden from players".
|
||||||
|
- **Props**: decor tokens (chest, barrel, table, chair, bed, door, campfire, tree, rug, stairs, ... 19 built-in drawings, plus your own PNG/WebP uploads up to 3 MB, 40 per DM). Add them in the map editor (*+ Prop*) or on the table (*+ Prop*); drag like tokens, size width/height in squares, rotate by slider, 45° or 90° steps (also in the right click menu), hide from players. They are drawn below tokens; players cannot move them.
|
||||||
|
|
||||||
|
- **Prop library**: the *Maps* page lists all props (built-in and your own) and lets you upload, rename and remove your own ones.
|
||||||
|
- **DM notes**: NPC tokens and props have a "Notes (only you see these)" field (map editor and table; e.g. the contents of a chest). On the table click the token to edit; a 📝 marks tokens with notes and the text shows as tooltip. Players never receive notes.
|
||||||
|
- **Fog without walls**: sight range and fog also work on maps without walls (circle around the token). Sight needs walls or a range above 0; the explored memory is now stored at up to 1024 px.
|
||||||
|
|
||||||
|
- **Map ping**: everybody (players and DM) can point at a spot: double-click the map, or press *📍 Ping* and click. All see a ring in the pinger's colour with their name, and hear a short ping (rate limited to one per second and person). The DM can switch the sound off or upload an MP3 for it under *Lobby actions → Sounds → Map ping*; players can still mute sounds for themselves.
|
||||||
|
|
||||||
|
- **Measure, areas, markers** (for everybody, buttons above the map): *📏 Measure* - click start and end, the distance in squares is shown to the whole table for a few seconds. *⬡ Area* - spell templates (circle, cone with adjustable angle, line with width, square) in the colour of whoever placed them, visible to all until their author or the DM removes them (list top left, DM can clear all); they disappear when the map changes. *📌 Marker* - pins with icon, title and note on the map. Markers are private to their author until "Show to everybody at the table" is ticked; the DM can remove shared markers. They belong to the map, so they are back when you return to that map.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
|
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
|
||||||
@@ -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`) |
|
| GET | `/api/auth/me` | Current user (or `null`) |
|
||||||
| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts |
|
| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts |
|
||||||
| GET | `/api/rules` | Rule data including presets |
|
| GET | `/api/rules` | Rule data including presets |
|
||||||
| GET / POST | `/api/characters` | Own and shared-with-me characters / create one |
|
| GET / POST | `/api/characters[?npc=1]` | Own and shared-with-me characters (or the NPC library) / create one |
|
||||||
|
| GET | `/api/characters/{id}/portrait` | Portrait image (readers of the character) |
|
||||||
|
| POST / PUT / DELETE | `/api/lobbies/{id}/combat` | Start a fight / add, remove, set, reroll, next, prev / end (DM) |
|
||||||
|
| GET | `/api/lobbies/{id}/token-portrait/{token}` | Portrait of an NPC token (lobby members) |
|
||||||
|
| GET / PUT | `/api/admin/users[/{username}]` | List accounts / make or remove an admin (admins) |
|
||||||
| GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) |
|
| GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) |
|
||||||
| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) |
|
| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) |
|
||||||
| PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) |
|
| PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) |
|
||||||
@@ -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}` |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 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}/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 |
|
| POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -39,8 +39,9 @@ def compute(ch: dict, rules: dict) -> dict:
|
|||||||
unlocked = s["id"] in (ch.get("unlocked") or [])
|
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)
|
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
|
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,
|
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"]}
|
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]
|
chosen = [aug_by_id[i] for i in ch.get("augments", []) if i in aug_by_id]
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ def to_docx(ch: dict, rules: dict) -> bytes:
|
|||||||
p = doc.add_paragraph()
|
p = doc.add_paragraph()
|
||||||
p.add_run("Other: ").bold = True
|
p.add_run("Other: ").bold = True
|
||||||
p.add_run(s["other"])
|
p.add_run(s["other"])
|
||||||
for key, head in (("background", "Background"), ("notes", "Notes")):
|
for key, head in (("background", "Background"), ("conditions", "Conditions"), ("notes", "Notes")):
|
||||||
if ch.get(key):
|
if ch.get(key):
|
||||||
doc.add_heading(head, 1)
|
doc.add_heading(head, 1)
|
||||||
doc.add_paragraph(ch[key])
|
doc.add_paragraph(ch[key])
|
||||||
@@ -171,7 +171,7 @@ def to_pdf(ch: dict, rules: dict) -> bytes:
|
|||||||
el.append(Paragraph("-", body))
|
el.append(Paragraph("-", body))
|
||||||
if s["other"]:
|
if s["other"]:
|
||||||
el.append(Paragraph(f"<b>Other:</b> {esc(s['other'])}", body))
|
el.append(Paragraph(f"<b>Other:</b> {esc(s['other'])}", body))
|
||||||
for key, head in (("background", "Background"), ("notes", "Notes")):
|
for key, head in (("background", "Background"), ("conditions", "Conditions"), ("notes", "Notes")):
|
||||||
if ch.get(key):
|
if ch.get(key):
|
||||||
el += [Paragraph(head, h2), Paragraph(esc(ch[key]), body)]
|
el += [Paragraph(head, h2), Paragraph(esc(ch[key]), body)]
|
||||||
|
|
||||||
|
|||||||
+367
-36
@@ -7,12 +7,19 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response
|
import asyncio
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
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 fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import auth
|
from . import auth
|
||||||
from .derive import compute
|
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
|
from .export import to_docx, to_pdf
|
||||||
|
|
||||||
BASE = Path(__file__).resolve().parent.parent
|
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
|
# 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)
|
app = FastAPI(title="Arcane Wasteland Character Creator", docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
limiter = auth.Limiter()
|
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
|
TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse proxy that sets X-Forwarded-For / -Proto
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def load_rules() -> dict:
|
# Changes made in the rules editor live next to the database (writable), the shipped rules.json stays untouched.
|
||||||
# Re-read on every request so rules.json can be edited without a restart.
|
OVERRIDES_PATH = Path(DB_PATH).parent / "rules_overrides.json"
|
||||||
|
|
||||||
|
|
||||||
|
def base_rules() -> dict:
|
||||||
return json.loads(RULES_PATH.read_text(encoding="utf-8"))
|
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:
|
def db() -> sqlite3.Connection:
|
||||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
con = sqlite3.connect(DB_PATH)
|
con = sqlite3.connect(DB_PATH)
|
||||||
@@ -73,6 +104,13 @@ def db() -> sqlite3.Connection:
|
|||||||
("lobby_members", "char_mod", "INTEGER NOT NULL DEFAULT 0")):
|
("lobby_members", "char_mod", "INTEGER NOT NULL DEFAULT 0")):
|
||||||
if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
||||||
con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
||||||
|
if "is_admin" not in [r["name"] for r in con.execute("PRAGMA table_info(users)")]:
|
||||||
|
con.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
|
||||||
|
if "npc" not in [r["name"] for r in con.execute("PRAGMA table_info(characters)")]:
|
||||||
|
con.execute("ALTER TABLE characters ADD COLUMN npc INTEGER NOT NULL DEFAULT 0")
|
||||||
|
maps_mod.schema(con)
|
||||||
|
sounds_mod.schema(con)
|
||||||
|
combat_mod.schema(con)
|
||||||
for table in ("characters", "presets"): # older databases: add the owner column
|
for table in ("characters", "presets"): # older databases: add the owner column
|
||||||
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
||||||
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
|
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
|
||||||
@@ -147,13 +185,32 @@ async def security(request: Request, call_next):
|
|||||||
resp.headers["X-Content-Type-Options"] = "nosniff"
|
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
resp.headers["X-Frame-Options"] = "DENY"
|
resp.headers["X-Frame-Options"] = "DENY"
|
||||||
resp.headers["Referrer-Policy"] = "same-origin"
|
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'; "
|
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/"):
|
if request.url.path.startswith("/api/"):
|
||||||
resp.headers["Cache-Control"] = "no-store"
|
resp.headers["Cache-Control"] = "no-store"
|
||||||
return resp
|
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:
|
def current_user(request: Request) -> dict:
|
||||||
with db() as con:
|
with db() as con:
|
||||||
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
|
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):
|
def me(request: Request):
|
||||||
with db() as con:
|
with db() as con:
|
||||||
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
|
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())}
|
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()))
|
(username, auth.hash_password(password), time.time()))
|
||||||
token = auth.create_session(con, cur.lastrowid)
|
token = auth.create_session(con, cur.lastrowid)
|
||||||
set_cookie(request, response, token)
|
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")
|
@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"])
|
token = auth.create_session(con, row["id"])
|
||||||
limiter.reset(keys[1])
|
limiter.reset(keys[1])
|
||||||
set_cookie(request, response, token)
|
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")
|
@app.post("/api/auth/logout")
|
||||||
@@ -318,46 +379,79 @@ def _name(data: str) -> str:
|
|||||||
return json.loads(data).get("name") or "Nameless"
|
return json.loads(data).get("name") or "Nameless"
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(r, shares=None, owner=None) -> dict:
|
||||||
|
ch = json.loads(r["data"])
|
||||||
|
e = {"id": r["id"], "name": ch.get("name") or "Nameless", "updated": r["updated"], "portrait": portrait_mod.tag(ch.get("portrait"))}
|
||||||
|
if r["npc"]:
|
||||||
|
e["kind"] = str(ch.get("npc_kind") or "")
|
||||||
|
e["size"] = ch.get("token_size", 1)
|
||||||
|
e["hp"] = _health(r["data"])
|
||||||
|
if shares is not None:
|
||||||
|
e["shared_with"] = shares.get(r["id"], [])
|
||||||
|
if owner is not None:
|
||||||
|
e["owner"] = owner or "?"
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
@api.get("/characters")
|
@api.get("/characters")
|
||||||
def my_characters(user: dict = Depends(current_user)):
|
def my_characters(npc: int = 0, user: dict = Depends(current_user)):
|
||||||
|
"""Your characters and the ones shared with you. With ?npc=1 the NPC library (NPCs, enemies, creatures) instead."""
|
||||||
|
flag = 1 if npc else 0
|
||||||
with db() as con:
|
with db() as con:
|
||||||
rows = con.execute("SELECT id, data, updated FROM characters WHERE owner_id=? ORDER BY updated DESC",
|
rows = con.execute("SELECT id, data, updated, npc FROM characters WHERE owner_id=? AND npc=? ORDER BY updated DESC",
|
||||||
(user["id"],)).fetchall()
|
(user["id"], flag)).fetchall()
|
||||||
shares: dict = {}
|
shares: dict = {}
|
||||||
for r in con.execute("""SELECT s.character_id, u.username FROM shares s JOIN users u ON u.id = s.user_id
|
for r in con.execute("""SELECT s.character_id, u.username FROM shares s JOIN users u ON u.id = s.user_id
|
||||||
JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? ORDER BY u.username""",
|
JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? AND c.npc=? ORDER BY u.username""",
|
||||||
(user["id"],)):
|
(user["id"], flag)):
|
||||||
shares.setdefault(r["character_id"], []).append(r["username"])
|
shares.setdefault(r["character_id"], []).append(r["username"])
|
||||||
shared = con.execute("""SELECT c.id, c.data, c.updated, o.username AS owner FROM shares s
|
shared = con.execute("""SELECT c.id, c.data, c.updated, c.npc, o.username AS owner FROM shares s
|
||||||
JOIN characters c ON c.id = s.character_id LEFT JOIN users o ON o.id = c.owner_id
|
JOIN characters c ON c.id = s.character_id LEFT JOIN users o ON o.id = c.owner_id
|
||||||
WHERE s.user_id=? ORDER BY c.updated DESC""", (user["id"],)).fetchall()
|
WHERE s.user_id=? AND c.npc=? ORDER BY c.updated DESC""", (user["id"], flag)).fetchall()
|
||||||
return {"mine": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "shared_with": shares.get(r["id"], [])}
|
return {"mine": [_entry(r, shares=shares) for r in rows], "shared": [_entry(r, owner=r["owner"]) for r in shared]}
|
||||||
for r in rows],
|
|
||||||
"shared": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "owner": r["owner"] or "?"}
|
|
||||||
for r in shared]}
|
|
||||||
|
|
||||||
|
|
||||||
@api.post("/characters")
|
@api.post("/characters")
|
||||||
def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
|
def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
|
portrait_mod.clean(ch)
|
||||||
check_size(ch)
|
check_size(ch)
|
||||||
with db() as con:
|
with db() as con:
|
||||||
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
|
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
|
||||||
raise HTTPException(409, "Character limit reached")
|
raise HTTPException(409, "Character limit reached")
|
||||||
cid, now = uuid.uuid4().hex[:12], time.time()
|
cid, now = uuid.uuid4().hex[:12], time.time()
|
||||||
con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id) VALUES (?,?,?,?,?,?)",
|
con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id, npc) VALUES (?,?,?,?,?,?,?)",
|
||||||
(cid, "", json.dumps(ch), now, now, user["id"]))
|
(cid, "", json.dumps(ch), now, now, user["id"], 1 if ch.get("npc") else 0))
|
||||||
return {"id": cid}
|
return {"id": cid}
|
||||||
|
|
||||||
|
|
||||||
|
def _can_read_character(con, cid: str, user: dict) -> bool:
|
||||||
|
"""The owner, users the character is shared with, the DM of a lobby it plays in, and the DM of a lobby with a token of this NPC."""
|
||||||
|
row = con.execute("SELECT owner_id FROM characters WHERE id=?", (cid,)).fetchone()
|
||||||
|
return bool(row) and bool(row["owner_id"] == user["id"] or con.execute(
|
||||||
|
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone() or con.execute(
|
||||||
|
"""SELECT 1 FROM lobby_members m JOIN lobbies l ON l.id = m.lobby_id
|
||||||
|
WHERE m.character_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone() or con.execute(
|
||||||
|
"""SELECT 1 FROM lobby_tokens t JOIN lobbies l ON l.id = t.lobby_id
|
||||||
|
WHERE t.npc_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/characters/{cid}/portrait")
|
||||||
|
def character_portrait(cid: str, user: dict = Depends(current_user)):
|
||||||
|
with db() as con:
|
||||||
|
row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()
|
||||||
|
allowed = _can_read_character(con, cid, user)
|
||||||
|
img = portrait_mod.image(json.loads(row["data"]).get("portrait")) if row and allowed else None
|
||||||
|
if not img:
|
||||||
|
raise HTTPException(404, "No portrait")
|
||||||
|
return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})
|
||||||
|
|
||||||
|
|
||||||
@api.get("/characters/{cid}")
|
@api.get("/characters/{cid}")
|
||||||
def read_character(cid: str, user: dict = Depends(current_user)):
|
def read_character(cid: str, user: dict = Depends(current_user)):
|
||||||
"""Readable by the owner, by users the character is shared with, and by the DM of a lobby it plays in."""
|
"""Readable by the owner, by users the character is shared with, and by the DM of a lobby it plays in."""
|
||||||
with db() as con:
|
with db() as con:
|
||||||
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
|
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
|
||||||
allowed = bool(row) and (row["owner_id"] == user["id"] or con.execute(
|
allowed = _can_read_character(con, cid, user)
|
||||||
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone() or con.execute(
|
|
||||||
"""SELECT 1 FROM lobby_members m JOIN lobbies l ON l.id = m.lobby_id
|
|
||||||
WHERE m.character_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone())
|
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise HTTPException(404, "Character not found")
|
raise HTTPException(404, "Character not found")
|
||||||
return json.loads(row["data"])
|
return json.loads(row["data"])
|
||||||
@@ -390,10 +484,30 @@ def remove_share(cid: str, username: str, user: dict = Depends(current_user)):
|
|||||||
|
|
||||||
@api.put("/characters/{cid}")
|
@api.put("/characters/{cid}")
|
||||||
def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(current_user)):
|
def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
|
portrait_mod.clean(ch)
|
||||||
check_size(ch)
|
check_size(ch)
|
||||||
with db() as con:
|
with db() as con:
|
||||||
_owned(con, "characters", cid, user)
|
_owned(con, "characters", cid, user)
|
||||||
|
con.execute("UPDATE characters SET data=?, updated=?, npc=? WHERE id=?", (json.dumps(ch), time.time(), 1 if ch.get("npc") else 0, cid))
|
||||||
|
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
|
||||||
|
for lid in lobbies:
|
||||||
|
hub.notify(lid, "sheet", cid=cid)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@api.put("/characters/{cid}/notes")
|
||||||
|
def update_notes(cid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
|
"""Only the two note fields, so typing in the lobby never overwrites other changes made elsewhere."""
|
||||||
|
with db() as con:
|
||||||
|
_owned(con, "characters", cid, user)
|
||||||
|
ch = json.loads(con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()["data"])
|
||||||
|
for k in ("notes", "conditions"):
|
||||||
|
if k in body:
|
||||||
|
ch[k] = str(body[k] or "")[:5000]
|
||||||
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
|
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
|
||||||
|
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
|
||||||
|
for lid in lobbies:
|
||||||
|
hub.notify(lid, "sheet", cid=cid)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -432,7 +546,7 @@ def _new_code(con) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _own_character(con, cid, user: dict):
|
def _own_character(con, cid, user: dict):
|
||||||
row = con.execute("SELECT id FROM characters WHERE id=? AND owner_id=?", (cid, user["id"])).fetchone()
|
row = con.execute("SELECT id FROM characters WHERE id=? AND owner_id=? AND npc=0", (cid, user["id"])).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(422, "Pick one of your own characters")
|
raise HTTPException(422, "Pick one of your own characters")
|
||||||
|
|
||||||
@@ -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)."""
|
"""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', ?, ?)",
|
con.execute("INSERT INTO lobby_messages (lobby_id, user_id, kind, text, created) VALUES (?,?, 'event', ?, ?)",
|
||||||
(lid, uid, text, time.time()))
|
(lid, uid, text, time.time()))
|
||||||
|
hub.notify(lid, "messages")
|
||||||
|
|
||||||
|
|
||||||
def _char_name(con, cid) -> str:
|
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)
|
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],
|
return {"name": ch.get("name") or "Nameless", "concept": str(ch.get("concept") or "")[:120],
|
||||||
"class": (cls or {}).get("name", ""), "subclass": (sub or {}).get("name", ""),
|
"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")
|
@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 (?,?,?,?,?)
|
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""",
|
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"])))
|
(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"])
|
_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"]}
|
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}")
|
@api.get("/lobbies/{lid}")
|
||||||
def read_lobby(lid: str, user: dict = Depends(current_user)):
|
def read_lobby(lid: str, user: dict = Depends(current_user)):
|
||||||
rules = load_rules()
|
rules = load_rules()
|
||||||
@@ -545,24 +674,26 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
|
|||||||
lobby = _lobby_for(con, lid, user)
|
lobby = _lobby_for(con, lid, user)
|
||||||
is_dm = lobby["owner_id"] == user["id"]
|
is_dm = lobby["owner_id"] == user["id"]
|
||||||
_ensure_colors(con, lid)
|
_ensure_colors(con, lid)
|
||||||
rows = con.execute("""SELECT m.user_id, m.character_id, m.color, m.char_mod, u.username, c.data FROM lobby_members m
|
rows = con.execute("""SELECT m.user_id, m.character_id, m.color, m.char_mod, m.vgroup, u.username, c.data FROM lobby_members m
|
||||||
JOIN users u ON u.id = m.user_id LEFT JOIN characters c ON c.id = m.character_id
|
JOIN users u ON u.id = m.user_id LEFT JOIN characters c ON c.id = m.character_id
|
||||||
WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
|
WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
|
||||||
|
combat = combat_view(con, lobby, is_dm)
|
||||||
members = []
|
members = []
|
||||||
|
online = hub.online(lid)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
mine = r["user_id"] == user["id"]
|
mine = r["user_id"] == user["id"]
|
||||||
card = _card(r["data"], rules) if r["data"] else None
|
card = _card(r["data"], rules) if r["data"] else None
|
||||||
# The id is only given out where the viewer may open the full character (the DM sees all, a player only their own)
|
# The id is only given out where the viewer may open the full character (the DM sees all, a player only their own)
|
||||||
members.append({"username": r["username"], "me": mine, "character": card, "color": r["color"],
|
members.append({"username": r["username"], "me": mine, "character": card, "color": r["color"],
|
||||||
"mod": r["char_mod"] if (is_dm or mine) else None,
|
"mod": r["char_mod"] if (is_dm or mine) else None, "online": r["user_id"] in online, "vgroup": r["vgroup"] if is_dm else None,
|
||||||
"character_id": r["character_id"] if (is_dm or mine) and card else None})
|
"character_id": r["character_id"] if (is_dm or mine) and card else None})
|
||||||
hidden = bool(lobby["diff_hidden"])
|
hidden = bool(lobby["diff_hidden"])
|
||||||
diff = {"label": lobby["diff_label"], "mod": lobby["diff_mod"], "hidden": hidden}
|
diff = {"label": lobby["diff_label"], "mod": lobby["diff_mod"], "hidden": hidden}
|
||||||
if hidden and not is_dm:
|
if hidden and not is_dm:
|
||||||
diff = {"label": "", "mod": None, "hidden": True}
|
diff = {"label": "", "mod": None, "hidden": True}
|
||||||
return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm,
|
return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm,
|
||||||
"code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff,
|
"dm_online": lobby["owner_id"] in online, "combat": combat, "code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff,
|
||||||
"levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR}
|
"sounds": sounds_mod.settings(lobby["sounds"]), "levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR}
|
||||||
|
|
||||||
|
|
||||||
@api.put("/lobbies/{lid}/difficulty")
|
@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:
|
with db() as con:
|
||||||
_dm_only(_lobby_for(con, lid, user), user)
|
_dm_only(_lobby_for(con, lid, user), user)
|
||||||
con.execute("UPDATE lobbies SET diff_mod=?, diff_label=?, diff_hidden=? WHERE id=?", (mod, label, 1 if body.get("hidden") else 0, lid))
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -596,19 +728,20 @@ def set_member_mod(lid: str, username: str, body: dict = Body(...), user: dict =
|
|||||||
(mod, lid, username))
|
(mod, lid, username))
|
||||||
if not cur.rowcount:
|
if not cur.rowcount:
|
||||||
raise HTTPException(404, "No such player in this lobby")
|
raise HTTPException(404, "No such player in this lobby")
|
||||||
|
hub.notify(lid, "lobby")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
def _recipient(con, lobby, user: dict, to):
|
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()
|
to = str(to or "").strip()
|
||||||
if not to:
|
if not to:
|
||||||
return None
|
return None
|
||||||
target = con.execute("""SELECT u.id FROM users u WHERE u.username=? AND (u.id=? OR EXISTS
|
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))""",
|
(SELECT 1 FROM lobby_members m WHERE m.lobby_id=? AND m.user_id=u.id))""",
|
||||||
(to, lobby["owner_id"], lobby["id"])).fetchone()
|
(to, lobby["owner_id"], lobby["id"])).fetchone()
|
||||||
if not target or target["id"] == user["id"]:
|
if not target:
|
||||||
raise HTTPException(422, "You can only whisper to other people in this lobby")
|
raise HTTPException(422, "You can only whisper to people in this lobby")
|
||||||
return target["id"]
|
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()))
|
(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(
|
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))
|
(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}
|
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()))
|
(lid, uid, to_id, kind, json.dumps(payload), time.time()))
|
||||||
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
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))
|
(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
|
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)
|
lobby = _lobby_for(con, lid, user)
|
||||||
member = con.execute("""SELECT m.char_mod, c.data FROM lobby_members m LEFT JOIN characters c ON c.id = m.character_id
|
member = con.execute("""SELECT m.char_mod, c.data FROM lobby_members m LEFT JOIN characters c ON c.id = m.character_id
|
||||||
WHERE m.lobby_id=? AND m.user_id=?""", (lid, user["id"])).fetchone()
|
WHERE m.lobby_id=? AND m.user_id=?""", (lid, user["id"])).fetchone()
|
||||||
|
npc_name = ""
|
||||||
|
if body.get("npc_token"): # the DM rolls for an NPC on the table
|
||||||
|
_dm_only(lobby, user)
|
||||||
|
t = con.execute("""SELECT t.name, c.data FROM lobby_tokens t JOIN characters c ON c.id = t.npc_id
|
||||||
|
WHERE t.id=? AND t.lobby_id=?""", (str(body["npc_token"]), lid)).fetchone()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404, "This token has no NPC sheet")
|
||||||
|
member, npc_name = {"char_mod": 0, "data": t["data"]}, t["name"]
|
||||||
if not member or not member["data"]:
|
if not member or not member["data"]:
|
||||||
raise HTTPException(422, "Only players with a character in this lobby can roll checks")
|
raise HTTPException(422, "Only players with a character in this lobby can roll checks")
|
||||||
c = compute(json.loads(member["data"]), rules)
|
c = compute(json.loads(member["data"]), rules)
|
||||||
@@ -747,12 +890,12 @@ def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_us
|
|||||||
raise HTTPException(404, "Unknown skill")
|
raise HTTPException(404, "Unknown skill")
|
||||||
if not s["usable"]:
|
if not s["usable"]:
|
||||||
raise HTTPException(422, "This skill is still locked")
|
raise HTTPException(422, "This skill is still locked")
|
||||||
what, base = s["name"], s["total"]
|
what, base = (f"{npc_name}: " if npc_name else "") + s["name"], s["total"]
|
||||||
elif kind == "attr":
|
elif kind == "attr":
|
||||||
a = next((x for x in rules["attributes"] if x["id"] == key), None)
|
a = next((x for x in rules["attributes"] if x["id"] == key), None)
|
||||||
if not a:
|
if not a:
|
||||||
raise HTTPException(404, "Unknown attribute")
|
raise HTTPException(404, "Unknown attribute")
|
||||||
what, base = a["name"], c["attrs"][key]
|
what, base = (f"{npc_name}: " if npc_name else "") + a["name"], c["attrs"][key]
|
||||||
else:
|
else:
|
||||||
raise HTTPException(422, "Roll a skill or an attribute")
|
raise HTTPException(422, "Roll a skill or an attribute")
|
||||||
manual = _manual_results(None if body.get("manual") in (None, "") else [body.get("manual")], 100, 1)
|
manual = _manual_results(None if body.get("manual") in (None, "") else [body.get("manual")], 100, 1)
|
||||||
@@ -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"],
|
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"]),
|
"target": target, "roll": roll, "margin": target - roll, "outcome": outcome, "hidden": bool(lobby["diff_hidden"]),
|
||||||
"manual": bool(manual)}
|
"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}
|
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)
|
_dm_only(_lobby_for(con, lid, user), user)
|
||||||
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
|
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
|
||||||
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
|
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
|
||||||
|
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_scenes", "lobby_templates", "lobby_marks"):
|
||||||
|
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
|
||||||
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
|
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
|
||||||
|
purge_sounds(lid)
|
||||||
|
hub.close_lobby(lid)
|
||||||
return {"ok": True}
|
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():
|
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")
|
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"]))
|
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:
|
if "character_id" in body:
|
||||||
_own_character(con, body.get("character_id"), user)
|
_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"]))
|
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
|
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).rowcount
|
||||||
if gone:
|
if gone:
|
||||||
_event(con, lid, f"{user['username']} left the lobby", user["id"])
|
_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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -840,6 +992,10 @@ def kick_member(lid: str, username: str, user: dict = Depends(current_user)):
|
|||||||
(lid, username)).rowcount
|
(lid, username)).rowcount
|
||||||
if gone:
|
if gone:
|
||||||
_event(con, lid, f"{username} was removed by the DM", user["id"])
|
_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}
|
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)}"'})
|
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)
|
app.include_router(api)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1218
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"skill_points_default": 150,
|
"skill_points_default": 150,
|
||||||
"skill_max_divisor": 2,
|
"skill_max_divisor": 2,
|
||||||
"advanced_unlock_cost": 10,
|
"advanced_unlock_cost": 10,
|
||||||
|
"advanced_attr_percent": 50,
|
||||||
"free_proficiencies": 2,
|
"free_proficiencies": 2,
|
||||||
"max_proficiency_pips": 2,
|
"max_proficiency_pips": 2,
|
||||||
"talents_min": 1,
|
"talents_min": 1,
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
{"id": "standard", "name": "Standard", "desc": "The numbers from the brainstorm document.", "creation": {}},
|
{"id": "standard", "name": "Standard", "desc": "The numbers from the brainstorm document.", "creation": {}},
|
||||||
{"id": "gritty", "name": "Gritty survival", "desc": "Example preset: fewer points, one starting talent, harder to learn advanced skills.", "creation": {"attr_points": 70, "skill_points_default": 100, "talents_max": 1, "free_proficiencies": 1, "advanced_unlock_cost": 15}},
|
{"id": "gritty", "name": "Gritty survival", "desc": "Example preset: fewer points, one starting talent, harder to learn advanced skills.", "creation": {"attr_points": 70, "skill_points_default": 100, "talents_max": 1, "free_proficiencies": 1, "advanced_unlock_cost": 15}},
|
||||||
{"id": "heroic", "name": "Heroic", "desc": "Example preset: more points, higher attribute cap, three starting talents.", "creation": {"attr_points": 110, "attr_max_raise": 25, "attr_max": 50, "skill_points_default": 200, "talents_max": 3, "free_proficiencies": 3, "advanced_unlock_cost": 5}},
|
{"id": "heroic", "name": "Heroic", "desc": "Example preset: more points, higher attribute cap, three starting talents.", "creation": {"attr_points": 110, "attr_max_raise": 25, "attr_max": 50, "skill_points_default": 200, "talents_max": 3, "free_proficiencies": 3, "advanced_unlock_cost": 5}},
|
||||||
|
{"id": "npc", "name": "NPC / creature (free)", "desc": "Used by the NPC editor: huge pools, no limits.", "npc": true, "creation": {"attr_points": 500, "attr_max_raise": 100, "attr_max": 150, "skill_points_default": 1000, "talents_min": 0, "talents_max": 10, "free_proficiencies": 20, "advanced_unlock_cost": 0}},
|
||||||
{"id": "oneshot", "name": "One-shot", "desc": "Example preset: quick, strong characters for a single session.", "creation": {"attr_points": 100, "skill_points_default": 120, "talents_max": 2, "free_proficiencies": 3, "advanced_unlock_cost": 0}}
|
{"id": "oneshot", "name": "One-shot", "desc": "Example preset: quick, strong characters for a single session.", "creation": {"attr_points": 100, "skill_points_default": 120, "talents_max": 2, "free_proficiencies": 3, "advanced_unlock_cost": 0}}
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
{"key": "profession_attr_bonus", "label": "Profession attribute bonus", "group": "Profession", "min": 0, "max": 50},
|
{"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": "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": "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": "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": "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},
|
{"key": "proficiency_bonus", "label": "Proficiency bonus", "group": "Skills", "min": 0, "max": 50},
|
||||||
@@ -434,7 +437,7 @@
|
|||||||
"placeholder": true
|
"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
|
"placeholder": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ fastapi>=0.115
|
|||||||
uvicorn[standard]>=0.30
|
uvicorn[standard]>=0.30
|
||||||
python-docx>=1.1
|
python-docx>=1.1
|
||||||
reportlab>=4.2
|
reportlab>=4.2
|
||||||
|
Pillow>=10
|
||||||
|
|||||||
@@ -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).
|
# Code that new users must enter to create an account (set it in the .env file next to this file).
|
||||||
# Empty = registration is disabled.
|
# Empty = registration is disabled.
|
||||||
AW_REGISTRATION_CODE: ${AW_REGISTRATION_CODE:-}
|
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.
|
# Set to 1 when a reverse proxy (nginx, Caddy, Traefik) in front sets X-Forwarded-* headers.
|
||||||
AW_TRUST_PROXY: ${AW_TRUST_PROXY:-0}
|
AW_TRUST_PROXY: ${AW_TRUST_PROXY:-0}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Binary file not shown.
+11
-1
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Creator from './Creator.jsx'
|
import Creator from './Creator.jsx'
|
||||||
import Rules from './Rules.jsx'
|
import Rules from './Rules.jsx'
|
||||||
|
import Maps from './Maps.jsx'
|
||||||
import Characters from './Characters.jsx'
|
import Characters from './Characters.jsx'
|
||||||
import Presets from './Presets.jsx'
|
import Presets from './Presets.jsx'
|
||||||
import Lobbies from './Lobbies.jsx'
|
import Lobbies from './Lobbies.jsx'
|
||||||
import Login from './Login.jsx'
|
import Login from './Login.jsx'
|
||||||
import Account from './Account.jsx'
|
import Account from './Account.jsx'
|
||||||
|
import Editor from './Editor.jsx'
|
||||||
import { claimLegacy } from './legacy.js'
|
import { claimLegacy } from './legacy.js'
|
||||||
|
|
||||||
const useHash = () => {
|
const useHash = () => {
|
||||||
@@ -25,6 +27,7 @@ export default function App() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
// Used to load a saved character into the creator
|
// Used to load a saved character into the creator
|
||||||
const [loadRequest, setLoadRequest] = useState(null)
|
const [loadRequest, setLoadRequest] = useState(null)
|
||||||
|
const [npcLoad, setNpcLoad] = useState(null)
|
||||||
const [charName, setCharName] = useState('') // name of the character being worked on, shown in the nav
|
const [charName, setCharName] = useState('') // name of the character being worked on, shown in the nav
|
||||||
|
|
||||||
// Ask the server who we are; any later 401 (session expired) sends us back to the login
|
// Ask the server who we are; any later 401 (session expired) sends us back to the login
|
||||||
@@ -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>
|
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
|
||||||
|
|
||||||
const page = hash.split('/')[0]
|
const page = hash.split('/')[0]
|
||||||
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['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 (
|
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 === '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 === 'rules' && <Rules rules={rules} section={hash.split('/')[1]} />}
|
||||||
{rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />}
|
{rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />}
|
||||||
|
{page === 'maps' && <Maps />}
|
||||||
{rules && page === 'characters' && (
|
{rules && page === 'characters' && (
|
||||||
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
|
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
|
||||||
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
|
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
|
||||||
)}
|
)}
|
||||||
|
{rules && page === 'npcs' && (
|
||||||
|
<Characters npc onOpen={(entry) => { setNpcLoad(entry); window.location.hash = 'npc' }}
|
||||||
|
onNew={() => { setNpcLoad({ new: true }); window.location.hash = 'npc' }} />
|
||||||
|
)}
|
||||||
|
{rules && page === 'npc' && <Creator npc rules={rules} user={username} loadRequest={npcLoad} clearLoad={() => setNpcLoad(null)} onName={() => {}} />}
|
||||||
{rules && page === 'lobbies' && (
|
{rules && page === 'lobbies' && (
|
||||||
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
|
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
|
||||||
)}
|
)}
|
||||||
|
{rules && page === 'editor' && auth.user.admin && <Editor rules={rules} onReload={loadRules} />}
|
||||||
{page === 'account' && <Account user={username} onLogout={logout} />}
|
{page === 'account' && <Account user={username} onLogout={logout} />}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+29
-12
@@ -19,7 +19,7 @@ function SharePanel({ c, reload }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sharepanel">
|
<div className="sharepanel">
|
||||||
<p className="small muted">These users can view this character (read-only), for example your DM:</p>
|
<p className="small muted">These users can view this entry (read-only), for example another DM:</p>
|
||||||
<div className="chips">
|
<div className="chips">
|
||||||
{c.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
|
{c.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
|
||||||
{c.shared_with.map((u) => (
|
{c.shared_with.map((u) => (
|
||||||
@@ -35,12 +35,13 @@ function SharePanel({ c, reload }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Characters({ onOpen, onNew }) {
|
export default function Characters({ onOpen, onNew, npc = false }) {
|
||||||
|
const what = npc ? 'NPC' : 'character'
|
||||||
const [data, setData] = useState(null)
|
const [data, setData] = useState(null)
|
||||||
const [open, setOpen] = useState(null) // id of the character whose share panel is open
|
const [open, setOpen] = useState(null) // id of the character whose share panel is open
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
const load = () => fetch('/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your characters.'))
|
const load = () => fetch(npc ? '/api/characters?npc=1' : '/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load the list.'))
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
const remove = async (c) => {
|
const remove = async (c) => {
|
||||||
@@ -49,32 +50,46 @@ export default function Characters({ onOpen, onNew }) {
|
|||||||
if (!r || !r.ok) return setErr('Deleting failed.')
|
if (!r || !r.ok) return setErr('Deleting failed.')
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
const copy = async (c) => {
|
||||||
|
const r = await fetch(`/api/characters/${c.id}`).catch(() => null)
|
||||||
|
if (!r || !r.ok) return setErr('Duplicating failed.')
|
||||||
|
const data = await r.json()
|
||||||
|
const p = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, name: `${data.name || 'NPC'} (copy)` }) }).catch(() => null)
|
||||||
|
if (!p || !p.ok) return setErr('Duplicating failed.')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
const kindName = (k) => ({ npc: 'NPC', enemy: 'Enemy', monster: 'Monster', creature: 'Creature', animal: 'Animal', robot: 'Robot / machine' })[k] || ''
|
||||||
|
const thumb = (c) => c.portrait ? <img className="npcthumb" src={`/api/characters/${c.id}/portrait?v=${c.portrait}`} alt="" /> : <span className="npcthumb none" aria-hidden="true">{npc ? '?' : ''}</span>
|
||||||
const when = (t) => new Date(t * 1000).toLocaleString('en-GB')
|
const when = (t) => new Date(t * 1000).toLocaleString('en-GB')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page narrow">
|
<section className="page narrow">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<h1>My characters</h1>
|
<h1>{npc ? 'NPCs and creatures' : 'My characters'}</h1>
|
||||||
<button onClick={onNew}>+ New character</button>
|
<button onClick={onNew}>{npc ? '+ New NPC' : '+ New character'}</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
These characters are saved in your account. Only you and the users you share a character with can see it.
|
{npc ? 'Your library of NPCs, enemies, monsters and creatures. Place them as tokens in the map editor or at the table; a DM sees their sheet by clicking the token. You can share NPCs with other DMs.'
|
||||||
Shared users can look at it but not change it.
|
: 'These characters are saved in your account. Only you and the users you share a character with can see it. Shared users can look at it but not change it.'}
|
||||||
</p>
|
</p>
|
||||||
{err && <p className="warn">{err}</p>}
|
{err && <p className="warn">{err}</p>}
|
||||||
{data && data.mine.length === 0 && <p className="card">Nothing saved yet. Use "New character", then click "Save" on the Summary page.</p>}
|
{data && data.mine.length === 0 && <p className="card">Nothing saved yet. Use "{npc ? 'New NPC' : 'New character'}", then click "{npc ? 'Save NPC' : 'Save'}" on the Summary page.</p>}
|
||||||
<ul className="charlist">
|
<ul className="charlist">
|
||||||
{(data?.mine || []).map((c) => (
|
{(data?.mine || []).map((c) => (
|
||||||
<li key={c.id} className="card">
|
<li key={c.id} className="card">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div>
|
<div className="npchead">
|
||||||
<strong>{c.name}</strong>
|
{thumb(c)}
|
||||||
|
<div>
|
||||||
|
<strong>{c.name}</strong>{npc && c.kind && <span className="badge">{kindName(c.kind)}</span>}
|
||||||
<div className="muted small">
|
<div className="muted small">
|
||||||
ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`}
|
ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button onClick={() => onOpen(c)}>Open</button>
|
<button onClick={() => onOpen(c)}>Open</button>
|
||||||
|
{npc && <button className="ghost" onClick={() => copy(c)}>Duplicate</button>}
|
||||||
<button className="ghost" onClick={() => setOpen(open === c.id ? null : c.id)}>Share</button>
|
<button className="ghost" onClick={() => setOpen(open === c.id ? null : c.id)}>Share</button>
|
||||||
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
|
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,9 +105,11 @@ export default function Characters({ onOpen, onNew }) {
|
|||||||
<ul className="charlist">
|
<ul className="charlist">
|
||||||
{data.shared.map((c) => (
|
{data.shared.map((c) => (
|
||||||
<li key={c.id} className="card row">
|
<li key={c.id} className="card row">
|
||||||
<div>
|
<div className="npchead">
|
||||||
<strong>{c.name}</strong>
|
{thumb(c)}
|
||||||
|
<div><strong>{c.name}</strong>
|
||||||
<div className="muted small">from {c.owner} · {when(c.updated)}</div>
|
<div className="muted small">from {c.owner} · {when(c.updated)}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions"><button onClick={() => onOpen(c)}>View</button></div>
|
<div className="actions"><button onClick={() => onOpen(c)}>View</button></div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+47
-20
@@ -1,3 +1,4 @@
|
|||||||
|
import PortraitEditor from './Portrait.jsx'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
|
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
|
||||||
import SheetPanel from './SheetPanel.jsx'
|
import SheetPanel from './SheetPanel.jsx'
|
||||||
@@ -13,9 +14,13 @@ function readDraft(key) {
|
|||||||
try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
|
try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName }) {
|
const NPC_KINDS = [['npc', 'NPC'], ['enemy', 'Enemy'], ['monster', 'Monster'], ['creature', 'Creature'], ['animal', 'Animal'], ['robot', 'Robot / machine']]
|
||||||
const DRAFT = `aw_draft:${user}` // drafts are kept per account
|
|
||||||
const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
|
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName, npc = false }) {
|
||||||
|
const DRAFT = `${npc ? 'aw_draft_npc' : 'aw_draft'}:${user}` // drafts are kept per account
|
||||||
|
const freshCharacter = () => npc
|
||||||
|
? { ...applyPreset(emptyCharacter(baseRules), baseRules, 'npc'), npc: true, npc_kind: 'enemy', token_size: 1, abilities: '', loot: '' }
|
||||||
|
: applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
|
||||||
const [ch, setCh] = useState(() => {
|
const [ch, setCh] = useState(() => {
|
||||||
const d = readDraft(DRAFT)
|
const d = readDraft(DRAFT)
|
||||||
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
|
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
|
||||||
@@ -57,7 +62,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
if (!loadRequest) return
|
if (!loadRequest) return
|
||||||
if (loadRequest.new) { // "New character" button on the My characters page
|
if (loadRequest.new) { // "New character" button on the My characters page
|
||||||
const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved
|
const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved
|
||||||
if (untouched || window.confirm('Start a new character? Your current draft is discarded unless you saved it.')) {
|
if (untouched || window.confirm('Start a new ' + (npc ? 'NPC' : 'character') + '? Your current draft is discarded unless you saved it.')) {
|
||||||
setBoth(freshCharacter()); setSaved(null); setStep(0)
|
setBoth(freshCharacter()); setSaved(null); setStep(0)
|
||||||
}
|
}
|
||||||
clearLoad()
|
clearLoad()
|
||||||
@@ -73,7 +78,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
// Direct link #creator/<id> (read only; saving creates a copy)
|
// Direct link #creator/<id> (read only; saving creates a copy)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.location.hash.split('/')[1]
|
const id = window.location.hash.split('/')[1]
|
||||||
if (!id || loadRequest) return
|
if (!id || loadRequest || npc) return
|
||||||
fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
|
fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
|
||||||
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') })
|
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') })
|
||||||
.catch(() => setMsg('Character not found, or it is not shared with you.'))
|
.catch(() => setMsg('Character not found, or it is not shared with you.'))
|
||||||
@@ -93,7 +98,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
|
|
||||||
const resetAll = () => {
|
const resetAll = () => {
|
||||||
if (!window.confirm('Discard all input and start over?')) return
|
if (!window.confirm('Discard all input and start over?')) return
|
||||||
setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation }); setSaved(null); setStep(0)
|
setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation, ...(npc ? { npc: true, npc_kind: ch.npc_kind, token_size: ch.token_size, abilities: '', loot: '' } : {}) }); setSaved(null); setStep(0)
|
||||||
}
|
}
|
||||||
const resetThisStep = () => {
|
const resetThisStep = () => {
|
||||||
if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return
|
if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return
|
||||||
@@ -153,10 +158,25 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
const stepConcept = (
|
const stepConcept = (
|
||||||
<>
|
<>
|
||||||
<h2>Concept</h2>
|
<h2>Concept</h2>
|
||||||
<div className="card presetbox">
|
{npc && (
|
||||||
|
<div className="card presetbox">
|
||||||
|
<div className="grid2">
|
||||||
|
<label>Type
|
||||||
|
<select value={ch.npc_kind || 'npc'} onChange={(e) => set({ npc_kind: e.target.value })}>
|
||||||
|
{NPC_KINDS.map(([id, label]) => <option key={id} value={id}>{label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Token size (grid squares)
|
||||||
|
<input type="number" min="1" max="8" value={ch.token_size || 1} onChange={(e) => set({ token_size: Math.max(1, Math.min(8, Number(e.target.value) || 1)) })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="small muted">NPCs use generous pools (attributes, skills, talents), so you can build anything from a rat to a boss.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!npc && <div className="card presetbox">
|
||||||
<label>Rules preset
|
<label>Rules preset
|
||||||
<select value={ch.preset} onChange={(e) => e.target.value !== 'custom' && setCh((x) => applyPreset(x, baseRules, e.target.value))}>
|
<select value={ch.preset} onChange={(e) => e.target.value !== 'custom' && setCh((x) => applyPreset(x, baseRules, e.target.value))}>
|
||||||
{(baseRules.presets || []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
{(baseRules.presets || []).filter((p) => !p.npc).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
{ch.preset === 'custom' && <option value="custom">Custom</option>}
|
{ch.preset === 'custom' && <option value="custom">Custom</option>}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -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>
|
return diff.length > 0 && <p className="small muted">Differs from Standard: {diff.map((x) => `${x.label} ${cr[x.key]}`).join(' · ')}</p>
|
||||||
})()}
|
})()}
|
||||||
<p className="small">Create or edit presets on the <a href="#presets">Presets</a> page.</p>
|
<p className="small">Create or edit presets on the <a href="#presets">Presets</a> page.</p>
|
||||||
</div>
|
</div>}
|
||||||
|
<div className="card portraitbox"><PortraitEditor value={ch.portrait} onChange={(p) => set({ portrait: p })} /></div>
|
||||||
<div className="grid2">
|
<div className="grid2">
|
||||||
<label>Character name<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
|
<label>{npc ? 'NPC name' : 'Character name'}<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
|
||||||
<label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>
|
{!npc && <label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid2">
|
<div className="grid2">
|
||||||
<label>Class (optional)
|
<label>Class (optional)
|
||||||
@@ -187,7 +208,9 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
</div>
|
</div>
|
||||||
{sub && <p className="small muted">{sub.desc}</p>}
|
{sub && <p className="small muted">{sub.desc}</p>}
|
||||||
<label>Concept in one sentence<input value={ch.concept} onChange={(e) => set({ concept: e.target.value })} placeholder="e.g. Former militia fighter searching for his family" /></label>
|
<label>Concept in one sentence<input value={ch.concept} onChange={(e) => set({ concept: e.target.value })} placeholder="e.g. Former militia fighter searching for his family" /></label>
|
||||||
<label>Background<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
|
<label>{npc ? 'Description' : 'Background'}<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
|
||||||
|
{npc && <label>Special abilities and attacks<textarea rows="5" value={ch.abilities || ''} onChange={(e) => set({ abilities: e.target.value })} placeholder="e.g. Bite 2d6, poison spit (range 10, VIT check or poisoned), regenerates 2 HP per round" /></label>}
|
||||||
|
{npc && <label>Loot and drops<textarea rows="3" value={ch.loot || ''} onChange={(e) => set({ loot: e.target.value })} placeholder="e.g. 2 scrap, hide, rusty knife" /></label>}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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`}
|
<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>
|
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>
|
<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>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -415,6 +440,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
)
|
)
|
||||||
|
|
||||||
const warnings = []
|
const warnings = []
|
||||||
|
if (npc) { if (c.attrLeft < 0) warnings.push('Too many attribute points spent.'); if (c.skillLeft < 0) warnings.push('More skill points spent than the pool allows.') } else {
|
||||||
if (c.attrLeft < 0) warnings.push('Too many attribute points spent.')
|
if (c.attrLeft < 0) warnings.push('Too many attribute points spent.')
|
||||||
if (c.attrLeft > 0) warnings.push(`${c.attrLeft} attribute points left.`)
|
if (c.attrLeft > 0) warnings.push(`${c.attrLeft} attribute points left.`)
|
||||||
if (!ch.profession) warnings.push('No profession chosen.')
|
if (!ch.profession) warnings.push('No profession chosen.')
|
||||||
@@ -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 (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
|
||||||
if (over) warnings.push('Augmentation Capacity exceeded.')
|
if (over) warnings.push('Augmentation Capacity exceeded.')
|
||||||
if (overWeight) warnings.push('Carrying more than the carry capacity.')
|
if (overWeight) warnings.push('Carrying more than the carry capacity.')
|
||||||
|
}
|
||||||
|
|
||||||
const gearLines = kitItems(ch, rules)
|
const gearLines = kitItems(ch, rules)
|
||||||
const stepSummary = (
|
const stepSummary = (
|
||||||
<>
|
<>
|
||||||
<h2>Summary</h2>
|
<h2>Summary</h2>
|
||||||
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
|
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
|
||||||
<PaperSheet data={ch} baseRules={baseRules} />
|
<PaperSheet data={ch} baseRules={baseRules} onNotes={(p) => setBoth({ ...ch, ...p })} />
|
||||||
<div className="actions wrap">
|
<div className="actions wrap">
|
||||||
<button onClick={() => download('pdf')}>Download as PDF</button>
|
{!npc && <button onClick={() => download('pdf')}>Download as PDF</button>}
|
||||||
<button onClick={() => download('docx')}>Download as Word</button>
|
{!npc && <button onClick={() => download('docx')}>Download as Word</button>}
|
||||||
<button className="ghost" onClick={save}>{saved ? 'Save changes' : 'Save on server'}</button>
|
<button className="ghost" onClick={save}>{saved ? 'Save changes' : npc ? 'Save NPC' : 'Save on server'}</button>
|
||||||
<button className="ghost" onClick={exportJson}>JSON backup</button>
|
<button className="ghost" onClick={exportJson}>JSON backup</button>
|
||||||
<button className="ghost" onClick={() => fileRef.current.click()}>Import JSON</button>
|
<button className="ghost" onClick={() => fileRef.current.click()}>Import JSON</button>
|
||||||
<input ref={fileRef} type="file" accept="application/json" hidden onChange={importJson} />
|
<input ref={fileRef} type="file" accept="application/json" hidden onChange={importJson} />
|
||||||
</div>
|
</div>
|
||||||
{saved && (
|
{saved && !npc && (
|
||||||
<p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p>
|
<p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -453,7 +480,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page creator">
|
<div className="page creator">
|
||||||
<p className="small muted presetchip">Rules: <button className="linklike" onClick={() => go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}</button></p>
|
{!npc && <p className="small muted presetchip">Rules: <button className="linklike" onClick={() => go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}</button></p>}
|
||||||
<ol className="steps">
|
<ol className="steps">
|
||||||
{STEPS.map((s, i) => (
|
{STEPS.map((s, i) => (
|
||||||
<li key={s}><button className={`${i === step ? 'on' : ''} ${i < step ? 'done' : ''}`} onClick={() => go(i)}>
|
<li key={s}><button className={`${i === step ? 'on' : ''} ${i < step ? 'done' : ''}`} onClick={() => go(i)}>
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
|
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
|
||||||
|
import { sound, playRoll, 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 J = { 'Content-Type': 'application/json' }
|
||||||
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
|
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
|
||||||
@@ -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 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!' }
|
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.
|
// 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 [msgs, setMsgs] = useState([])
|
||||||
const last = useRef(0)
|
const last = useRef(0)
|
||||||
const live = useRef(false) // false until the history is loaded: old rolls are not animated again
|
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])
|
}, [id])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
poll()
|
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)
|
return () => clearInterval(t)
|
||||||
}, [poll])
|
}, [poll, socketUp])
|
||||||
return { msgs, poll }
|
return { msgs, poll }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +248,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
|
|||||||
const who = (m) => (
|
const who = (m) => (
|
||||||
<>
|
<>
|
||||||
<strong style={m.color ? { color: m.color } : undefined}>{m.mine ? 'You' : m.from || '?'}</strong>
|
<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}>
|
<form className="row chatform" onSubmit={submit}>
|
||||||
<select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}>
|
<select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}>
|
||||||
<option value="">Everyone</option>
|
<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>)}
|
{people.filter((p) => p !== me).map((p) => <option key={p} value={p}>Only {p} (whisper)</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input value={text} onChange={(e) => setText(e.target.value)} placeholder={to ? `Whisper to ${to} …` : 'Message …'} maxLength={500} aria-label="Message" autoComplete="off" />
|
<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']]
|
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 (
|
return (
|
||||||
<div className="dicebar" role="toolbar" aria-label="Dice">
|
<div className="dicebar" role="toolbar" aria-label="Dice">
|
||||||
<label className="small muted dicecount">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">
|
<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
|
<input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} style={{ width: 'auto' }} /> Own dice
|
||||||
</label>
|
</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 && (
|
{isDm && !to && (
|
||||||
<span className="seg small" role="group" aria-label="Who sees the DM's rolls">
|
<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>)}
|
{MODES.map(([k, label, tip]) => <button key={k} className={mode === k ? 'on' : 'ghost'} title={tip} onClick={() => setMode(k)}>{label}</button>)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -346,7 +424,7 @@ function DiceTray({ rolls }) {
|
|||||||
const who = <b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b>
|
const who = <b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b>
|
||||||
if (m.kind === 'check') {
|
if (m.kind === 'check') {
|
||||||
const c = m.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)
|
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' : ''}
|
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 }) {
|
function CharBar({ lobby, sel, onSel }) {
|
||||||
return (
|
return (
|
||||||
<div className="charbar" role="list">
|
<div className="charbar" role="list">
|
||||||
|
<div role="listitem" className={`pcard dmcard${lobby.dm_online ? '' : ' off'}`} title={`Dungeon master${lobby.dm_online ? ' (online)' : ' (offline)'}`}>
|
||||||
|
<span className="pavatar dm"><i className={`presence ${lobby.dm_online ? 'on' : ''}`} aria-label={lobby.dm_online ? 'online' : 'offline'} />DM</span>
|
||||||
|
<span className="pinfo"><b>{lobby.dm}{lobby.is_dm ? ' (you)' : ''}</b></span>
|
||||||
|
</div>
|
||||||
{lobby.members.map((m) => {
|
{lobby.members.map((m) => {
|
||||||
const c = m.character, can = !!m.character_id
|
const c = m.character, can = !!m.character_id
|
||||||
return (
|
return (
|
||||||
<div key={m.username} role="listitem" className={`pcard${sel === m.username ? ' sel' : ''}${can ? ' can' : ''}`} style={{ '--pc': m.color || 'var(--line)' }}
|
<div key={m.username} role="listitem" className={`pcard${sel === m.username ? ' sel' : ''}${can ? ' can' : ''}${m.online ? '' : ' off'}`} title={m.online ? `${m.username} is online` : `${m.username} is offline`} style={{ '--pc': m.color || 'var(--line)' }}
|
||||||
onClick={() => can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}>
|
onClick={() => can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}>
|
||||||
<span className="pavatar">{(c?.name || m.username).slice(0, 1).toUpperCase()}</span>
|
<span className="pavatar"><i className={`presence ${m.online ? 'on' : ''}`} aria-label={m.online ? 'online' : 'offline'} />{c?.portrait ? <img src={`/api/lobbies/${lobby.id}/portrait/${encodeURIComponent(m.username)}?v=${c.portrait}`} alt="" /> : (c?.name || m.username).slice(0, 1).toUpperCase()}</span>
|
||||||
<span className="pinfo">
|
<span className="pinfo">
|
||||||
<b>{c ? c.name : 'No character'}</b>
|
<b title={m.username}>{c ? c.name : 'No character'}</b>
|
||||||
<span className="muted small">{m.username}{m.me ? ' (you)' : ''}{c?.class ? ` · ${c.class}` : ''}</span>
|
|
||||||
</span>
|
</span>
|
||||||
{lobby.is_dm
|
{lobby.is_dm
|
||||||
? <input type="number" className="modin" key={`${m.username}:${m.mod}`} defaultValue={m.mod || 0} min={-100} max={100} title={`Bonus / malus for ${m.username}`} aria-label={`Modifier for ${m.username}`}
|
? <input type="number" className="modin" key={`${m.username}:${m.mod}`} defaultValue={m.mod || 0} min={-100} max={100} title={`Bonus / malus for ${m.username}`} aria-label={`Modifier for ${m.username}`}
|
||||||
@@ -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
|
// ---- the DM sets the difficulty for all checks, from the rules table or a custom number
|
||||||
function DifficultyBar({ lobby, onSet }) {
|
function DifficultyBar({ lobby, onSet }) {
|
||||||
const d = lobby.difficulty
|
const d = lobby.difficulty
|
||||||
@@ -410,33 +536,108 @@ function DifficultyBar({ lobby, onSet }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
|
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
|
||||||
function CharTab({ cid, rules, onRoll }) {
|
function CharTab({ cid, rules, onRoll, tick, editable }) {
|
||||||
const [data, setData] = useState(null)
|
const [data, setData] = useState(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
|
const typing = useRef(0) // time of the last keystroke: refreshes must not overwrite what is being typed
|
||||||
|
const timer = useRef(null)
|
||||||
|
const saveNotes = (patch) => {
|
||||||
|
typing.current = Date.now()
|
||||||
|
setData((d) => ({ ...d, ...patch }))
|
||||||
|
clearTimeout(timer.current)
|
||||||
|
timer.current = setTimeout(() => { send(`/api/characters/${cid}/notes`, 'PUT', patch).catch(() => setErr('Could not save the notes.')) }, 700)
|
||||||
|
}
|
||||||
|
useEffect(() => () => clearTimeout(timer.current), [])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setData(null); setErr('')
|
setData(null); setErr('')
|
||||||
const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then(setData).catch(() => setErr('Could not load this character.'))
|
const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then((d) => { if (Date.now() - typing.current > 4000) setData(d) }).catch(() => setErr('Could not load this character.'))
|
||||||
load()
|
load()
|
||||||
const t = setInterval(() => !document.hidden && load(), 15000)
|
const t = setInterval(() => !document.hidden && load(), 15000)
|
||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [cid])
|
}, [cid])
|
||||||
|
useEffect(() => { // pushed by the server when the player saved the character
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{err && <p className="warn">{err}</p>}
|
{err && <p className="warn">{err}</p>}
|
||||||
{data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} />}
|
{data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} onNotes={editable ? saveNotes : undefined} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DM: players in the same sight group see what anyone in the group sees
|
||||||
|
function VisionGroups({ lobby, id, act }) {
|
||||||
|
const set = (b) => act(`/api/lobbies/${id}/vision-groups`, 'PUT', b)
|
||||||
|
return (
|
||||||
|
<div className="card visiongroups">
|
||||||
|
<h4>Sight groups</h4>
|
||||||
|
<p className="small muted">Players in the same group share what they see (with line of sight on). “Alone” means only their own view.</p>
|
||||||
|
<div className="row"><button className="ghost" onClick={() => set({ preset: 'shared' })}>Everyone shares sight</button><button className="ghost" onClick={() => set({ preset: 'separate' })}>Everyone alone</button></div>
|
||||||
|
{lobby.members.map((m) => (
|
||||||
|
<label key={m.username} className="small row vgrow">{m.character?.name || m.username}
|
||||||
|
<select value={m.vgroup || 0} onChange={(e) => set({ groups: { [m.username]: Number(e.target.value) } })} aria-label={`Sight group of ${m.username}`}>
|
||||||
|
<option value={0}>Alone</option>{[1, 2, 3, 4].map((g) => <option key={g} value={g}>Group {'ABCD'[g - 1]}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// lobby settings: join code, rename, colour, character switch, members, leave / delete
|
// 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 [rename, setRename] = useState('')
|
||||||
const [pick, setPick] = useState('')
|
const [pick, setPick] = useState('')
|
||||||
const me = lobby.members.find((m) => m.me)
|
const me = lobby.members.find((m) => m.me)
|
||||||
const taken = new Set(lobby.members.filter((m) => !m.me).map((m) => m.color))
|
const taken = new Set(lobby.members.filter((m) => !m.me).map((m) => m.color))
|
||||||
return (
|
return (
|
||||||
<div className="settings card">
|
<div className={inline ? 'settings inline' : 'settings card'}>
|
||||||
<div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div>
|
{!inline && <div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div>}
|
||||||
{lobby.is_dm && (
|
{lobby.is_dm && (
|
||||||
<>
|
<>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
@@ -505,6 +706,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [mErr, setMErr] = useState('')
|
const [mErr, setMErr] = useState('')
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
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 [sheetOpen, setSheetOpen] = useState(() => store('aw_sheetopen') !== '0')
|
||||||
const setOwn = (v) => { setOwnState(v); store('aw_owndice', v ? '1' : '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)
|
if (!r.ok) throw new Error(r.status)
|
||||||
setLobby(await r.json())
|
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])
|
}).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(() => {
|
useEffect(() => {
|
||||||
load()
|
load(); loadMap()
|
||||||
const t = setInterval(() => !document.hidden && load(), 5000) // picks up joins, colours, difficulty and character changes
|
const t = setInterval(() => { if (!document.hidden) { load(); loadMap() } }, up ? 30000 : 5000) // joins, colours, difficulty: pushed, with a timer as safety net
|
||||||
return () => clearInterval(t)
|
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 onRoll = useCallback((m) => {
|
||||||
const key = `${m.id}`
|
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 }])
|
setTray((t) => [...t.slice(-2), { ...m, key }])
|
||||||
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
|
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
|
||||||
}, [])
|
}, [])
|
||||||
const feed = useFeed(id, load, onRoll)
|
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) => {
|
const act = async (url, method, body, done) => {
|
||||||
if (!url) return load()
|
if (!url) return load()
|
||||||
@@ -534,6 +778,52 @@ function LobbyView({ id, rules, user }) {
|
|||||||
done ? done(d) : load()
|
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 (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>
|
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 people = [lobby.dm, ...lobby.members.map((m) => m.username)]
|
||||||
const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open
|
const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open
|
||||||
const cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0]
|
const cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0]
|
||||||
|
const npcTok = lobby.is_dm && !viewAs ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc' && t.npc_id) : null
|
||||||
const dest = people.includes(to) ? to : ''
|
const dest = people.includes(to) ? to : ''
|
||||||
const canCheck = !!(me && me.character_id)
|
const canCheck = !!(me && me.character_id)
|
||||||
lobby.setMod = (u, v) => act(`/api/lobbies/${id}/members/${encodeURIComponent(u)}/mod`, 'PUT', { mod: parseInt(v, 10) || 0 })
|
lobby.setMod = (u, v) => act(`/api/lobbies/${id}/members/${encodeURIComponent(u)}/mod`, 'PUT', { mod: parseInt(v, 10) || 0 })
|
||||||
@@ -557,56 +848,174 @@ function LobbyView({ id, rules, user }) {
|
|||||||
if (own) return setPending({ type: 'dice', sides, count })
|
if (own) return setPending({ type: 'dice', sides, count })
|
||||||
post(`/api/lobbies/${id}/rolls`, { sides, count, to: dest, ...(lobby.is_dm ? { mode } : {}) })
|
post(`/api/lobbies/${id}/rolls`, { sides, count, to: dest, ...(lobby.is_dm ? { mode } : {}) })
|
||||||
}
|
}
|
||||||
const check = (kind, cid, name) => {
|
const check = (kind, cid, name, npcToken) => {
|
||||||
if (busy) return
|
if (busy) return
|
||||||
if (own) return setPending({ type: 'check', kind, id: cid, name })
|
if (own) return setPending({ type: 'check', kind, id: cid, name, npcToken })
|
||||||
post(`/api/lobbies/${id}/checks`, { kind, id: cid })
|
post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '', ...(npcToken ? { npc_token: npcToken } : {}) })
|
||||||
}
|
}
|
||||||
const submitManual = (vals) => {
|
const submitManual = (vals) => {
|
||||||
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0] }, () => setPending(null))
|
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0], to: dest === user ? user : '', ...(pending.npcToken ? { npc_token: pending.npcToken } : {}) }, () => setPending(null))
|
||||||
else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null))
|
else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page lobbypage">
|
<section className="page lobbypage">
|
||||||
<div className="lobbyhead">
|
|
||||||
<a className="small" href="#lobbies">← Lobbies</a>
|
|
||||||
<h1>{lobby.name}</h1>
|
|
||||||
<span className="muted small">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
|
|
||||||
<span className="tabright">
|
|
||||||
<button className="ghost" onClick={() => setShowSettings((v) => !v)} aria-expanded={showSettings}>⚙ Lobby</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{err && <p className="warn">{err}</p>}
|
{err && <p className="warn">{err}</p>}
|
||||||
{info && <p className="small">{info}</p>}
|
{info && <p className="small">{info}</p>}
|
||||||
|
|
||||||
<div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`}>
|
<div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`} style={{ '--sheetw': `${sheetW}px` }}>
|
||||||
{sheetOpen && (
|
{sheetOpen && (
|
||||||
<div className="sheetcol">
|
<div className="sheetcol">
|
||||||
<div className="sheetbar">
|
<div className="sheetbar">
|
||||||
<b>{cur ? cur.character.name : 'Character sheet'}</b>
|
{lobby.is_dm ? (
|
||||||
<span className="tabright"><ThemeSwitch /> <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide the sheet">◀</button></span>
|
<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>
|
</div>
|
||||||
{cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} onRoll={cur.me && canCheck ? check : undefined} />
|
{lobby.is_dm && leftTab === 'lobby'
|
||||||
: <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>}
|
? <><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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sheetOpen && <Splitter value={sheetW} onChange={setSheetW} min={320} max={Math.max(400, window.innerWidth - chatW - 360)} label="Resize the sheet" className="sheetsplit" />}
|
||||||
<div className="playarea">
|
<div className="playarea">
|
||||||
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
|
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
|
||||||
<CharBar lobby={lobby} sel={cur?.username} onSel={setSel} />
|
<div className="topbar">
|
||||||
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
|
<div className="topctl" role="group" aria-label="Fight controls">
|
||||||
|
{lobby.is_dm && !lobby.combat && <button className="ghost mini wide" onClick={() => act(`/api/lobbies/${id}/combat`, 'POST')} title="Start a fight">⚔</button>}
|
||||||
|
{lobby.is_dm && lobby.combat && <>
|
||||||
|
<button className="ghost mini" onClick={() => combatOp({ op: 'prev' })} title="Previous turn" aria-label="Previous turn">◀</button>
|
||||||
|
<button className="on mini" onClick={() => combatOp({ op: 'next' })} title="Next turn" aria-label="Next turn">▶</button>
|
||||||
|
<button className="ghost mini danger" onClick={() => window.confirm('End the fight?') && act(`/api/lobbies/${id}/combat`, 'DELETE')} title="End the fight" aria-label="End the fight">■</button>
|
||||||
|
</>}
|
||||||
|
<button className={`mini ${topOpen ? 'on' : 'ghost'}`} onClick={toggleTop} aria-expanded={topOpen} title="Players, difficulty and details">{topOpen ? '▴' : '▾'}</button>
|
||||||
|
</div>
|
||||||
|
<div className="topscroll">
|
||||||
|
{lobby.combat
|
||||||
|
? <InitiativeCards lobby={lobby} tokens={mapState.tokens} onOp={combatOp}
|
||||||
|
onPick={(e) => { if (e.kind === 'player') { setSel(e.user); setMapSel(null); setLeftTab('sheet') } else if (lobby.is_dm) { setMapSel(e.token); setLeftTab('sheet') } }} />
|
||||||
|
: <CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setMapSel(null); setLeftTab('sheet') }} />}
|
||||||
|
</div>
|
||||||
|
<button className="ghost lobbycfg" title="Lobby settings" onClick={() => (lobby.is_dm ? (setSheetOpen(true), setLeftTab('lobby')) : setShowSettings((v) => !v))} aria-expanded={lobby.is_dm ? sheetOpen && leftTab === 'lobby' : showSettings}>⚙ {lobby.name}</button>
|
||||||
|
</div>
|
||||||
|
{topOpen && (
|
||||||
|
<div className="topmore">
|
||||||
|
{lobby.combat && <CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setMapSel(null); setLeftTab('sheet') }} />}
|
||||||
|
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="table">
|
<div className="table">
|
||||||
{showSettings && <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} />
|
<DiceTray rolls={tray} />
|
||||||
{canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>}
|
{canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>}
|
||||||
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
|
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
|
||||||
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
|
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<aside className="lobbychat card" aria-label="Chat">
|
<aside className="lobbychat card" aria-label="Chat">
|
||||||
|
<Splitter value={chatW} onChange={setChatW} min={240} max={Math.max(300, Math.min(900, window.innerWidth - 700))} dir={-1} label="Resize the chat" className="chatsplit" />
|
||||||
<Chat id={id} me={user} people={people} msgs={feed.msgs} poll={feed.poll} to={dest} setTo={setTo} />
|
<Chat id={id} me={user} people={people} msgs={feed.msgs} poll={feed.poll} to={dest} setTo={setTo} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -31,6 +31,8 @@ export function printSheet(root, title) {
|
|||||||
const win = window.open('', '_blank')
|
const win = window.open('', '_blank')
|
||||||
if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return }
|
if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return }
|
||||||
const clone = root.cloneNode(true)
|
const clone = root.cloneNode(true)
|
||||||
|
const src = root.querySelectorAll('textarea'), dst = clone.querySelectorAll('textarea')
|
||||||
|
dst.forEach((t, i) => { t.textContent = src[i].value }) // typed text does not survive cloning on its own
|
||||||
clone.style.zoom = ''
|
clone.style.zoom = ''
|
||||||
clone.classList.remove('dark') // printed pages are always light
|
clone.classList.remove('dark') // printed pages are always light
|
||||||
const head = [...document.querySelectorAll('link[rel=stylesheet], style')].map((n) => n.outerHTML).join('')
|
const head = [...document.querySelectorAll('link[rel=stylesheet], style')].map((n) => n.outerHTML).join('')
|
||||||
@@ -47,6 +49,17 @@ export function printSheet(root, title) {
|
|||||||
links.forEach((l) => { l.addEventListener('load', () => { if (--pending === 0) setTimeout(go, 150) }); l.addEventListener('error', () => { if (--pending === 0) go() }) })
|
links.forEach((l) => { l.addEventListener('load', () => { if (--pending === 0) setTimeout(go, 150) }); l.addEventListener('error', () => { if (--pending === 0) go() }) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ruled note area. With onChange (own sheet in the summary or the lobby) it can be typed into, otherwise it just shows the text.
|
||||||
|
function Notes({ title, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="psec pnotes"><h3>{title}</h3>
|
||||||
|
{onChange
|
||||||
|
? <textarea className="plines pwrite" value={value || ''} maxLength={5000} onChange={(e) => onChange(e.target.value)} placeholder="Click here to write …" aria-label={title} />
|
||||||
|
: <div className="plines pwrite">{value}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Scales the A4 pages down when the space is narrower than the paper
|
// Scales the A4 pages down when the space is narrower than the paper
|
||||||
function Fit({ children, rootRef, dark }) {
|
function Fit({ children, rootRef, dark }) {
|
||||||
const wrap = useRef(null)
|
const wrap = useRef(null)
|
||||||
@@ -68,7 +81,7 @@ const Pips = ({ n, locked }) => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes).
|
// The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes).
|
||||||
export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
|
export default function PaperSheet({ data, baseRules, tools, bare, onRoll, onNotes }) {
|
||||||
const ch = useMemo(() => normalize(data, baseRules), [data, baseRules])
|
const ch = useMemo(() => normalize(data, baseRules), [data, baseRules])
|
||||||
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
|
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
|
||||||
const c = useMemo(() => compute(ch, rules), [ch, rules])
|
const c = useMemo(() => compute(ch, rules), [ch, rules])
|
||||||
@@ -108,17 +121,26 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
|
|||||||
)}
|
)}
|
||||||
<Fit rootRef={root} dark={dark}>
|
<Fit rootRef={root} dark={dark}>
|
||||||
<section className="paper">
|
<section className="paper">
|
||||||
<header className="phead">
|
{(() => {
|
||||||
<div className="pname">{name}</div>
|
const head = (
|
||||||
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
|
<header className="phead">
|
||||||
</header>
|
<div className="pname">{name}</div>
|
||||||
<div className="pfields">
|
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
|
||||||
<Field label="Player">{ch.player}</Field>
|
</header>
|
||||||
<Field label="Concept">{ch.concept}</Field>
|
)
|
||||||
<Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field>
|
const fields = (
|
||||||
<Field label="Profession">{prof?.name}</Field>
|
<div className="pfields">
|
||||||
<Field label="Rules">{preset}</Field>
|
<Field label="Player">{ch.player}</Field>
|
||||||
</div>
|
<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">
|
<div className="pattrs">
|
||||||
{rules.attributes.map((a) => (
|
{rules.attributes.map((a) => (
|
||||||
@@ -157,7 +179,14 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="psec pnotes"><h3>Conditions & 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 & notes" value={ch.conditions} onChange={onNotes && ((v) => onNotes({ conditions: v }))} />
|
||||||
<Foot n={1} />
|
<Foot n={1} />
|
||||||
</section>
|
</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>}
|
{ch.background && <div className="psec"><h3>Background</h3><p className="pbg">{ch.background}</p></div>}
|
||||||
|
|
||||||
<div className="psec pnotes"><h3>Notes</h3><div className="plines" /></div>
|
<Notes title="Notes" value={ch.notes} onChange={onNotes && ((v) => onNotes({ notes: v }))} />
|
||||||
<Foot n={2} />
|
<Foot n={2} />
|
||||||
</section>
|
</section>
|
||||||
</Fit>
|
</Fit>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ export default function Presets({ rules, reload }) {
|
|||||||
const [sel, setSel] = useState(rules.default_preset || 'standard')
|
const [sel, setSel] = useState(rules.default_preset || 'standard')
|
||||||
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
|
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
const presets = rules.presets || []
|
const presets = (rules.presets || []).filter((p) => !p.npc)
|
||||||
const current = draft || presets.find((p) => p.id === sel) || presets[0]
|
const current = draft || presets.find((p) => p.id === sel) || presets[0]
|
||||||
const editable = !!draft
|
const editable = !!draft
|
||||||
const own = !!current?.mine
|
const own = !!current?.mine
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ export function emptyCharacter(rules) {
|
|||||||
const attributes = {}
|
const attributes = {}
|
||||||
rules.attributes.forEach((a) => (attributes[a.id] = rules.creation.attr_start))
|
rules.attributes.forEach((a) => (attributes[a.id] = rules.creation.attr_start))
|
||||||
return {
|
return {
|
||||||
name: '', player: '', concept: '', background: '', class_id: '', subclass_id: '',
|
name: '', player: '', concept: '', background: '', portrait: '', class_id: '', subclass_id: '',
|
||||||
attributes,
|
attributes,
|
||||||
profession: '', profession_bonus_attr: '', profession_picks: [],
|
profession: '', profession_bonus_attr: '', profession_picks: [],
|
||||||
preset: rules.default_preset || 'standard', creation: {}, // creation = overrides of rules.creation for this character
|
preset: rules.default_preset || 'standard', creation: {}, // creation = overrides of rules.creation for this character
|
||||||
@@ -14,7 +14,7 @@ export function emptyCharacter(rules) {
|
|||||||
talents: [], augments: [],
|
talents: [], augments: [],
|
||||||
kit: { extras: {} }, // item id -> quantity
|
kit: { extras: {} }, // item id -> quantity
|
||||||
gear: { other: '' },
|
gear: { other: '' },
|
||||||
notes: '',
|
notes: '', conditions: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +124,9 @@ export function compute(ch, rules) {
|
|||||||
const pips = profPip + freePips
|
const pips = profPip + freePips
|
||||||
const prof = [0, cr.proficiency_bonus, cr.expertise_bonus][pips]
|
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 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)
|
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)
|
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
|
free: s.type === 'advanced' && profPip > 0, // unlocked by the profession, costs nothing
|
||||||
max: Math.floor(best / cr.skill_max_divisor) }
|
max: Math.floor(best / cr.skill_max_divisor) }
|
||||||
@@ -181,7 +182,7 @@ export function setProfessionPicks(ch, rules, picks) {
|
|||||||
export function resetStep(ch, rules, step) {
|
export function resetStep(ch, rules, step) {
|
||||||
const e = emptyCharacter(rules)
|
const e = emptyCharacter(rules)
|
||||||
switch (step) {
|
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 1: return { ...ch, attributes: e.attributes }
|
||||||
case 2: return pruneLocked({ ...ch, proficiencies: stripProfessionProfs(ch, rules), profession: '', profession_bonus_attr: '', profession_picks: [] }, rules)
|
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: {} }
|
case 3: return { ...ch, skill_points: {}, unlocked: [], proficiency_picks: {} }
|
||||||
|
|||||||
@@ -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 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 training = s.pips === 2 ? `Expertise +${s.prof}` : s.pips === 1 ? `Proficient +${s.prof}` : 'no training +0'
|
||||||
const lines = [
|
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}`,
|
`Most points you can put in: best attribute ÷ ${cr.skill_max_divisor}, rounded down = ${s.max}`,
|
||||||
]
|
]
|
||||||
if (s.type === 'advanced') {
|
if (s.type === 'advanced') {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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 { 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; }
|
.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; } }
|
@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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user