diff --git a/README.md b/README.md index d9a52ac..7fae45b 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,10 @@ While creating, the full character sheet stays visible next to the wizard. Edits Each wizard page has a **Reset this page** button that clears only that page's data, plus **Start over** for the whole character. +### Character sheet (A4) + +The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. **Print / save as PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). In a lobby, **A4 sheet / Wide** switches between the compact page and the full-width table view (your choice is remembered). The Word and PDF downloads on the Summary page are separate exports with their own layout. + ### Lobbies The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave). diff --git a/frontend/src/Creator.jsx b/frontend/src/Creator.jsx index 2bac4bc..1c561fb 100644 --- a/frontend/src/Creator.jsx +++ b/frontend/src/Creator.jsx @@ -2,6 +2,7 @@ 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' import Tip from './Tip.jsx' +import PaperSheet from './PaperSheet.jsx' import { explainAttr, explainAttrPool, explainCapacity, explainCarry, explainDerived, explainProfPool, explainSkill, explainSkillPool } from './explain.js' const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary'] @@ -433,47 +434,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad <>

Summary

{warnings.length > 0 && } -
-

{ch.name || 'Nameless'}

-

{ch.player && `Player: ${ch.player} · `}Profession: {prof?.name || '–'}{cls && ` · Class: ${[cls.name, sub?.name].filter(Boolean).join(' / ')}`}

-
-
-

Attributes

- {rules.attributes.map((a) => )}
{a.name}{c.attrs[a.id]}Mod {c.mods[a.id]}
-

Derived stats

- {rules.derived.map((d) => )}
{d.name}{c.derived[d.id]}
-
-
-

Talents

-
    {rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) =>
  • {t.name}
    {t.desc}
  • )}
- {!ch.talents.length &&

–

} -

Mutations / implants ({c.capacityUsed}/{c.capacityMax})

-
    {rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) =>
  • {a.name} cost {a.cost}
    {a.desc}
  • )}
- {!ch.augments.length &&

–

} -

Equipment ({c.weight}/{c.carryMax} kg)

-
    {gearLines.map((g) =>
  • {g.item.name}{g.qty > 1 ? ` x${g.qty}` : ''}
    {[g.item.stat, `${Math.round(g.item.kg * g.qty * 10) / 10} kg`, g.item.note].filter(Boolean).join(' · ')}
  • )}
- {!gearLines.length &&

–

} - {ch.gear.other &&

Other: {ch.gear.other}

} -
-
-

Skills

