Lobby: 3-column layout with player area, skill checks, difficulty, colours, own dice, mystery dice
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -83,18 +83,22 @@ Each wizard page has a **Reset this page** button that clears only that page's d
|
||||
|
||||
### 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 in the tab bar) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout.
|
||||
The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits above the sheet) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout.
|
||||
|
||||
### 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: the main area on the left, the chat as a column at the right edge of the window and a dice hotbar at the bottom centre of the main area.
|
||||
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 column): live chat for everyone, plus **whispers** to a single person (pick them in the 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); it can be hidden with a checkbox. The chat polls the server every few seconds, keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages per minute.
|
||||
- **Dice** (bottom centre): d4, d6, d8, d10, d12, d20 and d100, with a count (1 to 10 dice). A click rolls on the server (so nobody can cheat), the dice tumble into a tray above the hotbar for everybody in the lobby and the result is posted in the chat. Rolls follow the chat's recipient dropdown: with a person selected it is a private roll only that person (and you) can see. **The DM's rolls are hidden from the players** (the hotbar says "Hidden (only you)"); the DM can still pick a player as recipient to show them a roll.
|
||||
- **Party** (tab): member cards (name, concept, class, profession). The DM also finds the join code, rename, remove player and delete lobby here.
|
||||
- **One sheet tab per character** (next to Party): the DM gets a tab for every character in the lobby (read-only, always the current saved version, refreshed every 15 s); a player gets "My character". Hover or tap a value to see how it is made up. "Open in the Character tab" jumps to the full editor (for the DM it is a read-only look, "Save" would create the DM's own copy).
|
||||
- **Chat** (right): live chat for everyone, plus **whispers** to a single person (dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes, can be hidden), dice rolls and skill checks. Names are shown in the player's colour. The chat polls every few seconds, keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages/rolls per minute.
|
||||
- **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*.
|
||||
- **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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -116,8 +120,11 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
|
||||
| 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/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper |
|
||||
| POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?}` (d4–d20, d100; up to 10 dice), result goes into the chat |
|
||||
| PUT / DELETE | `/api/lobbies/{id}/me` | Switch your character / leave |
|
||||
| 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) |
|
||||
|
||||
+196
-25
@@ -12,6 +12,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import auth
|
||||
from .derive import compute
|
||||
from .export import to_docx, to_pdf
|
||||
|
||||
BASE = Path(__file__).resolve().parent.parent
|
||||
@@ -26,6 +27,11 @@ MAX_LOBBIES = 20 # per DM
|
||||
MAX_MEMBERS = 20 # per lobby
|
||||
MAX_MESSAGE = 500 # characters per chat message
|
||||
KEEP_MESSAGES = 1000 # per lobby, older ones are dropped
|
||||
PALETTE = [ # player colours (dice, cards, chat names); every player in a lobby has a different one
|
||||
("red", "#e0524d"), ("orange", "#ea8a3b"), ("yellow", "#e6c52e"), ("green", "#6fc24b"), ("teal", "#2fb8a6"),
|
||||
("blue", "#4f93e6"), ("purple", "#9d6fda"), ("pink", "#e870b1"), ("white", "#e9e6da"), ("brown", "#a8764f")]
|
||||
PALETTE_HEX = dict(PALETTE)
|
||||
DM_COLOR = "#d4af37"
|
||||
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I
|
||||
|
||||
# No public API docs: everything about the API stays behind the login
|
||||
@@ -62,6 +68,11 @@ def db() -> sqlite3.Connection:
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, lobby_id TEXT NOT NULL, user_id INTEGER, to_user_id INTEGER,
|
||||
kind TEXT NOT NULL, text TEXT NOT NULL, created REAL NOT NULL)""")
|
||||
con.execute("CREATE INDEX IF NOT EXISTS lobby_messages_lobby ON lobby_messages (lobby_id, id)")
|
||||
for table, col, ddl in (("lobbies", "diff_mod", "INTEGER NOT NULL DEFAULT 0"), ("lobbies", "diff_label", "TEXT NOT NULL DEFAULT 'Challenging'"),
|
||||
("lobbies", "diff_hidden", "INTEGER NOT NULL DEFAULT 0"), ("lobby_members", "color", "TEXT"),
|
||||
("lobby_members", "char_mod", "INTEGER NOT NULL DEFAULT 0")):
|
||||
if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
||||
con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
||||
for table in ("characters", "presets"): # older databases: add the owner column
|
||||
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
||||
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
|
||||
@@ -426,6 +437,32 @@ def _own_character(con, cid, user: dict):
|
||||
raise HTTPException(422, "Pick one of your own characters")
|
||||
|
||||
|
||||
def _free_color(con, lid: str):
|
||||
used = {r["color"] for r in con.execute("SELECT color FROM lobby_members WHERE lobby_id=?", (lid,))}
|
||||
return next((cid for cid, _ in PALETTE if cid not in used), PALETTE[0][0])
|
||||
|
||||
|
||||
def _ensure_colors(con, lid: str):
|
||||
"""Members that joined before colours existed get the next free one."""
|
||||
for r in con.execute("SELECT user_id FROM lobby_members WHERE lobby_id=? AND color IS NULL ORDER BY joined", (lid,)).fetchall():
|
||||
con.execute("UPDATE lobby_members SET color=? WHERE lobby_id=? AND user_id=?", (_free_color(con, lid), lid, r["user_id"]))
|
||||
|
||||
|
||||
def _levels(rules: dict) -> list:
|
||||
"""Difficulty levels from the rules text (section 'difficulty', first table): [{name, mod}]."""
|
||||
out = []
|
||||
for sec in rules.get("sections", []):
|
||||
if sec.get("id") != "difficulty":
|
||||
continue
|
||||
for block in sec.get("blocks", []):
|
||||
for row in (block.get("table") or {}).get("rows", []):
|
||||
try:
|
||||
out.append({"name": row[0], "mod": int(row[1].replace("\u2212", "-").replace("\u00b1", "").replace("+", "").strip() or 0)})
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _event(con, lid: str, text: str, uid=None):
|
||||
"""Writes a line into the lobby's event log (shown in the chat feed to everybody)."""
|
||||
con.execute("INSERT INTO lobby_messages (lobby_id, user_id, kind, text, created) VALUES (?,?, 'event', ?, ?)",
|
||||
@@ -494,9 +531,9 @@ def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(cu
|
||||
known = con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lobby["id"], user["id"])).fetchone()
|
||||
if not known and con.execute("SELECT COUNT(*) FROM lobby_members WHERE lobby_id=?", (lobby["id"],)).fetchone()[0] >= MAX_MEMBERS:
|
||||
raise HTTPException(409, "This lobby is full")
|
||||
con.execute("""INSERT INTO lobby_members (lobby_id, user_id, character_id, joined) VALUES (?,?,?,?)
|
||||
con.execute("""INSERT INTO lobby_members (lobby_id, user_id, character_id, joined, color) VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(lobby_id, user_id) DO UPDATE SET character_id=excluded.character_id""",
|
||||
(lobby["id"], user["id"], body["character_id"], time.time()))
|
||||
(lobby["id"], user["id"], body["character_id"], time.time(), _free_color(con, lobby["id"])))
|
||||
_event(con, lobby["id"], f"{user['username']} {'switched to' if known else 'joined as'} {_char_name(con, body['character_id'])}", user["id"])
|
||||
return {"id": lobby["id"]}
|
||||
|
||||
@@ -507,7 +544,8 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
lobby = _lobby_for(con, lid, user)
|
||||
is_dm = lobby["owner_id"] == user["id"]
|
||||
rows = con.execute("""SELECT m.user_id, m.character_id, u.username, c.data FROM lobby_members m
|
||||
_ensure_colors(con, lid)
|
||||
rows = con.execute("""SELECT m.user_id, m.character_id, m.color, m.char_mod, u.username, c.data FROM lobby_members m
|
||||
JOIN users u ON u.id = m.user_id LEFT JOIN characters c ON c.id = m.character_id
|
||||
WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
|
||||
members = []
|
||||
@@ -515,10 +553,50 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
|
||||
mine = r["user_id"] == user["id"]
|
||||
card = _card(r["data"], rules) if r["data"] else None
|
||||
# The id is only given out where the viewer may open the full character (the DM sees all, a player only their own)
|
||||
members.append({"username": r["username"], "me": mine, "character": card,
|
||||
members.append({"username": r["username"], "me": mine, "character": card, "color": r["color"],
|
||||
"mod": r["char_mod"] if (is_dm or mine) else None,
|
||||
"character_id": r["character_id"] if (is_dm or mine) and card else None})
|
||||
hidden = bool(lobby["diff_hidden"])
|
||||
diff = {"label": lobby["diff_label"], "mod": lobby["diff_mod"], "hidden": hidden}
|
||||
if hidden and not is_dm:
|
||||
diff = {"label": "", "mod": None, "hidden": True}
|
||||
return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm,
|
||||
"code": lobby["code"] if is_dm else None, "members": members}
|
||||
"code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff,
|
||||
"levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR}
|
||||
|
||||
|
||||
@api.put("/lobbies/{lid}/difficulty")
|
||||
def set_difficulty(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""The DM's current difficulty: a level from the rules table or a custom modifier. It applies to every check."""
|
||||
try:
|
||||
mod = int(body.get("mod", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(422, "The modifier must be a whole number")
|
||||
if not -100 <= mod <= 100:
|
||||
raise HTTPException(422, "The modifier must be between -100 and 100")
|
||||
label = (str(body.get("label") or "").strip() or "Custom")[:24]
|
||||
with db() as con:
|
||||
_dm_only(_lobby_for(con, lid, user), user)
|
||||
con.execute("UPDATE lobbies SET diff_mod=?, diff_label=?, diff_hidden=? WHERE id=?", (mod, label, 1 if body.get("hidden") else 0, lid))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.put("/lobbies/{lid}/members/{username}/mod")
|
||||
def set_member_mod(lid: str, username: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""A personal bonus or malus of one character (talent, condition ...), added on top of the lobby difficulty."""
|
||||
try:
|
||||
mod = int(body.get("mod", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(422, "The modifier must be a whole number")
|
||||
if not -100 <= mod <= 100:
|
||||
raise HTTPException(422, "The modifier must be between -100 and 100")
|
||||
with db() as con:
|
||||
_dm_only(_lobby_for(con, lid, user), user)
|
||||
cur = con.execute("UPDATE lobby_members SET char_mod=? WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)",
|
||||
(mod, lid, username))
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "No such player in this lobby")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _recipient(con, lobby, user: dict, to):
|
||||
@@ -536,12 +614,16 @@ def _recipient(con, lobby, user: dict, to):
|
||||
|
||||
@api.get("/lobbies/{lid}/messages")
|
||||
def read_messages(lid: str, after: int = 0, user: dict = Depends(current_user)):
|
||||
"""Chat and event log. Whispers are only returned to the sender and the recipient (not even the DM sees them).
|
||||
"""Chat, event log, dice rolls and checks. Whispers are only returned to the sender and the recipient (not even the DM
|
||||
sees them). Mystery rolls of the DM come without results, hidden-difficulty checks without the numbers behind them.
|
||||
after=0 returns the latest 100 entries, otherwise everything newer than that id (the client polls)."""
|
||||
with db() as con:
|
||||
_lobby_for(con, lid, user)
|
||||
q = """SELECT m.id, m.kind, m.text, m.created, m.user_id, m.to_user_id, f.username AS sender, t.username AS target
|
||||
lobby = _lobby_for(con, lid, user)
|
||||
is_dm = lobby["owner_id"] == user["id"]
|
||||
q = """SELECT m.id, m.kind, m.text, m.created, m.user_id, m.to_user_id, f.username AS sender, t.username AS target,
|
||||
lm.color AS color
|
||||
FROM lobby_messages m LEFT JOIN users f ON f.id = m.user_id LEFT JOIN users t ON t.id = m.to_user_id
|
||||
LEFT JOIN lobby_members lm ON lm.lobby_id = m.lobby_id AND lm.user_id = m.user_id
|
||||
WHERE m.lobby_id=? AND (m.to_user_id IS NULL OR m.to_user_id=? OR m.user_id=?)"""
|
||||
if after > 0:
|
||||
rows = con.execute(q + " AND m.id>? ORDER BY m.id LIMIT 200", (lid, user["id"], user["id"], after)).fetchall()
|
||||
@@ -549,10 +631,22 @@ def read_messages(lid: str, after: int = 0, user: dict = Depends(current_user)):
|
||||
rows = list(reversed(con.execute(q + " ORDER BY m.id DESC LIMIT 100", (lid, user["id"], user["id"])).fetchall()))
|
||||
out = []
|
||||
for r in rows:
|
||||
mine = r["user_id"] == user["id"]
|
||||
item = {"id": r["id"], "kind": r["kind"], "text": r["text"], "time": r["created"], "from": r["sender"] or "",
|
||||
"to": r["target"] or "", "mine": r["user_id"] == user["id"] and r["kind"] != "event"}
|
||||
"to": r["target"] or "", "mine": mine and r["kind"] != "event",
|
||||
"color": DM_COLOR if r["user_id"] == lobby["owner_id"] else PALETTE_HEX.get(r["color"], "")}
|
||||
if r["kind"] == "roll":
|
||||
item["roll"], item["text"] = json.loads(r["text"]), ""
|
||||
roll = json.loads(r["text"])
|
||||
roll["n"] = len(roll["results"])
|
||||
if roll.get("mystery") and not mine:
|
||||
roll["results"] = None # players see that the DM rolled, not what
|
||||
item["roll"], item["text"] = roll, ""
|
||||
elif r["kind"] == "check":
|
||||
chk = json.loads(r["text"])
|
||||
if chk.get("hidden") and not is_dm:
|
||||
for k in ("base", "mod", "cmod", "diff", "target", "margin"):
|
||||
chk.pop(k, None)
|
||||
item["check"], item["text"] = chk, ""
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
@@ -580,10 +674,32 @@ DICE = (4, 6, 8, 10, 12, 20, 100)
|
||||
MAX_DICE = 10
|
||||
|
||||
|
||||
def _manual_results(raw, sides: int, count: int):
|
||||
"""Results typed in by a player who rolled real dice. None when nothing was typed."""
|
||||
if raw in (None, [], ""):
|
||||
return None
|
||||
try:
|
||||
vals = [int(v) for v in raw]
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(422, "Enter whole numbers")
|
||||
if len(vals) != count or any(not 1 <= v <= sides for v in vals):
|
||||
raise HTTPException(422, f"Enter {count} number{'s' if count > 1 else ''} from 1 to {sides}")
|
||||
return vals
|
||||
|
||||
|
||||
def _post(con, lid: str, uid: int, to_id, kind: str, payload: dict) -> int:
|
||||
cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)",
|
||||
(lid, uid, to_id, kind, json.dumps(payload), time.time()))
|
||||
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
||||
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
@api.post("/lobbies/{lid}/rolls")
|
||||
def roll_dice(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Rolls on the server (so nobody can cheat) and posts the result in the chat; with `to` it is a private roll.
|
||||
Rolls by the DM are only visible to the DM, or to the person picked with `to`."""
|
||||
Players with real dice can send their own results in `manual` (marked as such in the chat).
|
||||
The DM picks `mode`: hidden (only the DM sees it), mystery (players see that dice were rolled, not the result) or public."""
|
||||
try:
|
||||
sides, count = int(body.get("sides")), int(body.get("count", 1))
|
||||
except (TypeError, ValueError):
|
||||
@@ -592,17 +708,62 @@ def roll_dice(lid: str, body: dict = Body(...), user: dict = Depends(current_use
|
||||
raise HTTPException(422, f"Roll 1 to {MAX_DICE} dice of: " + ", ".join(f"d{d}" for d in DICE))
|
||||
throttle(f"chat:{user['id']}", 30, 60)
|
||||
limiter.fail(f"chat:{user['id']}")
|
||||
results = [secrets.randbelow(sides) + 1 for _ in range(count)]
|
||||
manual = _manual_results(body.get("manual"), sides, count)
|
||||
results = manual or [secrets.randbelow(sides) + 1 for _ in range(count)]
|
||||
with db() as con:
|
||||
lobby = _lobby_for(con, lid, user)
|
||||
is_dm = lobby["owner_id"] == user["id"]
|
||||
to_id = _recipient(con, lobby, user, body.get("to"))
|
||||
if to_id is None and lobby["owner_id"] == user["id"]:
|
||||
to_id = user["id"] # the DM's rolls are hidden from the players (unless the DM picks someone to show them to)
|
||||
cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)",
|
||||
(lid, user["id"], to_id, "roll", json.dumps({"sides": sides, "results": results}), time.time()))
|
||||
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
||||
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
|
||||
return {"id": cur.lastrowid, "sides": sides, "results": results}
|
||||
mystery = False
|
||||
if is_dm and to_id is None:
|
||||
mode = body.get("mode") or "hidden"
|
||||
if mode not in ("hidden", "mystery", "public"):
|
||||
raise HTTPException(422, "Unknown roll mode")
|
||||
if mode == "hidden":
|
||||
to_id = user["id"] # only the DM sees it
|
||||
mystery = mode == "mystery"
|
||||
mid = _post(con, lid, user["id"], to_id, "roll", {"sides": sides, "results": results, "mystery": mystery, "manual": bool(manual)})
|
||||
return {"id": mid, "sides": sides, "results": results}
|
||||
|
||||
|
||||
@api.post("/lobbies/{lid}/checks")
|
||||
def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""A d100 roll-under check for a skill or attribute of the player's current lobby character.
|
||||
Target = attribute + skill + the DM's difficulty + the character's personal modifier. 01-05 always succeeds, 96-100 always fails."""
|
||||
rules = load_rules()
|
||||
kind, key = str(body.get("kind", "")), str(body.get("id", ""))
|
||||
throttle(f"chat:{user['id']}", 30, 60)
|
||||
limiter.fail(f"chat:{user['id']}")
|
||||
with db() as con:
|
||||
lobby = _lobby_for(con, lid, user)
|
||||
member = con.execute("""SELECT m.char_mod, c.data FROM lobby_members m LEFT JOIN characters c ON c.id = m.character_id
|
||||
WHERE m.lobby_id=? AND m.user_id=?""", (lid, user["id"])).fetchone()
|
||||
if not member or not member["data"]:
|
||||
raise HTTPException(422, "Only players with a character in this lobby can roll checks")
|
||||
c = compute(json.loads(member["data"]), rules)
|
||||
if kind == "skill":
|
||||
s = next((x for x in c["skills"] if x["id"] == key), None)
|
||||
if not s:
|
||||
raise HTTPException(404, "Unknown skill")
|
||||
if not s["usable"]:
|
||||
raise HTTPException(422, "This skill is still locked")
|
||||
what, base = s["name"], s["total"]
|
||||
elif kind == "attr":
|
||||
a = next((x for x in rules["attributes"] if x["id"] == key), None)
|
||||
if not a:
|
||||
raise HTTPException(404, "Unknown attribute")
|
||||
what, base = a["name"], c["attrs"][key]
|
||||
else:
|
||||
raise HTTPException(422, "Roll a skill or an attribute")
|
||||
manual = _manual_results(None if body.get("manual") in (None, "") else [body.get("manual")], 100, 1)
|
||||
roll = manual[0] if manual else secrets.randbelow(100) + 1
|
||||
target = base + lobby["diff_mod"] + member["char_mod"]
|
||||
outcome = "crit_success" if roll <= 5 else "crit_failure" if roll >= 96 else "success" if roll <= target else "failure"
|
||||
payload = {"kind": kind, "what": what, "base": base, "mod": lobby["diff_mod"], "cmod": member["char_mod"], "diff": lobby["diff_label"],
|
||||
"target": target, "roll": roll, "margin": target - roll, "outcome": outcome, "hidden": bool(lobby["diff_hidden"]),
|
||||
"manual": bool(manual)}
|
||||
mid = _post(con, lid, user["id"], None, "check", payload)
|
||||
return {"id": mid, "roll": roll, "outcome": outcome}
|
||||
|
||||
|
||||
@api.put("/lobbies/{lid}")
|
||||
@@ -639,15 +800,25 @@ def new_lobby_code(lid: str, user: dict = Depends(current_user)):
|
||||
|
||||
|
||||
@api.put("/lobbies/{lid}/me")
|
||||
def switch_lobby_character(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
def update_my_membership(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Switch your character and/or pick your colour (no two players share one)."""
|
||||
with db() as con:
|
||||
_lobby_for(con, lid, user)
|
||||
_own_character(con, body.get("character_id"), user)
|
||||
cur = con.execute("UPDATE lobby_members SET character_id=? WHERE lobby_id=? AND user_id=?",
|
||||
(body["character_id"], lid, user["id"]))
|
||||
if not cur.rowcount:
|
||||
if not con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone():
|
||||
raise HTTPException(422, "You are the DM of this lobby")
|
||||
_event(con, lid, f"{user['username']} switched to {_char_name(con, body['character_id'])}", user["id"])
|
||||
if "character_id" not in body and "color" not in body:
|
||||
raise HTTPException(422, "Nothing to change")
|
||||
if "color" in body:
|
||||
color = body["color"]
|
||||
if color not in PALETTE_HEX:
|
||||
raise HTTPException(422, "Unknown colour")
|
||||
if con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND color=? AND user_id!=?", (lid, color, user["id"])).fetchone():
|
||||
raise HTTPException(409, "Another player already uses this colour")
|
||||
con.execute("UPDATE lobby_members SET color=? WHERE lobby_id=? AND user_id=?", (color, lid, user["id"]))
|
||||
if "character_id" in body:
|
||||
_own_character(con, body.get("character_id"), user)
|
||||
con.execute("UPDATE lobby_members SET character_id=? WHERE lobby_id=? AND user_id=?", (body["character_id"], lid, user["id"]))
|
||||
_event(con, lid, f"{user['username']} switched to {_char_name(con, body['character_id'])}", user["id"])
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
||||
+268
-132
@@ -102,35 +102,14 @@ function LobbyList() {
|
||||
)
|
||||
}
|
||||
|
||||
function Card({ m, isDm, onOpen, onKick }) {
|
||||
const c = m.character
|
||||
return (
|
||||
<li className={`card lobbycard ${m.me ? 'me' : ''}`}>
|
||||
<div className="row">
|
||||
<div>
|
||||
<strong>{c ? c.name : 'No character'}</strong>
|
||||
<div className="muted small">{m.username}{m.me ? ' (you)' : ''}</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{m.character_id && <button onClick={() => onOpen(m)}>{m.me ? 'Sheet' : 'View sheet'}</button>}
|
||||
{isDm && <button className="ghost danger" onClick={() => onKick(m)}>Remove</button>}
|
||||
</div>
|
||||
</div>
|
||||
{c && (
|
||||
<div className="chips">
|
||||
{[c.class && (c.subclass ? `${c.class} · ${c.subclass}` : c.class), c.profession].filter(Boolean).map((t) => <span key={t} className="chip">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
{c?.concept && <p className="small muted">{c.concept}</p>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const clock = (t) => new Date(t * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
const DICE = [4, 6, 8, 10, 12, 20, 100]
|
||||
const reduced = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
const sgn = (n) => (n > 0 ? `+${n}` : n < 0 ? `−${-n}` : '±0')
|
||||
const store = (k, v) => { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v) } catch { /* storage may be blocked */ } return null }
|
||||
const OUTCOME = { crit_success: 'Critical success!', success: 'Success', failure: 'Failure', crit_failure: 'Critical failure!' }
|
||||
|
||||
// Chat, whispers, event log and dice rolls arrive through one feed that is polled while the page is open.
|
||||
// Chat, whispers, event log, dice rolls and checks arrive through one feed that is polled while the page is open.
|
||||
function useFeed(id, onEvent, onRoll) {
|
||||
const [msgs, setMsgs] = useState([])
|
||||
const last = useRef(0)
|
||||
@@ -148,7 +127,7 @@ function useFeed(id, onEvent, onRoll) {
|
||||
last.current = fresh[fresh.length - 1].id
|
||||
setMsgs((old) => [...old, ...fresh].slice(-300))
|
||||
if (fresh.some((m) => m.kind === 'event')) cb.current.onEvent()
|
||||
if (wasLive) fresh.filter((m) => m.kind === 'roll').forEach((m) => cb.current.onRoll(m))
|
||||
if (wasLive) fresh.filter((m) => m.kind === 'roll' || m.kind === 'check').forEach((m) => cb.current.onRoll(m))
|
||||
}, [id])
|
||||
useEffect(() => {
|
||||
poll()
|
||||
@@ -159,8 +138,17 @@ function useFeed(id, onEvent, onRoll) {
|
||||
}
|
||||
|
||||
const rollText = (m) => {
|
||||
const n = m.roll.results.length, sum = m.roll.results.reduce((a, b) => a + b, 0)
|
||||
return { dice: `${n > 1 ? n : ''}d${m.roll.sides}`, list: m.roll.results.join(' + '), sum, n }
|
||||
const res = m.roll.results, n = m.roll.n
|
||||
return { dice: `${n > 1 ? n : ''}d${m.roll.sides}`, list: res && res.join(' + '), sum: res && res.reduce((a, b) => a + b, 0), n }
|
||||
}
|
||||
|
||||
// the text of a skill / attribute check; players do not see the numbers when the DM hides the difficulty
|
||||
function checkLine(c) {
|
||||
const out = OUTCOME[c.outcome]
|
||||
if (c.target === undefined) return <>rolled <strong>{c.roll}</strong> → <span className={`out ${c.outcome}`}>{out}</span></>
|
||||
const parts = `${c.base}${c.mod ? ` ${sgn(c.mod)} ${c.diff}` : ''}${c.cmod ? ` ${sgn(c.cmod)} bonus` : ''}`
|
||||
return <>rolled <strong>{c.roll}</strong> vs {c.target}{parts !== String(c.target) && <span className="muted small"> ({parts})</span>} →{' '}
|
||||
<span className={`out ${c.outcome}`}>{out}</span> <span className="muted small">{c.margin >= 0 ? `by ${c.margin}` : `by ${-c.margin}`}</span></>
|
||||
}
|
||||
|
||||
function Chat({ me, people, msgs, poll, id, to, setTo }) {
|
||||
@@ -185,7 +173,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
|
||||
const shown = msgs.filter((m) => events || m.kind !== 'event')
|
||||
const who = (m) => (
|
||||
<>
|
||||
<strong>{m.mine ? 'You' : m.from || '?'}</strong>
|
||||
<strong style={m.color ? { color: m.color } : undefined}>{m.mine ? 'You' : m.from || '?'}</strong>
|
||||
{m.to && <em className="small whispertag"> {m.to === m.from ? '(only visible to you, the DM)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>}
|
||||
</>
|
||||
)
|
||||
@@ -197,12 +185,21 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
|
||||
{shown.length === 0 && <p className="muted small">Nothing here yet.</p>}
|
||||
{shown.map((m) => {
|
||||
if (m.kind === 'event') return <div key={m.id} className="msg event"><span className="time">{clock(m.time)}</span> {m.text}</div>
|
||||
if (m.kind === 'check') {
|
||||
return (
|
||||
<div key={m.id} className={`msg rollmsg ${m.mine ? 'mine' : ''}`}>
|
||||
<span className="time">{clock(m.time)}</span> {who(m)} – <b>{m.check.what}</b>: {checkLine(m.check)}{m.check.manual && <span className="manualtag small"> own dice</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (m.kind === 'roll') {
|
||||
const t = rollText(m)
|
||||
return (
|
||||
<div key={m.id} className={`msg rollmsg ${m.to ? 'whisper' : ''} ${m.mine ? 'mine' : ''}`}>
|
||||
<span className="time">{clock(m.time)}</span> {who(m)} rolled <strong>{t.dice}</strong>:{' '}
|
||||
<span className="rollres">{t.n > 1 ? `${t.list} = ` : ''}<b>{t.sum}</b></span>
|
||||
{t.list === null ? <span className="rollres mystery">{Array(t.n).fill('?').join(' ')}</span>
|
||||
: <span className="rollres">{t.n > 1 ? `${t.list} = ` : ''}<b>{t.sum}</b></span>}
|
||||
{m.roll.manual && <span className="manualtag small"> own dice</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -228,7 +225,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- dice: hotbar at the bottom, results fly into a tray above it and are posted in the chat
|
||||
// ---- dice: hotbar below the table, results are thrown into the middle of the table and posted in the chat
|
||||
const SHAPE = { 4: '50% 4%, 96% 92%, 4% 92%', 6: '6% 6%, 94% 6%, 94% 94%, 6% 94%', 8: '50% 0, 100% 50%, 50% 100%, 0 50%', 10: '50% 0, 98% 40%, 50% 100%, 2% 40%',
|
||||
12: '50% 0, 98% 38%, 80% 96%, 20% 96%, 2% 38%', 20: '25% 4%, 75% 4%, 100% 50%, 75% 96%, 25% 96%, 0 50%', 100: '' }
|
||||
|
||||
@@ -241,18 +238,9 @@ function DieIcon({ sides }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DiceBar({ id, to, poll, isDm }) {
|
||||
const [count, setCount] = useState(1)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const roll = async (sides) => {
|
||||
if (busy) return
|
||||
setBusy(true); setErr('')
|
||||
const r = await send(`/api/lobbies/${id}/rolls`, 'POST', { sides, count, to }).catch(() => null)
|
||||
if (!r || !r.ok) setErr(msg(r ? await r.json().catch(() => ({})) : {}, 'Roll failed.'))
|
||||
else poll()
|
||||
setTimeout(() => setBusy(false), 350)
|
||||
}
|
||||
const MODES = [['mystery', 'Mystery', 'Players see grey dice, but not the result'], ['hidden', 'Hidden', 'Only you see the roll'], ['public', 'Public', 'Everybody sees the roll']]
|
||||
|
||||
function DiceBar({ to, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
|
||||
return (
|
||||
<div className="dicebar" role="toolbar" aria-label="Dice">
|
||||
<label className="small muted dicecount">Dice
|
||||
@@ -261,17 +249,46 @@ function DiceBar({ id, to, poll, isDm }) {
|
||||
</select>
|
||||
</label>
|
||||
{DICE.map((d) => (
|
||||
<button key={d} className="ghost diebtn" onClick={() => roll(d)} disabled={busy} title={`Roll ${count > 1 ? count : ''}d${d}`} aria-label={`Roll ${count} d${d}`}>
|
||||
<button key={d} className="ghost diebtn" onClick={() => onRoll(d)} disabled={busy} title={`Roll ${count > 1 ? count : ''}d${d}`} aria-label={`Roll ${count} d${d}`}>
|
||||
<DieIcon sides={d} /><span>d{d}</span>
|
||||
</button>
|
||||
))}
|
||||
<span className={`small dicedest ${to || isDm ? 'priv' : 'muted'}`}>{to ? `Private: ${to}` : isDm ? 'Hidden (only you)' : 'Public'}</span>
|
||||
{err && <span className="warn small">{err}</span>}
|
||||
<label className="small ownswitch" title="You rolled real dice: type in the result instead of rolling here">
|
||||
<input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} style={{ width: 'auto' }} /> Own dice
|
||||
</label>
|
||||
{isDm && !to && (
|
||||
<span className="seg small" role="group" aria-label="Who sees the DM's rolls">
|
||||
{MODES.map(([k, label, tip]) => <button key={k} className={mode === k ? 'on' : 'ghost'} title={tip} onClick={() => setMode(k)}>{label}</button>)}
|
||||
</span>
|
||||
)}
|
||||
{to && <span className="small dicedest priv">Private: {to}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Die({ sides, value, delay }) {
|
||||
// shown instead of rolling when "Own dice" is on: the player types what the real dice showed
|
||||
function ManualEntry({ pending, onSubmit, onCancel, err }) {
|
||||
const n = pending.type === 'check' ? 1 : pending.count
|
||||
const sides = pending.type === 'check' ? 100 : pending.sides
|
||||
const [vals, setVals] = useState(Array(n).fill(''))
|
||||
const ok = vals.every((v) => /^\d+$/.test(v) && +v >= 1 && +v <= sides)
|
||||
return (
|
||||
<form className="manual card" onSubmit={(e) => { e.preventDefault(); if (ok) onSubmit(vals.map(Number)) }}>
|
||||
<div className="small">{pending.type === 'check' ? <>Your real d100 for <b>{pending.name}</b></> : <>Your real result{n > 1 ? 's' : ''} for <b>{n > 1 ? n : ''}d{sides}</b></>} (1–{sides})</div>
|
||||
<div className="row">
|
||||
{vals.map((v, i) => (
|
||||
<input key={i} autoFocus={i === 0} inputMode="numeric" value={v} aria-label={`Result ${i + 1}`} style={{ width: '4.2rem' }}
|
||||
onChange={(e) => setVals(vals.map((x, j) => (j === i ? e.target.value.replace(/\D/g, '').slice(0, 3) : x)))} />
|
||||
))}
|
||||
<button type="submit" disabled={!ok}>Send</button>
|
||||
<button type="button" className="ghost" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
{err && <span className="warn small">{err}</span>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function Die({ sides, value, delay, mystery }) {
|
||||
const [shown, setShown] = useState(reduced() ? value : null)
|
||||
useEffect(() => {
|
||||
if (reduced()) return
|
||||
@@ -283,24 +300,39 @@ function Die({ sides, value, delay }) {
|
||||
}, [sides, value, delay])
|
||||
const rolling = shown !== value
|
||||
return (
|
||||
<span className={`die d${sides} ${rolling ? 'rolling' : 'landed'}`} style={{ animationDelay: `${delay % 200}ms` }}>
|
||||
<span>{shown ?? '?'}</span>
|
||||
<span className={`die d${sides} ${rolling ? 'rolling' : 'landed'}${mystery ? ' mystery' : ''}`} style={{ animationDelay: `${delay % 200}ms` }}>
|
||||
<span>{mystery && !rolling ? '?' : shown ?? '?'}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DiceTray({ rolls, me }) {
|
||||
// the tray is the middle of the table: dice in the colour of whoever threw them
|
||||
function DiceTray({ rolls }) {
|
||||
if (!rolls.length) return null
|
||||
return (
|
||||
<div className="dicetray" aria-hidden="true">
|
||||
{rolls.map((m) => {
|
||||
const style = { '--dc': m.color || 'var(--noxium)' }
|
||||
if (m.kind === 'check') {
|
||||
const c = m.check
|
||||
return (
|
||||
<div key={m.key} className="trayroll" style={style}>
|
||||
<div className="small"><b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b> – {c.what}{c.manual ? ' · own dice' : ''}</div>
|
||||
<div className="trayrow"><Die sides={100} value={c.roll} delay={0} />
|
||||
<span className="trayout"><span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>
|
||||
{c.target !== undefined && <small>{c.roll} vs {c.target}</small>}</span></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const t = rollText(m)
|
||||
const sec = m.roll.mystery && !m.mine
|
||||
return (
|
||||
<div key={m.key} className="trayroll">
|
||||
<div className="small">{m.mine ? 'You' : m.from} rolled <strong>{t.dice}</strong>{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}</div>
|
||||
<div key={m.key} className="trayroll" style={sec ? { '--dc': '#8b8f85' } : style}>
|
||||
<div className="small"><b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b> rolled <strong>{t.dice}</strong>
|
||||
{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}</div>
|
||||
<div className="trayrow">
|
||||
{m.roll.results.map((v, i) => <Die key={i} sides={m.roll.sides} value={v} delay={i * 120} />)}
|
||||
{t.n > 1 && <span className="traytotal">= {t.sum}</span>}
|
||||
{Array.from({ length: t.n }, (_, i) => <Die key={i} sides={m.roll.sides} value={m.roll.results ? m.roll.results[i] : 0} delay={i * 120} mystery={!m.roll.results} />)}
|
||||
{t.n > 1 && t.sum !== null && <span className="traytotal">= {t.sum}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -309,8 +341,60 @@ function DiceTray({ rolls, me }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- character bar: one card per player, later also for initiative and enemies
|
||||
function CharBar({ lobby, sel, onSel }) {
|
||||
return (
|
||||
<div className="charbar" role="list">
|
||||
{lobby.members.map((m) => {
|
||||
const c = m.character, can = !!m.character_id
|
||||
return (
|
||||
<div key={m.username} role="listitem" className={`pcard${sel === m.username ? ' sel' : ''}${can ? ' can' : ''}`} style={{ '--pc': m.color || 'var(--line)' }}
|
||||
onClick={() => can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}>
|
||||
<span className="pavatar">{(c?.name || m.username).slice(0, 1).toUpperCase()}</span>
|
||||
<span className="pinfo">
|
||||
<b>{c ? c.name : 'No character'}</b>
|
||||
<span className="muted small">{m.username}{m.me ? ' (you)' : ''}{c?.class ? ` · ${c.class}` : ''}</span>
|
||||
</span>
|
||||
{lobby.is_dm
|
||||
? <input type="number" className="modin" key={`${m.username}:${m.mod}`} defaultValue={m.mod || 0} min={-100} max={100} title={`Bonus / malus for ${m.username}`} aria-label={`Modifier for ${m.username}`}
|
||||
onClick={(e) => e.stopPropagation()} onBlur={(e) => lobby.setMod(m.username, e.target.value)} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} />
|
||||
: m.mod ? <span className={`modchip ${m.mod > 0 ? 'up' : 'down'}`} title="Your personal bonus / malus">{sgn(m.mod)}</span> : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{lobby.members.length === 0 && <span className="muted small">Nobody has joined yet – share the join code (⚙ Lobby).</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the DM sets the difficulty for all checks, from the rules table or a custom number
|
||||
function DifficultyBar({ lobby, onSet }) {
|
||||
const d = lobby.difficulty
|
||||
const idx = lobby.levels.findIndex((l) => l.name === d.label && l.mod === d.mod)
|
||||
const [custom, setCustom] = useState('')
|
||||
if (!lobby.is_dm) {
|
||||
return <div className="diffbar small muted">{d.hidden ? 'The DM sets the difficulty' : <>Difficulty: <b>{d.label}</b> {sgn(d.mod)}</>}</div>
|
||||
}
|
||||
return (
|
||||
<div className="diffbar">
|
||||
<label className="small">Difficulty
|
||||
<select value={idx >= 0 ? idx : 'custom'} aria-label="Difficulty"
|
||||
onChange={(e) => { const l = lobby.levels[e.target.value]; if (l) onSet({ label: l.name, mod: l.mod, hidden: d.hidden }) }}>
|
||||
{lobby.levels.map((l, i) => <option key={l.name} value={i}>{l.name} ({sgn(l.mod)})</option>)}
|
||||
{idx < 0 && <option value="custom">{d.label} ({sgn(d.mod)})</option>}
|
||||
</select>
|
||||
</label>
|
||||
<form className="row" onSubmit={(e) => { e.preventDefault(); if (/^[-+−]?\d+$/.test(custom.trim())) { onSet({ label: 'Custom', mod: parseInt(custom.replace('−', '-'), 10), hidden: d.hidden }); setCustom('') } }}>
|
||||
<input value={custom} onChange={(e) => setCustom(e.target.value)} placeholder="custom ±N" aria-label="Custom modifier" style={{ width: '6rem' }} />
|
||||
<button type="submit" className="ghost" disabled={!custom.trim()}>Set</button>
|
||||
</form>
|
||||
<label className="small"><input type="checkbox" checked={d.hidden} onChange={(e) => onSet({ label: d.label, mod: d.mod, hidden: e.target.checked })} style={{ width: 'auto' }} /> Hide numbers from players</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
|
||||
function CharTab({ cid, owner, rules, onOpen, name }) {
|
||||
function CharTab({ cid, rules, onRoll }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
useEffect(() => {
|
||||
@@ -323,32 +407,100 @@ function CharTab({ cid, owner, rules, onOpen, name }) {
|
||||
return (
|
||||
<div>
|
||||
{err && <p className="warn">{err}</p>}
|
||||
{data && <PaperSheet data={data} baseRules={rules} bare />}
|
||||
{data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- one lobby: member cards and one sheet tab per visible character on the left, chat on the right, dice at the bottom
|
||||
function LobbyView({ id, rules, user, onOpen }) {
|
||||
// lobby settings: join code, rename, colour, character switch, members, leave / delete
|
||||
function Settings({ lobby, id, mine, act, setInfo, onClose }) {
|
||||
const [rename, setRename] = useState('')
|
||||
const [pick, setPick] = useState('')
|
||||
const me = lobby.members.find((m) => m.me)
|
||||
const taken = new Set(lobby.members.filter((m) => !m.me).map((m) => m.color))
|
||||
return (
|
||||
<div className="settings card">
|
||||
<div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div>
|
||||
{lobby.is_dm && (
|
||||
<>
|
||||
<div className="row">
|
||||
<div>
|
||||
<div className="muted small">Join code – give it to your players</div>
|
||||
<strong className="joincode">{lobby.code}</strong>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="ghost" onClick={() => navigator.clipboard?.writeText(lobby.code).then(() => setInfo('Code copied.')).catch(() => {})}>Copy</button>
|
||||
<button className="ghost" onClick={() => window.confirm('The old code stops working. Current players stay in the lobby.') && act(`/api/lobbies/${id}/code`, 'POST')}>New code</button>
|
||||
</div>
|
||||
</div>
|
||||
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (rename.trim()) act(`/api/lobbies/${id}`, 'PUT', { name: rename }, () => { setRename(''); act() }) }}>
|
||||
<input value={rename} onChange={(e) => setRename(e.target.value)} placeholder="Rename lobby" aria-label="New lobby name" maxLength={60} />
|
||||
<button type="submit" className="ghost" disabled={!rename.trim()}>Rename</button>
|
||||
</form>
|
||||
<h3>Players</h3>
|
||||
{lobby.members.length === 0 && <p className="muted small">Nobody yet.</p>}
|
||||
{lobby.members.map((m) => (
|
||||
<div key={m.username} className="row">
|
||||
<span><span className="swatch" style={{ background: m.color }} /> {m.username} <span className="muted small">{m.character?.name}</span></span>
|
||||
<button className="ghost danger" onClick={() => window.confirm(`Remove ${m.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(m.username)}`, 'DELETE')}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!lobby.is_dm && me && (
|
||||
<>
|
||||
<h3>Your colour</h3>
|
||||
<div className="swatches">
|
||||
{lobby.palette.map((p) => (
|
||||
<button key={p.id} className={`swatchbtn${me.color === p.id ? ' on' : ''}`} style={{ background: p.hex }} disabled={taken.has(p.id)} title={taken.has(p.id) ? `${p.id} (taken)` : p.id}
|
||||
aria-label={p.id} onClick={() => act(`/api/lobbies/${id}/me`, 'PUT', { color: p.id })} />
|
||||
))}
|
||||
</div>
|
||||
<h3>Your character</h3>
|
||||
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (pick) act(`/api/lobbies/${id}/me`, 'PUT', { character_id: pick }, () => { setPick(''); act() }) }}>
|
||||
<CharSelect mine={mine} value={pick} onChange={setPick} label="Switch character" />
|
||||
<button type="submit" className="ghost" disabled={!pick}>Switch</button>
|
||||
</form>
|
||||
<p className="small muted">The DM always sees the current saved version of your character. Of the others you only see a short card.</p>
|
||||
</>
|
||||
)}
|
||||
<div className="actions wrap">
|
||||
{lobby.is_dm
|
||||
? <button className="ghost danger" onClick={() => window.confirm(`Delete the lobby "${lobby.name}" for everyone? Characters are not deleted.`) && act(`/api/lobbies/${id}`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Delete lobby</button>
|
||||
: <button className="ghost danger" onClick={() => window.confirm('Leave this lobby?') && act(`/api/lobbies/${id}/me`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Leave lobby</button>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- one lobby: sheet on the left, player area (character bar, difficulty, dice table) in the middle, chat on the right
|
||||
function LobbyView({ id, rules, user }) {
|
||||
const mine = useMine()
|
||||
const [lobby, setLobby] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [info, setInfo] = useState('')
|
||||
const [pick, setPick] = useState('')
|
||||
const [rename, setRename] = useState('')
|
||||
const [tab, setTab] = useState('party')
|
||||
const [sel, setSel] = useState('')
|
||||
const [to, setTo] = useState('') // whisper recipient, used for chat messages and dice rolls
|
||||
const [tray, setTray] = useState([])
|
||||
const [count, setCount] = useState(1)
|
||||
const [mode, setMode] = useState('mystery')
|
||||
const [own, setOwnState] = useState(() => store('aw_owndice') === '1')
|
||||
const [pending, setPending] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [mErr, setMErr] = useState('')
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [sheetOpen, setSheetOpen] = useState(() => store('aw_sheetopen') !== '0')
|
||||
const setOwn = (v) => { setOwnState(v); store('aw_owndice', v ? '1' : '0') }
|
||||
|
||||
const load = () => fetch(`/api/lobbies/${id}`).then(async (r) => {
|
||||
const load = useCallback(() => fetch(`/api/lobbies/${id}`).then(async (r) => {
|
||||
if (!r.ok) throw new Error(r.status)
|
||||
setLobby(await r.json())
|
||||
}).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') })
|
||||
}).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') }), [id])
|
||||
useEffect(() => {
|
||||
load()
|
||||
const t = setInterval(() => !document.hidden && load(), 15000) // picks up joins and character changes of the others
|
||||
const t = setInterval(() => !document.hidden && load(), 5000) // picks up joins, colours, difficulty and character changes
|
||||
return () => clearInterval(t)
|
||||
}, [id]) // eslint-disable-line
|
||||
}, [load])
|
||||
|
||||
const onRoll = useCallback((m) => {
|
||||
const key = `${m.id}`
|
||||
@@ -358,6 +510,7 @@ function LobbyView({ id, rules, user, onOpen }) {
|
||||
const feed = useFeed(id, load, onRoll)
|
||||
|
||||
const act = async (url, method, body, done) => {
|
||||
if (!url) return load()
|
||||
setErr(''); setInfo('')
|
||||
const r = await send(url, method, body).catch(() => null)
|
||||
const d = r ? await r.json().catch(() => ({})) : {}
|
||||
@@ -369,11 +522,34 @@ function LobbyView({ id, rules, user, onOpen }) {
|
||||
if (!lobby) return <section className="page lobbypage"><p className="muted">Loading …</p></section>
|
||||
|
||||
const me = lobby.members.find((m) => m.me)
|
||||
const open = (m) => onOpen({ id: m.character_id, name: m.character?.name, ...(m.me ? {} : { owner: m.username }) })
|
||||
const people = [lobby.dm, ...lobby.members.map((m) => m.username)]
|
||||
const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open
|
||||
const active = tab.startsWith('sheet:') && !sheets.find((m) => m.username === tab.slice(6)) ? 'party' : tab
|
||||
const cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0]
|
||||
const dest = people.includes(to) ? to : ''
|
||||
const canCheck = !!(me && me.character_id)
|
||||
lobby.setMod = (u, v) => act(`/api/lobbies/${id}/members/${encodeURIComponent(u)}/mod`, 'PUT', { mod: parseInt(v, 10) || 0 })
|
||||
|
||||
const post = async (url, body, after) => {
|
||||
setBusy(true); setMErr('')
|
||||
const r = await send(url, 'POST', body).catch(() => null)
|
||||
if (!r || !r.ok) setMErr(msg(r ? await r.json().catch(() => ({})) : {}, 'That did not work.'))
|
||||
else { after?.(); feed.poll() }
|
||||
setTimeout(() => setBusy(false), 350)
|
||||
}
|
||||
const throwDice = (sides) => {
|
||||
if (busy) return
|
||||
if (own) return setPending({ type: 'dice', sides, count })
|
||||
post(`/api/lobbies/${id}/rolls`, { sides, count, to: dest, ...(lobby.is_dm ? { mode } : {}) })
|
||||
}
|
||||
const check = (kind, cid, name) => {
|
||||
if (busy) return
|
||||
if (own) return setPending({ type: 'check', kind, id: cid, name })
|
||||
post(`/api/lobbies/${id}/checks`, { kind, id: cid })
|
||||
}
|
||||
const submitManual = (vals) => {
|
||||
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0] }, () => setPending(null))
|
||||
else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page lobbypage">
|
||||
@@ -381,83 +557,43 @@ function LobbyView({ id, rules, user, onOpen }) {
|
||||
<a className="small" href="#lobbies">← Lobbies</a>
|
||||
<h1>{lobby.name}</h1>
|
||||
<span className="muted small">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
|
||||
<span className="tabright">
|
||||
<button className="ghost" onClick={() => setShowSettings((v) => !v)} aria-expanded={showSettings}>⚙ Lobby</button>
|
||||
</span>
|
||||
</div>
|
||||
{err && <p className="warn">{err}</p>}
|
||||
{info && <p className="small">{info}</p>}
|
||||
|
||||
<div className="lobbygrid">
|
||||
<div className="lobbymain">
|
||||
<div className="tabs" role="tablist">
|
||||
<button role="tab" aria-selected={active === 'party'} className={active === 'party' ? 'on' : ''} onClick={() => setTab('party')}>Party ({lobby.members.length})</button>
|
||||
{sheets.map((m) => (
|
||||
<button key={m.username} role="tab" aria-selected={active === `sheet:${m.username}`} className={active === `sheet:${m.username}` ? 'on' : ''}
|
||||
onClick={() => setTab(`sheet:${m.username}`)} title={m.username}>{m.me && !lobby.is_dm ? 'My character' : m.character.name}</button>
|
||||
))}
|
||||
{active.startsWith('sheet:') && <span className="tabright"><ThemeSwitch /></span>}
|
||||
<div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`}>
|
||||
{sheetOpen && (
|
||||
<div className="sheetcol">
|
||||
<div className="sheetbar">
|
||||
<b>{cur ? cur.character.name : 'Character sheet'}</b>
|
||||
<span className="tabright"><ThemeSwitch /> <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide the sheet">◀</button></span>
|
||||
</div>
|
||||
{cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} onRoll={cur.me && canCheck ? check : undefined} />
|
||||
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active.startsWith('sheet:') && (() => {
|
||||
const m = sheets.find((x) => x.username === active.slice(6))
|
||||
return <CharTab key={m.character_id} cid={m.character_id} owner={m.me ? '' : m.username} name={m.character.name} rules={rules} onOpen={() => open(m)} />
|
||||
})()}
|
||||
|
||||
{active === 'party' && (
|
||||
<>
|
||||
{lobby.is_dm && (
|
||||
<div className="card">
|
||||
<div className="row">
|
||||
<div>
|
||||
<div className="muted small">Join code – give it to your players</div>
|
||||
<strong className="joincode">{lobby.code}</strong>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="ghost" onClick={() => navigator.clipboard?.writeText(lobby.code).then(() => setInfo('Code copied.')).catch(() => {})}>Copy</button>
|
||||
<button className="ghost" onClick={() => window.confirm('The old code stops working. Current players stay in the lobby.') && act(`/api/lobbies/${id}/code`, 'POST', undefined, (d) => setLobby({ ...lobby, code: d.code }))}>New code</button>
|
||||
</div>
|
||||
</div>
|
||||
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (rename.trim()) act(`/api/lobbies/${id}`, 'PUT', { name: rename }, () => { setRename(''); load() }) }}>
|
||||
<input value={rename} onChange={(e) => setRename(e.target.value)} placeholder="Rename lobby" aria-label="New lobby name" maxLength={60} />
|
||||
<button type="submit" className="ghost" disabled={!rename.trim()}>Rename</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lobby.members.length === 0 && <p className="card">Nobody has joined yet. Share the code above.</p>}
|
||||
<ul className="charlist partygrid">
|
||||
{lobby.members.map((m) => (
|
||||
<Card key={m.username} m={m} isDm={lobby.is_dm} onOpen={(x) => setTab(`sheet:${x.username}`)}
|
||||
onKick={(x) => window.confirm(`Remove ${x.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(x.username)}`, 'DELETE')} />
|
||||
))}
|
||||
</ul>
|
||||
{!lobby.is_dm && <p className="small muted">You see your own character in full. Of the others you only see this short card.</p>}
|
||||
|
||||
{!lobby.is_dm && me && (
|
||||
<div className="card">
|
||||
<h2>Your character</h2>
|
||||
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (pick) act(`/api/lobbies/${id}/me`, 'PUT', { character_id: pick }, () => { setPick(''); load() }) }}>
|
||||
<CharSelect mine={mine} value={pick} onChange={setPick} label="Switch character" />
|
||||
<button type="submit" className="ghost" disabled={!pick}>Switch</button>
|
||||
</form>
|
||||
<p className="small muted">The DM always sees the current saved version of your character.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="actions wrap">
|
||||
{lobby.is_dm
|
||||
? <button className="ghost danger" onClick={() => window.confirm(`Delete the lobby "${lobby.name}" for everyone? Characters are not deleted.`) && act(`/api/lobbies/${id}`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Delete lobby</button>
|
||||
: <button className="ghost danger" onClick={() => window.confirm('Leave this lobby?') && act(`/api/lobbies/${id}/me`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Leave lobby</button>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="playarea">
|
||||
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
|
||||
<CharBar lobby={lobby} sel={cur?.username} onSel={setSel} />
|
||||
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
|
||||
<div className="table">
|
||||
{showSettings && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
|
||||
<DiceTray rolls={tray} />
|
||||
{canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>}
|
||||
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
|
||||
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
|
||||
</div>
|
||||
<DiceBar to={dest} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} />
|
||||
</div>
|
||||
|
||||
<aside className="lobbychat card" aria-label="Chat">
|
||||
<Chat id={id} me={user} people={people} msgs={feed.msgs} poll={feed.poll} to={dest} setTo={setTo} />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<DiceTray rolls={tray} me={user} />
|
||||
<DiceBar id={id} to={dest} poll={feed.poll} isDm={lobby.is_dm} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ const Pips = ({ n, locked }) => (
|
||||
)
|
||||
|
||||
// The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes).
|
||||
export default function PaperSheet({ data, baseRules, tools, bare }) {
|
||||
export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
|
||||
const ch = useMemo(() => normalize(data, baseRules), [data, baseRules])
|
||||
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
|
||||
const c = useMemo(() => compute(ch, rules), [ch, rules])
|
||||
@@ -86,6 +86,11 @@ export default function PaperSheet({ data, baseRules, tools, bare }) {
|
||||
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>
|
||||
@@ -117,7 +122,7 @@ export default function PaperSheet({ data, baseRules, tools, bare }) {
|
||||
|
||||
<div className="pattrs">
|
||||
{rules.attributes.map((a) => (
|
||||
<div key={a.id} className="pattr">
|
||||
<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>
|
||||
@@ -140,7 +145,7 @@ export default function PaperSheet({ data, baseRules, tools, bare }) {
|
||||
<div key={g.id} className="pgroup">
|
||||
<h4>{g.name}</h4>
|
||||
{c.skills.filter((s) => s.group === g.id).map((s) => (
|
||||
<div key={s.id} className={`prow ${s.usable ? '' : 'locked'}`}>
|
||||
<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>
|
||||
|
||||
+46
-6
@@ -208,7 +208,7 @@ h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
|
||||
/* 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: 1200px; margin: 0; padding-bottom: 8rem; }
|
||||
.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; }
|
||||
@@ -220,19 +220,16 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
|
||||
.whispertag { color: #8a63c7; }
|
||||
|
||||
|
||||
.dicebar { position: fixed; bottom: 1rem; left: calc((100vw - var(--chatw)) / 2); transform: translateX(-50%); z-index: 30; display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; justify-content: center;
|
||||
max-width: calc(100vw - var(--chatw) - 1.5rem); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: .4rem .8rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .22); }
|
||||
.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; }
|
||||
.dicetray { position: fixed; bottom: 5.4rem; left: calc((100vw - var(--chatw)) / 2); transform: translateX(-50%); z-index: 29; display: grid; gap: .5rem; justify-items: center; pointer-events: none; }
|
||||
.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(--noxium); color: #12150f; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.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%); }
|
||||
@@ -247,7 +244,6 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
|
||||
@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; } }
|
||||
|
||||
@media (max-width: 1050px) { body:has(.lobbychat) main { max-width: 1280px; margin: 0 auto; padding-right: 1rem; } .lobbychat { position: static; width: auto; height: 28rem; border-width: 1px; border-radius: var(--radius); margin-top: 1rem; } .dicebar, .dicetray { left: 50%; } .dicebar { max-width: calc(100vw - 1.5rem); } }
|
||||
.lobbycard { align-content: start; }
|
||||
.lobbycard .chips { align-items: flex-start; align-content: flex-start; }
|
||||
.lobbycard .chip { align-self: flex-start; }
|
||||
@@ -335,3 +331,47 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
|
||||
.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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user