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:
@@ -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