diff --git a/README.md b/README.md index fafa75c..03e7f18 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ cd frontend && npm install && npm run dev Characters are saved in your account ("My characters" page). Only the owner can see a character, change it or delete it. On "My characters" the **Share** button lets you add usernames (e.g. your DM): they get read-only access, see the character under "Shared with me" and can open it through `/#creator/`. Saving a viewed character creates their own copy. Use the JSON backup (Summary page) to move a character between servers. +Hover (or tap / focus) the dotted-underlined labels and the small "i" icons to see where a number comes from, e.g. how a skill's target number and maximum are derived, or what a derived stat is made of. The explanations are generated from the rules, so they follow presets and `rules.json` changes. + While creating, the full character sheet stays visible next to the wizard. Edits are shown as a difference (old → new, added / removed items) until you press **Apply** (or **Apply & next**); **Discard** drops them. Each wizard page has a **Reset this page** button that clears only that page's data, plus **Start over** for the whole character. diff --git a/frontend/src/Creator.jsx b/frontend/src/Creator.jsx index f8b5850..20ba1b9 100644 --- a/frontend/src/Creator.jsx +++ b/frontend/src/Creator.jsx @@ -1,6 +1,8 @@ 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 { explainAttr, explainAttrPool, explainCapacity, explainCarry, explainDerived, explainProfPool, explainSkill, explainSkillPool } from './explain.js' const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary'] const SOURCE = { noxium: 'Noxium', diesium: 'Diesium', hybrid: 'Hybrid', precyber: 'Pre-meteor cybernetics' } @@ -40,6 +42,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad const sub = cls?.subclasses.find((x) => x.id === ch.subclass_id) const set = (patch) => setCh((x) => ({ ...x, ...patch })) const cr = rules.creation + const ctx = { ch, rules, c } useEffect(() => { onName?.(ch.name) }, [ch.name]) // eslint-disable-line @@ -190,7 +193,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad <>

Attributes

All attributes start at {cr.attr_start}. Distribute {cr.attr_points} points (at most +{cr.attr_max_raise} per attribute, cap {cr.attr_max}).

-
Points left: {c.attrLeft} / {cr.attr_points}
+
Points left: {c.attrLeft} / {cr.attr_points}
{rules.attributes.map((a) => { const v = ch.attributes[a.id] @@ -198,14 +201,14 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad const setV = (n) => set({ attributes: { ...ch.attributes, [a.id]: n } }) return (
-
{a.name} {a.short}
{a.desc}
+
{a.name} {a.short}
{a.desc}
{v} - Mod {c.mods[a.id]} + Mod {c.mods[a.id]}
) @@ -263,11 +266,11 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad (free if your profession grants them). The pool size comes from the rules preset.

-
- Points left: {c.skillLeft} / {cr.skill_points_default} +
+ Points left: {c.skillLeft} / {cr.skill_points_default}
-
- Proficiency picks left: {c.profLeft} / {c.profAllowed} +
+ Proficiency picks left: {c.profLeft} / {c.profAllowed}

@@ -283,7 +286,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad return (

- {s.name} {s.placeholder && } + {s.name} {s.placeholder && }
{s.attrs.map((id) => rules.attributes.find((a) => a.id === id).short).join('/')} · {s.type === 'basic' ? 'Basic' : s.usable ? 'Advanced (unlocked)' : 'Advanced (locked)'} {s.pips === 1 && ' · Proficient'}{s.pips === 2 && ' · Expertise'} @@ -312,7 +315,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad {s.points} - {s.total} + {s.total} max {s.max}
)}
@@ -379,7 +382,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad <>

Equipment

Pick everything your character carries. Ask your GM what is allowed. Use + and − on a tile to change the amount.

-
Carried: {c.weight} / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}
+
Carried: {c.weight} / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}
{rules.equipment_categories.map((cat) => { const list = items.filter((e) => e.cat === cat.id) const n = list.reduce((sum, e) => sum + (ch.kit.extras[e.id] || 0), 0) @@ -435,18 +438,18 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad

Attributes

- {rules.attributes.map((a) => )}
{a.name}{c.attrs[a.id]}Mod {c.mods[a.id]}
+ {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]}
+ {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})

+

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)

