Add explanation tooltips for derived values
Hover/focus/tap tooltips show how attributes, derived stats, skill target numbers and limits, capacity, carry weight and point pools are calculated. Used in the wizard pages, the side sheet and the summary. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -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/<ID>`. 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.
|
||||
|
||||
+18
-15
@@ -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
|
||||
<>
|
||||
<h2>Attributes</h2>
|
||||
<p className="muted">All attributes start at {cr.attr_start}. Distribute {cr.attr_points} points (at most +{cr.attr_max_raise} per attribute, cap {cr.attr_max}).</p>
|
||||
<div className={`pool ${c.attrLeft < 0 ? 'over' : ''}`}>Points left: <strong>{c.attrLeft}</strong> / {cr.attr_points}</div>
|
||||
<div className={`pool ${c.attrLeft < 0 ? 'over' : ''}`}><Tip text={explainAttrPool(ctx)}>Points left</Tip>: <strong>{c.attrLeft}</strong> / {cr.attr_points}</div>
|
||||
<div className="attrs">
|
||||
{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 (
|
||||
<div key={a.id} className="attr card">
|
||||
<div><strong>{a.name}</strong> <span className="muted small">{a.short}</span><div className="small muted">{a.desc}</div></div>
|
||||
<div><strong>{a.name}</strong> <span className="muted small">{a.short}</span><Tip icon text={explainAttr(a, ctx)} /><div className="small muted">{a.desc}</div></div>
|
||||
<div className="stepper">
|
||||
<button onClick={() => setV(Math.max(v - 5, cr.attr_start))} aria-label={`${a.name} minus 5`}>−5</button>
|
||||
<button onClick={() => setV(Math.max(v - 1, cr.attr_start))} aria-label={`${a.name} minus 1`}>−</button>
|
||||
<span className="val">{v}</span>
|
||||
<button onClick={() => setV(Math.min(v + 1, cap))} disabled={c.attrLeft <= 0} aria-label={`${a.name} plus 1`}>+</button>
|
||||
<button onClick={() => setV(Math.min(v + Math.min(5, c.attrLeft), cap))} disabled={c.attrLeft <= 0} aria-label={`${a.name} plus 5`}>+5</button>
|
||||
<span className="mod" title="Modifier">Mod {c.mods[a.id]}</span>
|
||||
<Tip text={explainAttr(a, ctx)}><span className="mod">Mod {c.mods[a.id]}</span></Tip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -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.
|
||||
</p>
|
||||
<div className="pools">
|
||||
<div className={`pool ${c.skillLeft < 0 ? 'over' : ''}`} title="Change the pool size in the rules preset on the Concept page">
|
||||
Points left: <strong>{c.skillLeft}</strong> / {cr.skill_points_default}
|
||||
<div className={`pool ${c.skillLeft < 0 ? 'over' : ''}`}>
|
||||
<Tip text={[...explainSkillPool(ctx), 'The pool size comes from the rules preset (Concept page).']}>Points left</Tip>: <strong>{c.skillLeft}</strong> / {cr.skill_points_default}
|
||||
</div>
|
||||
<div className={`pool ${c.profLeft < 0 ? 'over' : ''}`} title="Two picks on the same skill make it Expertise">
|
||||
Proficiency picks left: <strong>{c.profLeft}</strong> / {c.profAllowed}
|
||||
<div className={`pool ${c.profLeft < 0 ? 'over' : ''}`}>
|
||||
<Tip text={explainProfPool(ctx)}>Proficiency picks left</Tip>: <strong>{c.profLeft}</strong> / {c.profAllowed}
|
||||
</div>
|
||||
</div>
|
||||
<p className="small muted">
|
||||
@@ -283,7 +286,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
return (
|
||||
<div key={s.id} className={`skill ${s.usable ? '' : 'locked'}`} title={s.desc}>
|
||||
<div>
|
||||
<strong>{s.name}</strong> {s.placeholder && <Badge />}
|
||||
<strong>{s.name}</strong> {s.placeholder && <Badge />}<Tip icon text={explainSkill(s, ctx)} />
|
||||
<div className="small muted">
|
||||
{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
|
||||
<button onClick={() => setPts(s.points - 1)} aria-label={`${s.name} minus`}>−</button>
|
||||
<span className="val">{s.points}</span>
|
||||
<button onClick={() => setPts(s.points + 1)} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+</button>
|
||||
<span className="mod" title="Target number">{s.total}</span>
|
||||
<Tip text={explainSkill(s, ctx)}><span className="mod">{s.total}</span></Tip><span className="small muted" title="Most points you can add"> max {s.max}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -379,7 +382,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
<>
|
||||
<h2>Equipment</h2>
|
||||
<p className="muted">Pick everything your character carries. Ask your GM what is allowed. Use + and − on a tile to change the amount.</p>
|
||||
<div className={`pool ${overWeight ? 'over' : ''}`}>Carried: <strong>{c.weight}</strong> / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}</div>
|
||||
<div className={`pool ${overWeight ? 'over' : ''}`}><Tip text={explainCarry(ctx)}>Carried</Tip>: <strong>{c.weight}</strong> / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}</div>
|
||||
{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
|
||||
<div className="sheet-cols">
|
||||
<div>
|
||||
<h4>Attributes</h4>
|
||||
<table><tbody>{rules.attributes.map((a) => <tr key={a.id}><td>{a.name}</td><td>{c.attrs[a.id]}</td><td className="muted">Mod {c.mods[a.id]}</td></tr>)}</tbody></table>
|
||||
<table><tbody>{rules.attributes.map((a) => <tr key={a.id}><td><Tip text={explainAttr(a, ctx)}>{a.name}</Tip></td><td>{c.attrs[a.id]}</td><td className="muted">Mod {c.mods[a.id]}</td></tr>)}</tbody></table>
|
||||
<h4>Derived stats</h4>
|
||||
<table><tbody>{rules.derived.map((d) => <tr key={d.id}><td>{d.name}</td><td>{c.derived[d.id]}</td></tr>)}</tbody></table>
|
||||
<table><tbody>{rules.derived.map((d) => <tr key={d.id}><td><Tip text={explainDerived(d, ctx)}>{d.name}</Tip></td><td>{c.derived[d.id]}</td></tr>)}</tbody></table>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Talents</h4>
|
||||
<ul className="desclist">{rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => <li key={t.id}><strong>{t.name}</strong><div className="rowdesc">{t.desc}</div></li>)}</ul>
|
||||
{!ch.talents.length && <p>–</p>}
|
||||
<h4>Mutations / implants ({c.capacityUsed}/{c.capacityMax})</h4>
|
||||
<h4><Tip text={explainCapacity(ctx)}>Mutations / implants ({c.capacityUsed}/{c.capacityMax})</Tip></h4>
|
||||
<ul className="desclist">{rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => <li key={a.id}><strong>{a.name}</strong> <span className="muted small">cost {a.cost}</span><div className="rowdesc">{a.desc}</div></li>)}</ul>
|
||||
{!ch.augments.length && <p>–</p>}
|
||||
<h4>Equipment ({c.weight}/{c.carryMax} kg)</h4>
|
||||
<h4><Tip text={explainCarry(ctx)}>Equipment ({c.weight}/{c.carryMax} kg)</Tip></h4>
|
||||
<ul className="desclist">{gearLines.map((g) => <li key={g.item.id}><strong>{g.item.name}{g.qty > 1 ? ` x${g.qty}` : ''}</strong><div className="rowdesc">{[g.item.stat, `${Math.round(g.item.kg * g.qty * 10) / 10} kg`, g.item.note].filter(Boolean).join(' · ')}</div></li>)}</ul>
|
||||
{!gearLines.length && <p>–</p>}
|
||||
{ch.gear.other && <p className="small"><strong>Other:</strong> {ch.gear.other}</p>}
|
||||
@@ -460,7 +463,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
<table><tbody>
|
||||
{c.skills.filter((s) => s.group === g.id).map((s) => (
|
||||
<tr key={s.id} className={s.usable ? '' : 'dim'}>
|
||||
<td>{s.name}{s.type === 'advanced' && <span className="muted small"> (adv.)</span>}</td>
|
||||
<td><Tip text={explainSkill(s, ctx)}>{s.name}</Tip>{s.type === 'advanced' && <span className="muted small"> (adv.)</span>}</td>
|
||||
<td>{s.total}</td>
|
||||
<td className="muted">{s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.points ? `+${s.points}` : ''}</td>
|
||||
</tr>
|
||||
|
||||
@@ -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 }) {
|
||||
<table className="difftable"><tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.k} className={`d-${r.status} ${r.dim ? 'dim' : ''}`}>
|
||||
<td>{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}{r.label}{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
|
||||
<td>{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}<Tip text={r.tip}>{r.label}</Tip>{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
|
||||
<td>
|
||||
{r.status === 'changed' && r.old.val !== r.val && r.old.val !== undefined && <span className="was">{r.old.val} → </span>}
|
||||
{valueLabel ? valueLabel(r) : r.val}
|
||||
@@ -51,12 +52,12 @@ export default function SheetPanel({ ch, committed, baseRules, onApply, onDiscar
|
||||
<Rows now={now.skills} before={before.skills} />
|
||||
<h4>Talents</h4>
|
||||
<Rows now={now.talents} before={before.talents} />
|
||||
<h4>Mutations / implants <span className="muted small">{c.capacityUsed}/{c.capacityMax}</span></h4>
|
||||
<h4><Tip text={now.tips.capacity}>Mutations / implants <span className="muted small">{c.capacityUsed}/{c.capacityMax}</span></Tip></h4>
|
||||
<Rows now={now.augments} before={before.augments} />
|
||||
<h4>Equipment <span className="muted small">{c.weight}/{c.carryMax} kg</span></h4>
|
||||
<h4><Tip text={now.tips.carry}>Equipment <span className="muted small">{c.weight}/{c.carryMax} kg</span></Tip></h4>
|
||||
<Rows now={now.gear} before={before.gear} valueLabel={(r) => (r.val > 1 ? `x${r.val}` : '')} />
|
||||
<h4>Points left</h4>
|
||||
<div className="small muted">Attributes {c.attrLeft} · Skills {c.skillLeft} · Proficiency {c.profLeft}</div>
|
||||
<div className="small muted"><Tip text={now.tips.pools}>Attributes {c.attrLeft} · Skills {c.skillLeft} · Proficiency {c.profLeft}</Tip></div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<span ref={ref} tabIndex={0} className={icon ? 'tipwrap' : 'tipped'} aria-describedby={open ? id : undefined}
|
||||
onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide} onClick={show}
|
||||
onKeyDown={(e) => e.key === 'Escape' && hide()}>
|
||||
{children}{icon && <span className="tipicon" aria-label="Explanation">i</span>}
|
||||
</span>
|
||||
{open && createPortal(
|
||||
<div ref={pop} id={id} role="tooltip" className="tippop" style={{ left: pos.left, top: pos.top, visibility: pos.ready ? 'visible' : 'hidden' }}>
|
||||
{lines.map((l, i) => <div key={i}>{l}</div>)}
|
||||
</div>, document.body)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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.`]
|
||||
}
|
||||
@@ -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(' · ') })),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user