-
- {rules.skill_groups.map((g) => ( -
-
{g.name}
- - {c.skills.filter((s) => s.group === g.id).map((s) => ( - - - - - - ))} -
{s.name}{s.type === 'advanced' && (adv.)}{s.total}{s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.points ? `+${s.points}` : ''}
-
- ))} -
-
+
diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index 4bad711..3aa7eb6 100644 --- a/frontend/src/Lobbies.jsx +++ b/frontend/src/Lobbies.jsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import SheetView from './SheetView.jsx' +import PaperSheet from './PaperSheet.jsx' const J = { 'Content-Type': 'application/json' } const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) }) @@ -320,6 +321,14 @@ function CharTab({ cid, owner, rules, onOpen, name }) { const t = setInterval(() => !document.hidden && load(), 15000) return () => clearInterval(t) }, [cid]) + const [view, setView] = useState(() => { try { return localStorage.getItem('aw_sheetview') === 'wide' ? 'wide' : 'paper' } catch { return 'paper' } }) + const pick = (v) => { setView(v); try { localStorage.setItem('aw_sheetview', v) } catch { /* storage may be blocked */ } } + const viewSwitch = ( + + + + + ) return (
@@ -328,7 +337,8 @@ function CharTab({ cid, owner, rules, onOpen, name }) {

{owner ? `Played by ${owner}. ` : ''}Read-only, hover or tap a value to see how it is made up.

{err &&

{err}

} - {data && } + {data && view === 'paper' && } + {data && view === 'wide' && <>
{viewSwitch}
}
) } diff --git a/frontend/src/PaperSheet.jsx b/frontend/src/PaperSheet.jsx new file mode 100644 index 0000000..d6c95fc --- /dev/null +++ b/frontend/src/PaperSheet.jsx @@ -0,0 +1,177 @@ +import { useLayoutEffect, useMemo, useRef, useState } 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 + +// 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 = '' + 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 }) { + 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 }) { + 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 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}`) + + const Field = ({ label, children }) =>
{label}{children || '–'}
+ const Foot = ({ n }) =>
Arcane Wasteland{name}Page {n} / 2
+ + return ( +
+
+ {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

+ +
+
+
+ ) +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9ad124a..18e29f5 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -208,7 +208,7 @@ h2 + .charlist, .card + h2 { margin-top: 1.4rem; } /* lobby desktop layout: main area left, chat column fixed at the right edge of the window, dice bar bottom centre of the main area */ :root { --chatw: 380px; --hdr: 3.7rem; } body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(--chatw) + 1.5rem); } -.lobbypage { max-width: 1200px; margin: 0 auto; padding-bottom: 8rem; } +.lobbypage { max-width: 1200px; margin: 0; padding-bottom: 8rem; } .lobbychat { position: fixed; right: 0; top: var(--hdr); bottom: 0; width: var(--chatw); z-index: 10; border-radius: 0; border-width: 0 0 0 1px; padding: .8rem; display: flex; flex-direction: column; overflow: hidden; } .lobbychat .chatwrap { flex: 1; min-height: 0; grid-template-rows: minmax(0, 1fr) auto auto auto auto; } .lobbychat .chatbox { height: auto; min-height: 0; } @@ -251,3 +251,69 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var( .lobbycard { align-content: start; } .lobbycard .chips { align-items: flex-start; align-content: flex-start; } .lobbycard .chip { align-self: flex-start; } + +/* ---- A4 character sheet (fixed colours on purpose: it is printed on white paper) ---- */ +.papertools { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: .4rem 0 .8rem; } +.seg { display: inline-flex; } +.seg button { border-radius: 0; padding: .4rem .9rem; } +.seg button:first-child { border-radius: var(--radius) 0 0 var(--radius); } +.seg button:last-child { border-radius: 0 var(--radius) var(--radius) 0; margin-left: -1px; } +.seg button.on { background: var(--noxium); color: #12150f; border-color: var(--noxium); } +.paperfit { width: 100%; } +.papers { display: grid; gap: 14px; justify-items: start; width: 210mm; } +.paper { box-sizing: border-box; width: 210mm; min-height: 297mm; padding: 11mm 12mm 8mm; background: #fff; color: #1a1f14; border: 1px solid #cfd5c3; box-shadow: 0 2px 12px rgba(0, 0, 0, .14); + display: flex; flex-direction: column; gap: 3.5mm; font-size: 8.6pt; line-height: 1.25; break-after: page; } +.paper * { color: inherit; } +.paper .tipped { border-bottom: 0; text-decoration: none; cursor: default; } +.paper p { margin: 0; } +.phead { display: flex; justify-content: space-between; align-items: flex-end; gap: 8mm; border-bottom: 2px solid #2f6a0d; padding-bottom: 2mm; } +.pname { font-size: 24pt; font-weight: 800; line-height: 1.05; letter-spacing: .01em; overflow-wrap: anywhere; } +.phead.small .pname { font-size: 15pt; } +.pbrand { text-align: right; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; font-size: 8.5pt; color: #2f6a0d !important; white-space: nowrap; } +.pbrand span { display: block; font-weight: 500; letter-spacing: .08em; font-size: 7pt; color: #6a7260 !important; } +.pfields { display: grid; grid-template-columns: 1fr 1.6fr 1.3fr 1fr .8fr; gap: 3mm; } +.pfield { border-bottom: 1px solid #1a1f14; padding-bottom: .6mm; min-width: 0; } +.pfield span { display: block; font-size: 6.2pt; text-transform: uppercase; letter-spacing: .1em; color: #6a7260 !important; } +.pfield b { font-weight: 600; font-size: 9pt; overflow-wrap: anywhere; } +.pattrs { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2.5mm; } +.pattr { border: 1.4px solid #1a1f14; border-radius: 3mm; text-align: center; padding: 1.6mm 1mm 1.4mm; display: grid; gap: .2mm; } +.pattr-n { font-size: 6.6pt; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; } +.pattr-v { font-size: 20pt; line-height: 1; } +.pattr-m { font-size: 7pt; color: #4d5645 !important; border-top: 1px solid #cfd5c3; padding-top: .8mm; } +.pderived { display: grid; grid-template-columns: repeat(auto-fit, minmax(28mm, 1fr)); gap: 2.5mm; } +.pstat { background: #eef3e6; border-radius: 2mm; padding: 1.4mm 2.4mm; display: flex; justify-content: space-between; align-items: baseline; font-weight: 600; font-size: 8pt; } +.pstat b { font-size: 13pt; } +.psec h3 { margin: 0 0 1.6mm; font-size: 9pt; text-transform: uppercase; letter-spacing: .12em; color: #fff !important; background: #2f6a0d; padding: .8mm 2.4mm; border-radius: 1.4mm; display: flex; justify-content: space-between; align-items: center; gap: 3mm; } +.psec h3 * { color: #fff !important; } +.psec h3 small { font-size: 6.6pt; font-weight: 500; letter-spacing: .04em; text-transform: none; } +.pskills { column-count: 2; column-gap: 6mm; } +.pgroup { break-inside: avoid; margin-bottom: 3mm; } +.pgroup h4 { margin: 0 0 .6mm; font-size: 7.4pt; text-transform: uppercase; letter-spacing: .1em; color: #2f6a0d !important; border-bottom: 1px solid #2f6a0d; } +.prow { display: grid; grid-template-columns: 6.5mm 1fr 11mm 6mm 8mm; align-items: center; gap: 1mm; padding: .35mm 0; border-bottom: 1px dotted #c3cab6; } +.prow .pn { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } +.prow .pn em { font-size: 6pt; color: #6a7260 !important; font-style: normal; text-transform: uppercase; letter-spacing: .05em; margin-left: .8mm; } +.prow .pa { font-size: 6.4pt; color: #6a7260 !important; text-align: center; } +.prow .pp { font-size: 7pt; color: #4d5645 !important; text-align: right; } +.prow .pt { text-align: center; border: 1px solid #1a1f14; border-radius: 1.4mm; font-size: 8.6pt; padding: .1mm 0; background: #fff; } +.prow.locked { opacity: .45; } +.prow.locked .pn { text-decoration: line-through; } +.ppips { display: inline-flex; gap: .7mm; } +.ppips i { width: 2.1mm; height: 2.1mm; border: 1px solid #1a1f14; border-radius: 50%; background: #fff; display: inline-block; } +.ppips i.on { background: #2f6a0d; border-color: #2f6a0d; } +.pcols { column-count: 2; column-gap: 6mm; } +.pitem { break-inside: avoid; margin-bottom: 2.2mm; } +.pitem b { font-size: 9pt; } +.pitem p { font-size: 7.8pt; color: #3b4433 !important; } +.ptag { font-size: 6.6pt; color: #6a7260 !important; text-transform: uppercase; letter-spacing: .05em; } +.pnone { color: #6a7260 !important; } +.pgear { width: 100%; border-collapse: collapse; font-size: 8pt; } +.pgear th { text-align: left; font-size: 6.4pt; text-transform: uppercase; letter-spacing: .08em; color: #6a7260 !important; border-bottom: 1px solid #1a1f14; padding: .6mm 1mm; } +.pgear td { padding: .45mm 1mm; border-bottom: 1px dotted #c3cab6; vertical-align: top; } +.pgear td:nth-child(n+4), .pgear th:nth-child(n+4) { text-align: right; white-space: nowrap; } +.pnote { font-size: 6.6pt; color: #5c6553 !important; } +.pother { margin-top: 1.6mm; font-size: 8pt; } +.pbg { font-size: 8pt; white-space: pre-wrap; } +.pnotes { flex: 1; display: flex; flex-direction: column; min-height: 18mm; } +.plines { flex: 1; background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 6.4mm, #b9c1ab 6.4mm, #b9c1ab calc(6.4mm + 1px)); border-radius: 1mm; } +.pfoot { margin-top: auto; display: flex; justify-content: space-between; font-size: 6.4pt; color: #6a7260 !important; text-transform: uppercase; letter-spacing: .08em; border-top: 1px solid #cfd5c3; padding-top: 1.2mm; } +@media print { .paper { box-shadow: none; } }