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 ( ) } // 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(`${(title || 'Character').replace(/[<>&]/g, '')}${head} ${clone.outerHTML}`) 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
{children}
} const Pips = ({ n, locked }) => ( = 1 ? 'on' : ''} />= 2 ? 'on' : ''} /> ) // 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 }) =>
{label}{children || '–'}
const Foot = ({ n }) =>
Arcane Wasteland{name}Page {n} / 2
return (
{!bare && (
{tools}
)}
{name}
Arcane WastelandCharacter sheet
{ch.player} {ch.concept} {cls && [cls.name, sub?.name].filter(Boolean).join(' / ')} {prof?.name} {preset}
{rules.attributes.map((a) => (
{a.name} {c.attrs[a.id]} Mod {sign(c.mods[a.id])}
))}
{rules.derived.map((d) => (
{d.name} {c.derived[d.id]}
))}

Skills ● proficient · ●● expertise · attribute · points · total

{rules.skill_groups.map((g) => (

{g.name}

{c.skills.filter((s) => s.group === g.id).map((s) => (
{s.name}{s.type === 'advanced' && adv} {short(s)} {s.points || ''} {s.total}
))}
))}

Conditions & notes

{name}
{[cls?.name, prof?.name].filter(Boolean).join(' · ')}

Talents

{talents.length === 0 &&

–

}
{talents.map((t) =>
{t.name}

{t.desc}

)}

Mutations & implants Capacity {c.capacityUsed} / {c.capacityMax}

{augments.length === 0 &&

–

}
{augments.map((a) => (
{a.name} {a.kind === 'implant' ? 'Implant' : 'Mutation'} · cost {a.cost}

{a.desc}

))}

Equipment Carried {c.weight} / {c.carryMax} kg

{gear.length === 0 &&

–

} {gear.length > 0 && ( {gear.map((g) => ( ))}
ItemTypeStatsQtykg
{g.item.name}{g.item.note && {g.item.note}}{g.label}{g.item.stat} {g.qty}{Math.round(g.item.kg * g.qty * 10) / 10}
)} {ch.gear?.other &&

Other: {ch.gear.other}

}
{ch.background &&

Background

{ch.background}

}

Notes

) }