Files
AWCharacterCreator/frontend/src/PaperSheet.jsx
T

239 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import Tip from './Tip.jsx'
import { compute, effectiveRules, kitItems, normalize } from './calc.js'
import { explainAttr, explainCapacity, explainCarry, explainDerived, explainSkill } from './explain.js'
const PAPER_PX = 794 // 210 mm at 96 dpi
// Light/dark choice for the sheet, shared by all sheets and switches on the page and remembered in the browser
const THEME_KEY = 'aw_sheettheme'
const listeners = new Set()
const readTheme = () => { try { return localStorage.getItem(THEME_KEY) === 'dark' } catch { return false } }
let darkNow = readTheme()
export function useSheetTheme() {
const dark = useSyncExternalStore((cb) => { listeners.add(cb); return () => listeners.delete(cb) }, () => darkNow)
const set = (d) => { darkNow = d; try { localStorage.setItem(THEME_KEY, d ? 'dark' : 'light') } catch { /* storage may be blocked */ } listeners.forEach((f) => f()) }
return [dark, set]
}
export function ThemeSwitch() {
const [dark, setDark] = useSheetTheme()
return (
<span className="seg" role="group" aria-label="Sheet colours">
<button className={dark ? 'ghost' : 'on'} onClick={() => setDark(false)} title="Light sheet">Light</button>
<button className={dark ? 'on' : 'ghost'} onClick={() => setDark(true)} title="Dark sheet (prints light)">Dark</button>
</span>
)
}
// Opens the sheet pages in a clean window (only the paper, A4, no margins) and starts the print dialog:
// "Save as PDF" there gives an A4 PDF that looks exactly like the screen.
export function printSheet(root, title) {
const win = window.open('', '_blank')
if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return }
const clone = root.cloneNode(true)
const src = root.querySelectorAll('textarea'), dst = clone.querySelectorAll('textarea')
dst.forEach((t, i) => { t.textContent = src[i].value }) // typed text does not survive cloning on its own
clone.style.zoom = ''
clone.classList.remove('dark') // printed pages are always light
const head = [...document.querySelectorAll('link[rel=stylesheet], style')].map((n) => n.outerHTML).join('')
win.document.open()
win.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>${(title || 'Character').replace(/[<>&]/g, '')}</title>${head}
<style>@page { size: A4; margin: 0 } html, body { background: #fff !important; margin: 0; padding: 0 }
.papers { display: block !important; gap: 0 !important } .paper { border: 0 !important; box-shadow: none !important; margin: 0 !important; min-height: 296.5mm !important; break-after: page; }
.paper:last-child { break-after: auto }</style></head><body>${clone.outerHTML}</body></html>`)
win.document.close()
const go = () => { win.focus(); win.print() }
const links = [...win.document.querySelectorAll('link[rel=stylesheet]')]
let pending = links.length
if (!pending) return go()
links.forEach((l) => { l.addEventListener('load', () => { if (--pending === 0) setTimeout(go, 150) }); l.addEventListener('error', () => { if (--pending === 0) go() }) })
}
// Ruled note area. With onChange (own sheet in the summary or the lobby) it can be typed into, otherwise it just shows the text.
function Notes({ title, value, onChange }) {
return (
<div className="psec pnotes"><h3>{title}</h3>
{onChange
? <textarea className="plines pwrite" value={value || ''} maxLength={5000} onChange={(e) => onChange(e.target.value)} placeholder="Click here to write …" aria-label={title} />
: <div className="plines pwrite">{value}</div>}
</div>
)
}
// Scales the A4 pages down when the space is narrower than the paper
function Fit({ children, rootRef, dark }) {
const wrap = useRef(null)
const [z, setZ] = useState(1)
useLayoutEffect(() => {
const f = () => wrap.current && setZ(Math.min(1, wrap.current.clientWidth / PAPER_PX))
f()
const ro = new ResizeObserver(f)
ro.observe(wrap.current)
return () => ro.disconnect()
}, [])
return <div ref={wrap} className="paperfit"><div ref={rootRef} className={`papers${dark ? ' dark' : ''}`} style={{ zoom: z }}>{children}</div></div>
}
const Pips = ({ n, locked }) => (
<span className={`ppips ${locked ? 'locked' : ''}`} aria-label={n === 2 ? 'Expertise' : n === 1 ? 'Proficient' : locked ? 'Locked' : ''}>
<i className={n >= 1 ? 'on' : ''} /><i className={n >= 2 ? 'on' : ''} />
</span>
)
// The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes).
export default function PaperSheet({ data, baseRules, tools, bare, onRoll, onNotes }) {
const ch = useMemo(() => normalize(data, baseRules), [data, baseRules])
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
const c = useMemo(() => compute(ch, rules), [ch, rules])
const x = { ch, rules, c }
const root = useRef(null)
const [dark] = useSheetTheme()
const prof = rules.professions.find((p) => p.id === ch.profession)
const cls = rules.classes.find((k) => k.id === ch.class_id)
const sub = cls?.subclasses.find((s) => s.id === ch.subclass_id)
const preset = ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name || 'Custom'
const short = (s) => rules.attributes.filter((a) => s.attrs.includes(a.id)).map((a) => a.short).join('/')
const talents = rules.talents.filter((t) => ch.talents.includes(t.id))
const augments = rules.augments.filter((a) => ch.augments.includes(a.id))
const gear = kitItems(ch, rules)
const name = ch.name || 'Nameless'
const sign = (n) => (n > 0 ? `+${n}` : `${n}`)
// With onRoll (own character in a lobby) attributes and usable skills can be clicked to roll a check
const roll = (kind, id, label, ok = true) => (onRoll && ok ? {
className: 'rollable', role: 'button', tabIndex: 0,
onClick: () => onRoll(kind, id, label), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRoll(kind, id, label) } },
} : {})
const Field = ({ label, children }) => <div className="pfield"><span>{label}</span><b>{children || '–'}</b></div>
const Foot = ({ n }) => <div className="pfoot"><span>Arcane Wasteland</span><span>{name}</span><span>Page {n} / 2</span></div>
return (
<div className="papersheet">
{!bare && (
<div className="papertools">
{tools}
<span className="toolright">
<ThemeSwitch />
<button className="ghost" onClick={() => printSheet(root.current, name)} title="Opens the pages for printing, choose Save as PDF there">Print / PDF (A4)</button>
</span>
</div>
)}
<Fit rootRef={root} dark={dark}>
<section className="paper">
{(() => {
const head = (
<header className="phead">
<div className="pname">{name}</div>
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
</header>
)
const fields = (
<div className="pfields">
<Field label="Player">{ch.player}</Field>
<Field label="Concept">{ch.concept}</Field>
<Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field>
<Field label="Profession">{prof?.name}</Field>
<Field label="Rules">{preset}</Field>
</div>
)
return ch.portrait
? <div className="pidentity"><img className="pportrait" src={ch.portrait} alt="" /><div className="pidright">{head}{fields}</div></div>
: <>{head}{fields}</>
})()}
<div className="pattrs">
{rules.attributes.map((a) => (
<div key={a.id} {...roll('attr', a.id, a.name)} className={`pattr${onRoll ? ' rollable' : ''}`}>
<Tip text={explainAttr(a, x)}><span className="pattr-n">{a.name}</span></Tip>
<b className="pattr-v">{c.attrs[a.id]}</b>
<span className="pattr-m">Mod {sign(c.mods[a.id])}</span>
</div>
))}
</div>
<div className="pderived">
{rules.derived.map((d) => (
<div key={d.id} className="pstat">
<Tip text={explainDerived(d, x)}><span>{d.name}</span></Tip>
<b>{c.derived[d.id]}</b>
</div>
))}
</div>
<div className="psec">
<h3>Skills <small>● proficient · ●● expertise · attribute · points · total</small></h3>
<div className="pskills">
{rules.skill_groups.map((g) => (
<div key={g.id} className="pgroup">
<h4>{g.name}</h4>
{c.skills.filter((s) => s.group === g.id).map((s) => (
<div key={s.id} {...roll('skill', s.id, s.name, s.usable)} className={`prow ${s.usable ? '' : 'locked'}${onRoll && s.usable ? ' rollable' : ''}`}>
<Pips n={s.pips} locked={!s.usable} />
<Tip text={explainSkill(s, x)}><span className="pn">{s.name}{s.type === 'advanced' && <em> adv</em>}</span></Tip>
<span className="pa">{short(s)}</span>
<span className="pp">{s.points || ''}</span>
<b className="pt">{s.total}</b>
</div>
))}
</div>
))}
</div>
</div>
{data.npc && (data.abilities || data.loot || data.background) && (
<div className="psec pnpc">
{data.background && <><h3>Description</h3><p className="pre">{data.background}</p></>}
{data.abilities && <><h3>Abilities and attacks</h3><p className="pre">{data.abilities}</p></>}
{data.loot && <><h3>Loot</h3><p className="pre">{data.loot}</p></>}
</div>
)}
<Notes title="Conditions &amp; notes" value={ch.conditions} onChange={onNotes && ((v) => onNotes({ conditions: v }))} />
<Foot n={1} />
</section>
<section className="paper">
<header className="phead small">
<div className="pname">{name}</div>
<div className="pbrand">{[cls?.name, prof?.name].filter(Boolean).join(' · ')}</div>
</header>
<div className="psec">
<h3>Talents</h3>
{talents.length === 0 && <p className="pnone">–</p>}
<div className="pcols">
{talents.map((t) => <div key={t.id} className="pitem"><b>{t.name}</b><p>{t.desc}</p></div>)}
</div>
</div>
<div className="psec">
<h3><Tip text={explainCapacity(x)}>Mutations &amp; implants</Tip> <small>Capacity {c.capacityUsed} / {c.capacityMax}</small></h3>
{augments.length === 0 && <p className="pnone">–</p>}
<div className="pcols">
{augments.map((a) => (
<div key={a.id} className="pitem"><b>{a.name}</b> <span className="ptag">{a.kind === 'implant' ? 'Implant' : 'Mutation'} · cost {a.cost}</span><p>{a.desc}</p></div>
))}
</div>
</div>
<div className="psec">
<h3><Tip text={explainCarry(x)}>Equipment</Tip> <small>Carried {c.weight} / {c.carryMax} kg</small></h3>
{gear.length === 0 && <p className="pnone">–</p>}
{gear.length > 0 && (
<table className="pgear"><thead><tr><th>Item</th><th>Type</th><th>Stats</th><th>Qty</th><th>kg</th></tr></thead>
<tbody>{gear.map((g) => (
<tr key={g.item.id}><td><b>{g.item.name}</b>{g.item.note && <span className="pnote"> {g.item.note}</span>}</td><td>{g.label}</td><td>{g.item.stat}</td>
<td>{g.qty}</td><td>{Math.round(g.item.kg * g.qty * 10) / 10}</td></tr>
))}</tbody></table>
)}
{ch.gear?.other && <p className="pother"><b>Other:</b> {ch.gear.other}</p>}
</div>
{ch.background && <div className="psec"><h3>Background</h3><p className="pbg">{ch.background}</p></div>}
<Notes title="Notes" value={ch.notes} onChange={onNotes && ((v) => onNotes({ notes: v }))} />
<Foot n={2} />
</section>
</Fit>
</div>
)
}