+

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}

} @@ -460,7 +463,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad {c.skills.filter((s) => s.group === g.id).map((s) => ( - + diff --git a/frontend/src/SheetPanel.jsx b/frontend/src/SheetPanel.jsx index 6416a91..b328460 100644 --- a/frontend/src/SheetPanel.jsx +++ b/frontend/src/SheetPanel.jsx @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import Tip from './Tip.jsx' import { countChanges, diffRows, sheetModel } from './sheetModel.js' function Rows({ now, before, valueLabel }) { @@ -8,7 +9,7 @@ function Rows({ now, before, valueLabel }) {
{s.name}{s.type === 'advanced' && (adv.)}{s.name}{s.type === 'advanced' && (adv.)} {s.total} {s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.points ? `+${s.points}` : ''}
{rows.map((r) => ( - +
{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}{r.label}{r.desc &&
{r.desc}
}
{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}{r.label}{r.desc &&
{r.desc}
}
{r.status === 'changed' && r.old.val !== r.val && r.old.val !== undefined && {r.old.val} → } {valueLabel ? valueLabel(r) : r.val} @@ -51,12 +52,12 @@ export default function SheetPanel({ ch, committed, baseRules, onApply, onDiscar

Talents

-

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

+

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

-

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

+

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

(r.val > 1 ? `x${r.val}` : '')} />

Points left

-
Attributes {c.attrLeft} · Skills {c.skillLeft} · Proficiency {c.profLeft}
+
Attributes {c.attrLeft} · Skills {c.skillLeft} · Proficiency {c.profLeft}
) } diff --git a/frontend/src/Tip.jsx b/frontend/src/Tip.jsx new file mode 100644 index 0000000..48787fa --- /dev/null +++ b/frontend/src/Tip.jsx @@ -0,0 +1,40 @@ +import { useId, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +// Tooltip that works with mouse, keyboard focus and touch. `text` is a string or a list of lines. +// Without `icon` the children get a dotted underline; with `icon` a small "i" is shown after them. +export default function Tip({ text, children, icon }) { + const lines = (Array.isArray(text) ? text : [text]).filter(Boolean) + const ref = useRef(null) + const pop = useRef(null) + const id = useId() + const [open, setOpen] = useState(false) + const [pos, setPos] = useState({ left: 0, top: 0, ready: false }) + + useLayoutEffect(() => { + if (!open || !ref.current || !pop.current) return + const r = ref.current.getBoundingClientRect() + const w = pop.current.offsetWidth, h = pop.current.offsetHeight + const left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left + r.width / 2 - w / 2)) + const below = r.bottom + 6 + h < window.innerHeight + setPos({ left, top: below ? r.bottom + 6 : Math.max(8, r.top - 6 - h), ready: true }) + }, [open, lines.join('|')]) // eslint-disable-line + + if (!lines.length) return children + const show = () => setOpen(true) + const hide = () => { setOpen(false); setPos((p) => ({ ...p, ready: false })) } + + return ( + <> + e.key === 'Escape' && hide()}> + {children}{icon && i} + + {open && createPortal( + , document.body)} + + ) +} diff --git a/frontend/src/explain.js b/frontend/src/explain.js new file mode 100644 index 0000000..65ae9a5 --- /dev/null +++ b/frontend/src/explain.js @@ -0,0 +1,70 @@ +// Plain-language explanations of where numbers come from, shown in tooltips. +// `x` is { ch, rules, c } (character, effective rules, result of compute()). + +const attrName = (rules, id) => rules.attributes.find((a) => a.id === id)?.name || id +const attrShort = (rules, id) => rules.attributes.find((a) => a.id === id)?.short || id + +export function explainAttr(a, { ch, rules, c }) { + const cr = rules.creation + const base = ch.attributes[a.id] ?? cr.attr_start + const bonus = ch.profession && ch.profession_bonus_attr === a.id ? cr.profession_attr_bonus : 0 + const cap = Math.min(cr.attr_max, cr.attr_start + cr.attr_max_raise) + const used = rules.derived.filter((d) => d.formula.includes(a.id)).map((d) => d.name) + return [ + `${a.name} = ${cr.attr_start} start + ${base - cr.attr_start} from your points${bonus ? ` + ${bonus} profession bonus` : ''} = ${c.attrs[a.id]}`, + `Modifier = value ÷ 10, rounded down = ${c.mods[a.id]}`, + `Highest value you can reach with your points: ${cap} (start ${cr.attr_start}, at most +${cr.attr_max_raise}, cap ${cr.attr_max}). The profession bonus comes on top.`, + used.length ? `Its modifier feeds: ${used.join(', ')}.` : '', + ].filter(Boolean) +} + +export function explainDerived(d, { rules, c }) { + const parts = d.formula.map((id) => `${attrName(rules, id)} mod ${c.mods[id]}`) + return [`${d.name} = ${parts.join(' + ')} = ${c.derived[d.id]}`, d.note || ''].filter(Boolean) +} + +export function explainSkill(s, { ch, rules, c }) { + const cr = rules.creation + const best = Math.max(...s.attrs.map((id) => c.attrs[id])) + const list = s.attrs.map((id) => `${attrShort(rules, id)} ${c.attrs[id]}`).join(', ') + const training = s.pips === 2 ? `Expertise +${s.prof}` : s.pips === 1 ? `Proficient +${s.prof}` : 'no training +0' + const lines = [ + `Target number = best attribute ${best} (${s.attrs.length > 1 ? `higher of ${list}` : list}) + ${s.points} skill points + ${training} = ${s.total}`, + `Most points you can put in: best attribute ÷ ${cr.skill_max_divisor}, rounded down = ${s.max}`, + ] + if (s.type === 'advanced') { + lines.push(s.free ? 'Advanced skill: unlocked for free by a proficiency.' + : s.unlocked ? `Advanced skill: unlocked (cost ${c.unlockCost} points from the pool).` + : `Advanced skill: locked. Unlock it for ${c.unlockCost} points, or get a proficiency in it.`) + } + if (s.pips === 1 && s.profPip) lines.push('Proficient through your profession.') + return lines +} + +export function explainCapacity({ rules, c }) { + const d = rules.derived.find((x) => x.id === 'augmentation') + return [`Capacity = ${d ? d.formula.map((id) => `${attrName(rules, id)} mod ${c.mods[id]}`).join(' + ') : 'see rules'} = ${c.capacityMax}`, + `Each mutation or implant costs capacity; you used ${c.capacityUsed} of ${c.capacityMax}.`] +} + +export function explainCarry({ rules, c }) { + return [`Carry capacity = Strength mod ${c.mods.strength} × ${rules.creation.carry_per_strength_mod} kg = ${c.carryMax} kg`, + `Carried now: ${c.weight} kg (sum of all chosen items).`] +} + +export function explainAttrPool({ rules, c }) { + return [`Attribute points: ${c.attrSpent} spent of ${rules.creation.attr_points}. Each point raises an attribute by 1.`] +} + +export function explainSkillPool({ rules, c }) { + const cr = rules.creation + const paid = c.skills.filter((s) => s.type === 'advanced' && s.unlocked && !s.prof).length + return [`Skill pool: ${c.skillSpent} spent of ${cr.skill_points_default}.`, + `Skill points count 1 each; unlocking an advanced skill costs ${c.unlockCost}${paid ? ` (${paid} unlocked = ${paid * c.unlockCost})` : ''}.`] +} + +export function explainProfPool({ rules, c }) { + const cr = rules.creation + return [`Proficiency picks: ${c.profSpent} used of ${c.profAllowed} (${cr.free_proficiencies} free plus talents like Versatile Training).`, + `One pick = Proficient (+${cr.proficiency_bonus}). Two picks on one skill = Expertise (+${cr.expertise_bonus}); a profession proficiency counts as one.`] +} diff --git a/frontend/src/sheetModel.js b/frontend/src/sheetModel.js index 4925c2b..82f4196 100644 --- a/frontend/src/sheetModel.js +++ b/frontend/src/sheetModel.js @@ -1,9 +1,11 @@ import { compute, effectiveRules, kitItems } from './calc.js' +import { explainAttr, explainCapacity, explainCarry, explainDerived, explainSkill, explainAttrPool, explainSkillPool, explainProfPool } from './explain.js' // Flat, comparable view of a character, used by the side panel to show what changed. export function sheetModel(ch, baseRules) { const rules = effectiveRules(baseRules, ch) const c = compute(ch, rules) + const x = { ch, rules, c } 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((x) => x.id === ch.subclass_id) @@ -11,15 +13,16 @@ export function sheetModel(ch, baseRules) { const training = (s) => (s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.type === 'advanced' ? 'Unlocked' : '') return { c, + tips: { capacity: explainCapacity(x), carry: explainCarry(x), pools: [...explainAttrPool(x), ...explainSkillPool(x), ...explainProfPool(x)] }, head: [ { k: 'name', label: 'Name', val: ch.name || 'Nameless' }, { k: 'profession', label: 'Profession', val: prof?.name || '–' }, { k: 'class', label: 'Class', val: cls ? [cls.name, sub?.name].filter(Boolean).join(' / ') : '–' }, { k: 'preset', label: 'Rules', val: preset }, ], - attrs: rules.attributes.map((a) => ({ k: a.id, label: a.name, val: c.attrs[a.id], extra: `Mod ${c.mods[a.id]}` })), - derived: rules.derived.map((d) => ({ k: d.id, label: d.name, val: c.derived[d.id] })), - skills: c.skills.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s), dim: !s.usable })), + attrs: rules.attributes.map((a) => ({ k: a.id, label: a.name, val: c.attrs[a.id], extra: `Mod ${c.mods[a.id]}`, tip: explainAttr(a, x) })), + derived: rules.derived.map((d) => ({ k: d.id, label: d.name, val: c.derived[d.id], tip: explainDerived(d, x) })), + skills: c.skills.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s), dim: !s.usable, tip: explainSkill(s, x) })), talents: rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => ({ k: t.id, label: t.name, desc: t.desc })), augments: rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => ({ k: a.id, label: a.name, desc: `${a.kind === 'implant' ? 'Implant' : 'Mutation'} · cost ${a.cost}${a.desc ? ` · ${a.desc}` : ''}` })), gear: kitItems(ch, rules).map((g) => ({ k: g.item.id, label: g.item.name, val: g.qty, desc: [g.item.stat, `${g.item.kg} kg`, g.item.note].filter(Boolean).join(' · ') })), diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 589267e..2b71974 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -162,3 +162,10 @@ tr.dim td { opacity: .55; } .card.tile { display: flex; flex-direction: column; gap: .3rem; } .card.tile.on { border-color: var(--noxium); box-shadow: inset 0 0 0 1px var(--noxium); background: var(--panel2); } .tilestepper { margin-top: auto; padding-top: .4rem; justify-content: flex-end; } + +.tipped { border-bottom: 1px dotted var(--muted); cursor: help; } +.tipwrap { cursor: help; } +.tipicon { display: inline-grid; place-items: center; width: 1.05rem; height: 1.05rem; margin-left: .3rem; border: 1px solid var(--muted); border-radius: 50%; color: var(--muted); font-size: .7rem; font-style: italic; font-weight: 700; vertical-align: middle; } +.tipwrap:hover .tipicon, .tipwrap:focus .tipicon { color: var(--noxium); border-color: var(--noxium); } +.tippop { position: fixed; z-index: 1000; max-width: 340px; padding: .55rem .75rem; background: var(--panel2); color: var(--text); border: 1px solid var(--diesium); border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.35); font-size: .85rem; line-height: 1.4; pointer-events: none; } +.tippop div + div { margin-top: .35rem; }