Compare commits
42
Commits
876a68fe4d
...
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 | ||
|
|
2ae64420d9 | ||
|
|
a2e719afd0 | ||
|
|
4c0d7906cd | ||
|
|
5f3b2b2874 |
@@ -0,0 +1,9 @@
|
||||
# Copy this file to ".env" (it is ignored by git) and fill it in.
|
||||
# The code people must enter to create an account:
|
||||
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=
|
||||
@@ -5,3 +5,5 @@ backend/__pycache__/
|
||||
backend/app/__pycache__/
|
||||
backend/aw.db
|
||||
db/
|
||||
.env
|
||||
_to_delete/
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Running it on a Linux server
|
||||
|
||||
Goal: the app runs as its own unprivileged user, only nginx can reach it, and everything is behind HTTPS.
|
||||
|
||||
## 1. Dedicated user with rootless Docker
|
||||
|
||||
Members of the `docker` group are effectively root on the machine, so do **not** add the new user to it. Use *rootless* Docker instead: the daemon, the images and the container processes all belong to that one user. If something breaks out of the container, it only gets that user's rights. This runs separately from any existing (root) Docker, for example the one AMP uses.
|
||||
|
||||
```bash
|
||||
# as an admin user
|
||||
sudo adduser --disabled-password --gecos "" awcc # no password, no sudo
|
||||
sudo apt install -y uidmap dbus-user-session slirp4netns docker-ce-rootless-extras
|
||||
sudo loginctl enable-linger awcc # user services start at boot
|
||||
```
|
||||
|
||||
Leave the system Docker alone if other things (AMP) use it; rootless Docker runs next to it.
|
||||
`docker-ce-rootless-extras` comes from Docker's own apt repository. If Docker was installed from your distribution (`docker.io`), add Docker's repository first (see docs.docker.com/engine/install).
|
||||
|
||||
Switch to the user and install the rootless daemon:
|
||||
|
||||
```bash
|
||||
sudo -iu awcc
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus
|
||||
dockerd-rootless-setuptool.sh install
|
||||
systemctl --user enable --now docker
|
||||
echo 'export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock' >> ~/.bashrc
|
||||
echo 'export XDG_RUNTIME_DIR=/run/user/$(id -u)' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
docker run --rm hello-world # test
|
||||
```
|
||||
|
||||
## 2. Get the code
|
||||
|
||||
Use a read-only **deploy key** so this user never holds your personal Gitea credentials:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "awcc deploy key"
|
||||
cat ~/.ssh/id_ed25519.pub # Gitea: repository -> Settings -> Deploy Keys -> add, leave "write access" off
|
||||
git clone ssh://git@git.bitrux.de/otto/AWCharacterCreator.git # adjust the SSH port/URL to your Gitea
|
||||
cd AWCharacterCreator
|
||||
```
|
||||
|
||||
## 3. Configure and start
|
||||
|
||||
```bash
|
||||
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:8090 | head -1 # should print HTTP/1.1 200 OK
|
||||
```
|
||||
|
||||
The container only listens on `127.0.0.1:8090` (change it with `AW_PORT` in `.env` if the port is taken, e.g. AMP uses 8080), so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`; 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
|
||||
|
||||
Point a DNS name (for example `aw.yourdomain.tld`) at the server, then create `/etc/nginx/sites-available/aw`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name aw.yourdomain.tld;
|
||||
|
||||
client_max_body_size 13m; # map uploads (the app itself limits them to 12 MB and keeps characters small)
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8090; # same as AW_PORT in .env
|
||||
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
|
||||
sudo certbot --nginx -d aw.yourdomain.tld # gets the certificate, adds HTTPS and the redirect
|
||||
```
|
||||
|
||||
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. 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.
|
||||
|
||||
## 5. Updating
|
||||
|
||||
```bash
|
||||
cd ~/AWCharacterCreator
|
||||
git pull
|
||||
docker compose up -d --build # only needed when code changed
|
||||
```
|
||||
|
||||
If only `backend/data/rules.json` changed, `git pull` is enough (it is re-read on every request).
|
||||
|
||||
## 6. Backups
|
||||
|
||||
```bash
|
||||
sudo apt install -y sqlite3
|
||||
mkdir -p ~/backups
|
||||
crontab -e
|
||||
# add:
|
||||
0 3 * * * sqlite3 $HOME/AWCharacterCreator/db/aw.db ".backup '$HOME/backups/aw-$(date +\%F).db'" && find $HOME/backups -name 'aw-*.db' -mtime +14 -delete
|
||||
```
|
||||
|
||||
Copy `~/backups` somewhere off the server now and then.
|
||||
|
||||
## 7. Handy commands
|
||||
|
||||
```bash
|
||||
docker compose logs -f # logs
|
||||
docker compose exec awcc python -m app.admin list # users
|
||||
docker compose exec awcc python -m app.admin passwd NAME # reset a password
|
||||
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,29 @@ Website for creating characters for the Arcane Wasteland TTRPG, with a searchabl
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Then open `http://<server>:8080`. Characters are stored in `./db/aw.db`. For HTTPS and a domain, put the container behind a reverse proxy (nginx, Caddy, Traefik).
|
||||
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
|
||||
|
||||
Everything (the whole API, rules, characters) is behind a login. The only public things are the login page itself and the app's JavaScript.
|
||||
|
||||
1. Copy `.env.example` to `.env` and set `AW_REGISTRATION_CODE`. People need this code **once** to create an account; with an empty code registration is disabled.
|
||||
2. Open the site, choose "Create account", enter a username, a password (min. 8 characters) and the registration code.
|
||||
3. Sessions last 30 days (HttpOnly cookie, stored server-side). Logging out or changing the password ends them.
|
||||
|
||||
Wrong logins and wrong registration codes are throttled (10 failures per address, 8 per username, 5 wrong codes per address and 30 per hour for the whole server), so a short code is not trivially guessable, but a long one is still better. Passwords are stored as scrypt hashes.
|
||||
|
||||
**HTTPS is a must when the site is reachable from the internet**, otherwise passwords travel in plain text. Put the container behind a reverse proxy (Caddy, nginx, Traefik) that terminates TLS, pass the `Host` header through, and set `AW_TRUST_PROXY=1` in `.env` so the app sees the real client address and marks the cookie `Secure`.
|
||||
|
||||
Admin tasks (list users, reset a password, delete a user):
|
||||
|
||||
```bash
|
||||
docker compose exec awcc python -m app.admin list
|
||||
docker compose exec awcc python -m app.admin passwd <username>
|
||||
docker compose exec awcc python -m app.admin delete <username>
|
||||
```
|
||||
|
||||
Characters and presets made before accounts existed are adopted automatically by the account that logs in from the same browser (the old secret keys are used once and then removed).
|
||||
|
||||
## Edit the rules
|
||||
|
||||
@@ -30,13 +52,14 @@ In the creator, the Concept page only has a "Rules preset" selector. Presets are
|
||||
|
||||
### Skills, proficiency and expertise
|
||||
|
||||
- `creation.advanced_unlock_cost`: pool points needed to unlock an advanced skill (free if a profession or proficiency covers it).
|
||||
- `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`.
|
||||
|
||||
### Equipment
|
||||
|
||||
`equipment` lists every item with `id`, `name`, `cat` (category), `stat` (damage / armor / type), `kg` and `note`. The starting-kit rules are in `creation.kit` (allowed weapon categories, armor tiers, number of resources). Weights are approximate; for ranges in the source document the upper value is used.
|
||||
`equipment` lists every item with `id`, `name`, `cat` (category), `stat` (damage / armor / type), `kg` and `note`. There are no starting-kit rules: players pick any items (with an amount) on the Equipment page and the total weight is checked against the carry capacity. Weights are approximate; for ranges in the source document the upper value is used.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -51,22 +74,153 @@ cd frontend && npm install && npm run dev
|
||||
|
||||
## Saving and sharing
|
||||
|
||||
There are no user accounts. When saved, a character gets an ID and a secret write key that stays in the browser ("My characters" page). The link `/#creator/<ID>` lets others view the character; only the holder of the key can change it. To move to another browser use the JSON backup (Summary page: "JSON backup" and "Import JSON").
|
||||
Characters are saved in your account ("My characters" page). Only the owner can see a character, change it or delete it. On "My characters" the **Share** button lets you add usernames (e.g. your DM): they get read-only access, see the character under "Shared with me" and can open it through `/#creator/<ID>`. Saving a viewed character creates their own copy. Use the JSON backup (Summary page) to move a character between servers.
|
||||
|
||||
If the site is reachable from the internet, protect it with Basic Auth at the reverse proxy.
|
||||
Hover (or tap / focus) the dotted-underlined labels and the small "i" icons to see where a number comes from, e.g. how a skill's target number and maximum are derived, or what a derived stat is made of. The explanations are generated from the rules, so they follow presets and `rules.json` changes.
|
||||
|
||||
While creating, the full character sheet stays visible next to the wizard. Edits are shown as a difference (old → new, added / removed items) until you press **Apply** (or **Apply & next**); **Discard** drops them.
|
||||
|
||||
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.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/rules` | Rule data |
|
||||
| POST | `/api/characters` | Create a character, returns `id` and `token` |
|
||||
| GET | `/api/characters/{id}` | Read a character |
|
||||
| PUT / DELETE | `/api/characters/{id}` | Change / delete, header `X-Edit-Token` |
|
||||
| POST | `/api/presets` | Create a preset, returns `id` and `token` |
|
||||
| PUT / DELETE | `/api/presets/{id}` | Change / delete, header `X-Edit-Token` |
|
||||
| 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[?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 |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,48 @@
|
||||
"""Small admin CLI: python -m app.admin list | passwd <user> | delete <user>"""
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
from . import auth
|
||||
from .main import db
|
||||
|
||||
|
||||
def main(argv):
|
||||
if not argv:
|
||||
print(__doc__)
|
||||
return 1
|
||||
cmd = argv[0]
|
||||
with db() as con:
|
||||
if cmd == "list":
|
||||
for r in con.execute("SELECT username, created FROM users ORDER BY id"):
|
||||
print(r["username"])
|
||||
elif cmd == "passwd" and len(argv) == 2:
|
||||
pw = getpass.getpass("New password: ")
|
||||
if len(pw) < auth.MIN_PASSWORD:
|
||||
print(f"Password must have at least {auth.MIN_PASSWORD} characters.")
|
||||
return 1
|
||||
cur = con.execute("UPDATE users SET pw_hash=? WHERE username=?", (auth.hash_password(pw), argv[1]))
|
||||
if not cur.rowcount:
|
||||
print("No such user.")
|
||||
return 1
|
||||
con.execute("DELETE FROM sessions WHERE user_id=(SELECT id FROM users WHERE username=?)", (argv[1],))
|
||||
print("Password changed, user logged out everywhere.")
|
||||
elif cmd == "delete" and len(argv) == 2:
|
||||
row = con.execute("SELECT id FROM users WHERE username=?", (argv[1],)).fetchone()
|
||||
if not row:
|
||||
print("No such user.")
|
||||
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 lobbies are removed; their characters and presets stay on the server without an owner.")
|
||||
else:
|
||||
print(__doc__)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Accounts, sessions and brute-force protection (standard library only)."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
|
||||
SESSION_COOKIE = "aw_session"
|
||||
SESSION_DAYS = 30
|
||||
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$")
|
||||
MIN_PASSWORD = 8
|
||||
MAX_PASSWORD = 200
|
||||
|
||||
# scrypt parameters (about 16 MB of memory per hash)
|
||||
_N, _R, _P = 2 ** 14, 8, 1
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
h = hashlib.scrypt(password.encode(), salt=salt, n=_N, r=_R, p=_P, dklen=32)
|
||||
return f"scrypt${_N}${_R}${_P}${salt.hex()}${h.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
_, n, r, p, salt, h = stored.split("$")
|
||||
calc = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=int(n), r=int(r), p=int(p), dklen=32)
|
||||
return hmac.compare_digest(calc.hex(), h)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
_DUMMY = hash_password("dummy-password-for-timing")
|
||||
|
||||
|
||||
def init_tables(con: sqlite3.Connection) -> None:
|
||||
con.execute("""CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
pw_hash TEXT NOT NULL, created REAL NOT NULL)""")
|
||||
con.execute("""CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL, expires REAL NOT NULL)""")
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def create_session(con: sqlite3.Connection, user_id: int) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
con.execute("DELETE FROM sessions WHERE expires < ?", (time.time(),))
|
||||
con.execute("INSERT INTO sessions VALUES (?,?,?)", (_hash_token(token), user_id, time.time() + SESSION_DAYS * 86400))
|
||||
return token
|
||||
|
||||
|
||||
def user_for_token(con: sqlite3.Connection, token: str | None):
|
||||
if not token:
|
||||
return None
|
||||
row = con.execute(
|
||||
"SELECT u.id, u.username, s.expires FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?",
|
||||
(_hash_token(token),)).fetchone()
|
||||
if not row or row["expires"] < time.time():
|
||||
return None
|
||||
return {"id": row["id"], "username": row["username"]}
|
||||
|
||||
|
||||
def end_session(con: sqlite3.Connection, token: str | None) -> None:
|
||||
if token:
|
||||
con.execute("DELETE FROM sessions WHERE token_hash = ?", (_hash_token(token),))
|
||||
|
||||
|
||||
def timing_dummy(password: str) -> None:
|
||||
"""Burn the same time as a real check when the user does not exist."""
|
||||
verify_password(password, _DUMMY)
|
||||
|
||||
|
||||
class Limiter:
|
||||
"""In-memory failure counter: too many failures within the window block further attempts."""
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.hits: dict[str, list[float]] = {}
|
||||
|
||||
def blocked(self, key: str, limit: int, window: int) -> int:
|
||||
"""Returns seconds to wait (0 = allowed)."""
|
||||
now = time.time()
|
||||
with self.lock:
|
||||
xs = [t for t in self.hits.get(key, []) if now - t < window]
|
||||
self.hits[key] = xs
|
||||
return int(window - (now - xs[0])) + 1 if len(xs) >= limit else 0
|
||||
|
||||
def fail(self, key: str) -> None:
|
||||
with self.lock:
|
||||
self.hits.setdefault(key, []).append(time.time())
|
||||
if len(self.hits) > 20000: # keep memory bounded
|
||||
self.hits.clear()
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
with self.lock:
|
||||
self.hits.pop(key, None)
|
||||
|
||||
|
||||
def registration_code() -> str:
|
||||
return os.environ.get("AW_REGISTRATION_CODE", "")
|
||||
@@ -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
|
||||
+12
-14
@@ -2,20 +2,17 @@
|
||||
|
||||
|
||||
def kit_items(ch: dict, rules: dict) -> list:
|
||||
"""Returns [(label, item, qty)] for the starting kit and extra items."""
|
||||
"""Returns [(category, item, qty)] for the carried items. Old starting-kit slots count as one item each."""
|
||||
items = {e["id"]: e for e in rules.get("equipment", [])}
|
||||
cats = {c["id"]: c["name"] for c in rules.get("equipment_categories", [])}
|
||||
kit = ch.get("kit") or {}
|
||||
out = []
|
||||
for label, key in (("Weapon", "weapon"), ("Armor", "armor"), ("Tool", "tool")):
|
||||
if kit.get(key) in items:
|
||||
out.append((label, items[kit[key]], 1))
|
||||
for rid in kit.get("resources", []):
|
||||
if rid in items:
|
||||
out.append(("Resource", items[rid], 1))
|
||||
for iid, qty in (kit.get("extras") or {}).items():
|
||||
if iid in items and int(qty) > 0:
|
||||
out.append(("Extra", items[iid], int(qty)))
|
||||
return out
|
||||
qty: dict = {}
|
||||
for iid in [kit.get("weapon"), kit.get("armor"), kit.get("tool"), *(kit.get("resources") or [])]:
|
||||
if iid:
|
||||
qty[iid] = qty.get(iid, 0) + 1
|
||||
for iid, n in (kit.get("extras") or {}).items():
|
||||
qty[iid] = qty.get(iid, 0) + int(n)
|
||||
return [(cats.get(items[i]["cat"], ""), items[i], n) for i, n in qty.items() if i in items and n > 0]
|
||||
|
||||
|
||||
def compute(ch: dict, rules: dict) -> dict:
|
||||
@@ -40,10 +37,11 @@ def compute(ch: dict, rules: dict) -> dict:
|
||||
pips = prof_pip + max(0, free_pips)
|
||||
prof = [0, cr["proficiency_bonus"], cr["expertise_bonus"]][pips]
|
||||
unlocked = s["id"] in (ch.get("unlocked") or [])
|
||||
usable = s["type"] == "basic" or unlocked or prof > 0 # advanced skills must be unlocked
|
||||
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]
|
||||
|
||||
@@ -40,8 +40,8 @@ def _sheet(ch: dict, rules: dict) -> dict:
|
||||
"derived_rows": [[d["name"], str(c["derived"][d["id"]])] for d in rules["derived"]],
|
||||
"skill_rows": [
|
||||
[s["name"], "/".join(a["short"] for a in rules["attributes"] if a["id"] in s["attrs"]),
|
||||
str(s["points"]), {0: "", 1: "Proficient", 2: "Expertise"}.get(s["pips"], ""), str(s["total"])]
|
||||
for s in c["skills"] if s["points"] or s["prof"] or (s["unlocked"] and s["type"] == "advanced")
|
||||
str(s["points"]), {1: "Proficient", 2: "Expertise"}.get(s["pips"]) or ("" if s["usable"] else "Locked"), str(s["total"])]
|
||||
for s in c["skills"]
|
||||
],
|
||||
"augment_rows": [[a["name"], KIND.get(a["kind"], a["kind"]), SOURCE.get(a["source"]) or "-", str(a["cost"])]
|
||||
for a in c["augments"]],
|
||||
@@ -92,14 +92,14 @@ def to_docx(ch: dict, rules: dict) -> bytes:
|
||||
doc.add_paragraph("-")
|
||||
doc.add_heading(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", 1)
|
||||
if s["gear_rows"]:
|
||||
table(["", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"])
|
||||
table(["Category", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"])
|
||||
else:
|
||||
doc.add_paragraph("-")
|
||||
if s["other"]:
|
||||
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])
|
||||
@@ -166,12 +166,12 @@ def to_pdf(ch: dict, rules: dict) -> bytes:
|
||||
|
||||
el.append(Paragraph(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", h2))
|
||||
if s["gear_rows"]:
|
||||
el.append(tbl(["", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"], [22 * mm, 65 * mm, 55 * mm, 22 * mm]))
|
||||
el.append(tbl(["Category", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"], [30 * mm, 60 * mm, 50 * mm, 22 * mm]))
|
||||
else:
|
||||
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)]
|
||||
|
||||
|
||||
+1100
-62
File diff suppressed because it is too large
Load Diff
+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
|
||||
+5
-14
@@ -15,24 +15,13 @@
|
||||
"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,
|
||||
"talents_max": 2,
|
||||
"proficiency_bonus": 10,
|
||||
"expertise_bonus": 20,
|
||||
"kit": {
|
||||
"weapon_cats": [
|
||||
"melee",
|
||||
"firearm"
|
||||
],
|
||||
"armor_tiers": [
|
||||
"light"
|
||||
],
|
||||
"resources_min": 1,
|
||||
"resources_max": 2,
|
||||
"tools": 1
|
||||
},
|
||||
"carry_per_strength_mod": 10
|
||||
},
|
||||
|
||||
@@ -42,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}}
|
||||
],
|
||||
|
||||
@@ -53,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},
|
||||
@@ -433,7 +424,7 @@
|
||||
"Proficiency: +10. Expertise: +20.",
|
||||
"Every character gets 2 free proficiency picks on top of the profession's proficiencies. A talent can grant 2 more.",
|
||||
"Putting two proficiency picks into the same skill makes it Expertise (a profession proficiency counts as one of the two).",
|
||||
"Proficiency in an advanced skill unlocks it for free.",
|
||||
"Proficiency picks can only be used on skills you can use, so an advanced skill has to be unlocked first.",
|
||||
"Groups: Combat, Survival, Social, Technical, Knowledge & Mental."
|
||||
]
|
||||
},
|
||||
@@ -446,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
|
||||
|
||||
+15
-3
@@ -3,7 +3,19 @@ services:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8000" # Host-Port bei Bedarf ändern, oder hinter einen Reverse Proxy hängen
|
||||
# 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:${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:
|
||||
- ./db:/db # SQLite-Datenbank (Charaktere)
|
||||
- ./backend/data:/app/data:ro # rules.json: bearbeiten, Seite neu laden, fertig
|
||||
- ./db:/db # SQLite database (users, characters, presets)
|
||||
- ./backend/data:/app/data:ro # rules.json: edit, reload the page, done
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,34 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function Account({ user, onLogout }) {
|
||||
const [f, setF] = useState({ old: '', new: '', new2: '' })
|
||||
const [msg, setMsg] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const set = (k) => (e) => setF({ ...f, [k]: e.target.value })
|
||||
|
||||
const change = async (e) => {
|
||||
e.preventDefault(); setMsg(''); setErr('')
|
||||
if (f.new !== f.new2) return setErr('The two new passwords do not match.')
|
||||
const r = await fetch('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ old: f.old, new: f.new }) })
|
||||
const data = await r.json().catch(() => ({}))
|
||||
if (!r.ok) return setErr(typeof data.detail === 'string' ? data.detail : 'Could not change the password.')
|
||||
setF({ old: '', new: '', new2: '' }); setMsg('Password changed. Other devices were logged out.')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page narrow">
|
||||
<h1>Account</h1>
|
||||
<p>Logged in as <strong>{user}</strong>.</p>
|
||||
<form className="card" onSubmit={change}>
|
||||
<h3>Change password</h3>
|
||||
<label>Current password<input type="password" value={f.old} onChange={set('old')} autoComplete="current-password" required /></label>
|
||||
<label>New password<input type="password" value={f.new} onChange={set('new')} autoComplete="new-password" required /></label>
|
||||
<label>Repeat new password<input type="password" value={f.new2} onChange={set('new2')} autoComplete="new-password" required /></label>
|
||||
{err && <p className="warn" role="alert">{err}</p>}
|
||||
{msg && <p className="toast" role="status">{msg}</p>}
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
<p><button className="ghost" onClick={onLogout}>Log out</button></p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+50
-7
@@ -1,8 +1,14 @@
|
||||
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 = () => {
|
||||
const [h, setH] = useState(window.location.hash.slice(1) || 'creator')
|
||||
@@ -16,16 +22,41 @@ const useHash = () => {
|
||||
|
||||
export default function App() {
|
||||
const hash = useHash()
|
||||
const [auth, setAuth] = useState(null) // null = checking, {user, registration_open}
|
||||
const [rules, setRules] = useState(null)
|
||||
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
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me').then((r) => r.json()).then(setAuth).catch(() => setError('Could not reach the server.'))
|
||||
const out = () => { setAuth((a) => ({ user: null, registration_open: a?.registration_open })); setRules(null) }
|
||||
window.addEventListener('aw-unauthorized', out)
|
||||
return () => window.removeEventListener('aw-unauthorized', out)
|
||||
}, [])
|
||||
|
||||
const username = auth?.user?.username
|
||||
const loadRules = () => fetch('/api/rules').then((r) => r.json()).then(setRules).catch(() => setError('Could not load the rules.'))
|
||||
useEffect(() => { loadRules() }, [])
|
||||
useEffect(() => {
|
||||
if (!username) return
|
||||
claimLegacy().finally(loadRules)
|
||||
}, [username]) // eslint-disable-line
|
||||
|
||||
const logout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {})
|
||||
window.location.hash = 'creator'
|
||||
setAuth({ user: null, registration_open: auth.registration_open }); setRules(null)
|
||||
}
|
||||
|
||||
if (error) return <main><p className="warn">{error}</p></main>
|
||||
if (!auth) return <main><p className="muted">Loading …</p></main>
|
||||
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', 'Create 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 (
|
||||
<>
|
||||
@@ -36,19 +67,31 @@ export default function App() {
|
||||
</a>
|
||||
<nav>
|
||||
{nav.map(([id, label]) => (
|
||||
<a key={id} href={`#${id}`} className={page === id ? 'active' : ''}>{label}</a>
|
||||
<a key={id} href={`#${id}`} className={`${page === id ? 'active' : ''} ${id === 'creator' ? 'navchar' : ''}`}>{label}</a>
|
||||
))}
|
||||
<a href="#account" className={page === 'account' ? 'active' : ''} title="Account">{username}</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{error && <p className="warn">{error}</p>}
|
||||
{!rules && !error && <p className="muted">Loading rules …</p>}
|
||||
{rules && page === 'creator' && <Creator rules={rules} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} />}
|
||||
{!rules && <p className="muted">Loading rules …</p>}
|
||||
{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' }} />
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
|
||||
+106
-34
@@ -1,50 +1,122 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const KEY = 'aw_chars'
|
||||
export const loadList = () => {
|
||||
try { return JSON.parse(localStorage.getItem(KEY)) || [] } catch { return [] }
|
||||
}
|
||||
export const saveList = (l) => {
|
||||
try { localStorage.setItem(KEY, JSON.stringify(l)) } catch { /* storage blocked */ }
|
||||
}
|
||||
export const rememberCharacter = (id, token, name) => {
|
||||
const l = loadList().filter((c) => c.id !== id)
|
||||
l.unshift({ id, token, name: name || 'Nameless', updated: Date.now() })
|
||||
saveList(l)
|
||||
}
|
||||
function SharePanel({ c, reload }) {
|
||||
const [name, setName] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
export default function Characters({ onOpen }) {
|
||||
const [list, setList] = useState(loadList)
|
||||
|
||||
const remove = async (c) => {
|
||||
if (!window.confirm(`Really delete "${c.name}"?`)) return
|
||||
await fetch(`/api/characters/${c.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': c.token } }).catch(() => {})
|
||||
const l = list.filter((x) => x.id !== c.id)
|
||||
saveList(l); setList(l)
|
||||
const add = async (e) => {
|
||||
e.preventDefault(); setErr('')
|
||||
if (!name.trim()) return
|
||||
const r = await fetch(`/api/characters/${c.id}/shares`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: name }) })
|
||||
const data = await r.json().catch(() => ({}))
|
||||
if (!r.ok) return setErr(typeof data.detail === 'string' ? data.detail : 'Could not share.')
|
||||
setName(''); reload()
|
||||
}
|
||||
const remove = async (u) => {
|
||||
await fetch(`/api/characters/${c.id}/shares/${encodeURIComponent(u)}`, { method: 'DELETE' }).catch(() => {})
|
||||
reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sharepanel">
|
||||
<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) => (
|
||||
<span key={u} className="chip on">{u} <button className="linklike" onClick={() => remove(u)} aria-label={`Stop sharing with ${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">Share</button>
|
||||
</form>
|
||||
{err && <p className="warn small">{err}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(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) => {
|
||||
if (!window.confirm(`Really delete "${c.name}"?`)) return
|
||||
const r = await fetch(`/api/characters/${c.id}`, { method: 'DELETE' }).catch(() => null)
|
||||
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">
|
||||
<h1>My characters</h1>
|
||||
<div className="row">
|
||||
<h1>{npc ? 'NPCs and creatures' : 'My characters'}</h1>
|
||||
<button onClick={onNew}>{npc ? '+ New NPC' : '+ New character'}</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
This list is stored in this browser. The characters themselves live on the server and can be shared by link
|
||||
(<code>#creator/<ID></code>), but only this browser holds the key needed to change them.
|
||||
{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>
|
||||
{list.length === 0 && <p className="card">Nothing saved yet. Create a character and click "Save".</p>}
|
||||
{err && <p className="warn">{err}</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">
|
||||
{list.map((c) => (
|
||||
<li key={c.id} className="card row">
|
||||
<div>
|
||||
<strong>{c.name}</strong>
|
||||
<div className="muted small">ID {c.id} · {new Date(c.updated).toLocaleString('en-GB')}</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button onClick={() => onOpen(c)}>Open</button>
|
||||
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
|
||||
{(data?.mine || []).map((c) => (
|
||||
<li key={c.id} className="card">
|
||||
<div className="row">
|
||||
<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>
|
||||
</div>
|
||||
{open === c.id && <SharePanel c={c} reload={load} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{data && data.shared.length > 0 && (
|
||||
<>
|
||||
<h2>Shared with me</h2>
|
||||
<ul className="charlist">
|
||||
{data.shared.map((c) => (
|
||||
<li key={c.id} className="card row">
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
+114
-130
@@ -1,26 +1,33 @@
|
||||
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 { rememberCharacter } from './Characters.jsx'
|
||||
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 DRAFT = 'aw_draft'
|
||||
const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary']
|
||||
const SOURCE = { noxium: 'Noxium', diesium: 'Diesium', hybrid: 'Hybrid', precyber: 'Pre-meteor cybernetics' }
|
||||
const Badge = () => <span className="badge" title="Not yet worked out in the brainstorm">Placeholder</span>
|
||||
|
||||
function readDraft() {
|
||||
try { return JSON.parse(localStorage.getItem(DRAFT)) } catch { return null }
|
||||
function readDraft(key) {
|
||||
try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
|
||||
}
|
||||
|
||||
export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
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()
|
||||
const d = readDraft(DRAFT)
|
||||
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
|
||||
})
|
||||
// `ch` is the working copy the pages edit; `committed` is the applied state shown as "before" in the side panel
|
||||
const [committed, setCommitted] = useState(() => {
|
||||
const d = readDraft()
|
||||
const d = readDraft(DRAFT)
|
||||
const src = d?.committed || d?.ch
|
||||
return src ? normalize(src, baseRules) : freshCharacter()
|
||||
})
|
||||
@@ -28,7 +35,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
const pending = JSON.stringify(ch) !== JSON.stringify(committed)
|
||||
const apply = () => setCommitted(ch)
|
||||
const discard = () => setCh(committed)
|
||||
const [saved, setSaved] = useState(() => readDraft()?.saved || null) // {id, token}
|
||||
const [saved, setSaved] = useState(() => readDraft(DRAFT)?.saved || null) // {id}
|
||||
const [step, setStep] = useState(0)
|
||||
const [msg, setMsg] = useState('')
|
||||
const fileRef = useRef(null)
|
||||
@@ -41,6 +48,9 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
const sub = cls?.subclasses.find((x) => x.id === ch.subclass_id)
|
||||
const set = (patch) => setCh((x) => ({ ...x, ...patch }))
|
||||
const cr = rules.creation
|
||||
const ctx = { ch, rules, c }
|
||||
|
||||
useEffect(() => { onName?.(ch.name) }, [ch.name]) // eslint-disable-line
|
||||
|
||||
// Keep the draft in this browser
|
||||
useEffect(() => {
|
||||
@@ -50,8 +60,17 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
// Load a character picked in "My characters"
|
||||
useEffect(() => {
|
||||
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 ' + (npc ? 'NPC' : 'character') + '? Your current draft is discarded unless you saved it.')) {
|
||||
setBoth(freshCharacter()); setSaved(null); setStep(0)
|
||||
}
|
||||
clearLoad()
|
||||
return
|
||||
}
|
||||
fetch(`/api/characters/${loadRequest.id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
|
||||
.then((data) => { setBoth(normalize(data, baseRules)); setSaved({ id: loadRequest.id, token: loadRequest.token }); setStep(7) })
|
||||
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(loadRequest.owner ? null : { id: loadRequest.id }); setStep(7)
|
||||
if (loadRequest.owner) setMsg(`This is ${loadRequest.owner}'s character. You can look at it; "Save" creates your own copy.`) })
|
||||
.catch(() => setMsg('Could not load the character.'))
|
||||
.finally(clearLoad)
|
||||
}, [loadRequest]) // eslint-disable-line
|
||||
@@ -59,10 +78,10 @@ export default function Creator({ rules: baseRules, 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('Shared character not found.'))
|
||||
.catch(() => setMsg('Character not found, or it is not shared with you.'))
|
||||
}, []) // eslint-disable-line
|
||||
|
||||
// Moving between pages: pending edits are applied first (Next applies silently, other jumps ask).
|
||||
@@ -79,7 +98,7 @@ export default function Creator({ rules: baseRules, 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
|
||||
@@ -89,15 +108,14 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
const save = async () => {
|
||||
try {
|
||||
if (saved) {
|
||||
const r = await fetch(`/api/characters/${saved.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Edit-Token': saved.token }, body: JSON.stringify(ch) })
|
||||
const r = await fetch(`/api/characters/${saved.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) })
|
||||
if (!r.ok) throw new Error()
|
||||
rememberCharacter(saved.id, saved.token, ch.name)
|
||||
flash('Saved.')
|
||||
} else {
|
||||
const r = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) })
|
||||
if (!r.ok) throw new Error()
|
||||
const { id, token } = await r.json()
|
||||
setSaved({ id, token }); rememberCharacter(id, token, ch.name)
|
||||
const { id } = await r.json()
|
||||
setSaved({ id })
|
||||
flash('Saved. The share link is shown below.')
|
||||
}
|
||||
} catch { flash('Saving failed.') }
|
||||
@@ -122,6 +140,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
|
||||
// Click on pip k (1 = Proficient, 2 = Expertise) of a skill: sets the skill's total pips to k, or one lower if already there.
|
||||
const clickPip = (s, k) => {
|
||||
if (!s.usable) return flash('Unlock this advanced skill first.')
|
||||
const target = s.pips >= k ? k - 1 : k
|
||||
const wantFree = Math.max(0, target - s.profPip)
|
||||
if (wantFree - s.freePips > c.profLeft) return flash('No proficiency picks left.')
|
||||
@@ -139,10 +158,25 @@ export default function Creator({ rules: baseRules, 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>
|
||||
@@ -152,10 +186,11 @@ export default function Creator({ rules: baseRules, 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)
|
||||
@@ -173,7 +208,9 @@ export default function Creator({ rules: baseRules, 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>}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -181,7 +218,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
<>
|
||||
<h2>Attributes</h2>
|
||||
<p className="muted">All attributes start at {cr.attr_start}. Distribute {cr.attr_points} points (at most +{cr.attr_max_raise} per attribute, cap {cr.attr_max}).</p>
|
||||
<div className={`pool ${c.attrLeft < 0 ? 'over' : ''}`}>Points left: <strong>{c.attrLeft}</strong> / {cr.attr_points}</div>
|
||||
<div className={`pool ${c.attrLeft < 0 ? 'over' : ''}`}><Tip text={explainAttrPool(ctx)}>Points left</Tip>: <strong>{c.attrLeft}</strong> / {cr.attr_points}</div>
|
||||
<div className="attrs">
|
||||
{rules.attributes.map((a) => {
|
||||
const v = ch.attributes[a.id]
|
||||
@@ -189,14 +226,14 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
const setV = (n) => set({ attributes: { ...ch.attributes, [a.id]: n } })
|
||||
return (
|
||||
<div key={a.id} className="attr card">
|
||||
<div><strong>{a.name}</strong> <span className="muted small">{a.short}</span><div className="small muted">{a.desc}</div></div>
|
||||
<div><strong>{a.name}</strong> <span className="muted small">{a.short}</span><Tip icon text={explainAttr(a, ctx)} /><div className="small muted">{a.desc}</div></div>
|
||||
<div className="stepper">
|
||||
<button onClick={() => setV(Math.max(v - 5, cr.attr_start))} aria-label={`${a.name} minus 5`}>−5</button>
|
||||
<button onClick={() => setV(Math.max(v - 1, cr.attr_start))} aria-label={`${a.name} minus 1`}>−</button>
|
||||
<span className="val">{v}</span>
|
||||
<button onClick={() => setV(Math.min(v + 1, cap))} disabled={c.attrLeft <= 0} aria-label={`${a.name} plus 1`}>+</button>
|
||||
<button onClick={() => setV(Math.min(v + Math.min(5, c.attrLeft), cap))} disabled={c.attrLeft <= 0} aria-label={`${a.name} plus 5`}>+5</button>
|
||||
<span className="mod" title="Modifier">Mod {c.mods[a.id]}</span>
|
||||
<Tip text={explainAttr(a, ctx)}><span className="mod">Mod {c.mods[a.id]}</span></Tip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -251,19 +288,19 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
<h2>Skills</h2>
|
||||
<p className="muted">
|
||||
At most half the attribute per skill. Advanced skills must be unlocked first for {c.unlockCost} points from the pool
|
||||
(free if your profession grants them). The pool size comes from the rules preset.
|
||||
(free if your profession grants proficiency in them). The pool size comes from the rules preset.
|
||||
</p>
|
||||
<div className="pools">
|
||||
<div className={`pool ${c.skillLeft < 0 ? 'over' : ''}`} title="Change the pool size in the rules preset on the Concept page">
|
||||
Points left: <strong>{c.skillLeft}</strong> / {cr.skill_points_default}
|
||||
<div className={`pool ${c.skillLeft < 0 ? 'over' : ''}`}>
|
||||
<Tip text={[...explainSkillPool(ctx), 'The pool size comes from the rules preset (Concept page).']}>Points left</Tip>: <strong>{c.skillLeft}</strong> / {cr.skill_points_default}
|
||||
</div>
|
||||
<div className={`pool ${c.profLeft < 0 ? 'over' : ''}`} title="Two picks on the same skill make it Expertise">
|
||||
Proficiency picks left: <strong>{c.profLeft}</strong> / {c.profAllowed}
|
||||
<div className={`pool ${c.profLeft < 0 ? 'over' : ''}`}>
|
||||
<Tip text={explainProfPool(ctx)}>Proficiency picks left</Tip>: <strong>{c.profLeft}</strong> / {c.profAllowed}
|
||||
</div>
|
||||
</div>
|
||||
<p className="small muted">
|
||||
Proficiency adds +{cr.proficiency_bonus}. Two picks on the same skill make it Expertise (+{cr.expertise_bonus}); a profession proficiency counts as one of them.
|
||||
Proficiency in an advanced skill unlocks it for free. The talent "Versatile Training" grants 2 more picks.
|
||||
Proficiency picks only work on skills you can use, so unlock an advanced skill first. The talent "Versatile Training" grants 2 more picks.
|
||||
</p>
|
||||
{rules.skill_groups.map((g) => (
|
||||
<div key={g.id}>
|
||||
@@ -274,7 +311,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
return (
|
||||
<div key={s.id} className={`skill ${s.usable ? '' : 'locked'}`} title={s.desc}>
|
||||
<div>
|
||||
<strong>{s.name}</strong> {s.placeholder && <Badge />}
|
||||
<strong>{s.name}</strong> {s.placeholder && <Badge />}<Tip icon text={explainSkill(s, ctx)} />
|
||||
<div className="small muted">
|
||||
{s.attrs.map((id) => rules.attributes.find((a) => a.id === id).short).join('/')} · {s.type === 'basic' ? 'Basic' : s.usable ? 'Advanced (unlocked)' : 'Advanced (locked)'}
|
||||
{s.pips === 1 && ' · Proficient'}{s.pips === 2 && ' · Expertise'}
|
||||
@@ -283,8 +320,8 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
<span className="small muted">Training</span>
|
||||
{[1, 2].map((k) => (
|
||||
<button key={k} className={`pip ${s.pips >= k ? 'on' : ''} ${s.profPip >= k ? 'from-prof' : ''}`}
|
||||
disabled={s.profPip >= k} onClick={() => clickPip(s, k)}
|
||||
title={s.profPip >= k ? 'From your profession' : k === 2 ? 'Expertise (uses 2 picks in total)' : 'Proficiency'}
|
||||
disabled={s.profPip >= k || !s.usable} onClick={() => clickPip(s, k)}
|
||||
title={!s.usable ? 'Unlock the skill first' : s.profPip >= k ? 'From your profession' : k === 2 ? 'Expertise (uses 2 picks in total)' : 'Proficiency'}
|
||||
aria-label={`${s.name} proficiency ${k}`}>{s.pips >= k ? '◆' : '◇'}</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -297,13 +334,15 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
) : (
|
||||
<div className="stepper">
|
||||
{s.type === 'advanced' && !s.free && (
|
||||
<button className="ghost small-btn" title={`Lock again and get ${c.unlockCost} points back`}
|
||||
onClick={() => setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 } }))}>Lock</button>
|
||||
<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>
|
||||
<span className="mod" title="Target number">{s.total}</span>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -357,75 +396,51 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
)
|
||||
|
||||
// --- Equipment ---
|
||||
const kitRule = cr.kit
|
||||
const items = rules.equipment
|
||||
const catName = (id) => rules.equipment_categories.find((x) => x.id === id).name
|
||||
const setKit = (patch) => set({ kit: { ...ch.kit, ...patch } })
|
||||
const weaponItems = items.filter((e) => kitRule.weapon_cats.includes(e.cat))
|
||||
const armorItems = items.filter((e) => e.cat === 'armor' && kitRule.armor_tiers.includes(e.tier))
|
||||
const resourceItems = items.filter((e) => e.cat === 'resource')
|
||||
const toolItems = items.filter((e) => e.cat === 'tool')
|
||||
|
||||
const ItemCard = ({ e, on, onClick, disabled }) => (
|
||||
<button className={`card choice ${on ? 'on' : ''}`} onClick={onClick} disabled={disabled}>
|
||||
<strong>{e.name}</strong>
|
||||
<span className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg</span>
|
||||
{e.note && <span className="small">{e.note}</span>}
|
||||
</button>
|
||||
)
|
||||
const SingleSlot = ({ title, list, slot }) => (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
<div className="cards">{list.map((e) => (
|
||||
<ItemCard key={e.id} e={e} on={ch.kit[slot] === e.id} onClick={() => setKit({ [slot]: ch.kit[slot] === e.id ? '' : e.id })} />
|
||||
))}</div>
|
||||
</>
|
||||
)
|
||||
const setExtra = (id, qty) => {
|
||||
const extras = { ...ch.kit.extras }
|
||||
if (qty <= 0) delete extras[id]; else extras[id] = qty
|
||||
setKit({ extras })
|
||||
set({ kit: { ...ch.kit, extras } })
|
||||
}
|
||||
const overWeight = c.weight > c.carryMax
|
||||
|
||||
const stepGear = (
|
||||
<>
|
||||
<h2>Equipment</h2>
|
||||
<p className="muted">Starting gear: one weapon (melee or firearm), light armor or clothing, {kitRule.resources_min}-{kitRule.resources_max} resources and one tool. Everything else can be added as extra items below.</p>
|
||||
<div className={`pool ${overWeight ? 'over' : ''}`}>Carried: <strong>{c.weight}</strong> / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}</div>
|
||||
<SingleSlot title="Weapon" list={weaponItems} slot="weapon" />
|
||||
<SingleSlot title="Armor or clothing" list={armorItems} slot="armor" />
|
||||
<h3>Resources ({ch.kit.resources.length}/{kitRule.resources_max})</h3>
|
||||
<div className="cards">{resourceItems.map((e) => {
|
||||
const on = ch.kit.resources.includes(e.id)
|
||||
return <ItemCard key={e.id} e={e} on={on} disabled={!on && ch.kit.resources.length >= kitRule.resources_max}
|
||||
onClick={() => setKit({ resources: on ? ch.kit.resources.filter((x) => x !== e.id) : [...ch.kit.resources, e.id] })} />
|
||||
})}</div>
|
||||
<SingleSlot title="Tool or utility item" list={toolItems} slot="tool" />
|
||||
|
||||
<h3>Extra items</h3>
|
||||
<p className="small muted">Anything else from the document's item lists. Ask your GM what you may take along.</p>
|
||||
{rules.equipment_categories.map((cat) => (
|
||||
<details key={cat.id}>
|
||||
<summary>{cat.name} <span className="muted small">({items.filter((e) => e.cat === cat.id).reduce((n, e) => n + (ch.kit.extras[e.id] || 0), 0)} chosen)</span></summary>
|
||||
<div className="extras">{items.filter((e) => e.cat === cat.id).map((e) => (
|
||||
<div key={e.id} className="extra">
|
||||
<div><strong>{e.name}</strong><div className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg{e.note ? ` · ${e.note}` : ''}</div></div>
|
||||
<div className="stepper">
|
||||
<button onClick={() => setExtra(e.id, (ch.kit.extras[e.id] || 0) - 1)} aria-label={`${e.name} minus`}>−</button>
|
||||
<span className="val">{ch.kit.extras[e.id] || 0}</span>
|
||||
<button onClick={() => setExtra(e.id, (ch.kit.extras[e.id] || 0) + 1)} aria-label={`${e.name} plus`}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
))}</div>
|
||||
</details>
|
||||
))}
|
||||
<p className="muted">Pick everything your character carries. Ask your GM what is allowed. Use + and − on a tile to change the amount.</p>
|
||||
<div className={`pool ${overWeight ? 'over' : ''}`}><Tip text={explainCarry(ctx)}>Carried</Tip>: <strong>{c.weight}</strong> / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}</div>
|
||||
{rules.equipment_categories.map((cat) => {
|
||||
const list = items.filter((e) => e.cat === cat.id)
|
||||
const n = list.reduce((sum, e) => sum + (ch.kit.extras[e.id] || 0), 0)
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
<h4>{cat.name} {n > 0 && <span className="muted small">({n} chosen)</span>}</h4>
|
||||
<div className="cards">{list.map((e) => {
|
||||
const qty = ch.kit.extras[e.id] || 0
|
||||
return (
|
||||
<div key={e.id} className={`card tile ${qty > 0 ? 'on' : ''}`}>
|
||||
<strong>{e.name}</strong>
|
||||
<span className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg</span>
|
||||
{e.note && <span className="small">{e.note}</span>}
|
||||
<div className="stepper tilestepper">
|
||||
<button className="ghost" onClick={() => setExtra(e.id, qty - 1)} disabled={qty === 0} aria-label={`${e.name} minus`}>−</button>
|
||||
<span className="val">{qty}</span>
|
||||
<button onClick={() => setExtra(e.id, qty + 1)} aria-label={`${e.name} plus`}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<label>Other items (free text)<textarea rows="3" value={ch.gear.other} onChange={(e) => set({ gear: { ...ch.gear, other: e.target.value } })} placeholder="Anything not in the lists, e.g. a unique starter multi-tool" /></label>
|
||||
<label>Notes<textarea rows="4" value={ch.notes} onChange={(e) => set({ notes: e.target.value })} /></label>
|
||||
</>
|
||||
)
|
||||
|
||||
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.')
|
||||
@@ -438,55 +453,24 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
if (c.profLeft > 0) warnings.push(`${c.profLeft} proficiency pick${c.profLeft > 1 ? 's' : ''} left.`)
|
||||
if (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
|
||||
if (over) warnings.push('Augmentation Capacity exceeded.')
|
||||
if (!ch.kit.weapon) warnings.push('No starting weapon chosen.')
|
||||
if (!ch.kit.armor) warnings.push('No armor or clothing chosen.')
|
||||
if (ch.kit.resources.length < kitRule.resources_min) warnings.push('No starting resource chosen.')
|
||||
if (!ch.kit.tool) warnings.push('No tool or utility item chosen.')
|
||||
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>{a.name}</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>{d.name}</td><td>{c.derived[d.id]}</td></tr>)}</tbody></table>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Skills</h4>
|
||||
<table><tbody>
|
||||
{c.skills.filter((s) => s.points || s.prof || (s.unlocked && s.type === 'advanced')).map((s) => <tr key={s.id}><td>{s.name}</td><td>{s.total}</td><td className="muted">{s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : ''}</td></tr>)}
|
||||
{!c.skills.some((s) => s.points || s.prof || s.unlocked) && <tr><td className="muted">None</td></tr>}
|
||||
</tbody></table>
|
||||
<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>Mutations / implants ({c.capacityUsed}/{c.capacityMax})</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>Equipment ({c.weight}/{c.carryMax} kg)</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>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
@@ -496,7 +480,7 @@ export default function Creator({ rules: baseRules, 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,53 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function Login({ registrationOpen, onDone }) {
|
||||
const [mode, setMode] = useState('login')
|
||||
const [f, setF] = useState({ username: '', password: '', password2: '', code: '' })
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const set = (k) => (e) => setF({ ...f, [k]: e.target.value })
|
||||
const register = mode === 'register'
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setErr('')
|
||||
if (register && f.password !== f.password2) return setErr('The two passwords do not match.')
|
||||
setBusy(true)
|
||||
try {
|
||||
const r = await fetch(`/api/auth/${register ? 'register' : 'login'}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(register ? { username: f.username, password: f.password, code: f.code } : { username: f.username, password: f.password }),
|
||||
})
|
||||
const data = await r.json().catch(() => ({}))
|
||||
if (!r.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Something went wrong.')
|
||||
onDone(data.user)
|
||||
} catch (ex) { setErr(ex.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page narrow loginbox">
|
||||
<h1>Arcane Wasteland</h1>
|
||||
<p className="muted">Character creator. Please {register ? 'create an account' : 'log in'} to continue.</p>
|
||||
<div className="chips">
|
||||
<button className={`chip ${!register ? 'on' : ''}`} onClick={() => { setMode('login'); setErr('') }}>Log in</button>
|
||||
{registrationOpen && <button className={`chip ${register ? 'on' : ''}`} onClick={() => { setMode('register'); setErr('') }}>Create account</button>}
|
||||
</div>
|
||||
<form className="card" onSubmit={submit}>
|
||||
<label>Username<input value={f.username} onChange={set('username')} autoComplete="username" autoFocus required /></label>
|
||||
<label>Password<input type="password" value={f.password} onChange={set('password')} autoComplete={register ? 'new-password' : 'current-password'} required /></label>
|
||||
{register && (
|
||||
<>
|
||||
<label>Repeat password<input type="password" value={f.password2} onChange={set('password2')} autoComplete="new-password" required /></label>
|
||||
<label>Registration code
|
||||
<input type="password" value={f.code} onChange={set('code')} autoComplete="off" required />
|
||||
<span className="small muted">Ask the person who runs this server.</span>
|
||||
</label>
|
||||
<p className="small muted">Username: 3-32 characters (letters, digits, . _ -). Password: at least 8 characters.</p>
|
||||
</>
|
||||
)}
|
||||
{err && <p className="warn" role="alert">{err}</p>}
|
||||
<button type="submit" disabled={busy}>{register ? 'Create account' : 'Log in'}</button>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const KEY = 'aw_presets' // {presetId: editToken}, only for presets created in this browser
|
||||
const loadTokens = () => { try { return JSON.parse(localStorage.getItem(KEY)) || {} } catch { return {} } }
|
||||
const saveTokens = (t) => { try { localStorage.setItem(KEY, JSON.stringify(t)) } catch { /* storage blocked */ } }
|
||||
|
||||
const BLANK = { id: null, name: '', desc: '', creation: {} }
|
||||
|
||||
export default function Presets({ rules, reload }) {
|
||||
const [tokens, setTokens] = useState(loadTokens)
|
||||
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?.id && !!tokens[current.id]
|
||||
const own = !!current?.mine
|
||||
const base = rules.creation
|
||||
const groups = [...new Set((rules.tunables || []).map((x) => x.group))]
|
||||
const value = (k) => (current.creation?.[k] ?? base[k])
|
||||
@@ -36,17 +31,13 @@ export default function Presets({ rules, reload }) {
|
||||
try {
|
||||
const res = await fetch(draft.id ? `/api/presets/${draft.id}` : '/api/presets', {
|
||||
method: draft.id ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(draft.id ? { 'X-Edit-Token': tokens[draft.id] } : {}) },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || 'Saving failed')
|
||||
const data = await res.json()
|
||||
let id = draft.id
|
||||
if (!id) {
|
||||
id = data.id
|
||||
const t = { ...tokens, [id]: data.token }
|
||||
saveTokens(t); setTokens(t)
|
||||
}
|
||||
if (!id) id = data.id
|
||||
await reload()
|
||||
setSel(id); setDraft(null); flash('Preset saved.')
|
||||
} catch (e) { flash(String(e.message || e)) }
|
||||
@@ -55,22 +46,20 @@ export default function Presets({ rules, reload }) {
|
||||
const remove = async () => {
|
||||
if (!window.confirm(`Delete the preset "${current.name}"? Characters that already use it keep their numbers.`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/presets/${current.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': tokens[current.id] } })
|
||||
const res = await fetch(`/api/presets/${current.id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('Deleting failed')
|
||||
const t = { ...tokens }; delete t[current.id]
|
||||
saveTokens(t); setTokens(t)
|
||||
await reload(); setSel(rules.default_preset || 'standard')
|
||||
} catch (e) { flash(String(e.message || e)) }
|
||||
}
|
||||
|
||||
const kind = (p) => (p.builtin ? 'Built in' : tokens[p.id] ? 'Yours' : 'Shared')
|
||||
const kind = (p) => (p.builtin ? 'Built in' : p.mine ? 'Yours' : p.author ? `By ${p.author}` : 'Shared')
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<h1>Rules presets</h1>
|
||||
<p className="muted narrow">
|
||||
A preset is a named set of creation numbers (points for attributes and skills, talents, proficiency ...). Pick one on the Concept page of the creator.
|
||||
Presets are stored on the server, so everyone using this site can use them; only the browser that created a preset can change or delete it.
|
||||
Presets are stored on the server, so everyone using this site can use them; only the account that created a preset can change or delete it.
|
||||
Built-in presets come from <code>rules.json</code>. Duplicate one to make your own version.
|
||||
</p>
|
||||
<div className="presets-layout">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react'
|
||||
import Tip from './Tip.jsx'
|
||||
import { countChanges, diffRows, sheetModel } from './sheetModel.js'
|
||||
|
||||
function Rows({ now, before, valueLabel }) {
|
||||
@@ -7,8 +8,8 @@ function Rows({ now, before, valueLabel }) {
|
||||
return (
|
||||
<table className="difftable"><tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.k} className={`d-${r.status}`}>
|
||||
<td>{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}{r.label}{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
|
||||
<tr key={r.k} className={`d-${r.status} ${r.dim ? 'dim' : ''}`}>
|
||||
<td>{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}<Tip text={r.tip}>{r.label}</Tip>{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
|
||||
<td>
|
||||
{r.status === 'changed' && r.old.val !== r.val && r.old.val !== undefined && <span className="was">{r.old.val} → </span>}
|
||||
{valueLabel ? valueLabel(r) : r.val}
|
||||
@@ -51,12 +52,12 @@ export default function SheetPanel({ ch, committed, baseRules, onApply, onDiscar
|
||||
<Rows now={now.skills} before={before.skills} />
|
||||
<h4>Talents</h4>
|
||||
<Rows now={now.talents} before={before.talents} />
|
||||
<h4>Mutations / implants <span className="muted small">{c.capacityUsed}/{c.capacityMax}</span></h4>
|
||||
<h4><Tip text={now.tips.capacity}>Mutations / implants <span className="muted small">{c.capacityUsed}/{c.capacityMax}</span></Tip></h4>
|
||||
<Rows now={now.augments} before={before.augments} />
|
||||
<h4>Equipment <span className="muted small">{c.weight}/{c.carryMax} kg</span></h4>
|
||||
<h4><Tip text={now.tips.carry}>Equipment <span className="muted small">{c.weight}/{c.carryMax} kg</span></Tip></h4>
|
||||
<Rows now={now.gear} before={before.gear} valueLabel={(r) => (r.val > 1 ? `x${r.val}` : '')} />
|
||||
<h4>Points left</h4>
|
||||
<div className="small muted">Attributes {c.attrLeft} · Skills {c.skillLeft} · Proficiency {c.profLeft}</div>
|
||||
<div className="small muted"><Tip text={now.tips.pools}>Attributes {c.attrLeft} · Skills {c.skillLeft} · Proficiency {c.profLeft}</Tip></div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
// Tooltip that works with mouse, keyboard focus and touch. `text` is a string or a list of lines.
|
||||
// Without `icon` the children get a dotted underline; with `icon` a small "i" is shown after them.
|
||||
export default function Tip({ text, children, icon }) {
|
||||
const lines = (Array.isArray(text) ? text : [text]).filter(Boolean)
|
||||
const ref = useRef(null)
|
||||
const pop = useRef(null)
|
||||
const id = useId()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState({ left: 0, top: 0, ready: false })
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !ref.current || !pop.current) return
|
||||
const r = ref.current.getBoundingClientRect()
|
||||
const w = pop.current.offsetWidth, h = pop.current.offsetHeight
|
||||
const left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left + r.width / 2 - w / 2))
|
||||
const below = r.bottom + 6 + h < window.innerHeight
|
||||
setPos({ left, top: below ? r.bottom + 6 : Math.max(8, r.top - 6 - h), ready: true })
|
||||
}, [open, lines.join('|')]) // eslint-disable-line
|
||||
|
||||
if (!lines.length) return children
|
||||
const show = () => setOpen(true)
|
||||
const hide = () => { setOpen(false); setPos((p) => ({ ...p, ready: false })) }
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={ref} tabIndex={0} className={icon ? 'tipwrap' : 'tipped'} aria-describedby={open ? id : undefined}
|
||||
onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide} onClick={show}
|
||||
onKeyDown={(e) => e.key === 'Escape' && hide()}>
|
||||
{children}{icon && <span className="tipicon" aria-label="Explanation">i</span>}
|
||||
</span>
|
||||
{open && createPortal(
|
||||
<div ref={pop} id={id} role="tooltip" className="tippop" style={{ left: pos.left, top: pos.top, visibility: pos.ready ? 'visible' : 'hidden' }}>
|
||||
{lines.map((l, i) => <div key={i}>{l}</div>)}
|
||||
</div>, document.body)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+27
-20
@@ -6,15 +6,15 @@ 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
|
||||
skill_points: {}, proficiencies: {}, proficiency_picks: {}, unlocked: [],
|
||||
talents: [], augments: [],
|
||||
kit: { weapon: '', armor: '', tool: '', resources: [], extras: {} },
|
||||
kit: { extras: {} }, // item id -> quantity
|
||||
gear: { other: '' },
|
||||
notes: '',
|
||||
notes: '', conditions: '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function normalize(ch, rules) {
|
||||
const preset = ch.preset || rules.default_preset || 'standard'
|
||||
const creation = ch.creation ?? presetOverrides(rules, preset)
|
||||
const base = emptyCharacter(effectiveRules(rules, { creation }))
|
||||
const out = { ...base, ...ch, preset, creation, kit: { ...base.kit, ...(ch.kit || {}) }, gear: { ...base.gear, ...(ch.gear || {}) } }
|
||||
const out = { ...base, ...ch, preset, creation, kit: { extras: {}, ...(ch.kit || {}) }, gear: { ...base.gear, ...(ch.gear || {}) } }
|
||||
if (typeof out.skill_pool === 'number') { // older characters stored the pool size on the character
|
||||
if (out.skill_pool !== effectiveRules(rules, out).creation.skill_points_default) {
|
||||
out.creation = { ...out.creation, skill_points_default: out.skill_pool }
|
||||
@@ -73,31 +73,37 @@ export function normalize(ch, rules) {
|
||||
}
|
||||
}
|
||||
delete out.skill_pool
|
||||
// Older characters had starting-kit slots (weapon, armor, tool, resources): they become ordinary items
|
||||
const k = out.kit
|
||||
if (k.weapon || k.armor || k.tool || (k.resources || []).length) {
|
||||
const extras = { ...k.extras }
|
||||
;[k.weapon, k.armor, k.tool, ...(k.resources || [])].filter(Boolean).forEach((id) => { extras[id] = (extras[id] || 0) + 1 })
|
||||
out.kit = { extras }
|
||||
}
|
||||
// The preset may have been deleted since: fall back to whatever matches the numbers
|
||||
if (out.preset !== 'custom' && !(rules.presets || []).some((x) => x.id === out.preset)) out.preset = matchPreset(rules, out.creation)
|
||||
// Older characters: advanced skills that already hold points count as unlocked
|
||||
// Older characters: advanced skills that already hold points or proficiency picks count as unlocked
|
||||
const unlocked = new Set(out.unlocked)
|
||||
rules.skills.forEach((s) => { if (s.type === 'advanced' && (out.skill_points[s.id] || 0) > 0) unlocked.add(s.id) })
|
||||
rules.skills.forEach((s) => { if (s.type === 'advanced' && ((out.skill_points[s.id] || 0) > 0 || (out.proficiency_picks[s.id] || 0) > 0)) unlocked.add(s.id) })
|
||||
return { ...out, unlocked: [...unlocked] }
|
||||
}
|
||||
|
||||
// Clears points on advanced skills that are neither unlocked nor granted by a profession proficiency.
|
||||
// Clears points and proficiency picks on advanced skills that are neither unlocked nor granted by the profession.
|
||||
export function pruneLocked(ch, rules) {
|
||||
const points = { ...ch.skill_points }
|
||||
const picks = { ...ch.proficiency_picks }
|
||||
rules.skills.forEach((s) => {
|
||||
if (s.type === 'advanced' && !ch.unlocked.includes(s.id) && !ch.proficiencies[s.id] && !(ch.proficiency_picks[s.id] > 0)) delete points[s.id]
|
||||
if (s.type === 'advanced' && !ch.unlocked.includes(s.id) && !ch.proficiencies[s.id]) { delete points[s.id]; delete picks[s.id] }
|
||||
})
|
||||
return { ...ch, skill_points: points }
|
||||
return { ...ch, skill_points: points, proficiency_picks: picks }
|
||||
}
|
||||
|
||||
export function kitItems(ch, rules) {
|
||||
const items = Object.fromEntries(rules.equipment.map((e) => [e.id, e]))
|
||||
const k = ch.kit
|
||||
const out = []
|
||||
;[['Weapon', k.weapon], ['Armor', k.armor], ['Tool', k.tool]].forEach(([label, id]) => { if (items[id]) out.push({ label, item: items[id], qty: 1 }) })
|
||||
k.resources.forEach((id) => { if (items[id]) out.push({ label: 'Resource', item: items[id], qty: 1 }) })
|
||||
Object.entries(k.extras).forEach(([id, qty]) => { if (items[id] && qty > 0) out.push({ label: 'Extra', item: items[id], qty }) })
|
||||
return out
|
||||
const cats = Object.fromEntries((rules.equipment_categories || []).map((c) => [c.id, c.name]))
|
||||
return Object.entries(ch.kit.extras || {})
|
||||
.filter(([id, qty]) => items[id] && qty > 0)
|
||||
.map(([id, qty]) => ({ label: cats[items[id].cat] || '', item: items[id], qty }))
|
||||
}
|
||||
|
||||
export function compute(ch, rules) {
|
||||
@@ -118,17 +124,18 @@ 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,
|
||||
usable: s.type === 'basic' || unlocked || prof > 0, // advanced skills must be unlocked
|
||||
free: s.type === 'advanced' && prof > 0, // unlocked by the profession, costs nothing
|
||||
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) }
|
||||
})
|
||||
|
||||
const profSpent = skills.reduce((s, x) => s + x.freePips, 0)
|
||||
const profAllowed = (cr.free_proficiencies ?? 0) + rules.talents.filter((t) => ch.talents.includes(t.id)).reduce((s, t) => s + (t.proficiency_picks || 0), 0)
|
||||
const unlockCost = cr.advanced_unlock_cost ?? 0
|
||||
const paidUnlocks = skills.filter((s) => s.type === 'advanced' && s.unlocked && !s.prof).length
|
||||
const paidUnlocks = skills.filter((s) => s.type === 'advanced' && s.unlocked && !s.profPip).length
|
||||
const attrSpent = rules.attributes.reduce((s, a) => s + (ch.attributes[a.id] - cr.attr_start), 0)
|
||||
const skillSpent = Object.values(ch.skill_points).reduce((s, v) => s + v, 0) + paidUnlocks * unlockCost
|
||||
const chosen = rules.augments.filter((a) => ch.augments.includes(a.id))
|
||||
@@ -175,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: {} }
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Plain-language explanations of where numbers come from, shown in tooltips.
|
||||
// `x` is { ch, rules, c } (character, effective rules, result of compute()).
|
||||
|
||||
const attrName = (rules, id) => rules.attributes.find((a) => a.id === id)?.name || id
|
||||
const attrShort = (rules, id) => rules.attributes.find((a) => a.id === id)?.short || id
|
||||
|
||||
export function explainAttr(a, { ch, rules, c }) {
|
||||
const cr = rules.creation
|
||||
const base = ch.attributes[a.id] ?? cr.attr_start
|
||||
const bonus = ch.profession && ch.profession_bonus_attr === a.id ? cr.profession_attr_bonus : 0
|
||||
const cap = Math.min(cr.attr_max, cr.attr_start + cr.attr_max_raise)
|
||||
const used = rules.derived.filter((d) => d.formula.includes(a.id)).map((d) => d.name)
|
||||
return [
|
||||
`${a.name} = ${cr.attr_start} start + ${base - cr.attr_start} from your points${bonus ? ` + ${bonus} profession bonus` : ''} = ${c.attrs[a.id]}`,
|
||||
`Modifier = value ÷ 10, rounded down = ${c.mods[a.id]}`,
|
||||
`Highest value you can reach with your points: ${cap} (start ${cr.attr_start}, at most +${cr.attr_max_raise}, cap ${cr.attr_max}). The profession bonus comes on top.`,
|
||||
used.length ? `Its modifier feeds: ${used.join(', ')}.` : '',
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
export function explainDerived(d, { rules, c }) {
|
||||
const parts = d.formula.map((id) => `${attrName(rules, id)} mod ${c.mods[id]}`)
|
||||
return [`${d.name} = ${parts.join(' + ')} = ${c.derived[d.id]}`, d.note || ''].filter(Boolean)
|
||||
}
|
||||
|
||||
export function explainSkill(s, { ch, rules, c }) {
|
||||
const cr = rules.creation
|
||||
const best = Math.max(...s.attrs.map((id) => c.attrs[id]))
|
||||
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 = ${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') {
|
||||
lines.push(s.free ? 'Advanced skill: unlocked for free by your profession.'
|
||||
: s.unlocked ? `Advanced skill: unlocked (cost ${c.unlockCost} points from the pool).`
|
||||
: `Advanced skill: locked. Unlock it for ${c.unlockCost} points from the pool before you can use it.`)
|
||||
}
|
||||
if (s.pips === 1 && s.profPip) lines.push('Proficient through your profession.')
|
||||
return lines
|
||||
}
|
||||
|
||||
export function explainCapacity({ rules, c }) {
|
||||
const d = rules.derived.find((x) => x.id === 'augmentation')
|
||||
return [`Capacity = ${d ? d.formula.map((id) => `${attrName(rules, id)} mod ${c.mods[id]}`).join(' + ') : 'see rules'} = ${c.capacityMax}`,
|
||||
`Each mutation or implant costs capacity; you used ${c.capacityUsed} of ${c.capacityMax}.`]
|
||||
}
|
||||
|
||||
export function explainCarry({ rules, c }) {
|
||||
return [`Carry capacity = Strength mod ${c.mods.strength} × ${rules.creation.carry_per_strength_mod} kg = ${c.carryMax} kg`,
|
||||
`Carried now: ${c.weight} kg (sum of all chosen items).`]
|
||||
}
|
||||
|
||||
export function explainAttrPool({ rules, c }) {
|
||||
return [`Attribute points: ${c.attrSpent} spent of ${rules.creation.attr_points}. Each point raises an attribute by 1.`]
|
||||
}
|
||||
|
||||
export function explainSkillPool({ rules, c }) {
|
||||
const cr = rules.creation
|
||||
const paid = c.skills.filter((s) => s.type === 'advanced' && s.unlocked && !s.prof).length
|
||||
return [`Skill pool: ${c.skillSpent} spent of ${cr.skill_points_default}.`,
|
||||
`Skill points count 1 each; unlocking an advanced skill costs ${c.unlockCost}${paid ? ` (${paid} unlocked = ${paid * c.unlockCost})` : ''}.`]
|
||||
}
|
||||
|
||||
export function explainProfPool({ rules, c }) {
|
||||
const cr = rules.creation
|
||||
return [`Proficiency picks: ${c.profSpent} used of ${c.profAllowed} (${cr.free_proficiencies} free plus talents like Versatile Training).`,
|
||||
`One pick = Proficient (+${cr.proficiency_bonus}). Two picks on one skill = Expertise (+${cr.expertise_bonus}); a profession proficiency counts as one.`]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Characters and presets made before accounts existed were protected by secret keys in this browser.
|
||||
// After login they are handed over to the account once; the old keys are then removed.
|
||||
export async function claimLegacy() {
|
||||
const post = (kind, id, token) =>
|
||||
fetch(`/api/claim/${kind}/${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) }).catch(() => null)
|
||||
try {
|
||||
const chars = JSON.parse(localStorage.getItem('aw_chars')) || []
|
||||
for (const c of chars) if (c?.id && c?.token) await post('characters', c.id, c.token)
|
||||
localStorage.removeItem('aw_chars')
|
||||
} catch { /* nothing to claim */ }
|
||||
try {
|
||||
const presets = JSON.parse(localStorage.getItem('aw_presets')) || {}
|
||||
for (const [id, token] of Object.entries(presets)) await post('presets', id, token)
|
||||
localStorage.removeItem('aw_presets')
|
||||
} catch { /* nothing to claim */ }
|
||||
}
|
||||
@@ -3,4 +3,13 @@ import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './styles.css'
|
||||
|
||||
// A 401 from any data call means the session ended: tell the app so it shows the login page.
|
||||
const realFetch = window.fetch.bind(window)
|
||||
window.fetch = async (...args) => {
|
||||
const res = await realFetch(...args)
|
||||
const url = String(typeof args[0] === 'string' ? args[0] : args[0]?.url || '')
|
||||
if (res.status === 401 && url.startsWith('/api/') && !url.startsWith('/api/auth/login')) window.dispatchEvent(new Event('aw-unauthorized'))
|
||||
return res
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -1,26 +1,28 @@
|
||||
import { compute, effectiveRules, kitItems } from './calc.js'
|
||||
import { explainAttr, explainCapacity, explainCarry, explainDerived, explainSkill, explainAttrPool, explainSkillPool, explainProfPool } from './explain.js'
|
||||
|
||||
// Flat, comparable view of a character, used by the side panel to show what changed.
|
||||
export function sheetModel(ch, baseRules) {
|
||||
const rules = effectiveRules(baseRules, ch)
|
||||
const c = compute(ch, rules)
|
||||
const x = { ch, rules, c }
|
||||
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((x) => x.id === ch.subclass_id)
|
||||
const preset = ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name || 'Custom'
|
||||
const training = (s) => (s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : s.type === 'advanced' && s.unlocked ? 'Unlocked' : '')
|
||||
const training = (s) => (s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.type === 'advanced' ? 'Unlocked' : '')
|
||||
return {
|
||||
c,
|
||||
tips: { capacity: explainCapacity(x), carry: explainCarry(x), pools: [...explainAttrPool(x), ...explainSkillPool(x), ...explainProfPool(x)] },
|
||||
head: [
|
||||
{ k: 'name', label: 'Name', val: ch.name || 'Nameless' },
|
||||
{ k: 'profession', label: 'Profession', val: prof?.name || '–' },
|
||||
{ k: 'class', label: 'Class', val: cls ? [cls.name, sub?.name].filter(Boolean).join(' / ') : '–' },
|
||||
{ k: 'preset', label: 'Rules', val: preset },
|
||||
],
|
||||
attrs: rules.attributes.map((a) => ({ k: a.id, label: a.name, val: c.attrs[a.id], extra: `Mod ${c.mods[a.id]}` })),
|
||||
derived: rules.derived.map((d) => ({ k: d.id, label: d.name, val: c.derived[d.id] })),
|
||||
skills: c.skills.filter((s) => s.points || s.prof || (s.unlocked && s.type === 'advanced'))
|
||||
.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s) })),
|
||||
attrs: rules.attributes.map((a) => ({ k: a.id, label: a.name, val: c.attrs[a.id], extra: `Mod ${c.mods[a.id]}`, tip: explainAttr(a, x) })),
|
||||
derived: rules.derived.map((d) => ({ k: d.id, label: d.name, val: c.derived[d.id], tip: explainDerived(d, x) })),
|
||||
skills: c.skills.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s), dim: !s.usable, tip: explainSkill(s, x) })),
|
||||
talents: rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => ({ k: t.id, label: t.name, desc: t.desc })),
|
||||
augments: rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => ({ k: a.id, label: a.name, desc: `${a.kind === 'implant' ? 'Implant' : 'Mutation'} · cost ${a.cost}${a.desc ? ` · ${a.desc}` : ''}` })),
|
||||
gear: kitItems(ch, rules).map((g) => ({ k: g.item.id, label: g.item.name, val: g.qty, desc: [g.item.stat, `${g.item.kg} kg`, g.item.note].filter(Boolean).join(' · ') })),
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -147,3 +147,527 @@ ul.desclist { list-style: none; padding: 0; margin: 0 0 .5rem; display: grid; ga
|
||||
main { max-width: 1280px; }
|
||||
@media (max-width: 1100px) { .creator-body { grid-template-columns: minmax(0, 1fr) 360px; } }
|
||||
@media (max-width: 800px) { .creator-body { grid-template-columns: 1fr; } }
|
||||
tr.dim td { opacity: .55; }
|
||||
.skillcols { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0 2rem; }
|
||||
.skillgroup h5 { margin: .8em 0 .2em; color: var(--diesium); font-size: .9rem; }
|
||||
|
||||
.loginbox { margin-top: 3rem; }
|
||||
.loginbox form button[type="submit"] { margin-top: .5rem; }
|
||||
|
||||
.sharepanel { margin-top: .8rem; padding-top: .6rem; border-top: 1px solid var(--line); }
|
||||
.sharepanel form input { flex: 1; }
|
||||
.chip button.linklike { color: inherit; text-decoration: none; margin-left: .2rem; }
|
||||
.top nav a.navchar { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.card.tile { display: flex; flex-direction: column; gap: .3rem; }
|
||||
.card.tile.on { border-color: var(--noxium); box-shadow: inset 0 0 0 1px var(--noxium); background: var(--panel2); }
|
||||
.tilestepper { margin-top: auto; padding-top: .4rem; justify-content: flex-end; }
|
||||
|
||||
.tipped { border-bottom: 1px dotted var(--muted); cursor: help; }
|
||||
.tipwrap { cursor: help; }
|
||||
.tipicon { display: inline-grid; place-items: center; width: 1.05rem; height: 1.05rem; margin-left: .3rem; border: 1px solid var(--muted); border-radius: 50%; color: var(--muted); font-size: .7rem; font-style: italic; font-weight: 700; vertical-align: middle; }
|
||||
.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