Compare commits
38
Commits
2ae64420d9
...
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 | ||
|
|
3e67aba75c | ||
|
|
890cbfa993 | ||
|
|
59df681cfa | ||
|
|
7e69b68b37 | ||
|
|
d730022447 | ||
|
|
968d168ff1 | ||
|
|
8711b7fe0e | ||
|
|
69e53c1520 | ||
|
|
a88b8d2fa3 | ||
|
|
94d3505b82 | ||
|
|
aad8e0515b | ||
|
|
e8584e043d |
@@ -3,3 +3,7 @@
|
||||
AW_REGISTRATION_CODE=
|
||||
# 1 when running behind a reverse proxy that sets X-Forwarded-For / X-Forwarded-Proto / X-Forwarded-Host:
|
||||
AW_TRUST_PROXY=0
|
||||
# Port on the host (bound to 127.0.0.1 only). nginx proxies to it. Pick a free one (check: ss -tlnp | grep :8090):
|
||||
AW_PORT=8090
|
||||
# Who may use the rules editor (comma separated usernames). Empty = only the first account that was ever created:
|
||||
AW_ADMINS=
|
||||
|
||||
@@ -48,10 +48,10 @@ cp .env.example .env
|
||||
nano .env # set AW_REGISTRATION_CODE, and AW_TRUST_PROXY=1 (nginx is in front)
|
||||
chmod 600 .env
|
||||
docker compose up -d --build
|
||||
curl -sI http://127.0.0.1:8080 | 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:8080`, so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`.
|
||||
The container only listens on `127.0.0.1:8090` (change it with `AW_PORT` in `.env` if the port is taken, e.g. AMP uses 8080), so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`; changes from the rules editor are in `./db/rules_overrides.json` (back up the whole `db` folder). Set `AW_ADMINS` in `.env` to choose who may use the editor (default: the first account).
|
||||
|
||||
## 4. nginx + HTTPS
|
||||
|
||||
@@ -62,18 +62,25 @@ server {
|
||||
listen 80;
|
||||
server_name aw.yourdomain.tld;
|
||||
|
||||
client_max_body_size 1m;
|
||||
client_max_body_size 13m; # map uploads (the app itself limits them to 12 MB and keeps characters small)
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_pass http://127.0.0.1:8090; # same as AW_PORT in .env
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, so clients cannot fake their address
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
# the lobby uses a WebSocket for live updates (without these three lines it falls back to polling)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Already running an older config? Add the last four lines inside `location /` and change `client_max_body_size` to `13m` (needed for map uploads, otherwise nginx answers 413), then `sudo nginx -t && sudo systemctl reload nginx`. Nothing else changes; the lobby still works without them, just with a few seconds of delay.
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/aw /etc/nginx/sites-enabled/aw
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
@@ -82,7 +89,7 @@ sudo certbot --nginx -d aw.yourdomain.tld # gets the certificate, adds
|
||||
|
||||
Then add inside the HTTPS `server` block: `add_header Strict-Transport-Security "max-age=31536000" always;`
|
||||
|
||||
Firewall: only SSH, 80 and 443 should be open. Port 8080 is not published to the outside at all.
|
||||
Firewall: only SSH, 80 and 443 should be open. The app port (AW_PORT, default 8090) is not published to the outside at all.
|
||||
|
||||
Open the site, choose "Create account" and register with your registration code.
|
||||
|
||||
@@ -118,3 +125,13 @@ docker compose down # stop
|
||||
```
|
||||
|
||||
If `docker` says it cannot connect to the daemon, check the two `export` lines from step 1 and `systemctl --user status docker`.
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
cd ~/AWCharacterCreator
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The `db` folder (database and rules editor changes) is kept. New settings from `.env.example` have to be added to your `.env` by hand.
|
||||
|
||||
@@ -12,7 +12,7 @@ Website for creating characters for the Arcane Wasteland TTRPG, with a searchabl
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Then open `http://localhost:8080` (the port is bound to the machine itself; see **DEPLOY.md** for running it on a server with its own user, nginx and HTTPS). Users, characters and presets are stored in `./db/aw.db`.
|
||||
Then open `http://localhost:8090` (the port is bound to the machine itself; see **DEPLOY.md** for running it on a server with its own user, nginx and HTTPS). Users, characters and presets are stored in `./db/aw.db`.
|
||||
|
||||
### Login and accounts
|
||||
|
||||
@@ -52,6 +52,7 @@ In the creator, the Concept page only has a "Rules preset" selector. Presets are
|
||||
|
||||
### Skills, proficiency and expertise
|
||||
|
||||
- `creation.advanced_attr_percent`: share of the attribute that counts for **advanced skills** (default 50, rounded down; 100 = full attribute like basic skills).
|
||||
- `creation.advanced_unlock_cost`: pool points needed to unlock an advanced skill (free if the character's profession grants proficiency in it; proficiency picks do not unlock a skill).
|
||||
- `creation.free_proficiencies`: free proficiency picks every character gets. Talents can add more with `"proficiency_picks": N` (see "Versatile Training").
|
||||
- Proficiency is +`proficiency_bonus`; two picks on one skill (a profession proficiency counts as one) give Expertise, +`expertise_bonus`.
|
||||
@@ -81,6 +82,96 @@ While creating, the full character sheet stays visible next to the wizard. Edits
|
||||
|
||||
Each wizard page has a **Reset this page** button that clears only that page's data, plus **Start over** for the whole character.
|
||||
|
||||
### Character sheet (A4)
|
||||
|
||||
The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits above the sheet) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout.
|
||||
|
||||
### Character portrait
|
||||
|
||||
On the **Concept** page *Add picture* takes a PNG, JPEG or WebP: move and zoom it inside the square window, *Use this* stores a small 256 px picture inside the character (so it is saved, shared, exported as JSON and loaded with it). The server checks and re-encodes it. The picture appears in the header of the character sheet (also when printing), as icon in the player bar of the lobby and as the token of the character on the map (in the player's colour ring). Only members of the lobby get the picture through `/api/lobbies/{id}/portrait/{username}`. The PDF / Word export from the server does not contain it yet.
|
||||
|
||||
The portrait sits next to the name and identity fields on page 1 of the sheet. The two ruled note areas ("Conditions & notes" on page 1, "Notes" on page 2) can be typed into on the Summary page and, for your own character, in the lobby (saved automatically through `PUT /api/characters/{id}/notes`, which only touches those two fields). They are included in print, PDF and Word.
|
||||
|
||||
### Rules editor
|
||||
|
||||
Admins get an **Editor** tab to add, change and delete **items, item categories, skills, skill groups and professions** with forms. Admins are the usernames in `AW_ADMINS` (comma separated, set in `.env`); without that setting only the first account ever created is admin. Admins can also promote other users: the **Users and admins** card above the tabs lists all accounts with *Make admin* / *Remove admin*. Names in `AW_ADMINS` stay admin and can only be changed in `.env`; the last admin cannot be removed.
|
||||
|
||||
- Changes are saved in `db/rules_overrides.json` (next to the database, so it is part of your backup). For each edited list it **replaces** the list from `rules.json`; the shipped file is not touched. A previous copy is kept as `rules_overrides.json.bak`. "Reset to shipped" removes the override of one list.
|
||||
- New entries get their id from the name (lowercase, `_`). Ids of existing entries cannot be changed, because characters refer to them.
|
||||
- The server checks every reference: an item needs an existing category, a skill a group and attributes, a profession existing skills. Removing a category, group or skill that is still used is refused. Removing an item, skill or profession that saved characters use asks for confirmation first (their data stays, the entry disappears from the sheets).
|
||||
- Talents, mutations, implants, classes and the rules text are still edited in `rules.json`. Players see changes after reloading the page.
|
||||
|
||||
### Lobbies
|
||||
|
||||
The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave).
|
||||
|
||||
The lobby page is built for desktop with three columns: the **character sheet** on the left (collapsible with the arrow), the **Player Area** in the middle and the **chat** at the right edge of the window.
|
||||
|
||||
- **Chat** (right): live chat for everyone, plus **whispers** to a single person (dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes, can be hidden), dice rolls and skill checks. Names are shown in the player's colour. Everything in the lobby (chat, dice, difficulty, colours, sheets) updates live over a **WebSocket** (`/api/lobbies/{id}/ws`); it only sends a short "something changed" note and the data is fetched through the normal API, so the visibility rules (whispers, hidden numbers) stay in one place. If the socket is blocked or drops, the page reconnects and meanwhile polls every few seconds. The chat keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages/rolls per minute.
|
||||
- **Character bar** (top of the Player Area): one card per player with the character, class and the player's colour. The DM clicks a card to show that character's sheet on the left (always the current saved version, refreshed every 15 s) and can enter a personal **bonus / malus** per character in the small number field. Players see their own sheet only and just a short card of the others. (The bar is also meant for initiative and enemies later.)
|
||||
- **Skill and attribute checks**: players click an attribute or an unlocked skill on their own sheet. The server rolls a d100 (roll under): target = attribute or skill total + the DM's difficulty + the character's personal modifier. 01-05 is always a critical success, 96-100 always a critical failure. The chat and the table show the roll, the target and by how much it succeeded or failed. Combat is not handled specially: roll the normal skill and let the DM adjust with the modifiers.
|
||||
- **Difficulty** (bar under the characters): the DM picks a level from the rules table (Trivial +60 ... Hellish -60) or a custom number. It applies to all checks until changed. With **Hide numbers from players** the players only see their roll and success / failure, not the target, difficulty or margin.
|
||||
- **Dice** (below the table): d4 ... d100 with a count (1 to 10 dice). The server rolls, the dice tumble into the middle of the table in the colour of whoever threw them, and the result goes into the chat. With a person selected as recipient in the chat dropdown it is a private roll. **DM rolls** have three modes: *Mystery* (default: players see grey dice with "?" but not the result), *Hidden* (only the DM sees it) and *Public*.
|
||||
- **Sound**: rolling plays `frontend/public/dice.mp3` (the default dice sound) with a slight random pitch; every die has its own base pitch, from a little higher and lighter (d4) to a little lower and heavier (d100). Many dice layer the sound up to three times. Checks add a short cue after the dice: built-in tones for success and failure, and for a critical success or failure either a built-in tone or a sound the DM uploaded. The speaker button next to the dice mutes everything for yourself, the slider sets the volume (remembered per browser). Browsers only allow sound after the page was clicked once, and nothing plays while the tab is in the background.
|
||||
- **DM sound settings** (*Lobby actions* -> *Sounds*): replace the dice sound with an own MP3 (up to 1.5 MB, per lobby) or reset it to the default, upload sounds for *Critical success* and *Critical failure* (reset = built-in tone), and switch the critical sounds and the short success / failure tones on or off for the whole lobby. *Test* plays a sound locally.
|
||||
- **Own dice**: with this switch on, a click on a die or a skill asks for the result of your real dice instead of rolling. The server only checks the range and marks the roll as "own dice" in the chat.
|
||||
- **Colours**: every player has a different colour (lobby menu -> Your colour); the DM is gold.
|
||||
- **Lobby menu** (top right): join code, rename, remove players, colour, switch character, leave or delete the lobby.
|
||||
|
||||
### NPCs, enemies and creatures
|
||||
|
||||
The **NPCs** tab is a library of rules-based NPCs. It uses the same wizard as the character creator (attributes, skills, talents, mutations, equipment, portrait), with a free "NPC / creature" preset that has large pools and no profession requirement, plus a type (NPC, enemy, monster, creature, animal, robot), a default token size, a description, special abilities and attacks, and loot.
|
||||
|
||||
- NPCs are stored like characters (flag `npc`) and are listed separately; they can be duplicated and shared with other DMs (read-only).
|
||||
- In the map editor and at the table, **+ From NPC library** / **+ NPC** places a token that takes the NPC's name, size and portrait. Plain **+ Token** still works.
|
||||
- The DM clicks an NPC token to see its sheet in the left column and can click attributes and skills to roll checks for it (shown in the chat as "NPC: Skill"). Players never see the NPC link or sheet.
|
||||
|
||||
Players and the DM show a green dot in the lobby while they have it open (online presence over the WebSocket; without WebSockets everyone appears offline).
|
||||
|
||||
### Initiative and fights
|
||||
|
||||
The DM presses **⚔ Start fight** in the lobby. Every player with a character joins automatically and rolls initiative (1d20 + the character's Initiative value, as in the rules). **Right click** a token (DM, full view) for a radial menu: add it to the fight, open its sheet, hide/reveal or remove an NPC token. Players who join later can be added with the **+ name** buttons in the initiative bar.
|
||||
|
||||
The bar shows the order, the round and whose turn it is (the token pulses on the map). The DM can overwrite every number, reroll, remove entries, step **◀ / Next turn ▶** and **End fight**. Hidden NPCs are not shown to players. Fight start, joins and the end are logged in the chat feed.
|
||||
|
||||
The top of the lobby is one compact row: small buttons on the left (start fight, previous/next turn, end fight, ▾ to expand players and difficulty), the player bar – replaced by a scrollable initiative bar during a fight – and the lobby settings button. Players only see the name of whoever's turn it is; NPC names are not sent to players otherwise, and token names show on hover, selection or turn. The sheet and the chat can be resized by dragging the thin lines next to them (remembered per browser).
|
||||
|
||||
### HP, conditions and fog of war
|
||||
|
||||
- **HP and conditions:** select a token (DM, full view) to edit its HP (−5/−1/+1/+5, max) and conditions from the rules (click raises the level 1 → 3 → off). NPC tokens from the library start with the NPC's Health value. Right click the token and use the **HP** and **Cond.** toggles of the radial menu to decide when players may see them; by default players see their own and other players' values but not NPCs'.
|
||||
- **Fog of war:** with line of sight on, ground a player has already seen stays visible as a dimmed memory (**Fog** button; **↺** forgets it for everybody). The memory is a small mask per player and lobby, the dim picture is made on the server, so players never get pixels they have not seen.
|
||||
- **Sight range:** the map's value is the default (map editor); the number next to the Sight button changes it live for the lobby (reset when you switch maps).
|
||||
|
||||
### Sight groups and revealing far away areas
|
||||
|
||||
- **Sight groups** (Lobby actions, DM): players in the same group (A–D) share what they see; "Everyone shares sight" / "Everyone alone" are shortcuts. Tokens and ground seen by one member are visible to the whole group.
|
||||
- **👁 Reveal** (DM, line of sight on): click the spot the view starts from, then click where it ends (distance = range, direction = where it looks), pick the cone width and who sees it (everyone or chosen players), then **Reveal**. Walls block it like any other view. The area stays visible until you press **Hide**; afterwards it remains as dimmed memory (fog of war). Everything is computed on the server, so players only receive the pixels you showed them.
|
||||
|
||||
### Maps, tokens and line of sight
|
||||
|
||||
- **Maps tab** (top navigation): all your maps with preview. Upload PNG, JPEG or WebP (up to 12 MB, 30 maps each; the server checks and re-encodes every image as WebP, max 4096 px per side, into `db/maps/`). **Edit** opens the map editor, **Share** gives other users access by username (like characters: they can put the map on their own table or **Copy** it, but not change it), **Duplicate** copies a map.
|
||||
- **Map editor**: grid (pixels per square, token sizes are counted in squares), **enemies and NPCs** placed in advance (name, size, colour, *Hidden from players*, duplicate), and **walls and doors** for line of sight: tools *Wall* (click points, chains lines), *Room* (two corners), *Door* (two ends), *Erase*, *Undo last*; new lines snap to existing corners, optionally to grid corners. An optional **sight range** in squares limits how far characters see (0 = unlimited).
|
||||
- **Show on table** (lobby, DM, *Lobby actions*): shows one of your or a shared map; the prepared tokens are copied into the lobby, so moving them during play never changes the map. Doors start closed. Every player has a token of their own. Without walls the players drag it onto the map themselves; with walls (line of sight) the DM places it from the tray.
|
||||
- **At the table**: pan (drag the background), zoom (wheel, pinch, buttons) and *Grid* are personal. Players move only their own token; the DM moves everything, edits NPC tokens (click one), adds NPCs (*+ NPC*) and clicks doors to open or close them (*Walls* shows the layout in the full view).
|
||||
- **Line of sight is strict**: the server calculates what each character can see from the walls and closed doors, cuts the map image to that area (everything else is black, only that piece is ever sent) and leaves out all other tokens. Walls are never sent to players. The DM chooses **Full view** or **View as <player>** (exactly what that player gets) and can switch **Sight on/off** for the table.
|
||||
|
||||
Players only see a short card of the other players, never their sheets. Guessing join codes is rate limited. Deleting a character takes it out of the lobbies (the player can pick another); deleting a lobby does not delete characters.
|
||||
|
||||
### Scenes and props
|
||||
|
||||
- **Scenes**: every map you put on the table becomes a *scene* of the lobby. Switching to another map (or back, in *Lobby actions → Scenes*) first saves the current one - token positions, HP and conditions, doors, explored ground, revealed areas, sight settings and the running fight - and restores the other exactly as you left it. State also survives pauses, since the table lives in the database. Per lobby up to 12 scenes; rename, delete, *Save now* and *Save a copy of the current table* (checkpoint) are available. Only one scene runs at a time (no simultaneous split party).
|
||||
- **Edit live**: the map editor can be opened for the running map. Walls, grid and sight range apply to the game at once; the prepared tokens are only used the next time the map is put on the table.
|
||||
- **Visibility in the editor**: the 👁 button beside each prepared token toggles "hidden from players".
|
||||
- **Props**: decor tokens (chest, barrel, table, chair, bed, door, campfire, tree, rug, stairs, ... 19 built-in drawings, plus your own PNG/WebP uploads up to 3 MB, 40 per DM). Add them in the map editor (*+ Prop*) or on the table (*+ Prop*); drag like tokens, size width/height in squares, rotate by slider, 45° or 90° steps (also in the right click menu), hide from players. They are drawn below tokens; players cannot move them.
|
||||
|
||||
- **Prop library**: the *Maps* page lists all props (built-in and your own) and lets you upload, rename and remove your own ones.
|
||||
- **DM notes**: NPC tokens and props have a "Notes (only you see these)" field (map editor and table; e.g. the contents of a chest). On the table click the token to edit; a 📝 marks tokens with notes and the text shows as tooltip. Players never receive notes.
|
||||
- **Fog without walls**: sight range and fog also work on maps without walls (circle around the token). Sight needs walls or a range above 0; the explored memory is now stored at up to 1024 px.
|
||||
|
||||
- **Map ping**: everybody (players and DM) can point at a spot: double-click the map, or press *📍 Ping* and click. All see a ring in the pinger's colour with their name, and hear a short ping (rate limited to one per second and person). The DM can switch the sound off or upload an MP3 for it under *Lobby actions → Sounds → Map ping*; players can still mute sounds for themselves.
|
||||
|
||||
- **Measure, areas, markers** (for everybody, buttons above the map): *📏 Measure* - click start and end, the distance in squares is shown to the whole table for a few seconds. *⬡ Area* - spell templates (circle, cone with adjustable angle, line with width, square) in the colour of whoever placed them, visible to all until their author or the DM removes them (list top left, DM can clear all); they disappear when the map changes. *📌 Marker* - pins with icon, title and note on the map. Markers are private to their author until "Show to everybody at the table" is ticked; the DM can remove shared markers. They belong to the map, so they are back when you return to that map.
|
||||
|
||||
## API
|
||||
|
||||
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
|
||||
@@ -90,10 +181,46 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
|
||||
| GET | `/api/auth/me` | Current user (or `null`) |
|
||||
| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts |
|
||||
| GET | `/api/rules` | Rule data including presets |
|
||||
| GET / POST | `/api/characters` | Own and shared-with-me characters / create one |
|
||||
| GET | `/api/characters/{id}` | Read (owner or shared users) |
|
||||
| GET / POST | `/api/characters[?npc=1]` | Own and shared-with-me characters (or the NPC library) / create one |
|
||||
| GET | `/api/characters/{id}/portrait` | Portrait image (readers of the character) |
|
||||
| POST / PUT / DELETE | `/api/lobbies/{id}/combat` | Start a fight / add, remove, set, reroll, next, prev / end (DM) |
|
||||
| GET | `/api/lobbies/{id}/token-portrait/{token}` | Portrait of an NPC token (lobby members) |
|
||||
| GET / PUT | `/api/admin/users[/{username}]` | List accounts / make or remove an admin (admins) |
|
||||
| GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) |
|
||||
| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) |
|
||||
| PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) |
|
||||
| GET / POST | `/api/lobbies` | Own and joined lobbies / create one (you are the DM) |
|
||||
| POST | `/api/lobbies/join` | Join with `{code, character_id}` |
|
||||
| GET / PUT / DELETE | `/api/lobbies/{id}` | Members and cards (DM sees all ids) / rename / delete (DM only) |
|
||||
| POST | `/api/lobbies/{id}/code` | New join code (DM only) |
|
||||
| GET / POST | `/api/maps` | Your maps and the ones shared with you / upload (raw image body, `?name=`) |
|
||||
| GET / PUT / DELETE | `/api/maps/{id}` | Map with tokens and walls / change (name, grid, sight, tokens, walls) / delete |
|
||||
| POST / DELETE | `/api/maps/{id}/shares[/{username}]` | Share with a user / stop sharing |
|
||||
| POST | `/api/maps/{id}/copy` | Your own editable copy |
|
||||
| GET | `/api/maps/{id}/thumb` | Preview image |
|
||||
| GET / PUT | `/api/lobbies/{id}/map` | Map and tokens as the viewer may see them (`?as=<player>` for the DM) / DM: show map (`map_id`, null clears) |
|
||||
| GET | `/api/lobbies/{id}/map/view` | The map image for this viewer, cut to their line of sight (`X-Crop` = x,y,w,h) |
|
||||
| PUT | `/api/lobbies/{id}/doors/{wall}` , `/sight` | DM: open / close a door; line of sight on / off |
|
||||
| GET / POST | `/api/lobbies/{id}/scenes` | DM: saved scenes / save a copy of the table (`name`) |
|
||||
| PUT / DELETE | `/api/lobbies/{id}/scenes/{sid}` | DM: rename (`name`), save now (`save`) / delete |
|
||||
| POST | `/api/lobbies/{id}/measure` | Share a measured distance (`x1,y1,x2,y2`) |
|
||||
| POST / DELETE | `/api/lobbies/{id}/templates[/{tid}]` | Place an area (`kind, x, y, dir, size, width`) / remove (author or DM; DELETE without id: DM clears all) |
|
||||
| POST / PUT / DELETE | `/api/lobbies/{id}/marks[/{kid}]` | Marker with note (`x, y, icon, title, note, shared`) / edit (author) / remove |
|
||||
| POST | `/api/lobbies/{id}/ping` | Ping a spot (`x`, `y`); everybody gets a push |
|
||||
| PUT | `/api/lobbies/{id}/scene` | DM: switch to a scene (`scene_id`); the current one is saved first |
|
||||
| GET / POST / DELETE | `/api/props[/{id}]` | Your own prop images / upload (raw body, `?name=`) / delete |
|
||||
| GET | `/api/props/{id}/image`, `/api/lobbies/{id}/prop-image/{pid}` | Prop image (owner / lobby members, DM's props) |
|
||||
| POST / PUT / DELETE | `/api/lobbies/{id}/tokens[/{tid}]` | Add NPC token (DM) / move (own token or DM) and edit (DM) / remove (DM) |
|
||||
| WebSocket | `/api/lobbies/{id}/ws` | Live notes `{t: messages / lobby / sheet / map}` for lobby members (cookie login, same origin) |
|
||||
| PUT | `/api/lobbies/{id}/sounds` | DM: switch result / critical sounds on or off |
|
||||
| GET / POST / DELETE | `/api/lobbies/{id}/sounds/{roll\|crit_success\|crit_failure}` | Custom lobby sound (MP3): everyone gets it / DM uploads (raw body) / DM resets |
|
||||
| GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper |
|
||||
| POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?, manual?, mode?}` (d4-d20, d100; up to 10 dice). `manual` = results of real dice, `mode` (DM only) = `hidden`/`mystery`/`public` |
|
||||
| POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character |
|
||||
| PUT | `/api/lobbies/{id}/difficulty` | `{label, mod, hidden}` (DM only) |
|
||||
| PUT | `/api/lobbies/{id}/members/{username}/mod` | Personal bonus / malus `{mod}` (DM only) |
|
||||
| PUT / DELETE | `/api/lobbies/{id}/me` | Switch your character and / or `{color}` / leave |
|
||||
| DELETE | `/api/lobbies/{id}/members/{username}` | Remove a player (DM only) |
|
||||
| POST | `/api/presets` | Create a preset |
|
||||
| PUT / DELETE | `/api/presets/{id}` | Change / delete (owner only) |
|
||||
| POST | `/api/export/pdf` or `/docx` | Character JSON in the body, file in the response |
|
||||
|
||||
@@ -33,8 +33,11 @@ def main(argv):
|
||||
return 1
|
||||
con.execute("DELETE FROM sessions WHERE user_id=?", (row["id"],))
|
||||
con.execute("DELETE FROM shares WHERE user_id=?", (row["id"],))
|
||||
con.execute("DELETE FROM lobby_members WHERE user_id=? OR lobby_id IN (SELECT id FROM lobbies WHERE owner_id=?)", (row["id"], row["id"]))
|
||||
con.execute("DELETE FROM lobby_messages WHERE lobby_id IN (SELECT id FROM lobbies WHERE owner_id=?)", (row["id"],))
|
||||
con.execute("DELETE FROM lobbies WHERE owner_id=?", (row["id"],))
|
||||
con.execute("DELETE FROM users WHERE id=?", (row["id"],))
|
||||
print("User deleted. Their characters and presets stay on the server without an owner.")
|
||||
print("User deleted. Their lobbies are removed; their characters and presets stay on the server without an owner.")
|
||||
else:
|
||||
print(__doc__)
|
||||
return 1
|
||||
|
||||
@@ -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 [])
|
||||
usable = s["type"] == "basic" or unlocked or prof_pip > 0 # advanced skills must be unlocked (a profession proficiency does it for free)
|
||||
best = max(attrs[a] for a in s["attrs"]) # with several attributes the higher one counts
|
||||
base = best * cr.get("advanced_attr_percent", 100) // 100 if s["type"] == "advanced" else best # advanced skills only use part of the attribute
|
||||
skills.append({**s, "points": pts, "prof": prof, "pips": pips, "usable": usable, "unlocked": unlocked,
|
||||
"total": best + pts + prof})
|
||||
"base": base, "total": base + pts + prof})
|
||||
|
||||
aug_by_id = {a["id"]: a for a in rules["augments"]}
|
||||
chosen = [aug_by_id[i] for i in ch.get("augments", []) if i in aug_by_id]
|
||||
|
||||
@@ -99,7 +99,7 @@ def to_docx(ch: dict, rules: dict) -> bytes:
|
||||
p = doc.add_paragraph()
|
||||
p.add_run("Other: ").bold = True
|
||||
p.add_run(s["other"])
|
||||
for key, head in (("background", "Background"), ("notes", "Notes")):
|
||||
for key, head in (("background", "Background"), ("conditions", "Conditions"), ("notes", "Notes")):
|
||||
if ch.get(key):
|
||||
doc.add_heading(head, 1)
|
||||
doc.add_paragraph(ch[key])
|
||||
@@ -171,7 +171,7 @@ def to_pdf(ch: dict, rules: dict) -> bytes:
|
||||
el.append(Paragraph("-", body))
|
||||
if s["other"]:
|
||||
el.append(Paragraph(f"<b>Other:</b> {esc(s['other'])}", body))
|
||||
for key, head in (("background", "Background"), ("notes", "Notes")):
|
||||
for key, head in (("background", "Background"), ("conditions", "Conditions"), ("notes", "Notes")):
|
||||
if ch.get(key):
|
||||
el += [Paragraph(head, h2), Paragraph(esc(ch[key]), body)]
|
||||
|
||||
|
||||
+818
-23
@@ -7,11 +7,19 @@ import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
import asyncio
|
||||
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import auth
|
||||
from .derive import compute
|
||||
from . import rules_edit
|
||||
from . import maps as maps_mod
|
||||
from . import sounds as sounds_mod
|
||||
from . import portrait as portrait_mod
|
||||
from . import combat as combat_mod
|
||||
from .realtime import hub
|
||||
from .export import to_docx, to_pdf
|
||||
|
||||
BASE = Path(__file__).resolve().parent.parent
|
||||
@@ -22,19 +30,53 @@ MAX_BODY = 200_000 # bytes per character
|
||||
MAX_PRESETS = 200
|
||||
MAX_CHARACTERS = 300 # per user
|
||||
MAX_SHARES = 30 # per character
|
||||
MAX_LOBBIES = 20 # per DM
|
||||
MAX_MEMBERS = 20 # per lobby
|
||||
MAX_MESSAGE = 500 # characters per chat message
|
||||
KEEP_MESSAGES = 1000 # per lobby, older ones are dropped
|
||||
PALETTE = [ # player colours (dice, cards, chat names); every player in a lobby has a different one
|
||||
("red", "#e0524d"), ("orange", "#ea8a3b"), ("yellow", "#e6c52e"), ("green", "#6fc24b"), ("teal", "#2fb8a6"),
|
||||
("blue", "#4f93e6"), ("purple", "#9d6fda"), ("pink", "#e870b1"), ("white", "#e9e6da"), ("brown", "#a8764f")]
|
||||
PALETTE_HEX = dict(PALETTE)
|
||||
DM_COLOR = "#d4af37"
|
||||
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I
|
||||
|
||||
# No public API docs: everything about the API stays behind the login
|
||||
app = FastAPI(title="Arcane Wasteland Character Creator", docs_url=None, redoc_url=None, openapi_url=None)
|
||||
limiter = auth.Limiter()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _attach_hub():
|
||||
hub.attach(asyncio.get_running_loop())
|
||||
TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse proxy that sets X-Forwarded-For / -Proto
|
||||
|
||||
|
||||
|
||||
def load_rules() -> dict:
|
||||
# Re-read on every request so rules.json can be edited without a restart.
|
||||
# Changes made in the rules editor live next to the database (writable), the shipped rules.json stays untouched.
|
||||
OVERRIDES_PATH = Path(DB_PATH).parent / "rules_overrides.json"
|
||||
|
||||
|
||||
def base_rules() -> dict:
|
||||
return json.loads(RULES_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def read_overrides() -> dict:
|
||||
try:
|
||||
return json.loads(OVERRIDES_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def load_rules() -> dict:
|
||||
# Re-read on every request so rules.json can be edited without a restart; editor changes replace whole sections.
|
||||
r = base_rules()
|
||||
for sec, items in read_overrides().items():
|
||||
if sec in rules_edit.SECTIONS:
|
||||
r[sec] = items
|
||||
return r
|
||||
|
||||
|
||||
def db() -> sqlite3.Connection:
|
||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
@@ -48,6 +90,27 @@ def db() -> sqlite3.Connection:
|
||||
auth.init_tables(con)
|
||||
con.execute("""CREATE TABLE IF NOT EXISTS shares (
|
||||
character_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (character_id, user_id))""")
|
||||
con.execute("""CREATE TABLE IF NOT EXISTS lobbies (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, owner_id INTEGER NOT NULL, code TEXT NOT NULL UNIQUE, created REAL NOT NULL)""")
|
||||
con.execute("""CREATE TABLE IF NOT EXISTS lobby_members (
|
||||
lobby_id TEXT NOT NULL, user_id INTEGER NOT NULL, character_id TEXT, joined REAL NOT NULL,
|
||||
PRIMARY KEY (lobby_id, user_id))""")
|
||||
con.execute("""CREATE TABLE IF NOT EXISTS lobby_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, lobby_id TEXT NOT NULL, user_id INTEGER, to_user_id INTEGER,
|
||||
kind TEXT NOT NULL, text TEXT NOT NULL, created REAL NOT NULL)""")
|
||||
con.execute("CREATE INDEX IF NOT EXISTS lobby_messages_lobby ON lobby_messages (lobby_id, id)")
|
||||
for table, col, ddl in (("lobbies", "diff_mod", "INTEGER NOT NULL DEFAULT 0"), ("lobbies", "diff_label", "TEXT NOT NULL DEFAULT 'Challenging'"),
|
||||
("lobbies", "diff_hidden", "INTEGER NOT NULL DEFAULT 0"), ("lobby_members", "color", "TEXT"),
|
||||
("lobby_members", "char_mod", "INTEGER NOT NULL DEFAULT 0")):
|
||||
if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
||||
con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
||||
if "is_admin" not in [r["name"] for r in con.execute("PRAGMA table_info(users)")]:
|
||||
con.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
|
||||
if "npc" not in [r["name"] for r in con.execute("PRAGMA table_info(characters)")]:
|
||||
con.execute("ALTER TABLE characters ADD COLUMN npc INTEGER NOT NULL DEFAULT 0")
|
||||
maps_mod.schema(con)
|
||||
sounds_mod.schema(con)
|
||||
combat_mod.schema(con)
|
||||
for table in ("characters", "presets"): # older databases: add the owner column
|
||||
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
||||
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
|
||||
@@ -122,13 +185,32 @@ async def security(request: Request, call_next):
|
||||
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||
resp.headers["X-Frame-Options"] = "DENY"
|
||||
resp.headers["Referrer-Policy"] = "same-origin"
|
||||
host = request.headers.get("x-forwarded-host", "") if TRUST_PROXY else ""
|
||||
host = re.sub(r"[^A-Za-z0-9.:\-\[\]]", "", host or request.headers.get("host", ""))
|
||||
resp.headers["Content-Security-Policy"] = ("default-src 'self'; style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; frame-ancestors 'none'")
|
||||
f"img-src 'self' data: blob:; connect-src 'self' ws://{host} wss://{host}; frame-ancestors 'none'")
|
||||
if request.url.path.startswith("/api/"):
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
|
||||
|
||||
def admin_ids(con) -> set:
|
||||
"""Admins: the names in AW_ADMINS, accounts an admin promoted, and (when there is neither) the very first account."""
|
||||
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
|
||||
ids = {r["id"] for r in con.execute("SELECT id, username FROM users") if r["username"].lower() in names}
|
||||
ids |= {r["id"] for r in con.execute("SELECT id FROM users WHERE is_admin=1")}
|
||||
if not ids:
|
||||
first = con.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
|
||||
if first:
|
||||
ids.add(first["id"])
|
||||
return ids
|
||||
|
||||
|
||||
def is_admin(user: dict) -> bool:
|
||||
with db() as con:
|
||||
return user["id"] in admin_ids(con)
|
||||
|
||||
|
||||
def current_user(request: Request) -> dict:
|
||||
with db() as con:
|
||||
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
|
||||
@@ -162,6 +244,8 @@ def check_password_rules(pw: str):
|
||||
def me(request: Request):
|
||||
with db() as con:
|
||||
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
|
||||
if user:
|
||||
user = {**user, "admin": is_admin(user)}
|
||||
return {"user": user, "registration_open": bool(auth.registration_code())}
|
||||
|
||||
|
||||
@@ -188,7 +272,8 @@ def register(request: Request, response: Response, body: dict = Body(...)):
|
||||
(username, auth.hash_password(password), time.time()))
|
||||
token = auth.create_session(con, cur.lastrowid)
|
||||
set_cookie(request, response, token)
|
||||
return {"user": {"id": cur.lastrowid, "username": username}}
|
||||
u = {"id": cur.lastrowid, "username": username}
|
||||
return {"user": {**u, "admin": is_admin(u)}}
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
@@ -210,7 +295,8 @@ def login(request: Request, response: Response, body: dict = Body(...)):
|
||||
token = auth.create_session(con, row["id"])
|
||||
limiter.reset(keys[1])
|
||||
set_cookie(request, response, token)
|
||||
return {"user": {"id": row["id"], "username": row["username"]}}
|
||||
u = {"id": row["id"], "username": row["username"]}
|
||||
return {"user": {**u, "admin": is_admin(u)}}
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
@@ -293,44 +379,79 @@ def _name(data: str) -> str:
|
||||
return json.loads(data).get("name") or "Nameless"
|
||||
|
||||
|
||||
def _entry(r, shares=None, owner=None) -> dict:
|
||||
ch = json.loads(r["data"])
|
||||
e = {"id": r["id"], "name": ch.get("name") or "Nameless", "updated": r["updated"], "portrait": portrait_mod.tag(ch.get("portrait"))}
|
||||
if r["npc"]:
|
||||
e["kind"] = str(ch.get("npc_kind") or "")
|
||||
e["size"] = ch.get("token_size", 1)
|
||||
e["hp"] = _health(r["data"])
|
||||
if shares is not None:
|
||||
e["shared_with"] = shares.get(r["id"], [])
|
||||
if owner is not None:
|
||||
e["owner"] = owner or "?"
|
||||
return e
|
||||
|
||||
|
||||
@api.get("/characters")
|
||||
def my_characters(user: dict = Depends(current_user)):
|
||||
def my_characters(npc: int = 0, user: dict = Depends(current_user)):
|
||||
"""Your characters and the ones shared with you. With ?npc=1 the NPC library (NPCs, enemies, creatures) instead."""
|
||||
flag = 1 if npc else 0
|
||||
with db() as con:
|
||||
rows = con.execute("SELECT id, data, updated FROM characters WHERE owner_id=? ORDER BY updated DESC",
|
||||
(user["id"],)).fetchall()
|
||||
rows = con.execute("SELECT id, data, updated, npc FROM characters WHERE owner_id=? AND npc=? ORDER BY updated DESC",
|
||||
(user["id"], flag)).fetchall()
|
||||
shares: dict = {}
|
||||
for r in con.execute("""SELECT s.character_id, u.username FROM shares s JOIN users u ON u.id = s.user_id
|
||||
JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? ORDER BY u.username""",
|
||||
(user["id"],)):
|
||||
JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? AND c.npc=? ORDER BY u.username""",
|
||||
(user["id"], flag)):
|
||||
shares.setdefault(r["character_id"], []).append(r["username"])
|
||||
shared = con.execute("""SELECT c.id, c.data, c.updated, o.username AS owner FROM shares s
|
||||
shared = con.execute("""SELECT c.id, c.data, c.updated, c.npc, o.username AS owner FROM shares s
|
||||
JOIN characters c ON c.id = s.character_id LEFT JOIN users o ON o.id = c.owner_id
|
||||
WHERE s.user_id=? ORDER BY c.updated DESC""", (user["id"],)).fetchall()
|
||||
return {"mine": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "shared_with": shares.get(r["id"], [])}
|
||||
for r in rows],
|
||||
"shared": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "owner": r["owner"] or "?"}
|
||||
for r in shared]}
|
||||
WHERE s.user_id=? AND c.npc=? ORDER BY c.updated DESC""", (user["id"], flag)).fetchall()
|
||||
return {"mine": [_entry(r, shares=shares) for r in rows], "shared": [_entry(r, owner=r["owner"]) for r in shared]}
|
||||
|
||||
|
||||
@api.post("/characters")
|
||||
def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
|
||||
portrait_mod.clean(ch)
|
||||
check_size(ch)
|
||||
with db() as con:
|
||||
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
|
||||
raise HTTPException(409, "Character limit reached")
|
||||
cid, now = uuid.uuid4().hex[:12], time.time()
|
||||
con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id) VALUES (?,?,?,?,?,?)",
|
||||
(cid, "", json.dumps(ch), now, now, user["id"]))
|
||||
con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id, npc) VALUES (?,?,?,?,?,?,?)",
|
||||
(cid, "", json.dumps(ch), now, now, user["id"], 1 if ch.get("npc") else 0))
|
||||
return {"id": cid}
|
||||
|
||||
|
||||
def _can_read_character(con, cid: str, user: dict) -> bool:
|
||||
"""The owner, users the character is shared with, the DM of a lobby it plays in, and the DM of a lobby with a token of this NPC."""
|
||||
row = con.execute("SELECT owner_id FROM characters WHERE id=?", (cid,)).fetchone()
|
||||
return bool(row) and bool(row["owner_id"] == user["id"] or con.execute(
|
||||
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone() or con.execute(
|
||||
"""SELECT 1 FROM lobby_members m JOIN lobbies l ON l.id = m.lobby_id
|
||||
WHERE m.character_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone() or con.execute(
|
||||
"""SELECT 1 FROM lobby_tokens t JOIN lobbies l ON l.id = t.lobby_id
|
||||
WHERE t.npc_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone())
|
||||
|
||||
|
||||
@api.get("/characters/{cid}/portrait")
|
||||
def character_portrait(cid: str, user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()
|
||||
allowed = _can_read_character(con, cid, user)
|
||||
img = portrait_mod.image(json.loads(row["data"]).get("portrait")) if row and allowed else None
|
||||
if not img:
|
||||
raise HTTPException(404, "No portrait")
|
||||
return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})
|
||||
|
||||
|
||||
@api.get("/characters/{cid}")
|
||||
def read_character(cid: str, user: dict = Depends(current_user)):
|
||||
"""Only the owner and the users the character is shared with may read it."""
|
||||
"""Readable by the owner, by users the character is shared with, and by the DM of a lobby it plays in."""
|
||||
with db() as con:
|
||||
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
|
||||
allowed = bool(row) and (row["owner_id"] == user["id"] or con.execute(
|
||||
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone())
|
||||
allowed = _can_read_character(con, cid, user)
|
||||
if not allowed:
|
||||
raise HTTPException(404, "Character not found")
|
||||
return json.loads(row["data"])
|
||||
@@ -363,10 +484,30 @@ def remove_share(cid: str, username: str, user: dict = Depends(current_user)):
|
||||
|
||||
@api.put("/characters/{cid}")
|
||||
def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(current_user)):
|
||||
portrait_mod.clean(ch)
|
||||
check_size(ch)
|
||||
with db() as con:
|
||||
_owned(con, "characters", cid, user)
|
||||
con.execute("UPDATE characters SET data=?, updated=?, npc=? WHERE id=?", (json.dumps(ch), time.time(), 1 if ch.get("npc") else 0, cid))
|
||||
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
|
||||
for lid in lobbies:
|
||||
hub.notify(lid, "sheet", cid=cid)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.put("/characters/{cid}/notes")
|
||||
def update_notes(cid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Only the two note fields, so typing in the lobby never overwrites other changes made elsewhere."""
|
||||
with db() as con:
|
||||
_owned(con, "characters", cid, user)
|
||||
ch = json.loads(con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()["data"])
|
||||
for k in ("notes", "conditions"):
|
||||
if k in body:
|
||||
ch[k] = str(body[k] or "")[:5000]
|
||||
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
|
||||
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
|
||||
for lid in lobbies:
|
||||
hub.notify(lid, "sheet", cid=cid)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -376,6 +517,485 @@ def delete_character(cid: str, user: dict = Depends(current_user)):
|
||||
_owned(con, "characters", cid, user)
|
||||
con.execute("DELETE FROM characters WHERE id=?", (cid,))
|
||||
con.execute("DELETE FROM shares WHERE character_id=?", (cid,))
|
||||
con.execute("UPDATE lobby_members SET character_id=NULL WHERE character_id=?", (cid,))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---- lobbies: the creator is the DM and sees every member's character; players only see a short card of the others
|
||||
|
||||
def _lobby_for(con, lid: str, user: dict):
|
||||
"""Returns the lobby row if the user is its DM or a member, otherwise 404 (hides that it exists)."""
|
||||
row = con.execute("""SELECT l.*, u.username AS dm FROM lobbies l JOIN users u ON u.id = l.owner_id WHERE l.id=?""",
|
||||
(lid,)).fetchone()
|
||||
if not row or (row["owner_id"] != user["id"] and not con.execute(
|
||||
"SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone()):
|
||||
raise HTTPException(404, "Lobby not found")
|
||||
return row
|
||||
|
||||
|
||||
def _dm_only(lobby, user: dict):
|
||||
if lobby["owner_id"] != user["id"]:
|
||||
raise HTTPException(403, "Only the DM can do this")
|
||||
|
||||
|
||||
def _new_code(con) -> str:
|
||||
while True:
|
||||
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(8))
|
||||
if not con.execute("SELECT 1 FROM lobbies WHERE code=?", (code,)).fetchone():
|
||||
return code
|
||||
|
||||
|
||||
def _own_character(con, cid, user: dict):
|
||||
row = con.execute("SELECT id FROM characters WHERE id=? AND owner_id=? AND npc=0", (cid, user["id"])).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(422, "Pick one of your own characters")
|
||||
|
||||
|
||||
def _free_color(con, lid: str):
|
||||
used = {r["color"] for r in con.execute("SELECT color FROM lobby_members WHERE lobby_id=?", (lid,))}
|
||||
return next((cid for cid, _ in PALETTE if cid not in used), PALETTE[0][0])
|
||||
|
||||
|
||||
def _ensure_colors(con, lid: str):
|
||||
"""Members that joined before colours existed get the next free one."""
|
||||
for r in con.execute("SELECT user_id FROM lobby_members WHERE lobby_id=? AND color IS NULL ORDER BY joined", (lid,)).fetchall():
|
||||
con.execute("UPDATE lobby_members SET color=? WHERE lobby_id=? AND user_id=?", (_free_color(con, lid), lid, r["user_id"]))
|
||||
|
||||
|
||||
def _levels(rules: dict) -> list:
|
||||
"""Difficulty levels from the rules text (section 'difficulty', first table): [{name, mod}]."""
|
||||
out = []
|
||||
for sec in rules.get("sections", []):
|
||||
if sec.get("id") != "difficulty":
|
||||
continue
|
||||
for block in sec.get("blocks", []):
|
||||
for row in (block.get("table") or {}).get("rows", []):
|
||||
try:
|
||||
out.append({"name": row[0], "mod": int(row[1].replace("\u2212", "-").replace("\u00b1", "").replace("+", "").strip() or 0)})
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _event(con, lid: str, text: str, uid=None):
|
||||
"""Writes a line into the lobby's event log (shown in the chat feed to everybody)."""
|
||||
con.execute("INSERT INTO lobby_messages (lobby_id, user_id, kind, text, created) VALUES (?,?, 'event', ?, ?)",
|
||||
(lid, uid, text, time.time()))
|
||||
hub.notify(lid, "messages")
|
||||
|
||||
|
||||
def _char_name(con, cid) -> str:
|
||||
row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()
|
||||
return _name(row["data"]) if row else "a character"
|
||||
|
||||
|
||||
def _card(data: str, rules: dict) -> dict:
|
||||
"""The superficial look at a character that fellow players get: name, concept and class/profession names only."""
|
||||
ch = json.loads(data)
|
||||
cls = next((c for c in rules["classes"] if c["id"] == ch.get("class_id")), None)
|
||||
sub = next((x for x in (cls or {}).get("subclasses", []) if x["id"] == ch.get("subclass_id")), 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],
|
||||
"class": (cls or {}).get("name", ""), "subclass": (sub or {}).get("name", ""),
|
||||
"profession": (prof or {}).get("name", ""), "portrait": portrait_mod.tag(ch.get("portrait"))}
|
||||
|
||||
|
||||
@api.get("/lobbies")
|
||||
def my_lobbies(user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
mine = con.execute("""SELECT l.id, l.name, (SELECT COUNT(*) FROM lobby_members m WHERE m.lobby_id = l.id) AS n
|
||||
FROM lobbies l WHERE l.owner_id=? ORDER BY l.created DESC""", (user["id"],)).fetchall()
|
||||
joined = con.execute("""SELECT l.id, l.name, u.username AS dm, c.data FROM lobby_members m
|
||||
JOIN lobbies l ON l.id = m.lobby_id JOIN users u ON u.id = l.owner_id
|
||||
LEFT JOIN characters c ON c.id = m.character_id
|
||||
WHERE m.user_id=? ORDER BY m.joined DESC""", (user["id"],)).fetchall()
|
||||
return {"mine": [{"id": r["id"], "name": r["name"], "members": r["n"]} for r in mine],
|
||||
"joined": [{"id": r["id"], "name": r["name"], "dm": r["dm"], "character": _name(r["data"]) if r["data"] else ""}
|
||||
for r in joined]}
|
||||
|
||||
|
||||
@api.post("/lobbies")
|
||||
def create_lobby(body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
name = str(body.get("name", "")).strip()[:60]
|
||||
if not name:
|
||||
raise HTTPException(422, "Give the lobby a name")
|
||||
with db() as con:
|
||||
if con.execute("SELECT COUNT(*) FROM lobbies WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_LOBBIES:
|
||||
raise HTTPException(409, "Lobby limit reached")
|
||||
lid, code = uuid.uuid4().hex[:12], _new_code(con)
|
||||
con.execute("INSERT INTO lobbies (id, name, owner_id, code, created) VALUES (?,?,?,?,?)",
|
||||
(lid, name, user["id"], code, time.time()))
|
||||
_event(con, lid, f"{user['username']} created the lobby", user["id"])
|
||||
return {"id": lid, "code": code}
|
||||
|
||||
|
||||
@api.post("/lobbies/join")
|
||||
def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
ip = client_ip(request)
|
||||
throttle(f"join:{ip}", 10, 600) # only wrong codes count, so guessing is slow
|
||||
throttle(f"join:u{user['id']}", 10, 600)
|
||||
code = re.sub(r"[^A-Za-z0-9]", "", str(body.get("code", ""))).upper()
|
||||
with db() as con:
|
||||
lobby = con.execute("SELECT * FROM lobbies WHERE code=?", (code,)).fetchone()
|
||||
if not lobby:
|
||||
limiter.fail(f"join:{ip}"); limiter.fail(f"join:u{user['id']}")
|
||||
raise HTTPException(404, "No lobby with this code")
|
||||
if lobby["owner_id"] == user["id"]:
|
||||
raise HTTPException(422, "You are the DM of this lobby")
|
||||
_own_character(con, body.get("character_id"), user)
|
||||
known = con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lobby["id"], user["id"])).fetchone()
|
||||
if not known and con.execute("SELECT COUNT(*) FROM lobby_members WHERE lobby_id=?", (lobby["id"],)).fetchone()[0] >= MAX_MEMBERS:
|
||||
raise HTTPException(409, "This lobby is full")
|
||||
con.execute("""INSERT INTO lobby_members (lobby_id, user_id, character_id, joined, color) VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(lobby_id, user_id) DO UPDATE SET character_id=excluded.character_id""",
|
||||
(lobby["id"], user["id"], body["character_id"], time.time(), _free_color(con, lobby["id"])))
|
||||
hub.notify(lobby["id"], "map")
|
||||
_event(con, lobby["id"], f"{user['username']} {'switched to' if known else 'joined as'} {_char_name(con, body['character_id'])}", user["id"])
|
||||
return {"id": lobby["id"]}
|
||||
|
||||
|
||||
@api.get("/lobbies/{lid}/portrait/{username}")
|
||||
def lobby_portrait(lid: str, username: str, user: dict = Depends(current_user)):
|
||||
"""The portrait of a player's character in this lobby (icon on the map and in the player bar). Members only."""
|
||||
with db() as con:
|
||||
_lobby_for(con, lid, user)
|
||||
row = con.execute("""SELECT c.data FROM lobby_members m JOIN users u ON u.id = m.user_id
|
||||
JOIN characters c ON c.id = m.character_id WHERE m.lobby_id=? AND u.username=?""", (lid, username)).fetchone()
|
||||
img = portrait_mod.image(json.loads(row["data"]).get("portrait")) if row else None
|
||||
if not img:
|
||||
raise HTTPException(404, "No portrait")
|
||||
return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})
|
||||
|
||||
|
||||
@api.get("/lobbies/{lid}")
|
||||
def read_lobby(lid: str, user: dict = Depends(current_user)):
|
||||
rules = load_rules()
|
||||
with db() as con:
|
||||
lobby = _lobby_for(con, lid, user)
|
||||
is_dm = lobby["owner_id"] == user["id"]
|
||||
_ensure_colors(con, lid)
|
||||
rows = con.execute("""SELECT m.user_id, m.character_id, m.color, m.char_mod, m.vgroup, u.username, c.data FROM lobby_members m
|
||||
JOIN users u ON u.id = m.user_id LEFT JOIN characters c ON c.id = m.character_id
|
||||
WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
|
||||
combat = combat_view(con, lobby, is_dm)
|
||||
members = []
|
||||
online = hub.online(lid)
|
||||
for r in rows:
|
||||
mine = r["user_id"] == user["id"]
|
||||
card = _card(r["data"], rules) if r["data"] else None
|
||||
# The id is only given out where the viewer may open the full character (the DM sees all, a player only their own)
|
||||
members.append({"username": r["username"], "me": mine, "character": card, "color": r["color"],
|
||||
"mod": r["char_mod"] if (is_dm or mine) else None, "online": r["user_id"] in online, "vgroup": r["vgroup"] if is_dm else None,
|
||||
"character_id": r["character_id"] if (is_dm or mine) and card else None})
|
||||
hidden = bool(lobby["diff_hidden"])
|
||||
diff = {"label": lobby["diff_label"], "mod": lobby["diff_mod"], "hidden": hidden}
|
||||
if hidden and not is_dm:
|
||||
diff = {"label": "", "mod": None, "hidden": True}
|
||||
return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm,
|
||||
"dm_online": lobby["owner_id"] in online, "combat": combat, "code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff,
|
||||
"sounds": sounds_mod.settings(lobby["sounds"]), "levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR}
|
||||
|
||||
|
||||
@api.put("/lobbies/{lid}/difficulty")
|
||||
def set_difficulty(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""The DM's current difficulty: a level from the rules table or a custom modifier. It applies to every check."""
|
||||
try:
|
||||
mod = int(body.get("mod", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(422, "The modifier must be a whole number")
|
||||
if not -100 <= mod <= 100:
|
||||
raise HTTPException(422, "The modifier must be between -100 and 100")
|
||||
label = (str(body.get("label") or "").strip() or "Custom")[:24]
|
||||
with db() as con:
|
||||
_dm_only(_lobby_for(con, lid, user), user)
|
||||
con.execute("UPDATE lobbies SET diff_mod=?, diff_label=?, diff_hidden=? WHERE id=?", (mod, label, 1 if body.get("hidden") else 0, lid))
|
||||
hub.notify(lid, "lobby")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.put("/lobbies/{lid}/members/{username}/mod")
|
||||
def set_member_mod(lid: str, username: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""A personal bonus or malus of one character (talent, condition ...), added on top of the lobby difficulty."""
|
||||
try:
|
||||
mod = int(body.get("mod", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(422, "The modifier must be a whole number")
|
||||
if not -100 <= mod <= 100:
|
||||
raise HTTPException(422, "The modifier must be between -100 and 100")
|
||||
with db() as con:
|
||||
_dm_only(_lobby_for(con, lid, user), user)
|
||||
cur = con.execute("UPDATE lobby_members SET char_mod=? WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)",
|
||||
(mod, lid, username))
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "No such player in this lobby")
|
||||
hub.notify(lid, "lobby")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _recipient(con, lobby, user: dict, to):
|
||||
"""User id to whisper to (the DM, a member or yourself for private rolls and notes), None for everybody."""
|
||||
to = str(to or "").strip()
|
||||
if not to:
|
||||
return None
|
||||
target = con.execute("""SELECT u.id FROM users u WHERE u.username=? AND (u.id=? OR EXISTS
|
||||
(SELECT 1 FROM lobby_members m WHERE m.lobby_id=? AND m.user_id=u.id))""",
|
||||
(to, lobby["owner_id"], lobby["id"])).fetchone()
|
||||
if not target:
|
||||
raise HTTPException(422, "You can only whisper to people in this lobby")
|
||||
return target["id"]
|
||||
|
||||
|
||||
@api.get("/lobbies/{lid}/messages")
|
||||
def read_messages(lid: str, after: int = 0, user: dict = Depends(current_user)):
|
||||
"""Chat, event log, dice rolls and checks. Whispers are only returned to the sender and the recipient (not even the DM
|
||||
sees them). Mystery rolls of the DM come without results, hidden-difficulty checks without the numbers behind them.
|
||||
after=0 returns the latest 100 entries, otherwise everything newer than that id (the client polls)."""
|
||||
with db() as con:
|
||||
lobby = _lobby_for(con, lid, user)
|
||||
is_dm = lobby["owner_id"] == user["id"]
|
||||
q = """SELECT m.id, m.kind, m.text, m.created, m.user_id, m.to_user_id, f.username AS sender, t.username AS target,
|
||||
lm.color AS color
|
||||
FROM lobby_messages m LEFT JOIN users f ON f.id = m.user_id LEFT JOIN users t ON t.id = m.to_user_id
|
||||
LEFT JOIN lobby_members lm ON lm.lobby_id = m.lobby_id AND lm.user_id = m.user_id
|
||||
WHERE m.lobby_id=? AND (m.to_user_id IS NULL OR m.to_user_id=? OR m.user_id=?)"""
|
||||
if after > 0:
|
||||
rows = con.execute(q + " AND m.id>? ORDER BY m.id LIMIT 200", (lid, user["id"], user["id"], after)).fetchall()
|
||||
else:
|
||||
rows = list(reversed(con.execute(q + " ORDER BY m.id DESC LIMIT 100", (lid, user["id"], user["id"])).fetchall()))
|
||||
out = []
|
||||
for r in rows:
|
||||
mine = r["user_id"] == user["id"]
|
||||
item = {"id": r["id"], "kind": r["kind"], "text": r["text"], "time": r["created"], "from": r["sender"] or "",
|
||||
"to": r["target"] or "", "mine": mine and r["kind"] != "event",
|
||||
"color": DM_COLOR if r["user_id"] == lobby["owner_id"] else PALETTE_HEX.get(r["color"], "")}
|
||||
if r["kind"] == "roll":
|
||||
roll = json.loads(r["text"])
|
||||
roll["n"] = len(roll["results"])
|
||||
if roll.get("mystery") and not mine:
|
||||
roll["results"] = None # players see that the DM rolled, not what
|
||||
item["roll"], item["text"] = roll, ""
|
||||
elif r["kind"] == "check":
|
||||
chk = json.loads(r["text"])
|
||||
if chk.get("hidden") and not is_dm:
|
||||
for k in ("base", "mod", "cmod", "diff", "target", "margin"):
|
||||
chk.pop(k, None)
|
||||
item["check"], item["text"] = chk, ""
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
@api.post("/lobbies/{lid}/messages")
|
||||
def send_message(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
text = str(body.get("text", "")).strip()
|
||||
if not text:
|
||||
raise HTTPException(422, "Write something first")
|
||||
if len(text) > MAX_MESSAGE:
|
||||
raise HTTPException(422, f"Messages can have at most {MAX_MESSAGE} characters")
|
||||
throttle(f"chat:{user['id']}", 30, 60) # 30 messages per minute
|
||||
limiter.fail(f"chat:{user['id']}")
|
||||
with db() as con:
|
||||
lobby = _lobby_for(con, lid, user)
|
||||
to_id = _recipient(con, lobby, user, body.get("to"))
|
||||
cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)",
|
||||
(lid, user["id"], to_id, "whisper" if to_id else "chat", text, time.time()))
|
||||
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
||||
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
|
||||
hub.notify(lid, "messages", None if to_id is None else {user["id"], to_id})
|
||||
return {"id": cur.lastrowid}
|
||||
|
||||
|
||||
DICE = (4, 6, 8, 10, 12, 20, 100)
|
||||
MAX_DICE = 10
|
||||
|
||||
|
||||
def _manual_results(raw, sides: int, count: int):
|
||||
"""Results typed in by a player who rolled real dice. None when nothing was typed."""
|
||||
if raw in (None, [], ""):
|
||||
return None
|
||||
try:
|
||||
vals = [int(v) for v in raw]
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(422, "Enter whole numbers")
|
||||
if len(vals) != count or any(not 1 <= v <= sides for v in vals):
|
||||
raise HTTPException(422, f"Enter {count} number{'s' if count > 1 else ''} from 1 to {sides}")
|
||||
return vals
|
||||
|
||||
|
||||
def _post(con, lid: str, uid: int, to_id, kind: str, payload: dict) -> int:
|
||||
cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)",
|
||||
(lid, uid, to_id, kind, json.dumps(payload), time.time()))
|
||||
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
||||
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
|
||||
hub.notify(lid, "messages", None if to_id is None else {uid, to_id})
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
@api.post("/lobbies/{lid}/rolls")
|
||||
def roll_dice(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Rolls on the server (so nobody can cheat) and posts the result in the chat; with `to` it is a private roll.
|
||||
Players with real dice can send their own results in `manual` (marked as such in the chat).
|
||||
The DM picks `mode`: hidden (only the DM sees it), mystery (players see that dice were rolled, not the result) or public."""
|
||||
try:
|
||||
sides, count = int(body.get("sides")), int(body.get("count", 1))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(422, "Invalid dice")
|
||||
if sides not in DICE or not 1 <= count <= MAX_DICE:
|
||||
raise HTTPException(422, f"Roll 1 to {MAX_DICE} dice of: " + ", ".join(f"d{d}" for d in DICE))
|
||||
throttle(f"chat:{user['id']}", 30, 60)
|
||||
limiter.fail(f"chat:{user['id']}")
|
||||
manual = _manual_results(body.get("manual"), sides, count)
|
||||
results = manual or [secrets.randbelow(sides) + 1 for _ in range(count)]
|
||||
with db() as con:
|
||||
lobby = _lobby_for(con, lid, user)
|
||||
is_dm = lobby["owner_id"] == user["id"]
|
||||
to_id = _recipient(con, lobby, user, body.get("to"))
|
||||
mystery = False
|
||||
if is_dm and to_id is None:
|
||||
mode = body.get("mode") or "hidden"
|
||||
if mode not in ("hidden", "mystery", "public"):
|
||||
raise HTTPException(422, "Unknown roll mode")
|
||||
if mode == "hidden":
|
||||
to_id = user["id"] # only the DM sees it
|
||||
mystery = mode == "mystery"
|
||||
mid = _post(con, lid, user["id"], to_id, "roll", {"sides": sides, "results": results, "mystery": mystery, "manual": bool(manual)})
|
||||
return {"id": mid, "sides": sides, "results": results}
|
||||
|
||||
|
||||
@api.post("/lobbies/{lid}/checks")
|
||||
def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""A d100 roll-under check for a skill or attribute of the player's current lobby character.
|
||||
Target = attribute + skill + the DM's difficulty + the character's personal modifier. 01-05 always succeeds, 96-100 always fails."""
|
||||
rules = load_rules()
|
||||
kind, key = str(body.get("kind", "")), str(body.get("id", ""))
|
||||
throttle(f"chat:{user['id']}", 30, 60)
|
||||
limiter.fail(f"chat:{user['id']}")
|
||||
with db() as con:
|
||||
lobby = _lobby_for(con, lid, user)
|
||||
member = con.execute("""SELECT m.char_mod, c.data FROM lobby_members m LEFT JOIN characters c ON c.id = m.character_id
|
||||
WHERE m.lobby_id=? AND m.user_id=?""", (lid, user["id"])).fetchone()
|
||||
npc_name = ""
|
||||
if body.get("npc_token"): # the DM rolls for an NPC on the table
|
||||
_dm_only(lobby, user)
|
||||
t = con.execute("""SELECT t.name, c.data FROM lobby_tokens t JOIN characters c ON c.id = t.npc_id
|
||||
WHERE t.id=? AND t.lobby_id=?""", (str(body["npc_token"]), lid)).fetchone()
|
||||
if not t:
|
||||
raise HTTPException(404, "This token has no NPC sheet")
|
||||
member, npc_name = {"char_mod": 0, "data": t["data"]}, t["name"]
|
||||
if not member or not member["data"]:
|
||||
raise HTTPException(422, "Only players with a character in this lobby can roll checks")
|
||||
c = compute(json.loads(member["data"]), rules)
|
||||
if kind == "skill":
|
||||
s = next((x for x in c["skills"] if x["id"] == key), None)
|
||||
if not s:
|
||||
raise HTTPException(404, "Unknown skill")
|
||||
if not s["usable"]:
|
||||
raise HTTPException(422, "This skill is still locked")
|
||||
what, base = (f"{npc_name}: " if npc_name else "") + s["name"], s["total"]
|
||||
elif kind == "attr":
|
||||
a = next((x for x in rules["attributes"] if x["id"] == key), None)
|
||||
if not a:
|
||||
raise HTTPException(404, "Unknown attribute")
|
||||
what, base = (f"{npc_name}: " if npc_name else "") + a["name"], c["attrs"][key]
|
||||
else:
|
||||
raise HTTPException(422, "Roll a skill or an attribute")
|
||||
manual = _manual_results(None if body.get("manual") in (None, "") else [body.get("manual")], 100, 1)
|
||||
roll = manual[0] if manual else secrets.randbelow(100) + 1
|
||||
target = base + lobby["diff_mod"] + member["char_mod"]
|
||||
outcome = "crit_success" if roll <= 5 else "crit_failure" if roll >= 96 else "success" if roll <= target else "failure"
|
||||
payload = {"kind": kind, "what": what, "base": base, "mod": lobby["diff_mod"], "cmod": member["char_mod"], "diff": lobby["diff_label"],
|
||||
"target": target, "roll": roll, "margin": target - roll, "outcome": outcome, "hidden": bool(lobby["diff_hidden"]),
|
||||
"manual": bool(manual)}
|
||||
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}
|
||||
|
||||
|
||||
@api.put("/lobbies/{lid}")
|
||||
def rename_lobby(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
name = str(body.get("name", "")).strip()[:60]
|
||||
if not name:
|
||||
raise HTTPException(422, "Give the lobby a name")
|
||||
with db() as con:
|
||||
_dm_only(_lobby_for(con, lid, user), user)
|
||||
con.execute("UPDATE lobbies SET name=? WHERE id=?", (name, lid))
|
||||
_event(con, lid, f"The lobby was renamed to \"{name}\"", user["id"])
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.delete("/lobbies/{lid}")
|
||||
def delete_lobby(lid: str, user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
_dm_only(_lobby_for(con, lid, user), user)
|
||||
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
|
||||
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
|
||||
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_scenes", "lobby_templates", "lobby_marks"):
|
||||
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
|
||||
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
|
||||
purge_sounds(lid)
|
||||
hub.close_lobby(lid)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.post("/lobbies/{lid}/code")
|
||||
def new_lobby_code(lid: str, user: dict = Depends(current_user)):
|
||||
"""New join code, the old one stops working (members stay)."""
|
||||
with db() as con:
|
||||
_dm_only(_lobby_for(con, lid, user), user)
|
||||
code = _new_code(con)
|
||||
con.execute("UPDATE lobbies SET code=? WHERE id=?", (code, lid))
|
||||
_event(con, lid, "The DM created a new join code", user["id"])
|
||||
return {"code": code}
|
||||
|
||||
|
||||
@api.put("/lobbies/{lid}/me")
|
||||
def update_my_membership(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Switch your character and/or pick your colour (no two players share one)."""
|
||||
with db() as con:
|
||||
_lobby_for(con, lid, user)
|
||||
if not con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone():
|
||||
raise HTTPException(422, "You are the DM of this lobby")
|
||||
if "character_id" not in body and "color" not in body:
|
||||
raise HTTPException(422, "Nothing to change")
|
||||
if "color" in body:
|
||||
color = body["color"]
|
||||
if color not in PALETTE_HEX:
|
||||
raise HTTPException(422, "Unknown colour")
|
||||
if con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND color=? AND user_id!=?", (lid, color, user["id"])).fetchone():
|
||||
raise HTTPException(409, "Another player already uses this colour")
|
||||
con.execute("UPDATE lobby_members SET color=? WHERE lobby_id=? AND user_id=?", (color, lid, user["id"]))
|
||||
hub.notify(lid, "lobby")
|
||||
if "character_id" in body:
|
||||
_own_character(con, body.get("character_id"), user)
|
||||
con.execute("UPDATE lobby_members SET character_id=? WHERE lobby_id=? AND user_id=?", (body["character_id"], lid, user["id"]))
|
||||
_event(con, lid, f"{user['username']} switched to {_char_name(con, body['character_id'])}", user["id"])
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.delete("/lobbies/{lid}/me")
|
||||
def leave_lobby(lid: str, user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
_lobby_for(con, lid, user)
|
||||
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).rowcount
|
||||
if gone:
|
||||
_event(con, lid, f"{user['username']} left the lobby", user["id"])
|
||||
if gone:
|
||||
hub.notify(lid, "map")
|
||||
hub.close_user(lid, user["id"])
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.delete("/lobbies/{lid}/members/{username}")
|
||||
def kick_member(lid: str, username: str, user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
_dm_only(_lobby_for(con, lid, user), user)
|
||||
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)",
|
||||
(lid, username)).rowcount
|
||||
if gone:
|
||||
_event(con, lid, f"{username} was removed by the DM", user["id"])
|
||||
gone_id = con.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"]
|
||||
if gone:
|
||||
hub.notify(lid, "map")
|
||||
hub.close_user(lid, gone_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -419,6 +1039,181 @@ def export(fmt: str, ch: dict = Body(...), user: dict = Depends(current_user)):
|
||||
headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'})
|
||||
|
||||
|
||||
# ---- rules editor (admins only)
|
||||
|
||||
def admin_user(user: dict = Depends(current_user)) -> dict:
|
||||
if not is_admin(user):
|
||||
raise HTTPException(403, "Only admins can edit the rules")
|
||||
return user
|
||||
|
||||
|
||||
@api.get("/admin/users")
|
||||
def admin_users(user: dict = Depends(admin_user)):
|
||||
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
|
||||
with db() as con:
|
||||
ids = admin_ids(con)
|
||||
rows = con.execute("SELECT id, username, created FROM users ORDER BY username COLLATE NOCASE").fetchall()
|
||||
return [{"username": r["username"], "admin": r["id"] in ids, "fixed": r["username"].lower() in names, "me": r["id"] == user["id"],
|
||||
"created": r["created"]} for r in rows]
|
||||
|
||||
|
||||
@api.put("/admin/users/{username}")
|
||||
def set_admin(username: str, body: dict = Body(...), user: dict = Depends(admin_user)):
|
||||
"""Make a user an admin or take it away again. Names listed in AW_ADMINS can only be changed in the server settings."""
|
||||
want = bool(body.get("admin"))
|
||||
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
|
||||
with db() as con:
|
||||
target = con.execute("SELECT id, username FROM users WHERE username=?", (username,)).fetchone()
|
||||
if not target:
|
||||
raise HTTPException(404, "No user with this name")
|
||||
ids = admin_ids(con)
|
||||
if want:
|
||||
if not any(r["is_admin"] for r in con.execute("SELECT is_admin FROM users")) and not names: # the implicit first-account admin becomes explicit
|
||||
con.execute("UPDATE users SET is_admin=1 WHERE id IN (%s)" % ",".join(str(i) for i in ids))
|
||||
con.execute("UPDATE users SET is_admin=1 WHERE id=?", (target["id"],))
|
||||
else:
|
||||
if target["username"].lower() in names:
|
||||
raise HTTPException(409, "This admin is set in AW_ADMINS on the server and can only be removed there")
|
||||
if ids - {target["id"]} == set():
|
||||
raise HTTPException(409, "There must be at least one admin")
|
||||
if target["id"] not in ids:
|
||||
return {"ok": True}
|
||||
if not any(r["is_admin"] for r in con.execute("SELECT is_admin FROM users")): # implicit first account: make it explicit first
|
||||
con.execute("UPDATE users SET is_admin=1 WHERE id IN (%s)" % ",".join(str(i) for i in ids))
|
||||
con.execute("UPDATE users SET is_admin=0 WHERE id=?", (target["id"],))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.get("/admin/rules")
|
||||
def editor_state(user: dict = Depends(admin_user)):
|
||||
"""The editable sections as they are now, and which of them differ from the shipped rules.json."""
|
||||
r = load_rules()
|
||||
return {"sections": {k: r[k] for k in rules_edit.SECTIONS}, "customized": [k for k in read_overrides() if k in rules_edit.SECTIONS]}
|
||||
|
||||
|
||||
def _all_characters(con) -> list:
|
||||
return [json.loads(x["data"]) for x in con.execute("SELECT data FROM characters")]
|
||||
|
||||
|
||||
def _save_overrides(data: dict):
|
||||
OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
if OVERRIDES_PATH.exists():
|
||||
OVERRIDES_PATH.with_suffix(".json.bak").write_bytes(OVERRIDES_PATH.read_bytes())
|
||||
tmp = OVERRIDES_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, OVERRIDES_PATH)
|
||||
|
||||
|
||||
def _apply(section: str, new: list, confirm: bool):
|
||||
r = load_rules()
|
||||
old_ids = {x["id"] for x in r[section]}
|
||||
removed = old_ids - {x["id"] for x in new}
|
||||
bad = rules_edit.dangling(section, new, r)
|
||||
if bad:
|
||||
raise HTTPException(422, "Cannot save: " + "; ".join(bad[:5]) + (" ..." if len(bad) > 5 else ""))
|
||||
with db() as con:
|
||||
used = rules_edit.usage(_all_characters(con), section, removed)
|
||||
if used and not confirm:
|
||||
raise HTTPException(409, f"{used} saved character{'s' if used != 1 else ''} use{'' if used != 1 else 's'} entries you are removing. "
|
||||
"Their data stays in the characters but the removed entries disappear from the sheets.")
|
||||
return used
|
||||
|
||||
|
||||
@api.put("/admin/rules/{section}")
|
||||
def editor_save(section: str, body: dict = Body(...), user: dict = Depends(admin_user)):
|
||||
try:
|
||||
new = rules_edit.clean(section, body.get("items"), load_rules())
|
||||
except rules_edit.RuleError as e:
|
||||
raise HTTPException(422, str(e))
|
||||
_apply(section, new, bool(body.get("confirm")))
|
||||
data = read_overrides()
|
||||
data[section] = new
|
||||
_save_overrides(data)
|
||||
return {"ok": True, "count": len(new)}
|
||||
|
||||
|
||||
@api.delete("/admin/rules/{section}")
|
||||
def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin_user)):
|
||||
"""Back to the shipped rules.json for this section."""
|
||||
if section not in rules_edit.SECTIONS:
|
||||
raise HTTPException(404, "Unknown section")
|
||||
data = read_overrides()
|
||||
if section in data:
|
||||
_apply(section, base_rules()[section], confirm)
|
||||
del data[section]
|
||||
_save_overrides(data)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _health(data) -> int | None:
|
||||
try:
|
||||
return int(compute(json.loads(data), load_rules())["derived"].get("health"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps", _can_read_character, _health)
|
||||
combat_view = combat_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, load_rules)
|
||||
purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds")
|
||||
|
||||
|
||||
@app.websocket("/api/lobbies/{lid}/ws")
|
||||
async def lobby_socket(websocket: WebSocket, lid: str):
|
||||
"""One socket per open lobby page. Only notes go out ("messages", "lobby", "sheet"), the data is fetched through the API."""
|
||||
origin = websocket.headers.get("origin")
|
||||
hosts = {websocket.headers.get("host", "")}
|
||||
if TRUST_PROXY:
|
||||
hosts.add(websocket.headers.get("x-forwarded-host", ""))
|
||||
if origin and origin.split("://", 1)[-1] not in hosts:
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
def check():
|
||||
with db() as con:
|
||||
user = auth.user_for_token(con, websocket.cookies.get(auth.SESSION_COOKIE))
|
||||
if not user:
|
||||
return None
|
||||
lobby = con.execute("SELECT owner_id FROM lobbies WHERE id=?", (lid,)).fetchone()
|
||||
ok = lobby and (lobby["owner_id"] == user["id"] or con.execute(
|
||||
"SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone())
|
||||
return user if ok else None
|
||||
|
||||
user = await asyncio.to_thread(check)
|
||||
if not user:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
await websocket.accept()
|
||||
was_online = user["id"] in hub.online(lid)
|
||||
if not hub.add(lid, websocket, user["id"]):
|
||||
await websocket.close(code=4429)
|
||||
return
|
||||
if not was_online:
|
||||
hub.notify(lid, "lobby") # presence changed
|
||||
try:
|
||||
last_check = time.time()
|
||||
while True:
|
||||
try:
|
||||
text = await asyncio.wait_for(websocket.receive_text(), timeout=70)
|
||||
except asyncio.TimeoutError:
|
||||
break # the client pings every 25 seconds, silence means it is gone
|
||||
if text == "ping":
|
||||
await websocket.send_text("pong")
|
||||
if time.time() - last_check > 300: # session or membership may have ended
|
||||
last_check = time.time()
|
||||
if not await asyncio.to_thread(check):
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
hub.remove(lid, websocket)
|
||||
if user["id"] not in hub.online(lid):
|
||||
hub.notify(lid, "lobby")
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
app.include_router(api)
|
||||
|
||||
|
||||
|
||||
+1218
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_max_divisor": 2,
|
||||
"advanced_unlock_cost": 10,
|
||||
"advanced_attr_percent": 50,
|
||||
"free_proficiencies": 2,
|
||||
"max_proficiency_pips": 2,
|
||||
"talents_min": 1,
|
||||
@@ -30,6 +31,7 @@
|
||||
{"id": "standard", "name": "Standard", "desc": "The numbers from the brainstorm document.", "creation": {}},
|
||||
{"id": "gritty", "name": "Gritty survival", "desc": "Example preset: fewer points, one starting talent, harder to learn advanced skills.", "creation": {"attr_points": 70, "skill_points_default": 100, "talents_max": 1, "free_proficiencies": 1, "advanced_unlock_cost": 15}},
|
||||
{"id": "heroic", "name": "Heroic", "desc": "Example preset: more points, higher attribute cap, three starting talents.", "creation": {"attr_points": 110, "attr_max_raise": 25, "attr_max": 50, "skill_points_default": 200, "talents_max": 3, "free_proficiencies": 3, "advanced_unlock_cost": 5}},
|
||||
{"id": "npc", "name": "NPC / creature (free)", "desc": "Used by the NPC editor: huge pools, no limits.", "npc": true, "creation": {"attr_points": 500, "attr_max_raise": 100, "attr_max": 150, "skill_points_default": 1000, "talents_min": 0, "talents_max": 10, "free_proficiencies": 20, "advanced_unlock_cost": 0}},
|
||||
{"id": "oneshot", "name": "One-shot", "desc": "Example preset: quick, strong characters for a single session.", "creation": {"attr_points": 100, "skill_points_default": 120, "talents_max": 2, "free_proficiencies": 3, "advanced_unlock_cost": 0}}
|
||||
],
|
||||
|
||||
@@ -41,6 +43,7 @@
|
||||
{"key": "profession_attr_bonus", "label": "Profession attribute bonus", "group": "Profession", "min": 0, "max": 50},
|
||||
{"key": "free_proficiencies", "label": "Free proficiency picks", "group": "Skills", "min": 0, "max": 20},
|
||||
{"key": "skill_points_default", "label": "Skill point pool", "group": "Skills", "min": 0, "max": 1000},
|
||||
{"key": "advanced_attr_percent", "label": "Advanced skills: share of the attribute that counts (%)", "group": "Skills", "min": 0, "max": 100},
|
||||
{"key": "advanced_unlock_cost", "label": "Cost to unlock an advanced skill", "group": "Skills", "min": 0, "max": 100},
|
||||
{"key": "skill_max_divisor", "label": "Skill maximum = attribute divided by", "group": "Skills", "min": 1, "max": 10},
|
||||
{"key": "proficiency_bonus", "label": "Proficiency bonus", "group": "Skills", "min": 0, "max": 50},
|
||||
@@ -434,7 +437,7 @@
|
||||
"placeholder": true
|
||||
},
|
||||
{
|
||||
"p": "The unlock cost for advanced skills (10 points) is a house rule of the app, not part of the brainstorm. It can be changed in rules.json (creation.advanced_unlock_cost).",
|
||||
"p": "The unlock cost for advanced skills (10 points) and the rule that advanced skills only use half of the attribute are house rules of the app, not part of the brainstorm. They can be changed in rules.json (creation.advanced_unlock_cost, creation.advanced_attr_percent).",
|
||||
"placeholder": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,3 +2,4 @@ fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
python-docx>=1.1
|
||||
reportlab>=4.2
|
||||
Pillow>=10
|
||||
|
||||
+3
-1
@@ -5,13 +5,15 @@ services:
|
||||
ports:
|
||||
# Only reachable from the server itself; the reverse proxy (nginx) is the public entrance.
|
||||
# Docker bypasses firewall rules for published ports, so never publish this on 0.0.0.0.
|
||||
- "127.0.0.1:8080:8000"
|
||||
- "127.0.0.1:${AW_PORT:-8090}:8000"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
environment:
|
||||
# Code that new users must enter to create an account (set it in the .env file next to this file).
|
||||
# Empty = registration is disabled.
|
||||
AW_REGISTRATION_CODE: ${AW_REGISTRATION_CODE:-}
|
||||
# Who may use the rules editor: comma separated usernames. Empty = only the first account that was ever created.
|
||||
AW_ADMINS: ${AW_ADMINS:-}
|
||||
# Set to 1 when a reverse proxy (nginx, Caddy, Traefik) in front sets X-Forwarded-* headers.
|
||||
AW_TRUST_PROXY: ${AW_TRUST_PROXY:-0}
|
||||
volumes:
|
||||
|
||||
Binary file not shown.
+15
-1
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Creator from './Creator.jsx'
|
||||
import Rules from './Rules.jsx'
|
||||
import Maps from './Maps.jsx'
|
||||
import Characters from './Characters.jsx'
|
||||
import Presets from './Presets.jsx'
|
||||
import Lobbies from './Lobbies.jsx'
|
||||
import Login from './Login.jsx'
|
||||
import Account from './Account.jsx'
|
||||
import Editor from './Editor.jsx'
|
||||
import { claimLegacy } from './legacy.js'
|
||||
|
||||
const useHash = () => {
|
||||
@@ -24,6 +27,7 @@ export default function App() {
|
||||
const [error, setError] = useState('')
|
||||
// Used to load a saved character into the creator
|
||||
const [loadRequest, setLoadRequest] = useState(null)
|
||||
const [npcLoad, setNpcLoad] = useState(null)
|
||||
const [charName, setCharName] = useState('') // name of the character being worked on, shown in the nav
|
||||
|
||||
// Ask the server who we are; any later 401 (session expired) sends us back to the login
|
||||
@@ -52,7 +56,7 @@ export default function App() {
|
||||
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
|
||||
|
||||
const page = hash.split('/')[0]
|
||||
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']]
|
||||
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 (
|
||||
<>
|
||||
@@ -73,10 +77,20 @@ export default function App() {
|
||||
{rules && page === 'creator' && <Creator rules={rules} user={username} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} onName={setCharName} />}
|
||||
{rules && page === 'rules' && <Rules rules={rules} section={hash.split('/')[1]} />}
|
||||
{rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />}
|
||||
{page === 'maps' && <Maps />}
|
||||
{rules && page === 'characters' && (
|
||||
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
|
||||
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
|
||||
)}
|
||||
{rules && page === 'npcs' && (
|
||||
<Characters npc onOpen={(entry) => { setNpcLoad(entry); window.location.hash = 'npc' }}
|
||||
onNew={() => { setNpcLoad({ new: true }); window.location.hash = 'npc' }} />
|
||||
)}
|
||||
{rules && page === 'npc' && <Creator npc rules={rules} user={username} loadRequest={npcLoad} clearLoad={() => setNpcLoad(null)} onName={() => {}} />}
|
||||
{rules && page === 'lobbies' && (
|
||||
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
|
||||
)}
|
||||
{rules && page === 'editor' && auth.user.admin && <Editor rules={rules} onReload={loadRules} />}
|
||||
{page === 'account' && <Account user={username} onLogout={logout} />}
|
||||
</main>
|
||||
</>
|
||||
|
||||
+29
-12
@@ -19,7 +19,7 @@ function SharePanel({ c, reload }) {
|
||||
|
||||
return (
|
||||
<div className="sharepanel">
|
||||
<p className="small muted">These users can view this character (read-only), for example your DM:</p>
|
||||
<p className="small muted">These users can view this entry (read-only), for example another DM:</p>
|
||||
<div className="chips">
|
||||
{c.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
|
||||
{c.shared_with.map((u) => (
|
||||
@@ -35,12 +35,13 @@ function SharePanel({ c, reload }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function Characters({ onOpen, onNew }) {
|
||||
export default function Characters({ onOpen, onNew, npc = false }) {
|
||||
const what = npc ? 'NPC' : 'character'
|
||||
const [data, setData] = useState(null)
|
||||
const [open, setOpen] = useState(null) // id of the character whose share panel is open
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const load = () => fetch('/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your characters.'))
|
||||
const load = () => fetch(npc ? '/api/characters?npc=1' : '/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load the list.'))
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const remove = async (c) => {
|
||||
@@ -49,32 +50,46 @@ export default function Characters({ onOpen, onNew }) {
|
||||
if (!r || !r.ok) return setErr('Deleting failed.')
|
||||
load()
|
||||
}
|
||||
const copy = async (c) => {
|
||||
const r = await fetch(`/api/characters/${c.id}`).catch(() => null)
|
||||
if (!r || !r.ok) return setErr('Duplicating failed.')
|
||||
const data = await r.json()
|
||||
const p = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, name: `${data.name || 'NPC'} (copy)` }) }).catch(() => null)
|
||||
if (!p || !p.ok) return setErr('Duplicating failed.')
|
||||
load()
|
||||
}
|
||||
const kindName = (k) => ({ npc: 'NPC', enemy: 'Enemy', monster: 'Monster', creature: 'Creature', animal: 'Animal', robot: 'Robot / machine' })[k] || ''
|
||||
const thumb = (c) => c.portrait ? <img className="npcthumb" src={`/api/characters/${c.id}/portrait?v=${c.portrait}`} alt="" /> : <span className="npcthumb none" aria-hidden="true">{npc ? '?' : ''}</span>
|
||||
const when = (t) => new Date(t * 1000).toLocaleString('en-GB')
|
||||
|
||||
return (
|
||||
<section className="page narrow">
|
||||
<div className="row">
|
||||
<h1>My characters</h1>
|
||||
<button onClick={onNew}>+ New character</button>
|
||||
<h1>{npc ? 'NPCs and creatures' : 'My characters'}</h1>
|
||||
<button onClick={onNew}>{npc ? '+ New NPC' : '+ New character'}</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
These characters are saved in your account. Only you and the users you share a character with can see it.
|
||||
Shared users can look at it but not change it.
|
||||
{npc ? 'Your library of NPCs, enemies, monsters and creatures. Place them as tokens in the map editor or at the table; a DM sees their sheet by clicking the token. You can share NPCs with other DMs.'
|
||||
: 'These characters are saved in your account. Only you and the users you share a character with can see it. Shared users can look at it but not change it.'}
|
||||
</p>
|
||||
{err && <p className="warn">{err}</p>}
|
||||
{data && data.mine.length === 0 && <p className="card">Nothing saved yet. Use "New character", then click "Save" on the Summary page.</p>}
|
||||
{data && data.mine.length === 0 && <p className="card">Nothing saved yet. Use "{npc ? 'New NPC' : 'New character'}", then click "{npc ? 'Save NPC' : 'Save'}" on the Summary page.</p>}
|
||||
<ul className="charlist">
|
||||
{(data?.mine || []).map((c) => (
|
||||
<li key={c.id} className="card">
|
||||
<div className="row">
|
||||
<div>
|
||||
<strong>{c.name}</strong>
|
||||
<div className="npchead">
|
||||
{thumb(c)}
|
||||
<div>
|
||||
<strong>{c.name}</strong>{npc && c.kind && <span className="badge">{kindName(c.kind)}</span>}
|
||||
<div className="muted small">
|
||||
ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button onClick={() => onOpen(c)}>Open</button>
|
||||
{npc && <button className="ghost" onClick={() => copy(c)}>Duplicate</button>}
|
||||
<button className="ghost" onClick={() => setOpen(open === c.id ? null : c.id)}>Share</button>
|
||||
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
|
||||
</div>
|
||||
@@ -90,9 +105,11 @@ export default function Characters({ onOpen, onNew }) {
|
||||
<ul className="charlist">
|
||||
{data.shared.map((c) => (
|
||||
<li key={c.id} className="card row">
|
||||
<div>
|
||||
<strong>{c.name}</strong>
|
||||
<div className="npchead">
|
||||
{thumb(c)}
|
||||
<div><strong>{c.name}</strong>
|
||||
<div className="muted small">from {c.owner} · {when(c.updated)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions"><button onClick={() => onOpen(c)}>View</button></div>
|
||||
</li>
|
||||
|
||||
+48
-60
@@ -1,7 +1,9 @@
|
||||
import PortraitEditor from './Portrait.jsx'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
|
||||
import SheetPanel from './SheetPanel.jsx'
|
||||
import Tip from './Tip.jsx'
|
||||
import PaperSheet from './PaperSheet.jsx'
|
||||
import { explainAttr, explainAttrPool, explainCapacity, explainCarry, explainDerived, explainProfPool, explainSkill, explainSkillPool } from './explain.js'
|
||||
|
||||
const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary']
|
||||
@@ -12,9 +14,13 @@ function readDraft(key) {
|
||||
try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
|
||||
}
|
||||
|
||||
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName }) {
|
||||
const DRAFT = `aw_draft:${user}` // drafts are kept per account
|
||||
const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
|
||||
const NPC_KINDS = [['npc', 'NPC'], ['enemy', 'Enemy'], ['monster', 'Monster'], ['creature', 'Creature'], ['animal', 'Animal'], ['robot', 'Robot / machine']]
|
||||
|
||||
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName, npc = false }) {
|
||||
const DRAFT = `${npc ? 'aw_draft_npc' : 'aw_draft'}:${user}` // drafts are kept per account
|
||||
const freshCharacter = () => npc
|
||||
? { ...applyPreset(emptyCharacter(baseRules), baseRules, 'npc'), npc: true, npc_kind: 'enemy', token_size: 1, abilities: '', loot: '' }
|
||||
: applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
|
||||
const [ch, setCh] = useState(() => {
|
||||
const d = readDraft(DRAFT)
|
||||
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
|
||||
@@ -56,7 +62,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
if (!loadRequest) return
|
||||
if (loadRequest.new) { // "New character" button on the My characters page
|
||||
const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved
|
||||
if (untouched || window.confirm('Start a new character? Your current draft is discarded unless you saved it.')) {
|
||||
if (untouched || window.confirm('Start a new ' + (npc ? 'NPC' : 'character') + '? Your current draft is discarded unless you saved it.')) {
|
||||
setBoth(freshCharacter()); setSaved(null); setStep(0)
|
||||
}
|
||||
clearLoad()
|
||||
@@ -72,7 +78,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
// Direct link #creator/<id> (read only; saving creates a copy)
|
||||
useEffect(() => {
|
||||
const id = window.location.hash.split('/')[1]
|
||||
if (!id || loadRequest) return
|
||||
if (!id || loadRequest || npc) return
|
||||
fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
|
||||
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') })
|
||||
.catch(() => setMsg('Character not found, or it is not shared with you.'))
|
||||
@@ -92,7 +98,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
|
||||
const resetAll = () => {
|
||||
if (!window.confirm('Discard all input and start over?')) return
|
||||
setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation }); setSaved(null); setStep(0)
|
||||
setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation, ...(npc ? { npc: true, npc_kind: ch.npc_kind, token_size: ch.token_size, abilities: '', loot: '' } : {}) }); setSaved(null); setStep(0)
|
||||
}
|
||||
const resetThisStep = () => {
|
||||
if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return
|
||||
@@ -152,10 +158,25 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
const stepConcept = (
|
||||
<>
|
||||
<h2>Concept</h2>
|
||||
<div className="card presetbox">
|
||||
{npc && (
|
||||
<div className="card presetbox">
|
||||
<div className="grid2">
|
||||
<label>Type
|
||||
<select value={ch.npc_kind || 'npc'} onChange={(e) => set({ npc_kind: e.target.value })}>
|
||||
{NPC_KINDS.map(([id, label]) => <option key={id} value={id}>{label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Token size (grid squares)
|
||||
<input type="number" min="1" max="8" value={ch.token_size || 1} onChange={(e) => set({ token_size: Math.max(1, Math.min(8, Number(e.target.value) || 1)) })} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="small muted">NPCs use generous pools (attributes, skills, talents), so you can build anything from a rat to a boss.</p>
|
||||
</div>
|
||||
)}
|
||||
{!npc && <div className="card presetbox">
|
||||
<label>Rules preset
|
||||
<select value={ch.preset} onChange={(e) => e.target.value !== 'custom' && setCh((x) => applyPreset(x, baseRules, e.target.value))}>
|
||||
{(baseRules.presets || []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
{(baseRules.presets || []).filter((p) => !p.npc).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
{ch.preset === 'custom' && <option value="custom">Custom</option>}
|
||||
</select>
|
||||
</label>
|
||||
@@ -165,10 +186,11 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
return diff.length > 0 && <p className="small muted">Differs from Standard: {diff.map((x) => `${x.label} ${cr[x.key]}`).join(' · ')}</p>
|
||||
})()}
|
||||
<p className="small">Create or edit presets on the <a href="#presets">Presets</a> page.</p>
|
||||
</div>
|
||||
</div>}
|
||||
<div className="card portraitbox"><PortraitEditor value={ch.portrait} onChange={(p) => set({ portrait: p })} /></div>
|
||||
<div className="grid2">
|
||||
<label>Character name<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
|
||||
<label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>
|
||||
<label>{npc ? 'NPC name' : 'Character name'}<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
|
||||
{!npc && <label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>}
|
||||
</div>
|
||||
<div className="grid2">
|
||||
<label>Class (optional)
|
||||
@@ -186,7 +208,9 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
</div>
|
||||
{sub && <p className="small muted">{sub.desc}</p>}
|
||||
<label>Concept in one sentence<input value={ch.concept} onChange={(e) => set({ concept: e.target.value })} placeholder="e.g. Former militia fighter searching for his family" /></label>
|
||||
<label>Background<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
|
||||
<label>{npc ? 'Description' : 'Background'}<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
|
||||
{npc && <label>Special abilities and attacks<textarea rows="5" value={ch.abilities || ''} onChange={(e) => set({ abilities: e.target.value })} placeholder="e.g. Bite 2d6, poison spit (range 10, VIT check or poisoned), regenerates 2 HP per round" /></label>}
|
||||
{npc && <label>Loot and drops<textarea rows="3" value={ch.loot || ''} onChange={(e) => set({ loot: e.target.value })} placeholder="e.g. 2 scrap, hide, rusty knife" /></label>}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -313,9 +337,11 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
<button className="ghost small-btn" title={`Lock again: you get ${c.unlockCost} points back, its points and proficiency picks are returned`}
|
||||
onClick={() => setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 }, proficiency_picks: { ...x.proficiency_picks, [s.id]: 0 } }))}>Lock</button>
|
||||
)}
|
||||
<button onClick={() => setPts(s.points - 1)} aria-label={`${s.name} minus`}>−</button>
|
||||
<button className="step5" onClick={() => setPts(s.points - 5)} disabled={s.points <= 0} aria-label={`${s.name} minus 5`}>−5</button>
|
||||
<button onClick={() => setPts(s.points - 1)} disabled={s.points <= 0} aria-label={`${s.name} minus`}>−</button>
|
||||
<span className="val">{s.points}</span>
|
||||
<button onClick={() => setPts(s.points + 1)} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+</button>
|
||||
<button onClick={() => setPts(Math.min(s.points + 1, s.max, s.points + c.skillLeft))} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+</button>
|
||||
<button className="step5" onClick={() => setPts(Math.min(s.points + 5, s.max, s.points + c.skillLeft))} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus 5`}>+5</button>
|
||||
<Tip text={explainSkill(s, ctx)}><span className="mod">{s.total}</span></Tip><span className="small muted" title="Most points you can add"> max {s.max}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -414,6 +440,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
)
|
||||
|
||||
const warnings = []
|
||||
if (npc) { if (c.attrLeft < 0) warnings.push('Too many attribute points spent.'); if (c.skillLeft < 0) warnings.push('More skill points spent than the pool allows.') } else {
|
||||
if (c.attrLeft < 0) warnings.push('Too many attribute points spent.')
|
||||
if (c.attrLeft > 0) warnings.push(`${c.attrLeft} attribute points left.`)
|
||||
if (!ch.profession) warnings.push('No profession chosen.')
|
||||
@@ -427,62 +454,23 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
if (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
|
||||
if (over) warnings.push('Augmentation Capacity exceeded.')
|
||||
if (overWeight) warnings.push('Carrying more than the carry capacity.')
|
||||
}
|
||||
|
||||
const gearLines = kitItems(ch, rules)
|
||||
const stepSummary = (
|
||||
<>
|
||||
<h2>Summary</h2>
|
||||
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
|
||||
<div className="sheet card">
|
||||
<h3 className="sheet-name">{ch.name || 'Nameless'}</h3>
|
||||
<p className="muted">{ch.player && `Player: ${ch.player} · `}Profession: {prof?.name || '–'}{cls && ` · Class: ${[cls.name, sub?.name].filter(Boolean).join(' / ')}`}</p>
|
||||
<div className="sheet-cols">
|
||||
<div>
|
||||
<h4>Attributes</h4>
|
||||
<table><tbody>{rules.attributes.map((a) => <tr key={a.id}><td><Tip text={explainAttr(a, ctx)}>{a.name}</Tip></td><td>{c.attrs[a.id]}</td><td className="muted">Mod {c.mods[a.id]}</td></tr>)}</tbody></table>
|
||||
<h4>Derived stats</h4>
|
||||
<table><tbody>{rules.derived.map((d) => <tr key={d.id}><td><Tip text={explainDerived(d, ctx)}>{d.name}</Tip></td><td>{c.derived[d.id]}</td></tr>)}</tbody></table>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Talents</h4>
|
||||
<ul className="desclist">{rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => <li key={t.id}><strong>{t.name}</strong><div className="rowdesc">{t.desc}</div></li>)}</ul>
|
||||
{!ch.talents.length && <p>–</p>}
|
||||
<h4><Tip text={explainCapacity(ctx)}>Mutations / implants ({c.capacityUsed}/{c.capacityMax})</Tip></h4>
|
||||
<ul className="desclist">{rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => <li key={a.id}><strong>{a.name}</strong> <span className="muted small">cost {a.cost}</span><div className="rowdesc">{a.desc}</div></li>)}</ul>
|
||||
{!ch.augments.length && <p>–</p>}
|
||||
<h4><Tip text={explainCarry(ctx)}>Equipment ({c.weight}/{c.carryMax} kg)</Tip></h4>
|
||||
<ul className="desclist">{gearLines.map((g) => <li key={g.item.id}><strong>{g.item.name}{g.qty > 1 ? ` x${g.qty}` : ''}</strong><div className="rowdesc">{[g.item.stat, `${Math.round(g.item.kg * g.qty * 10) / 10} kg`, g.item.note].filter(Boolean).join(' · ')}</div></li>)}</ul>
|
||||
{!gearLines.length && <p>–</p>}
|
||||
{ch.gear.other && <p className="small"><strong>Other:</strong> {ch.gear.other}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<h4>Skills</h4>
|
||||
<div className="skillcols">
|
||||
{rules.skill_groups.map((g) => (
|
||||
<div key={g.id} className="skillgroup">
|
||||
<h5>{g.name}</h5>
|
||||
<table><tbody>
|
||||
{c.skills.filter((s) => s.group === g.id).map((s) => (
|
||||
<tr key={s.id} className={s.usable ? '' : 'dim'}>
|
||||
<td><Tip text={explainSkill(s, ctx)}>{s.name}</Tip>{s.type === 'advanced' && <span className="muted small"> (adv.)</span>}</td>
|
||||
<td>{s.total}</td>
|
||||
<td className="muted">{s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.points ? `+${s.points}` : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody></table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PaperSheet data={ch} baseRules={baseRules} onNotes={(p) => setBoth({ ...ch, ...p })} />
|
||||
<div className="actions wrap">
|
||||
<button onClick={() => download('pdf')}>Download as PDF</button>
|
||||
<button onClick={() => download('docx')}>Download as Word</button>
|
||||
<button className="ghost" onClick={save}>{saved ? 'Save changes' : 'Save on server'}</button>
|
||||
{!npc && <button onClick={() => download('pdf')}>Download as PDF</button>}
|
||||
{!npc && <button onClick={() => download('docx')}>Download as Word</button>}
|
||||
<button className="ghost" onClick={save}>{saved ? 'Save changes' : npc ? 'Save NPC' : 'Save on server'}</button>
|
||||
<button className="ghost" onClick={exportJson}>JSON backup</button>
|
||||
<button className="ghost" onClick={() => fileRef.current.click()}>Import JSON</button>
|
||||
<input ref={fileRef} type="file" accept="application/json" hidden onChange={importJson} />
|
||||
</div>
|
||||
{saved && (
|
||||
{saved && !npc && (
|
||||
<p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p>
|
||||
)}
|
||||
</>
|
||||
@@ -492,7 +480,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
|
||||
return (
|
||||
<div className="page creator">
|
||||
<p className="small muted presetchip">Rules: <button className="linklike" onClick={() => go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}</button></p>
|
||||
{!npc && <p className="small muted presetchip">Rules: <button className="linklike" onClick={() => go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}</button></p>}
|
||||
<ol className="steps">
|
||||
{STEPS.map((s, i) => (
|
||||
<li key={s}><button className={`${i === step ? 'on' : ''} ${i < step ? 'done' : ''}`} onClick={() => go(i)}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import Tip from './Tip.jsx'
|
||||
import { compute, effectiveRules, kitItems, normalize } from './calc.js'
|
||||
import { explainAttr, explainCapacity, explainCarry, explainDerived, explainSkill } from './explain.js'
|
||||
|
||||
const PAPER_PX = 794 // 210 mm at 96 dpi
|
||||
|
||||
// Light/dark choice for the sheet, shared by all sheets and switches on the page and remembered in the browser
|
||||
const THEME_KEY = 'aw_sheettheme'
|
||||
const listeners = new Set()
|
||||
const readTheme = () => { try { return localStorage.getItem(THEME_KEY) === 'dark' } catch { return false } }
|
||||
let darkNow = readTheme()
|
||||
export function useSheetTheme() {
|
||||
const dark = useSyncExternalStore((cb) => { listeners.add(cb); return () => listeners.delete(cb) }, () => darkNow)
|
||||
const set = (d) => { darkNow = d; try { localStorage.setItem(THEME_KEY, d ? 'dark' : 'light') } catch { /* storage may be blocked */ } listeners.forEach((f) => f()) }
|
||||
return [dark, set]
|
||||
}
|
||||
export function ThemeSwitch() {
|
||||
const [dark, setDark] = useSheetTheme()
|
||||
return (
|
||||
<span className="seg" role="group" aria-label="Sheet colours">
|
||||
<button className={dark ? 'ghost' : 'on'} onClick={() => setDark(false)} title="Light sheet">Light</button>
|
||||
<button className={dark ? 'on' : 'ghost'} onClick={() => setDark(true)} title="Dark sheet (prints light)">Dark</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Opens the sheet pages in a clean window (only the paper, A4, no margins) and starts the print dialog:
|
||||
// "Save as PDF" there gives an A4 PDF that looks exactly like the screen.
|
||||
export function printSheet(root, title) {
|
||||
const win = window.open('', '_blank')
|
||||
if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return }
|
||||
const clone = root.cloneNode(true)
|
||||
const src = root.querySelectorAll('textarea'), dst = clone.querySelectorAll('textarea')
|
||||
dst.forEach((t, i) => { t.textContent = src[i].value }) // typed text does not survive cloning on its own
|
||||
clone.style.zoom = ''
|
||||
clone.classList.remove('dark') // printed pages are always light
|
||||
const head = [...document.querySelectorAll('link[rel=stylesheet], style')].map((n) => n.outerHTML).join('')
|
||||
win.document.open()
|
||||
win.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>${(title || 'Character').replace(/[<>&]/g, '')}</title>${head}
|
||||
<style>@page { size: A4; margin: 0 } html, body { background: #fff !important; margin: 0; padding: 0 }
|
||||
.papers { display: block !important; gap: 0 !important } .paper { border: 0 !important; box-shadow: none !important; margin: 0 !important; min-height: 296.5mm !important; break-after: page; }
|
||||
.paper:last-child { break-after: auto }</style></head><body>${clone.outerHTML}</body></html>`)
|
||||
win.document.close()
|
||||
const go = () => { win.focus(); win.print() }
|
||||
const links = [...win.document.querySelectorAll('link[rel=stylesheet]')]
|
||||
let pending = links.length
|
||||
if (!pending) return 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
|
||||
function Fit({ children, rootRef, dark }) {
|
||||
const wrap = useRef(null)
|
||||
const [z, setZ] = useState(1)
|
||||
useLayoutEffect(() => {
|
||||
const f = () => wrap.current && setZ(Math.min(1, wrap.current.clientWidth / PAPER_PX))
|
||||
f()
|
||||
const ro = new ResizeObserver(f)
|
||||
ro.observe(wrap.current)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
return <div ref={wrap} className="paperfit"><div ref={rootRef} className={`papers${dark ? ' dark' : ''}`} style={{ zoom: z }}>{children}</div></div>
|
||||
}
|
||||
|
||||
const Pips = ({ n, locked }) => (
|
||||
<span className={`ppips ${locked ? 'locked' : ''}`} aria-label={n === 2 ? 'Expertise' : n === 1 ? 'Proficient' : locked ? 'Locked' : ''}>
|
||||
<i className={n >= 1 ? 'on' : ''} /><i className={n >= 2 ? 'on' : ''} />
|
||||
</span>
|
||||
)
|
||||
|
||||
// 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, onNotes }) {
|
||||
const ch = useMemo(() => normalize(data, baseRules), [data, baseRules])
|
||||
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
|
||||
const c = useMemo(() => compute(ch, rules), [ch, rules])
|
||||
const x = { ch, rules, c }
|
||||
const root = useRef(null)
|
||||
const [dark] = useSheetTheme()
|
||||
|
||||
const prof = rules.professions.find((p) => p.id === ch.profession)
|
||||
const cls = rules.classes.find((k) => k.id === ch.class_id)
|
||||
const sub = cls?.subclasses.find((s) => s.id === ch.subclass_id)
|
||||
const preset = ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name || 'Custom'
|
||||
const short = (s) => rules.attributes.filter((a) => s.attrs.includes(a.id)).map((a) => a.short).join('/')
|
||||
const talents = rules.talents.filter((t) => ch.talents.includes(t.id))
|
||||
const augments = rules.augments.filter((a) => ch.augments.includes(a.id))
|
||||
const gear = kitItems(ch, rules)
|
||||
const name = ch.name || 'Nameless'
|
||||
const sign = (n) => (n > 0 ? `+${n}` : `${n}`)
|
||||
// With onRoll (own character in a lobby) attributes and usable skills can be clicked to roll a check
|
||||
const roll = (kind, id, label, ok = true) => (onRoll && ok ? {
|
||||
className: 'rollable', role: 'button', tabIndex: 0,
|
||||
onClick: () => onRoll(kind, id, label), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRoll(kind, id, label) } },
|
||||
} : {})
|
||||
|
||||
const Field = ({ label, children }) => <div className="pfield"><span>{label}</span><b>{children || '–'}</b></div>
|
||||
const Foot = ({ n }) => <div className="pfoot"><span>Arcane Wasteland</span><span>{name}</span><span>Page {n} / 2</span></div>
|
||||
|
||||
return (
|
||||
<div className="papersheet">
|
||||
{!bare && (
|
||||
<div className="papertools">
|
||||
{tools}
|
||||
<span className="toolright">
|
||||
<ThemeSwitch />
|
||||
<button className="ghost" onClick={() => printSheet(root.current, name)} title="Opens the pages for printing, choose Save as PDF there">Print / PDF (A4)</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Fit rootRef={root} dark={dark}>
|
||||
<section className="paper">
|
||||
{(() => {
|
||||
const head = (
|
||||
<header className="phead">
|
||||
<div className="pname">{name}</div>
|
||||
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
|
||||
</header>
|
||||
)
|
||||
const fields = (
|
||||
<div className="pfields">
|
||||
<Field label="Player">{ch.player}</Field>
|
||||
<Field label="Concept">{ch.concept}</Field>
|
||||
<Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field>
|
||||
<Field label="Profession">{prof?.name}</Field>
|
||||
<Field label="Rules">{preset}</Field>
|
||||
</div>
|
||||
)
|
||||
return ch.portrait
|
||||
? <div className="pidentity"><img className="pportrait" src={ch.portrait} alt="" /><div className="pidright">{head}{fields}</div></div>
|
||||
: <>{head}{fields}</>
|
||||
})()}
|
||||
|
||||
<div className="pattrs">
|
||||
{rules.attributes.map((a) => (
|
||||
<div key={a.id} {...roll('attr', a.id, a.name)} className={`pattr${onRoll ? ' rollable' : ''}`}>
|
||||
<Tip text={explainAttr(a, x)}><span className="pattr-n">{a.name}</span></Tip>
|
||||
<b className="pattr-v">{c.attrs[a.id]}</b>
|
||||
<span className="pattr-m">Mod {sign(c.mods[a.id])}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pderived">
|
||||
{rules.derived.map((d) => (
|
||||
<div key={d.id} className="pstat">
|
||||
<Tip text={explainDerived(d, x)}><span>{d.name}</span></Tip>
|
||||
<b>{c.derived[d.id]}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="psec">
|
||||
<h3>Skills <small>● proficient · ●● expertise · attribute · points · total</small></h3>
|
||||
<div className="pskills">
|
||||
{rules.skill_groups.map((g) => (
|
||||
<div key={g.id} className="pgroup">
|
||||
<h4>{g.name}</h4>
|
||||
{c.skills.filter((s) => s.group === g.id).map((s) => (
|
||||
<div key={s.id} {...roll('skill', s.id, s.name, s.usable)} className={`prow ${s.usable ? '' : 'locked'}${onRoll && s.usable ? ' rollable' : ''}`}>
|
||||
<Pips n={s.pips} locked={!s.usable} />
|
||||
<Tip text={explainSkill(s, x)}><span className="pn">{s.name}{s.type === 'advanced' && <em> adv</em>}</span></Tip>
|
||||
<span className="pa">{short(s)}</span>
|
||||
<span className="pp">{s.points || ''}</span>
|
||||
<b className="pt">{s.total}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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} />
|
||||
</section>
|
||||
|
||||
<section className="paper">
|
||||
<header className="phead small">
|
||||
<div className="pname">{name}</div>
|
||||
<div className="pbrand">{[cls?.name, prof?.name].filter(Boolean).join(' · ')}</div>
|
||||
</header>
|
||||
|
||||
<div className="psec">
|
||||
<h3>Talents</h3>
|
||||
{talents.length === 0 && <p className="pnone">–</p>}
|
||||
<div className="pcols">
|
||||
{talents.map((t) => <div key={t.id} className="pitem"><b>{t.name}</b><p>{t.desc}</p></div>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="psec">
|
||||
<h3><Tip text={explainCapacity(x)}>Mutations & implants</Tip> <small>Capacity {c.capacityUsed} / {c.capacityMax}</small></h3>
|
||||
{augments.length === 0 && <p className="pnone">–</p>}
|
||||
<div className="pcols">
|
||||
{augments.map((a) => (
|
||||
<div key={a.id} className="pitem"><b>{a.name}</b> <span className="ptag">{a.kind === 'implant' ? 'Implant' : 'Mutation'} · cost {a.cost}</span><p>{a.desc}</p></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="psec">
|
||||
<h3><Tip text={explainCarry(x)}>Equipment</Tip> <small>Carried {c.weight} / {c.carryMax} kg</small></h3>
|
||||
{gear.length === 0 && <p className="pnone">–</p>}
|
||||
{gear.length > 0 && (
|
||||
<table className="pgear"><thead><tr><th>Item</th><th>Type</th><th>Stats</th><th>Qty</th><th>kg</th></tr></thead>
|
||||
<tbody>{gear.map((g) => (
|
||||
<tr key={g.item.id}><td><b>{g.item.name}</b>{g.item.note && <span className="pnote"> {g.item.note}</span>}</td><td>{g.label}</td><td>{g.item.stat}</td>
|
||||
<td>{g.qty}</td><td>{Math.round(g.item.kg * g.qty * 10) / 10}</td></tr>
|
||||
))}</tbody></table>
|
||||
)}
|
||||
{ch.gear?.other && <p className="pother"><b>Other:</b> {ch.gear.other}</p>}
|
||||
</div>
|
||||
|
||||
{ch.background && <div className="psec"><h3>Background</h3><p className="pbg">{ch.background}</p></div>}
|
||||
|
||||
<Notes title="Notes" value={ch.notes} onChange={onNotes && ((v) => onNotes({ notes: v }))} />
|
||||
<Foot n={2} />
|
||||
</section>
|
||||
</Fit>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
|
||||
const [msg, setMsg] = useState('')
|
||||
const presets = rules.presets || []
|
||||
const presets = (rules.presets || []).filter((p) => !p.npc)
|
||||
const current = draft || presets.find((p) => p.id === sel) || presets[0]
|
||||
const editable = !!draft
|
||||
const own = !!current?.mine
|
||||
|
||||
@@ -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 = {}
|
||||
rules.attributes.forEach((a) => (attributes[a.id] = rules.creation.attr_start))
|
||||
return {
|
||||
name: '', player: '', concept: '', background: '', class_id: '', subclass_id: '',
|
||||
name: '', player: '', concept: '', background: '', portrait: '', class_id: '', subclass_id: '',
|
||||
attributes,
|
||||
profession: '', profession_bonus_attr: '', profession_picks: [],
|
||||
preset: rules.default_preset || 'standard', creation: {}, // creation = overrides of rules.creation for this character
|
||||
@@ -14,7 +14,7 @@ export function emptyCharacter(rules) {
|
||||
talents: [], augments: [],
|
||||
kit: { extras: {} }, // item id -> quantity
|
||||
gear: { other: '' },
|
||||
notes: '',
|
||||
notes: '', conditions: '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +124,9 @@ export function compute(ch, rules) {
|
||||
const pips = profPip + freePips
|
||||
const prof = [0, cr.proficiency_bonus, cr.expertise_bonus][pips]
|
||||
const best = Math.max(...s.attrs.map((a) => attrs[a])) // with several attributes the higher one counts
|
||||
const base = s.type === 'advanced' ? Math.floor((best * (cr.advanced_attr_percent ?? 100)) / 100) : best // advanced skills only use part of the attribute
|
||||
const unlocked = ch.unlocked.includes(s.id)
|
||||
return { ...s, points, prof, profPip, freePips, pips, unlocked, total: best + points + prof,
|
||||
return { ...s, points, prof, profPip, freePips, pips, unlocked, base, total: base + points + prof,
|
||||
usable: s.type === 'basic' || unlocked || profPip > 0, // advanced skills must be unlocked (a profession proficiency does it for free)
|
||||
free: s.type === 'advanced' && profPip > 0, // unlocked by the profession, costs nothing
|
||||
max: Math.floor(best / cr.skill_max_divisor) }
|
||||
@@ -181,7 +182,7 @@ export function setProfessionPicks(ch, rules, picks) {
|
||||
export function resetStep(ch, rules, step) {
|
||||
const e = emptyCharacter(rules)
|
||||
switch (step) {
|
||||
case 0: return { ...ch, name: '', player: '', concept: '', background: '', class_id: '', subclass_id: '' }
|
||||
case 0: return { ...ch, name: '', player: '', concept: '', background: '', portrait: '', class_id: '', subclass_id: '' }
|
||||
case 1: return { ...ch, attributes: e.attributes }
|
||||
case 2: return pruneLocked({ ...ch, proficiencies: stripProfessionProfs(ch, rules), profession: '', profession_bonus_attr: '', profession_picks: [] }, rules)
|
||||
case 3: return { ...ch, skill_points: {}, unlocked: [], proficiency_picks: {} }
|
||||
|
||||
@@ -29,7 +29,7 @@ export function explainSkill(s, { ch, rules, c }) {
|
||||
const list = s.attrs.map((id) => `${attrShort(rules, id)} ${c.attrs[id]}`).join(', ')
|
||||
const training = s.pips === 2 ? `Expertise +${s.prof}` : s.pips === 1 ? `Proficient +${s.prof}` : 'no training +0'
|
||||
const lines = [
|
||||
`Target number = best attribute ${best} (${s.attrs.length > 1 ? `higher of ${list}` : list}) + ${s.points} skill points + ${training} = ${s.total}`,
|
||||
`Target number = ${s.type === 'advanced' && s.base !== best ? `${cr.advanced_attr_percent}% of the best attribute (${best}) = ${s.base}` : `best attribute ${best}`} (${s.attrs.length > 1 ? `higher of ${list}` : list}) + ${s.points} skill points + ${training} = ${s.total}`,
|
||||
`Most points you can put in: best attribute ÷ ${cr.skill_max_divisor}, rounded down = ${s.max}`,
|
||||
]
|
||||
if (s.type === 'advanced') {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -169,3 +169,505 @@ tr.dim td { opacity: .55; }
|
||||
.tipwrap:hover .tipicon, .tipwrap:focus .tipicon { color: var(--noxium); border-color: var(--noxium); }
|
||||
.tippop { position: fixed; z-index: 1000; max-width: 340px; padding: .55rem .75rem; background: var(--panel2); color: var(--text); border: 1px solid var(--diesium); border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.35); font-size: .85rem; line-height: 1.4; pointer-events: none; }
|
||||
.tippop div + div { margin-top: .35rem; }
|
||||
|
||||
/* lobbies */
|
||||
a.button { display: inline-block; font-weight: 600; text-decoration: none; background: var(--noxium); color: #12150f; border: 1px solid var(--noxium); padding: .5rem 1rem; border-radius: var(--radius); }
|
||||
a.button:hover { filter: brightness(1.1); }
|
||||
.wrapform { flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
|
||||
.wrapform > input, .wrapform > select { flex: 1 1 12rem; width: auto; }
|
||||
.lobbycard { display: grid; gap: .45rem; }
|
||||
.lobbycard.me { border-color: var(--noxium); }
|
||||
.lobbycard p { margin: 0; }
|
||||
.joincode { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1.5rem; letter-spacing: .15em; }
|
||||
span.chip { border: 1px solid var(--line); font-size: .85rem; padding: .1rem .6rem; }
|
||||
.lobbycard .chips { gap: .4rem; }
|
||||
h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
|
||||
|
||||
/* lobby page: tabs, chat, sheet */
|
||||
.lobbypage { max-width: 980px; }
|
||||
.tabs { display: flex; flex-wrap: wrap; gap: .3rem; border-bottom: 1px solid var(--line); margin: .8rem 0 1rem; }
|
||||
.tabs button { background: transparent; color: var(--text); border: 1px solid transparent; border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; font-weight: 500; padding: .45rem .9rem; margin-bottom: -1px; }
|
||||
.tabs button.on { background: var(--panel); border-color: var(--line); border-bottom: 1px solid var(--panel); color: var(--text); font-weight: 700; }
|
||||
.chatwrap { display: grid; gap: .6rem; }
|
||||
.chatbox { height: min(55vh, 520px); overflow-y: auto; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: .6rem .8rem; display: flex; flex-direction: column; gap: .25rem; }
|
||||
.msg { overflow-wrap: anywhere; line-height: 1.35; }
|
||||
.msg .time { color: var(--muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
|
||||
.msg.event { color: var(--muted); font-size: .85rem; font-style: italic; }
|
||||
.msg.mine strong { color: var(--noxium); }
|
||||
.msg.whisper { background: var(--panel2); border-left: 3px solid #8a63c7; padding: .15rem .5rem; border-radius: 4px; }
|
||||
.msg.whisper em { color: #8a63c7; }
|
||||
.chatform { flex-wrap: nowrap; gap: .5rem; }
|
||||
.chatform select { width: 12rem; flex: 0 0 auto; }
|
||||
.chatform select.whisper { border-color: #8a63c7; }
|
||||
.chatform input { flex: 1 1 auto; width: auto; min-width: 0; }
|
||||
.sheetcols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 2rem; }
|
||||
.sheetview h4 { margin: 1rem 0 .3rem; }
|
||||
.sheetview table { width: 100%; }
|
||||
@media (max-width: 760px) { .sheetcols { grid-template-columns: 1fr; } .chatform { flex-wrap: wrap; } .chatform select { width: 100%; } }
|
||||
|
||||
/* lobby desktop layout: main area left, chat column fixed at the right edge of the window, dice bar bottom centre of the main area */
|
||||
:root { --chatw: 380px; --hdr: 3.7rem; }
|
||||
body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(--chatw) + 1.5rem); }
|
||||
.lobbypage { max-width: none; margin: 0; padding: 0; }
|
||||
.lobbychat { position: fixed; right: 0; top: var(--hdr); bottom: 0; width: var(--chatw); z-index: 10; border-radius: 0; border-width: 0 0 0 1px; padding: .8rem; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.lobbychat .chatwrap { flex: 1; min-height: 0; grid-template-rows: minmax(0, 1fr) auto auto auto auto; }
|
||||
.lobbychat .chatbox { height: auto; min-height: 0; }
|
||||
.lobbychat .chatform { flex-wrap: wrap; }
|
||||
.lobbychat .chatform select { width: 100%; flex-basis: 100%; }
|
||||
.lobbychat .chatform input { flex: 1 1 8rem; }
|
||||
.partygrid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
.msg.rollmsg .rollres b { font-size: 1.05rem; color: var(--noxium); }
|
||||
.whispertag { color: #8a63c7; }
|
||||
|
||||
|
||||
.dicecount { display: flex; align-items: center; gap: .3rem; margin-right: .3rem; }
|
||||
.dicecount select { width: auto; padding: .25rem .4rem; }
|
||||
.diebtn { display: flex; flex-direction: column; align-items: center; gap: 0; padding: .2rem .5rem; border-radius: 12px; min-width: 3.1rem; font-size: .75rem; line-height: 1.1; }
|
||||
.diebtn:hover:not(:disabled) { border-color: var(--noxium); color: var(--noxium); }
|
||||
.dicedest { margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); }
|
||||
.dicedest.priv { color: #8a63c7; font-weight: 600; }
|
||||
.trayroll { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: .5rem .9rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .25); animation: trayin .25s ease-out; }
|
||||
.trayrow { display: flex; align-items: center; gap: .5rem; margin-top: .3rem; flex-wrap: wrap; justify-content: center; max-width: 30rem; }
|
||||
.traytotal { font-weight: 700; font-size: 1.2rem; margin-left: .3rem; }
|
||||
.die { display: inline-flex; align-items: center; justify-content: center; width: 2.9rem; height: 2.9rem; background: var(--dc, var(--noxium)); color: #12150f; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.die.d4 { clip-path: polygon(50% 4%, 96% 92%, 4% 92%); padding-top: .55rem; }
|
||||
.die.d6 { border-radius: 12px; }
|
||||
.die.d8 { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
|
||||
.die.d10 { clip-path: polygon(50% 0, 98% 40%, 50% 100%, 2% 40%); }
|
||||
.die.d12 { clip-path: polygon(50% 0, 98% 38%, 80% 96%, 20% 96%, 2% 38%); }
|
||||
.die.d20 { clip-path: polygon(25% 4%, 75% 4%, 100% 50%, 75% 96%, 25% 96%, 0 50%); }
|
||||
.die.d100 { border-radius: 50%; }
|
||||
.die.rolling { animation: tumble .32s linear infinite; filter: brightness(.9); }
|
||||
.die.landed { animation: land .25s ease-out; }
|
||||
@keyframes tumble { 0% { transform: rotate(0) translateY(0); } 25% { transform: rotate(90deg) translateY(-8px); } 50% { transform: rotate(180deg) translateY(0); } 75% { transform: rotate(270deg) translateY(-5px); } 100% { transform: rotate(360deg) translateY(0); } }
|
||||
@keyframes land { 0% { transform: scale(1.35); } 100% { transform: scale(1); } }
|
||||
@keyframes trayin { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { .die.rolling, .die.landed, .trayroll { animation: none; } }
|
||||
|
||||
.lobbycard { align-content: start; }
|
||||
.lobbycard .chips { align-items: flex-start; align-content: flex-start; }
|
||||
.lobbycard .chip { align-self: flex-start; }
|
||||
|
||||
/* ---- A4 character sheet (fixed colours on purpose: it is printed on white paper) ---- */
|
||||
.papertools { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: 0 0 .5rem; }
|
||||
.papertools .toolright { margin-left: auto; display: flex; gap: .5rem; align-items: center; }
|
||||
.papertools button { padding: .3rem .8rem; }
|
||||
.seg { display: inline-flex; }
|
||||
.seg button { border-radius: 0; padding: .4rem .9rem; }
|
||||
.seg button:first-child { border-radius: var(--radius) 0 0 var(--radius); }
|
||||
.seg button:last-child { border-radius: 0 var(--radius) var(--radius) 0; margin-left: -1px; }
|
||||
.seg button.on { background: var(--noxium); color: #12150f; border-color: var(--noxium); }
|
||||
.paperfit { width: 100%; }
|
||||
.papers { display: grid; gap: 14px; justify-items: start; width: 210mm;
|
||||
--pi: #1a1f14; --pg: #2f6a0d; --pbar: #2f6a0d; --pm: #6a7260; --pl: #cfd5c3; --pd: #c3cab6; --pt: #eef3e6; --pb: #fff; --p2: #4d5645; --p3: #3b4433; --p4: #5c6553; --pn: #b9c1ab; }
|
||||
.papers.dark { --pi: #e4e9d8; --pg: #8fd44c; --pbar: #3c7a16; --pm: #98a28a; --pl: #343b2d; --pd: #353c2e; --pt: #252b1f; --pb: #161a12; --p2: #b4bda6; --p3: #c5cdb7; --p4: #a1aa93; --pn: #454d3a; }
|
||||
.papers.dark .paper { box-shadow: 0 2px 12px rgba(0, 0, 0, .5); }
|
||||
.paper { box-sizing: border-box; width: 210mm; min-height: 297mm; padding: 11mm 12mm 8mm; background: var(--pb); color: var(--pi); border: 1px solid var(--pl); box-shadow: 0 2px 12px rgba(0, 0, 0, .14);
|
||||
display: flex; flex-direction: column; gap: 3.5mm; font-size: 8.6pt; line-height: 1.25; break-after: page; }
|
||||
.paper * { color: inherit; }
|
||||
.paper .tipped { border-bottom: 0; text-decoration: none; cursor: default; }
|
||||
.paper p { margin: 0; }
|
||||
.phead { display: flex; justify-content: space-between; align-items: flex-end; gap: 8mm; border-bottom: 2px solid var(--pg); padding-bottom: 2mm; }
|
||||
.pname { font-size: 24pt; font-weight: 800; line-height: 1.05; letter-spacing: .01em; overflow-wrap: anywhere; }
|
||||
.phead.small .pname { font-size: 15pt; }
|
||||
.pbrand { text-align: right; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; font-size: 8.5pt; color: var(--pg) !important; white-space: nowrap; }
|
||||
.pbrand span { display: block; font-weight: 500; letter-spacing: .08em; font-size: 7pt; color: var(--pm) !important; }
|
||||
.pfields { display: grid; grid-template-columns: 1fr 1.6fr 1.3fr 1fr .8fr; gap: 3mm; }
|
||||
.pfield { border-bottom: 1px solid var(--pi); padding-bottom: .6mm; min-width: 0; }
|
||||
.pfield span { display: block; font-size: 6.2pt; text-transform: uppercase; letter-spacing: .1em; color: var(--pm) !important; }
|
||||
.pfield b { font-weight: 600; font-size: 9pt; overflow-wrap: anywhere; }
|
||||
.pattrs { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2.5mm; }
|
||||
.pattr { border: 1.4px solid var(--pi); border-radius: 3mm; text-align: center; padding: 1.6mm 1mm 1.4mm; display: grid; gap: .2mm; }
|
||||
.pattr-n { font-size: 6.6pt; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||
.pattr-v { font-size: 20pt; line-height: 1; }
|
||||
.pattr-m { font-size: 7pt; color: var(--p2) !important; border-top: 1px solid var(--pl); padding-top: .8mm; }
|
||||
.pderived { display: grid; grid-template-columns: repeat(auto-fit, minmax(28mm, 1fr)); gap: 2.5mm; }
|
||||
.pstat { background: var(--pt); border-radius: 2mm; padding: 1.4mm 2.4mm; display: flex; justify-content: space-between; align-items: baseline; font-weight: 600; font-size: 8pt; }
|
||||
.pstat b { font-size: 13pt; }
|
||||
.psec h3 { margin: 0 0 1.6mm; font-size: 9pt; text-transform: uppercase; letter-spacing: .12em; color: #fff !important; background: var(--pbar); padding: .8mm 2.4mm; border-radius: 1.4mm; display: flex; justify-content: space-between; align-items: center; gap: 3mm; }
|
||||
.psec h3 * { color: #fff !important; }
|
||||
.psec h3 small { font-size: 6.6pt; font-weight: 500; letter-spacing: .04em; text-transform: none; }
|
||||
.pskills { column-count: 2; column-gap: 6mm; }
|
||||
.pgroup { break-inside: avoid; margin-bottom: 3mm; }
|
||||
.pgroup h4 { margin: 0 0 .6mm; font-size: 7.4pt; text-transform: uppercase; letter-spacing: .1em; color: var(--pg) !important; border-bottom: 1px solid var(--pg); }
|
||||
.prow { display: grid; grid-template-columns: 6.5mm 1fr 11mm 6mm 8mm; align-items: center; gap: 1mm; padding: .35mm 0; border-bottom: 1px dotted var(--pd); }
|
||||
.prow .pn { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
||||
.prow .pn em { font-size: 6pt; color: var(--pm) !important; font-style: normal; text-transform: uppercase; letter-spacing: .05em; margin-left: .8mm; }
|
||||
.prow .pa { font-size: 6.4pt; color: var(--pm) !important; text-align: center; }
|
||||
.prow .pp { font-size: 7pt; color: var(--p2) !important; text-align: right; }
|
||||
.prow .pt { text-align: center; border: 1px solid var(--pi); border-radius: 1.4mm; font-size: 8.6pt; padding: .1mm 0; background: var(--pb); }
|
||||
.prow.locked { opacity: .45; }
|
||||
.prow.locked .pn { text-decoration: line-through; }
|
||||
.ppips { display: inline-flex; gap: .7mm; }
|
||||
.ppips i { width: 2.1mm; height: 2.1mm; border: 1px solid var(--pi); border-radius: 50%; background: var(--pb); display: inline-block; }
|
||||
.ppips i.on { background: var(--pg); border-color: var(--pg); }
|
||||
.pcols { column-count: 2; column-gap: 6mm; }
|
||||
.pitem { break-inside: avoid; margin-bottom: 2.2mm; }
|
||||
.pitem b { font-size: 9pt; }
|
||||
.pitem p { font-size: 7.8pt; color: var(--p3) !important; }
|
||||
.ptag { font-size: 6.6pt; color: var(--pm) !important; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.pnone { color: var(--pm) !important; }
|
||||
.pgear { width: 100%; border-collapse: collapse; font-size: 8pt; }
|
||||
.pgear th { text-align: left; font-size: 6.4pt; text-transform: uppercase; letter-spacing: .08em; color: var(--pm) !important; border-bottom: 1px solid var(--pi); padding: .6mm 1mm; }
|
||||
.pgear td { padding: .45mm 1mm; border-bottom: 1px dotted var(--pd); vertical-align: top; }
|
||||
.pgear td:nth-child(n+4), .pgear th:nth-child(n+4) { text-align: right; white-space: nowrap; }
|
||||
.pnote { font-size: 6.6pt; color: var(--p4) !important; }
|
||||
.pother { margin-top: 1.6mm; font-size: 8pt; }
|
||||
.pbg { font-size: 8pt; white-space: pre-wrap; }
|
||||
.pnotes { flex: 1; display: flex; flex-direction: column; min-height: 18mm; }
|
||||
.plines { flex: 1; background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 6.4mm, var(--pn) 6.4mm, var(--pn) calc(6.4mm + 1px)); border-radius: 1mm; }
|
||||
.pfoot { margin-top: auto; display: flex; justify-content: space-between; font-size: 6.4pt; color: var(--pm) !important; text-transform: uppercase; letter-spacing: .08em; border-top: 1px solid var(--pl); padding-top: 1.2mm; }
|
||||
@media print { .paper { box-shadow: none; } }
|
||||
|
||||
/* lobby header: one slim line instead of three */
|
||||
.lobbyhead { display: flex; align-items: baseline; gap: .8rem; flex-wrap: wrap; margin: 0 0 .3rem; }
|
||||
.lobbyhead h1 { font-size: 1.3rem; margin: 0; }
|
||||
.lobbypage .tabs { margin: .4rem 0 .7rem; }
|
||||
.lobbypage .tabs button { padding: .3rem .8rem; }
|
||||
|
||||
.tabs { align-items: center; }
|
||||
.tabs .tabright { margin-left: auto; align-self: center; margin-bottom: .3rem; }
|
||||
.tabs .tabright button { padding: .25rem .75rem; margin-bottom: 0; border-radius: 0; border-bottom: 1px solid var(--line); }
|
||||
.tabs .tabright button.on { border-color: var(--noxium); }
|
||||
.tabs .tabright button:first-child { border-radius: var(--radius) 0 0 var(--radius); }
|
||||
.tabs .tabright button:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
|
||||
|
||||
/* lobby: three columns - sheet | player area | chat (fixed at the right edge) */
|
||||
body:has(.lobbychat) main { padding: .5rem calc(var(--chatw) + .5rem) 0 .5rem; }
|
||||
.lobbyhead { margin: 0 0 .4rem; } .lobbyhead .tabright { margin-left: auto; }
|
||||
.lobbygrid { display: grid; grid-template-columns: clamp(480px, 42vw, 800px) minmax(0, 1fr); gap: .8rem; align-items: start; }
|
||||
.lobbygrid.nosheet { grid-template-columns: minmax(0, 1fr); }
|
||||
.sheetcol { position: sticky; top: calc(var(--hdr) + .4rem); max-height: calc(100vh - var(--hdr) - 3.2rem); overflow-y: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); padding: .5rem; }
|
||||
.sheetbar { display: flex; align-items: center; gap: .5rem; margin-bottom: .4rem; } .sheetbar .tabright { margin-left: auto; display: flex; gap: .4rem; align-items: center; }
|
||||
.sheetbar .seg button { padding: .2rem .6rem; }
|
||||
.playarea { display: flex; flex-direction: column; gap: .6rem; min-width: 0; height: calc(100vh - var(--hdr) - 3.2rem); position: sticky; top: calc(var(--hdr) + .4rem); }
|
||||
.showsheet { align-self: flex-start; }
|
||||
.charbar { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||
.pcard { display: flex; align-items: center; gap: .55rem; padding: .35rem .6rem; border: 2px solid var(--line); border-left: 6px solid var(--pc); border-radius: var(--radius); background: var(--panel); min-width: 11rem; }
|
||||
.pcard.can { cursor: pointer; } .pcard.can:hover { background: var(--panel2); }
|
||||
.pcard.sel { border-color: var(--pc); background: var(--panel2); box-shadow: 0 0 0 1px var(--pc); }
|
||||
.pavatar { width: 2.2rem; height: 2.2rem; border-radius: 50%; background: var(--pc); color: #12150f; font-weight: 800; display: grid; place-items: center; flex: none; }
|
||||
.pinfo { display: grid; line-height: 1.2; min-width: 0; } .pinfo b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.modin { width: 4rem; padding: .2rem .3rem; margin-left: auto; text-align: center; }
|
||||
.modchip { margin-left: auto; padding: .05rem .5rem; border-radius: 999px; font-weight: 700; border: 1px solid var(--line); } .modchip.up { color: var(--noxium); } .modchip.down { color: var(--danger); }
|
||||
.diffbar { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; padding: .3rem .6rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
|
||||
.diffbar label { display: flex; align-items: center; gap: .4rem; } .diffbar select { width: auto; padding: .25rem .4rem; }
|
||||
.table { position: relative; flex: 1; min-height: 14rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto;
|
||||
background: radial-gradient(ellipse at center, color-mix(in srgb, var(--noxium) 10%, var(--panel)) 0%, var(--panel) 75%); }
|
||||
.tablehint { position: absolute; top: .6rem; left: 0; right: 0; text-align: center; margin: 0; }
|
||||
.tableerr { position: absolute; bottom: .5rem; left: 0; right: 0; text-align: center; }
|
||||
.dicetray { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 3; display: grid; gap: .6rem; justify-items: center; pointer-events: none; max-width: 95%; }
|
||||
.trayroll { border-top: 3px solid var(--dc, var(--noxium)); }
|
||||
.trayout { display: grid; line-height: 1.2; margin-left: .5rem; } .trayout .out { font-weight: 800; }
|
||||
.die.mystery { filter: grayscale(.4); }
|
||||
.dicebar { display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; justify-content: center; background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: .4rem .8rem; align-self: center; max-width: 100%; }
|
||||
.ownswitch { display: flex; align-items: center; gap: .3rem; margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); }
|
||||
.dicebar .seg button { padding: .2rem .6rem; }
|
||||
.manual { position: absolute; left: 50%; top: 30%; transform: translateX(-50%); z-index: 4; display: grid; gap: .4rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .35); }
|
||||
.settings { position: absolute; inset: .6rem; z-index: 6; overflow: auto; align-content: start; display: grid; gap: .5rem; max-width: 34rem; margin: 0 auto; height: fit-content; max-height: calc(100% - 1.2rem); }
|
||||
.swatches { display: flex; gap: .4rem; flex-wrap: wrap; } .swatchbtn { width: 2rem; height: 2rem; border-radius: 50%; padding: 0; border: 2px solid var(--line); } .swatchbtn.on { border-color: var(--text); box-shadow: 0 0 0 2px var(--panel), 0 0 0 4px var(--text); } .swatchbtn:disabled { opacity: .25; cursor: not-allowed; }
|
||||
.swatch { display: inline-block; width: .8rem; height: .8rem; border-radius: 50%; vertical-align: middle; }
|
||||
.out.crit_success { color: var(--noxium); font-weight: 800; } .out.success { color: var(--noxium); } .out.failure { color: var(--danger); } .out.crit_failure { color: var(--danger); font-weight: 800; }
|
||||
.manualtag { color: var(--diesium); } .rollres.mystery { letter-spacing: .15em; font-weight: 700; color: var(--muted); }
|
||||
.papers .rollable { cursor: pointer; } .papers .rollable:hover { outline: 1.5px solid var(--pg); outline-offset: 1px; background: color-mix(in srgb, var(--pg) 12%, transparent); }
|
||||
@media (max-width: 1100px) {
|
||||
body:has(.lobbychat) main { padding: .5rem; }
|
||||
.lobbychat { position: static; width: auto; height: 28rem; border-width: 1px; border-radius: var(--radius); margin-top: 1rem; }
|
||||
.lobbygrid { grid-template-columns: minmax(0, 1fr); } .playarea { order: -1; height: 32rem; position: static; } .sheetcol { position: static; max-height: none; }
|
||||
}
|
||||
|
||||
/* the table: dice fly in from a random side and rest at a random spot */
|
||||
.table { overflow: hidden; }
|
||||
.dicetray { inset: 0; top: 0; left: 0; transform: none; display: block; max-width: none; }
|
||||
.thrown { position: absolute; width: 2.9rem; height: 2.9rem; margin: -1.45rem 0 0 -1.45rem; transform: rotate(var(--er)); animation: throwin .8s cubic-bezier(.15, .7, .3, 1) both; filter: drop-shadow(0 4px 4px rgba(0, 0, 0, .35)); }
|
||||
@keyframes throwin { from { transform: translate(var(--sx), var(--sy)) rotate(var(--sr)) scale(1.3); opacity: .2; } to { transform: rotate(var(--er)); opacity: 1; } }
|
||||
.tablelog { position: absolute; left: .6rem; bottom: .5rem; display: grid; gap: .25rem; max-width: 70%; }
|
||||
.tablelog .trayroll { padding: .2rem .6rem; border-radius: 8px; font-size: .85rem; background: color-mix(in srgb, var(--panel) 88%, transparent); border: 1px solid var(--line); border-top: 3px solid var(--line); box-shadow: none; }
|
||||
@media (prefers-reduced-motion: reduce) { .thrown { animation: none; } }
|
||||
.stepper .step5 { font-size: .75rem; padding: .25rem .4rem; min-width: 2.2rem; }
|
||||
|
||||
/* rules editor */
|
||||
.editor .edform { margin: .8rem 0; display: grid; gap: .6rem; }
|
||||
.edgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: .6rem .9rem; }
|
||||
.edfield { display: grid; gap: .2rem; }
|
||||
.edfield .multi { display: flex; flex-wrap: wrap; gap: .3rem .9rem; padding: .3rem 0; max-height: 11rem; overflow: auto; }
|
||||
.edtable td.actions { text-align: right; white-space: nowrap; }
|
||||
.edtable tr.sel { background: var(--panel2); }
|
||||
.editor .tabs { margin-top: .6rem; }
|
||||
|
||||
.settings.inline { position: static; max-width: none; margin: 0; border: 0; background: transparent; padding: 0; }
|
||||
|
||||
/* ---- maps */
|
||||
.table { background: color-mix(in srgb, var(--panel) 70%, #000); }
|
||||
.mapbox { position: absolute; inset: 0; overflow: hidden; touch-action: none; cursor: grab; background: #111; z-index: 1; }
|
||||
.mapbox:active { cursor: grabbing; }
|
||||
.mapworld { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; }
|
||||
.mapworld img { display: block; max-width: none; user-select: none; -webkit-user-drag: none; }
|
||||
.mapgrid { position: absolute; inset: 0; pointer-events: none; background-image: linear-gradient(to right, rgba(255,255,255,.28) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,.28) 1px, transparent 1px); }
|
||||
.maptoken { position: absolute; border-radius: 50%; background: var(--tc); border: 3px solid #fff; box-shadow: 0 0 0 2px rgba(0,0,0,.6), 0 2px 6px rgba(0,0,0,.6); display: grid; place-items: center; color: #fff; font-weight: 700; text-transform: uppercase; user-select: none; touch-action: none; cursor: default; }
|
||||
.maptoken.mov { cursor: move; }
|
||||
.maptoken.player { border-color: var(--tc); background: color-mix(in srgb, var(--tc) 55%, #000); box-shadow: 0 0 0 2px #fff, 0 2px 6px rgba(0,0,0,.6); }
|
||||
.maptoken.hid { opacity: .55; border-style: dashed; }
|
||||
.maptoken.sel { outline: 3px solid var(--accent, gold); outline-offset: 3px; }
|
||||
.maptoken span { pointer-events: none; text-shadow: 0 1px 2px #000; }
|
||||
.maptoken em { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); margin-top: 2px; font-style: normal; font-weight: 600; text-transform: none; white-space: nowrap; color: #fff; text-shadow: 0 0 3px #000, 0 0 3px #000; pointer-events: none; }
|
||||
.maptray { position: absolute; top: .5rem; left: .5rem; display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; padding: .3rem .5rem; border-radius: 8px; background: color-mix(in srgb, var(--panel) 90%, transparent); border: 1px solid var(--line); z-index: 4; max-width: 70%; }
|
||||
.traytoken { border-radius: 999px; border: 2px solid var(--tc); background: color-mix(in srgb, var(--tc) 40%, var(--panel)); color: var(--text); padding: .15rem .7rem; cursor: grab; touch-action: none; }
|
||||
.mapzoom { position: absolute; right: .5rem; bottom: .5rem; display: grid; gap: .25rem; z-index: 4; }
|
||||
.mapzoom button, .maptools button { background: color-mix(in srgb, var(--panel) 90%, transparent); }
|
||||
.maptools { position: absolute; top: .5rem; right: .5rem; display: flex; gap: .3rem; z-index: 4; }
|
||||
.tokenpanel { position: absolute; right: 3rem; bottom: .5rem; z-index: 5; width: 17rem; max-width: 60%; padding: .6rem; border-radius: 10px; background: var(--panel); border: 1px solid var(--line); cursor: default; }
|
||||
.tokenform { display: grid; gap: .45rem; }
|
||||
.tokenform input[type=number] { width: 4.5rem; }
|
||||
.swatches { display: flex; gap: .3rem; flex-wrap: wrap; }
|
||||
.swatch { width: 1.4rem; height: 1.4rem; padding: 0; border-radius: 50%; border: 2px solid transparent; }
|
||||
.swatch.on { border-color: var(--text); }
|
||||
.maplib { display: grid; gap: .5rem; margin-bottom: 1rem; }
|
||||
.uploadbtn { display: inline-block; padding: .4rem .8rem; border: 1px dashed var(--line); border-radius: 8px; cursor: pointer; text-align: center; }
|
||||
.maplist { list-style: none; padding: 0; margin: 0; display: grid; gap: .4rem; }
|
||||
.maplist li { padding: .5rem; border: 1px solid var(--line); border-radius: 8px; display: grid; gap: .3rem; }
|
||||
.maplist li.active { border-color: var(--accent, gold); }
|
||||
.mapeditor { position: fixed; inset: 0; z-index: 100; display: grid; grid-template-columns: 20rem 1fr; background: var(--bg); }
|
||||
.mapeditor aside { overflow-y: auto; border-radius: 0; display: grid; gap: .6rem; align-content: start; }
|
||||
.mapstage { position: relative; }
|
||||
.tokenlist { list-style: none; padding: 0; margin: 0; display: grid; gap: .25rem; }
|
||||
.tokenlist button { width: 100%; text-align: left; }
|
||||
.tokenlist i { display: inline-block; width: .8rem; height: .8rem; border-radius: 50%; vertical-align: middle; }
|
||||
@media (max-width: 800px) { .mapeditor { grid-template-columns: 1fr; grid-template-rows: 45% 1fr; } }
|
||||
.mapeditor, .mapeditor * { box-sizing: border-box; }
|
||||
.mapeditor { z-index: 1000; }
|
||||
.mapeditor .mapstage { min-width: 0; }
|
||||
label.chk { display: flex; align-items: center; gap: .45rem; }
|
||||
label.chk input[type=checkbox] { width: auto; margin: 0; }
|
||||
.mapeditor label.small:not(.chk) { display: grid; gap: .2rem; }
|
||||
.editactions { position: sticky; bottom: 0; background: var(--panel); padding: .5rem 0; }
|
||||
.mapbox.drawing { cursor: crosshair; }
|
||||
.mapworld { background: #000; }
|
||||
.wl { stroke: #ff4d4d; stroke-width: 3; stroke-linecap: round; }
|
||||
.wl.door { stroke: #ff9f1a; stroke-width: 5; }
|
||||
.wl.door.open { stroke: #4cd964; stroke-dasharray: 6 6; stroke-width: 4; }
|
||||
.wl.draft { stroke: #fff; stroke-dasharray: 5 5; stroke-width: 2; }
|
||||
.wldot { fill: #fff; }
|
||||
.toolrow { display: flex; flex-wrap: wrap; gap: .3rem; }
|
||||
.mapeditor input[type=number] { width: 100%; }
|
||||
.mapempty { position: absolute; inset: 0; display: grid; place-items: center; color: #ddd; z-index: 2; pointer-events: none; text-align: center; padding: 1rem; }
|
||||
.mapcards { list-style: none; padding: 0; margin: 1rem 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr)); gap: 1rem; }
|
||||
.mapcard { display: grid; grid-template-columns: 8rem 1fr; gap: .8rem; padding: .8rem; align-items: start; }
|
||||
.mapcard img { width: 8rem; height: 6rem; object-fit: cover; border-radius: 6px; background: #111; }
|
||||
.mapcardbody { display: grid; gap: .4rem; min-width: 0; }
|
||||
.mapcardbody .row { flex-wrap: wrap; }
|
||||
.maptools { flex-wrap: wrap; justify-content: flex-end; max-width: 60%; }
|
||||
.maptools button, .maptools select { padding: .2rem .55rem; font-size: .8rem; min-height: 0; white-space: nowrap; width: auto; }
|
||||
.maptray { max-width: 38%; }
|
||||
.traytoken { font-size: .85rem; }
|
||||
.soundctl { display: inline-flex; align-items: center; gap: .3rem; }
|
||||
.soundctl input[type=range] { width: 5rem; padding: 0; }
|
||||
.soundsettings { display: grid; gap: .5rem; margin-bottom: 1rem; }
|
||||
.soundrow { display: grid; gap: .25rem; padding: .4rem; border: 1px solid var(--line); border-radius: 8px; }
|
||||
.soundrow .row { justify-content: space-between; align-items: center; flex-wrap: wrap; }
|
||||
.uploadbtn.small { padding: .15rem .6rem; font-size: .85rem; }
|
||||
|
||||
/* ---- portraits */
|
||||
.portraitbox { display: block; }
|
||||
.portraitcur { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
||||
.portraitcur img, .portraitnone { width: 6.5rem; height: 6.5rem; border-radius: 10px; object-fit: cover; border: 1px solid var(--line); background: var(--bg); display: grid; place-items: center; }
|
||||
.portraitbtns { display: grid; gap: .4rem; justify-items: start; }
|
||||
.portraitcrop { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-start; }
|
||||
.cropwin { position: relative; overflow: hidden; border-radius: 10px; border: 2px solid var(--noxium, #7aa63c); background: #000; cursor: move; touch-action: none; flex: none; }
|
||||
.cropwin img { position: absolute; max-width: none; user-select: none; }
|
||||
.cropctl { display: grid; gap: .5rem; }
|
||||
.pavatar { overflow: hidden; border: 2px solid var(--pc); }
|
||||
.pavatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.maptoken .tokpic { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; }
|
||||
.adminusers { margin: .8rem 0; }
|
||||
.userlist { list-style: none; padding: 0; margin: .5rem 0 0; display: grid; gap: .4rem; }
|
||||
.userlist li { justify-content: space-between; align-items: center; }
|
||||
.chip.on { display: inline-block; }
|
||||
|
||||
.npchead { display: flex; align-items: center; gap: .7rem; }
|
||||
.npcthumb { width: 44px; height: 44px; border-radius: 8px; object-fit: cover; border: 1px solid var(--line, #444); flex: none; }
|
||||
.npcthumb.none { display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,.05); color: var(--muted, #888); }
|
||||
.npchead .badge { margin-left: .5rem; }
|
||||
|
||||
.npcpicker { display: grid; gap: .5rem; max-width: 320px; }
|
||||
.npcpick { list-style: none; margin: 0; padding: 0; max-height: 260px; overflow: auto; display: grid; gap: .25rem; }
|
||||
.npcpick button { display: flex; align-items: center; gap: .6rem; width: 100%; text-align: left; }
|
||||
.npcpickwrap { position: absolute; top: 3rem; left: .5rem; z-index: 5; }
|
||||
.pnpc .pre { white-space: pre-wrap; margin: .1rem 0 .6rem; font-size: .85rem; }
|
||||
|
||||
.pavatar { position: relative; }
|
||||
.presence { position: absolute; right: -3px; bottom: -3px; width: 11px; height: 11px; border-radius: 50%; background: #777; border: 2px solid var(--panel, #222); }
|
||||
.presence.on { background: #3fb950; }
|
||||
.presence.inline { position: static; display: inline-block; width: 9px; height: 9px; border: 0; margin-right: .2rem; }
|
||||
.pcard.off { opacity: .6; }
|
||||
|
||||
/* lobby header removed: DM chip and settings button live in the player bar */
|
||||
.playarea { height: calc(100vh - var(--hdr) - 1.2rem); }
|
||||
.sheetcol { max-height: calc(100vh - var(--hdr) - 1.2rem); }
|
||||
.dmcard { min-width: 0; border-left-color: var(--accent, gold); }
|
||||
.pavatar.dm { font-size: .7rem; font-weight: 700; }
|
||||
.lobbycfg { margin-left: auto; align-self: center; max-width: 16rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fightrow { display: flex; justify-content: flex-end; margin-top: -.3rem; }
|
||||
.initbar { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; padding: .35rem .5rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
|
||||
.initround { padding-right: .4rem; border-right: 1px solid var(--line); }
|
||||
.initcard { display: flex; align-items: center; gap: .35rem; padding: .2rem .5rem; border: 2px solid var(--line); border-radius: var(--radius); cursor: pointer; background: var(--panel2); }
|
||||
.initcard.player { border-left: 5px solid #4f93e6; } .initcard.npc { border-left: 5px solid #c0392b; }
|
||||
.initcard.cur { border-color: var(--accent, gold); box-shadow: 0 0 0 1px var(--accent, gold); background: color-mix(in srgb, var(--accent, gold) 18%, var(--panel2)); }
|
||||
.initnum { font-weight: 700; min-width: 1.6rem; text-align: center; }
|
||||
.initcard .modin { width: 3.2rem; }
|
||||
.initctl { margin-left: auto; display: flex; gap: .3rem; }
|
||||
.maptoken.turn { animation: turnpulse 1.4s ease-in-out infinite; }
|
||||
@keyframes turnpulse { 0%, 100% { box-shadow: 0 0 0 3px var(--accent, gold), 0 0 6px 3px rgba(255,215,0,.5); } 50% { box-shadow: 0 0 0 3px var(--accent, gold), 0 0 16px 8px rgba(255,215,0,.8); } }
|
||||
.radialback { position: fixed; inset: 0; z-index: 60; }
|
||||
.radial { position: fixed; width: 0; height: 0; }
|
||||
.radialtitle { position: absolute; transform: translate(-50%, -50%); background: rgba(0,0,0,.75); color: #fff; padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; white-space: nowrap; max-width: 9rem; overflow: hidden; text-overflow: ellipsis; }
|
||||
.radialbtn { position: absolute; left: -26px; top: -26px; width: 52px; height: 52px; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 0; gap: 0; line-height: 1; box-shadow: 0 2px 8px rgba(0,0,0,.6); background: var(--panel); border: 2px solid var(--line); }
|
||||
.radialbtn:hover:not(:disabled) { border-color: var(--accent, gold); background: var(--panel2); }
|
||||
.radialbtn .ri { font-size: 1.2rem; } .radialbtn .rl { font-size: .55rem; margin-top: 2px; }
|
||||
.initcard .modin { width: 2.8rem; padding: .1rem .25rem; min-height: 0; }
|
||||
.initcard { padding: .1rem .4rem; }
|
||||
|
||||
/* compact top area of the lobby */
|
||||
.topbar { display: flex; align-items: stretch; gap: .4rem; }
|
||||
.topctl { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; align-content: center; }
|
||||
.topctl button.mini { padding: 0; min-width: 28px; height: 26px; font-size: .8rem; line-height: 1; }
|
||||
.topctl button.mini.wide { grid-column: span 1; }
|
||||
.topscroll { flex: 1; min-width: 0; overflow-x: auto; overflow-y: hidden; padding-bottom: 4px; scrollbar-width: thin; }
|
||||
.topscroll .charbar, .topscroll .initcards { flex-wrap: nowrap; width: max-content; }
|
||||
.topbar .lobbycfg { margin-left: 0; align-self: center; flex: none; max-width: 11rem; }
|
||||
.topmore { display: grid; gap: .5rem; padding: .4rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
|
||||
.topmore .charbar { flex-wrap: wrap; }
|
||||
.topbar + .topmore, .topbar + .diffbar { margin-top: -.2rem; }
|
||||
.initcards { display: flex; gap: .35rem; align-items: center; }
|
||||
.initcard { padding: .15rem .45rem .15rem .15rem; border-radius: 999px; border-left-width: 2px; flex: none; }
|
||||
.initcard.player { border-left: 2px solid #4f93e6; border-color: #4f93e6; } .initcard.npc { border-left: 2px solid #c0392b; border-color: #c0392b; }
|
||||
.initcard.cur { border-color: var(--accent, gold); }
|
||||
.initpic { width: 30px; height: 30px; border-radius: 50%; overflow: hidden; display: grid; place-items: center; background: var(--panel); font-weight: 700; flex: none; }
|
||||
.initpic img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.initname { white-space: nowrap; }
|
||||
.initround { padding: 0 .4rem; flex: none; }
|
||||
.pcard { flex: none; }
|
||||
.playarea { gap: .4rem; }
|
||||
.maptoken em { display: none; }
|
||||
.maptoken:hover em, .maptoken.sel em, .maptoken.turn em { display: block; }
|
||||
|
||||
/* resizing */
|
||||
.splitter { width: 10px; cursor: col-resize; align-self: stretch; position: relative; touch-action: none; }
|
||||
.splitter::after { content: ''; position: absolute; left: 4px; top: 0; bottom: 0; width: 2px; border-radius: 2px; background: var(--line); transition: background .15s; }
|
||||
.splitter:hover::after, body.resizing .splitter::after { background: var(--accent, gold); }
|
||||
body.resizing { cursor: col-resize; user-select: none; }
|
||||
.lobbygrid:not(.nosheet) { grid-template-columns: var(--sheetw) 10px minmax(0, 1fr); gap: .3rem; }
|
||||
.chatsplit { position: absolute; left: -5px; top: 0; bottom: 0; z-index: 12; }
|
||||
.chatsplit::after { left: 4px; }
|
||||
@media (max-width: 1100px) {
|
||||
.lobbygrid, .lobbygrid:not(.nosheet) { grid-template-columns: minmax(0, 1fr); }
|
||||
.sheetsplit, .chatsplit { display: none; }
|
||||
}
|
||||
|
||||
/* portrait next to name and identity fields */
|
||||
.pidentity { display: flex; gap: 5mm; align-items: stretch; }
|
||||
.pidright { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3mm; justify-content: flex-end; }
|
||||
.pportrait { width: 36mm; height: 42mm; object-fit: cover; border: 1.5px solid var(--pg); border-radius: 1.5mm; flex: none; align-self: flex-start; }
|
||||
.pidright .pfields { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.pidright .pfield:nth-child(2) { grid-column: span 2; }
|
||||
.pwrite { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; line-height: calc(6.4mm + 1px); padding: 0; color: var(--pi); border: 0; resize: none; width: 100%; box-sizing: border-box; background-attachment: local; min-height: 18mm; }
|
||||
textarea.pwrite { background-color: transparent; outline: none; field-sizing: content; }
|
||||
textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
|
||||
.pwrite::placeholder { color: var(--pm); opacity: .6; }
|
||||
|
||||
/* token status, fog */
|
||||
.mapfog { position: absolute; left: 0; top: 0; filter: brightness(.42) saturate(.55); pointer-events: none; max-width: none; }
|
||||
.tokhp { position: absolute; left: 8%; right: 8%; bottom: -7px; height: 6px; background: rgba(0,0,0,.75); border-radius: 3px; border: 1px solid rgba(255,255,255,.7); overflow: hidden; pointer-events: none; font-style: normal; }
|
||||
.tokhp b { display: block; height: 100%; }
|
||||
.tokconds { position: absolute; left: 50%; top: -9px; transform: translateX(-50%); display: flex; gap: 2px; pointer-events: none; }
|
||||
.tokconds u { text-decoration: none; font-size: 9px; font-weight: 700; text-transform: none; background: #6a1b9a; border: 1px solid #fff; border-radius: 4px; padding: 0 3px; line-height: 12px; white-space: nowrap; color: #fff; }
|
||||
.statusform { display: grid; gap: .4rem; margin-top: .5rem; padding-top: .5rem; border-top: 1px solid var(--line); }
|
||||
.hprow { gap: .3rem; flex-wrap: wrap; align-items: center; }
|
||||
.hprow input { width: 3.6rem; }
|
||||
.condrow { display: flex; flex-wrap: wrap; gap: .25rem; }
|
||||
.sightrange input { width: 3.4rem; }
|
||||
.tokenpanel { max-height: calc(100% - 4rem); overflow-y: auto; }
|
||||
|
||||
/* reveal areas, sight groups */
|
||||
.rz { fill: rgba(255, 215, 0, .14); stroke: rgba(255, 215, 0, .9); stroke-width: 2; stroke-dasharray: 6 4; }
|
||||
.rz.draft { fill: rgba(255, 215, 0, .25); }
|
||||
.rzo { fill: gold; stroke: #000; stroke-width: 1; }
|
||||
.revealpanel { right: auto; left: .5rem; bottom: .5rem; top: auto; max-width: 18rem; display: grid; gap: .4rem; }
|
||||
.visiongroups { display: grid; gap: .4rem; }
|
||||
.vgrow { justify-content: space-between; }
|
||||
|
||||
/* radial menu: always a dark disc with light text, readable on both themes */
|
||||
.radialbtn { background: #1b2116 !important; border: 2px solid #8fd44c !important; color: #fff !important; }
|
||||
.radialbtn:hover:not(:disabled) { background: #2b3a1c !important; border-color: gold !important; }
|
||||
.radialbtn:disabled { opacity: .55; }
|
||||
.radialbtn .ri { font-size: 1.35rem; color: #fff; }
|
||||
.radialbtn .rl { font-size: .62rem; font-weight: 700; color: #fff; text-shadow: 0 1px 1px #000; }
|
||||
.radialbtn { width: 58px; height: 58px; left: -29px; top: -29px; }
|
||||
.radialtitle { background: #000; color: #fff; font-weight: 700; font-size: .8rem; border: 1px solid #8fd44c; }
|
||||
.radialbtn:disabled { opacity: .78; background: #262b22 !important; border-color: #5a634f !important; cursor: not-allowed; }
|
||||
|
||||
/* decor tokens (props) */
|
||||
.maptoken.prop { border: 0; border-radius: 0; background: none; box-shadow: none; display: block; transform-origin: center; }
|
||||
.maptoken.prop:not(.mov) { pointer-events: none; }
|
||||
.maptoken.prop.hid { opacity: .5; outline: 2px dashed rgba(255,255,255,.5); border-style: none; }
|
||||
.maptoken.prop.sel { outline: 3px solid var(--accent, gold); outline-offset: 2px; }
|
||||
.maptoken.prop em { display: none !important; }
|
||||
.proppicker { max-width: 360px; }
|
||||
.propgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(78px, 1fr)); gap: .3rem; max-height: 300px; overflow: auto; }
|
||||
.propbtn { display: grid; justify-items: center; gap: .15rem; padding: .3rem; font-size: .72rem; position: relative; }
|
||||
.propbtn svg, .propbtn img { width: 46px; height: 46px; object-fit: contain; }
|
||||
.propbtn.own { padding: 0; }
|
||||
.propbtn.own > .ghost:first-child { display: grid; justify-items: center; gap: .15rem; width: 100%; }
|
||||
.propbtn.own .mini { position: absolute; top: 0; right: 0; padding: 0 .3rem; min-height: 0; }
|
||||
.tokrow { display: flex; gap: .25rem; }
|
||||
.tokrow > button:first-child { flex: 1; min-width: 0; }
|
||||
.propdot { display: inline-block; width: .8rem; height: .8rem; border-radius: 2px; background: #b98545; vertical-align: middle; }
|
||||
.tokenform input[type=range] { width: 100%; }
|
||||
.tokenform .row { display: flex; flex-wrap: wrap; gap: .35rem; justify-content: flex-start; align-items: center; }
|
||||
.tokenform label.small { display: flex; align-items: center; gap: .3rem; flex-wrap: wrap; }
|
||||
.tokenform label.small input[type=number] { width: 4rem; }
|
||||
.tokenform .mini { min-height: 0; padding: .15rem .5rem; }
|
||||
.tokenlist .tokrow > button:first-child { width: auto; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tokenlist .tokrow > button.mini { width: auto; flex: none; }
|
||||
/* table tool buttons: low and readable on the dark map */
|
||||
.maptools button, .maptools select { padding: .1rem .5rem; font-size: .76rem; line-height: 1.2; height: 1.6rem; min-height: 0; }
|
||||
.maptools button.ghost, .maptools select { background: rgba(20,22,26,.85); color: #f1f1f1; border: 1px solid rgba(255,255,255,.35); }
|
||||
.maptools button.on { background: var(--accent, #2f7d32); color: #fff; border-color: transparent; }
|
||||
.maptools button:disabled { opacity: .45; }
|
||||
.maptools .sightrange input { height: 1.6rem; padding: 0 .3rem; }
|
||||
.maptools .sightrange { color: #f1f1f1; }
|
||||
.notefield { display: grid; gap: .2rem; }
|
||||
.notefield textarea { width: 100%; resize: vertical; font: inherit; font-size: .85rem; }
|
||||
.toknote { position: absolute; top: -.5em; right: -.5em; font-size: .75rem; line-height: 1; pointer-events: none; transform: none; filter: drop-shadow(0 1px 1px #000); }
|
||||
.maptoken.prop .toknote { transform: none; }
|
||||
.propmgr { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: .5rem; list-style: none; padding: 0; }
|
||||
.propmgr li { display: grid; justify-items: center; gap: .3rem; padding: .5rem; border: 1px solid var(--line); border-radius: 8px; }
|
||||
.propmgr svg, .propmgr img { width: 64px; height: 64px; object-fit: contain; }
|
||||
.propmgr .row { display: flex; gap: .25rem; }
|
||||
/* map pings */
|
||||
.mapping { position: absolute; width: 0; height: 0; pointer-events: none; z-index: 6; }
|
||||
.mapping i { position: absolute; left: calc(var(--ps) / -2); top: calc(var(--ps) / -2); width: var(--ps); height: var(--ps); border-radius: 50%; border: 4px solid var(--pc); box-shadow: 0 0 10px var(--pc), inset 0 0 10px var(--pc); opacity: 0; animation: pingring 1.6s ease-out 2; }
|
||||
.mapping i + i { animation-delay: .35s; }
|
||||
.mapping b { position: absolute; left: 0; top: calc(var(--ps) / 2 + 2px); transform: translateX(-50%); white-space: nowrap; color: #fff; text-shadow: 0 0 3px #000, 0 0 3px #000; animation: pingname 3.4s forwards; }
|
||||
@keyframes pingring { 0% { transform: scale(.1); opacity: .95; } 100% { transform: scale(1.4); opacity: 0; } }
|
||||
@keyframes pingname { 0%, 80% { opacity: 1; } 100% { opacity: 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .mapping i { animation-duration: 3s; animation-iteration-count: 1; } }
|
||||
/* table tools: measure, areas, markers */
|
||||
.toollayer { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; }
|
||||
.toolabel { fill: #fff; stroke: #000; stroke-width: 3px; paint-order: stroke; font-weight: 700; }
|
||||
.mappin { position: absolute; transform: translate(-50%, -92%); padding: 0; margin: 0; min-height: 0; background: none; border: 0; line-height: 1; cursor: pointer; filter: drop-shadow(0 2px 2px rgba(0,0,0,.8)); z-index: 4; }
|
||||
.mappin.shared { filter: drop-shadow(0 0 4px #fff) drop-shadow(0 2px 2px rgba(0,0,0,.8)); }
|
||||
.mappin.sel { outline: 2px solid var(--accent, gold); border-radius: 6px; }
|
||||
.mappin.draft { opacity: .6; pointer-events: none; }
|
||||
.toolspanel { right: auto; left: .5rem; top: .5rem; bottom: auto; display: grid; gap: .4rem; max-width: 18rem; }
|
||||
.toolspanel.tpllist { top: .5rem; left: .5rem; }
|
||||
.toolspanel.tpllist.low { left: 15.8rem; }
|
||||
.toolspanel textarea, .toolspanel input:not([type=range]):not([type=checkbox]) { width: 100%; font: inherit; }
|
||||
.toolspanel .toolrow, .toolspanel .iconrow { display: flex; flex-wrap: wrap; gap: .25rem; }
|
||||
.toolspanel .dot { display: inline-block; width: .7rem; height: .7rem; border-radius: 50%; }
|
||||
.toolspanel .notetext { white-space: pre-wrap; margin: 0; }
|
||||
.toolspanel { padding: .45rem .55rem; max-width: 15rem; gap: .3rem; font-size: .8rem; }
|
||||
.toolspanel button { padding: .15rem .5rem; font-size: .78rem; min-height: 0; width: auto; }
|
||||
.toolspanel p { margin: 0; }
|
||||
.toolspanel input[type=range] { width: 100%; }
|
||||
.toolspanel input:not([type=range]):not([type=checkbox]), .toolspanel textarea { padding: .25rem .4rem; font-size: .82rem; }
|
||||
|
||||
Reference in New Issue
Block a user