Character portrait
Crop-and-zoom picture editor on the Concept page, stored (re-encoded to a 256 px WebP data URL) inside the character; shown on the sheet header, in the lobby player bar and as token icon on the map. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -86,6 +86,10 @@ Each wizard page has a **Reset this page** button that clears only that page's d
|
||||
|
||||
The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits above the sheet) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout.
|
||||
|
||||
### Character portrait
|
||||
|
||||
On the **Concept** page *Add picture* takes a PNG, JPEG or WebP: move and zoom it inside the square window, *Use this* stores a small 256 px picture inside the character (so it is saved, shared, exported as JSON and loaded with it). The server checks and re-encodes it. The picture appears in the header of the character sheet (also when printing), as icon in the player bar of the lobby and as the token of the character on the map (in the player's colour ring). Only members of the lobby get the picture through `/api/lobbies/{id}/portrait/{username}`. The PDF / Word export from the server does not contain it yet.
|
||||
|
||||
### 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.
|
||||
|
||||
+18
-2
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
import asyncio
|
||||
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import auth
|
||||
@@ -17,6 +17,7 @@ from .derive import compute
|
||||
from . import rules_edit
|
||||
from . import maps as maps_mod
|
||||
from . import sounds as sounds_mod
|
||||
from . import portrait as portrait_mod
|
||||
from .realtime import hub
|
||||
from .export import to_docx, to_pdf
|
||||
|
||||
@@ -386,6 +387,7 @@ def my_characters(user: dict = Depends(current_user)):
|
||||
|
||||
@api.post("/characters")
|
||||
def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
|
||||
portrait_mod.clean(ch)
|
||||
check_size(ch)
|
||||
with db() as con:
|
||||
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
|
||||
@@ -437,6 +439,7 @@ def remove_share(cid: str, username: str, user: dict = Depends(current_user)):
|
||||
|
||||
@api.put("/characters/{cid}")
|
||||
def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(current_user)):
|
||||
portrait_mod.clean(ch)
|
||||
check_size(ch)
|
||||
with db() as con:
|
||||
_owned(con, "characters", cid, user)
|
||||
@@ -533,7 +536,7 @@ def _card(data: str, rules: dict) -> dict:
|
||||
prof = next((p for p in rules["professions"] if p["id"] == ch.get("profession")), None)
|
||||
return {"name": ch.get("name") or "Nameless", "concept": str(ch.get("concept") or "")[:120],
|
||||
"class": (cls or {}).get("name", ""), "subclass": (sub or {}).get("name", ""),
|
||||
"profession": (prof or {}).get("name", "")}
|
||||
"profession": (prof or {}).get("name", ""), "portrait": portrait_mod.tag(ch.get("portrait"))}
|
||||
|
||||
|
||||
@api.get("/lobbies")
|
||||
@@ -590,6 +593,19 @@ def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(cu
|
||||
return {"id": lobby["id"]}
|
||||
|
||||
|
||||
@api.get("/lobbies/{lid}/portrait/{username}")
|
||||
def lobby_portrait(lid: str, username: str, user: dict = Depends(current_user)):
|
||||
"""The portrait of a player's character in this lobby (icon on the map and in the player bar). Members only."""
|
||||
with db() as con:
|
||||
_lobby_for(con, lid, user)
|
||||
row = con.execute("""SELECT c.data FROM lobby_members m JOIN users u ON u.id = m.user_id
|
||||
JOIN characters c ON c.id = m.character_id WHERE m.lobby_id=? AND u.username=?""", (lid, username)).fetchone()
|
||||
img = portrait_mod.image(json.loads(row["data"]).get("portrait")) if row else None
|
||||
if not img:
|
||||
raise HTTPException(404, "No portrait")
|
||||
return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})
|
||||
|
||||
|
||||
@api.get("/lobbies/{lid}")
|
||||
def read_lobby(lid: str, user: dict = Depends(current_user)):
|
||||
rules = load_rules()
|
||||
|
||||
+8
-1
@@ -16,6 +16,7 @@ from fastapi.responses import Response
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from . import vision
|
||||
from .portrait import tag as portrait_tag
|
||||
|
||||
MAX_UPLOAD = 12_000_000 # bytes of the uploaded file
|
||||
MAX_SIDE = 4096 # larger images are scaled down
|
||||
@@ -350,7 +351,13 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
|
||||
name = (json.loads(r["data"]).get("name") if r["data"] else "") or r["username"]
|
||||
except ValueError:
|
||||
name = r["username"]
|
||||
out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name,
|
||||
pt = ""
|
||||
if player and r["data"]:
|
||||
try:
|
||||
pt = portrait_tag(json.loads(r["data"]).get("portrait"))
|
||||
except ValueError:
|
||||
pass
|
||||
out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name, "portrait": pt,
|
||||
"x": r["x"], "y": r["y"], "size": r["size"], "hidden": bool(r["hidden"]),
|
||||
"color": (r["mcolor"] or "#999999") if player else r["color"],
|
||||
"can_move": ctx["is_dm"] or (r["user_id"] == user["id"] and not (ctx["los"] and r["x"] is None))})
|
||||
|
||||
@@ -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
|
||||
@@ -1,3 +1,4 @@
|
||||
import PortraitEditor from './Portrait.jsx'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
|
||||
import SheetPanel from './SheetPanel.jsx'
|
||||
@@ -167,6 +168,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
})()}
|
||||
<p className="small">Create or edit presets on the <a href="#presets">Presets</a> page.</p>
|
||||
</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>
|
||||
|
||||
@@ -432,7 +432,7 @@ function CharBar({ lobby, sel, onSel }) {
|
||||
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="pavatar">{c?.portrait ? <img src={`/api/lobbies/${lobby.id}/portrait/${encodeURIComponent(m.username)}?v=${c.portrait}`} alt="" /> : (c?.name || m.username).slice(0, 1).toUpperCase()}</span>
|
||||
<span className="pinfo">
|
||||
<b>{c ? c.name : 'No character'}</b>
|
||||
<span className="muted small">{m.username}{m.me ? ' (you)' : ''}{c?.class ? ` · ${c.class}` : ''}</span>
|
||||
@@ -762,7 +762,7 @@ function LobbyView({ id, rules, user }) {
|
||||
const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null
|
||||
const walls = full && showWalls ? mapState.walls || [] : []
|
||||
return (
|
||||
<MapView map={mapState.map} image={mapImg} tokens={mapState.tokens} grid={showGrid}
|
||||
<MapView map={mapState.map} image={mapImg} portraitUrl={(t) => t.portrait ? `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}` : null} tokens={mapState.tokens} grid={showGrid}
|
||||
onMove={moveToken} selected={mapSel} onSelect={setMapSel}
|
||||
overlay={walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}>
|
||||
{mapState.vision?.empty && <div className="mapempty">{lobby.is_dm ? 'This player is not on the map yet. Switch to the full view and drag their token onto it.' : 'Waiting for the DM to put your token on the map.'}</div>}
|
||||
|
||||
@@ -6,7 +6,7 @@ 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, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
|
||||
export function MapView({ map, image, portraitUrl, 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)
|
||||
@@ -125,7 +125,7 @@ export function MapView({ map, image, tokens, onMove, selected, onSelect, grid:
|
||||
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`}
|
||||
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)' : '')}>
|
||||
<span>{(t.name || '?').trim().slice(0, 2)}</span>
|
||||
{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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -110,6 +110,7 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
|
||||
<section className="paper">
|
||||
<header className="phead">
|
||||
<div className="pname">{name}</div>
|
||||
{ch.portrait && <img className="pportrait" src={ch.portrait} alt="" />}
|
||||
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
|
||||
</header>
|
||||
<div className="pfields">
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
const BOX = 200 // size of the crop window on screen
|
||||
const OUT = 256 // size of the stored picture
|
||||
|
||||
// Picture of a character: choose a file, move and zoom it inside a square window, apply. Stored as a small data URL.
|
||||
export default function PortraitEditor({ value, onChange }) {
|
||||
const [pic, setPic] = useState(null) // { img, w, h }
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [off, setOff] = useState({ x: 0, y: 0 })
|
||||
const [err, setErr] = useState('')
|
||||
const drag = useRef(null)
|
||||
|
||||
const pick = (e) => {
|
||||
const f = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!f) return
|
||||
setErr('')
|
||||
if (!/^image\/(png|jpeg|webp)$/.test(f.type)) return setErr('Please choose a PNG, JPEG or WebP image.')
|
||||
if (f.size > 12_000_000) return setErr('The image is too large (at most 12 MB).')
|
||||
const url = URL.createObjectURL(f)
|
||||
const img = new Image()
|
||||
img.onload = () => { setPic({ img, w: img.naturalWidth, h: img.naturalHeight, url }); setZoom(1); setOff({ x: 0, y: 0 }) }
|
||||
img.onerror = () => { URL.revokeObjectURL(url); setErr('This image could not be read.') }
|
||||
img.src = url
|
||||
}
|
||||
const scale = pic ? (BOX / Math.min(pic.w, pic.h)) * zoom : 1
|
||||
const clampOff = (o, z = zoom) => {
|
||||
if (!pic) return o
|
||||
const s = (BOX / Math.min(pic.w, pic.h)) * z
|
||||
const mx = Math.max(0, (pic.w * s - BOX) / 2), my = Math.max(0, (pic.h * s - BOX) / 2)
|
||||
return { x: Math.min(mx, Math.max(-mx, o.x)), y: Math.min(my, Math.max(-my, o.y)) }
|
||||
}
|
||||
const down = (e) => { e.currentTarget.setPointerCapture(e.pointerId); drag.current = { x: e.clientX, y: e.clientY, off } }
|
||||
const move = (e) => { if (drag.current) setOff(clampOff({ x: drag.current.off.x + e.clientX - drag.current.x, y: drag.current.off.y + e.clientY - drag.current.y })) }
|
||||
const up = () => { drag.current = null }
|
||||
const close = () => { if (pic) URL.revokeObjectURL(pic.url); setPic(null) }
|
||||
const apply = () => {
|
||||
const size = BOX / scale // visible part of the original, in its pixels
|
||||
const cx = pic.w / 2 - off.x / scale, cy = pic.h / 2 - off.y / scale
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = cv.height = OUT
|
||||
cv.getContext('2d').drawImage(pic.img, cx - size / 2, cy - size / 2, size, size, 0, 0, OUT, OUT)
|
||||
const data = cv.toDataURL('image/webp', 0.85)
|
||||
onChange(data.startsWith('data:image/webp') ? data : cv.toDataURL('image/jpeg', 0.88))
|
||||
close()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portraiteditor">
|
||||
{!pic && (
|
||||
<div className="portraitcur">
|
||||
{value ? <img src={value} alt="Character portrait" /> : <span className="portraitnone muted small">No picture</span>}
|
||||
<div className="portraitbtns">
|
||||
<label className="uploadbtn small">{value ? 'Change picture' : 'Add picture'}<input type="file" accept="image/png,image/jpeg,image/webp" hidden onChange={pick} /></label>
|
||||
{value && <button className="ghost" onClick={() => onChange('')}>Remove</button>}
|
||||
<span className="small muted">Shown on the sheet, in lobbies and on the map.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pic && (
|
||||
<div className="portraitcrop">
|
||||
<div className="cropwin" style={{ width: BOX, height: BOX }} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
|
||||
<img src={pic.url} alt="" draggable={false} style={{ width: pic.w * scale, height: pic.h * scale, left: (BOX - pic.w * scale) / 2 + off.x, top: (BOX - pic.h * scale) / 2 + off.y }} />
|
||||
</div>
|
||||
<div className="cropctl">
|
||||
<label className="small">Zoom <input type="range" min="1" max="4" step="0.02" value={zoom} onChange={(e) => { const z = +e.target.value; setZoom(z); setOff((o) => clampOff(o, z)) }} /></label>
|
||||
<p className="small muted">Drag the picture to choose the section.</p>
|
||||
<div className="row"><button className="on" onClick={apply}>Use this</button><button className="ghost" onClick={close}>Cancel</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{err && <p className="warn small">{err}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export 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
|
||||
@@ -182,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: {} }
|
||||
|
||||
@@ -466,3 +466,17 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
|
||||
.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; }
|
||||
.pportrait { width: 21mm; height: 21mm; object-fit: cover; border: 1.5px solid var(--pg); border-radius: 1.5mm; flex: none; margin-left: auto; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user