Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
210 lines
11 KiB
React
210 lines
11 KiB
React
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)
|
||
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() }) })
|
||
}
|
||
|
||
// 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 }) {
|
||
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">
|
||
<header className="phead">
|
||
<div className="pname">{name}</div>
|
||
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
|
||
</header>
|
||
<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>
|
||
|
||
<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>
|
||
<div className="psec pnotes"><h3>Conditions & notes</h3><div className="plines" /></div>
|
||
<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 & 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>}
|
||
|
||
<div className="psec pnotes"><h3>Notes</h3><div className="plines" /></div>
|
||
<Foot n={2} />
|
||
</section>
|
||
</Fit>
|
||
</div>
|
||
)
|
||
}
|