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:
Claude
2026-10-23 19:33:28 +00:00
parent 03ebc3a138
commit d4d7b4d22e
11 changed files with 181 additions and 9 deletions
+2
View File
@@ -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>
+2 -2
View File
@@ -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>}
+2 -2
View File
@@ -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>
)
+1
View File
@@ -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">
+76
View File
@@ -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>
)
}
+2 -2
View File
@@ -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: {} }
+14
View File
@@ -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; }