first commit
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
|
||||
import { rememberCharacter } from './Characters.jsx'
|
||||
import SheetPanel from './SheetPanel.jsx'
|
||||
|
||||
const DRAFT = 'aw_draft'
|
||||
const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary']
|
||||
const SOURCE = { noxium: 'Noxium', diesium: 'Diesium', hybrid: 'Hybrid', precyber: 'Pre-meteor cybernetics' }
|
||||
const Badge = () => <span className="badge" title="Not yet worked out in the brainstorm">Placeholder</span>
|
||||
|
||||
function readDraft() {
|
||||
try { return JSON.parse(localStorage.getItem(DRAFT)) } catch { return null }
|
||||
}
|
||||
|
||||
export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
|
||||
const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
|
||||
const [ch, setCh] = useState(() => {
|
||||
const d = readDraft()
|
||||
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
|
||||
})
|
||||
// `ch` is the working copy the pages edit; `committed` is the applied state shown as "before" in the side panel
|
||||
const [committed, setCommitted] = useState(() => {
|
||||
const d = readDraft()
|
||||
const src = d?.committed || d?.ch
|
||||
return src ? normalize(src, baseRules) : freshCharacter()
|
||||
})
|
||||
const setBoth = (x) => { setCh(x); setCommitted(x) }
|
||||
const pending = JSON.stringify(ch) !== JSON.stringify(committed)
|
||||
const apply = () => setCommitted(ch)
|
||||
const discard = () => setCh(committed)
|
||||
const [saved, setSaved] = useState(() => readDraft()?.saved || null) // {id, token}
|
||||
const [step, setStep] = useState(0)
|
||||
const [msg, setMsg] = useState('')
|
||||
const fileRef = useRef(null)
|
||||
|
||||
// Rules for this character: base rules plus the creation numbers of the chosen preset
|
||||
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch.creation]) // eslint-disable-line
|
||||
const c = useMemo(() => compute(ch, rules), [ch, rules])
|
||||
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)
|
||||
const set = (patch) => setCh((x) => ({ ...x, ...patch }))
|
||||
const cr = rules.creation
|
||||
|
||||
// Keep the draft in this browser
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(DRAFT, JSON.stringify({ ch, committed, saved })) } catch { /* ignore */ }
|
||||
}, [ch, committed, saved])
|
||||
|
||||
// Load a character picked in "My characters"
|
||||
useEffect(() => {
|
||||
if (!loadRequest) return
|
||||
fetch(`/api/characters/${loadRequest.id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
|
||||
.then((data) => { setBoth(normalize(data, baseRules)); setSaved({ id: loadRequest.id, token: loadRequest.token }); setStep(7) })
|
||||
.catch(() => setMsg('Could not load the character.'))
|
||||
.finally(clearLoad)
|
||||
}, [loadRequest]) // eslint-disable-line
|
||||
|
||||
// Direct link #creator/<id> (read only; saving creates a copy)
|
||||
useEffect(() => {
|
||||
const id = window.location.hash.split('/')[1]
|
||||
if (!id || loadRequest) return
|
||||
fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
|
||||
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') })
|
||||
.catch(() => setMsg('Shared character not found.'))
|
||||
}, []) // eslint-disable-line
|
||||
|
||||
// Moving between pages: pending edits are applied first (Next applies silently, other jumps ask).
|
||||
const go = (i, silent) => {
|
||||
if (i === step) return
|
||||
if (pending) {
|
||||
if (!silent && !window.confirm('You have changes that are not applied yet.\nOK = apply them and continue, Cancel = stay on this page.')) return
|
||||
apply()
|
||||
}
|
||||
setStep(Math.max(0, Math.min(STEPS.length - 1, i)))
|
||||
}
|
||||
|
||||
const flash = (t) => { setMsg(t); setTimeout(() => setMsg(''), 4000) }
|
||||
|
||||
const resetAll = () => {
|
||||
if (!window.confirm('Discard all input and start over?')) return
|
||||
setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation }); setSaved(null); setStep(0)
|
||||
}
|
||||
const resetThisStep = () => {
|
||||
if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return
|
||||
setCh((x) => resetStep(x, rules, step))
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
if (saved) {
|
||||
const r = await fetch(`/api/characters/${saved.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Edit-Token': saved.token }, body: JSON.stringify(ch) })
|
||||
if (!r.ok) throw new Error()
|
||||
rememberCharacter(saved.id, saved.token, ch.name)
|
||||
flash('Saved.')
|
||||
} else {
|
||||
const r = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) })
|
||||
if (!r.ok) throw new Error()
|
||||
const { id, token } = await r.json()
|
||||
setSaved({ id, token }); rememberCharacter(id, token, ch.name)
|
||||
flash('Saved. The share link is shown below.')
|
||||
}
|
||||
} catch { flash('Saving failed.') }
|
||||
}
|
||||
|
||||
const fileName = (ext) => `${(ch.name || 'Character').replace(/[^\w-]+/g, '_')}.${ext}`
|
||||
const download = async (fmt) => {
|
||||
const r = await fetch(`/api/export/${fmt}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) })
|
||||
if (!r.ok) return flash('Export failed.')
|
||||
const url = URL.createObjectURL(await r.blob())
|
||||
const a = document.createElement('a'); a.href = url; a.download = fileName(fmt); a.click(); URL.revokeObjectURL(url)
|
||||
}
|
||||
const exportJson = () => {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(ch, null, 2)], { type: 'application/json' }))
|
||||
const a = document.createElement('a'); a.href = url; a.download = fileName('json'); a.click(); URL.revokeObjectURL(url)
|
||||
}
|
||||
const importJson = (e) => {
|
||||
const f = e.target.files[0]; if (!f) return
|
||||
f.text().then((t) => { setBoth(normalize(JSON.parse(t), baseRules)); setSaved(null); flash('Imported.') }).catch(() => flash('Invalid file.'))
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
// Click on pip k (1 = Proficient, 2 = Expertise) of a skill: sets the skill's total pips to k, or one lower if already there.
|
||||
const clickPip = (s, k) => {
|
||||
const target = s.pips >= k ? k - 1 : k
|
||||
const wantFree = Math.max(0, target - s.profPip)
|
||||
if (wantFree - s.freePips > c.profLeft) return flash('No proficiency picks left.')
|
||||
set({ proficiency_picks: { ...ch.proficiency_picks, [s.id]: wantFree } })
|
||||
}
|
||||
|
||||
const toggle = (key, id, max) => {
|
||||
const cur = ch[key]
|
||||
if (cur.includes(id)) return set({ [key]: cur.filter((x) => x !== id) })
|
||||
if (max && cur.length >= max) return flash(`At most ${max} can be chosen.`)
|
||||
set({ [key]: [...cur, id] })
|
||||
}
|
||||
|
||||
// ---------- Steps ----------
|
||||
const stepConcept = (
|
||||
<>
|
||||
<h2>Concept</h2>
|
||||
<div className="card presetbox">
|
||||
<label>Rules preset
|
||||
<select value={ch.preset} onChange={(e) => e.target.value !== 'custom' && setCh((x) => applyPreset(x, baseRules, e.target.value))}>
|
||||
{(baseRules.presets || []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
{ch.preset === 'custom' && <option value="custom">Custom</option>}
|
||||
</select>
|
||||
</label>
|
||||
<p className="small muted">{ch.preset === 'custom' ? 'Custom values (from an older version of this character).' : baseRules.presets?.find((p) => p.id === ch.preset)?.desc}</p>
|
||||
{(() => {
|
||||
const diff = (baseRules.tunables || []).filter((x) => cr[x.key] !== baseRules.creation[x.key])
|
||||
return diff.length > 0 && <p className="small muted">Differs from Standard: {diff.map((x) => `${x.label} ${cr[x.key]}`).join(' · ')}</p>
|
||||
})()}
|
||||
<p className="small">Create or edit presets on the <a href="#presets">Presets</a> page.</p>
|
||||
</div>
|
||||
<div className="grid2">
|
||||
<label>Character name<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
|
||||
<label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>
|
||||
</div>
|
||||
<div className="grid2">
|
||||
<label>Class (optional)
|
||||
<select value={ch.class_id} onChange={(e) => set({ class_id: e.target.value, subclass_id: '' })}>
|
||||
<option value="">–</option>
|
||||
{rules.classes.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Sub-class
|
||||
<select value={ch.subclass_id} disabled={!ch.class_id} onChange={(e) => set({ subclass_id: e.target.value })}>
|
||||
<option value="">–</option>
|
||||
{(cls?.subclasses || []).map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{sub && <p className="small muted">{sub.desc}</p>}
|
||||
<label>Concept in one sentence<input value={ch.concept} onChange={(e) => set({ concept: e.target.value })} placeholder="e.g. Former militia fighter searching for his family" /></label>
|
||||
<label>Background<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
|
||||
</>
|
||||
)
|
||||
|
||||
const stepAttrs = (
|
||||
<>
|
||||
<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="attrs">
|
||||
{rules.attributes.map((a) => {
|
||||
const v = ch.attributes[a.id]
|
||||
const cap = Math.min(cr.attr_max, cr.attr_start + cr.attr_max_raise)
|
||||
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 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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const stepProfession = (
|
||||
<>
|
||||
<h2>Profession</h2>
|
||||
<p className="muted">Your profession gives +{cr.profession_attr_bonus} to one attribute and proficiencies (+{cr.proficiency_bonus} to the skill).</p>
|
||||
<div className="cards">
|
||||
{rules.professions.map((p) => (
|
||||
<button key={p.id} className={`card choice ${ch.profession === p.id ? 'on' : ''}`} onClick={() => setCh(applyProfession(ch, rules, p.id))}>
|
||||
<strong>{p.name}</strong> {p.placeholder && <Badge />}
|
||||
<span className="small">{p.desc}</span>
|
||||
{p.note && <span className="small muted">{p.note}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{prof && (
|
||||
<>
|
||||
<label>+{cr.profession_attr_bonus} to:
|
||||
<select value={ch.profession_bonus_attr} onChange={(e) => set({ profession_bonus_attr: e.target.value })}>
|
||||
{prof.bonus_attrs.length !== 1 && <option value="">Please choose</option>}
|
||||
{rules.attributes.filter((a) => !prof.bonus_attrs.length || prof.bonus_attrs.includes(a.id)).map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{prof.picks > 0 && (
|
||||
<>
|
||||
<h3>Choose {prof.picks} basic skills ({ch.profession_picks.length}/{prof.picks})</h3>
|
||||
<div className="chips">
|
||||
{rules.skills.filter((s) => s.type === 'basic').map((s) => {
|
||||
const on = ch.profession_picks.includes(s.id)
|
||||
const next = on ? ch.profession_picks.filter((x) => x !== s.id) : [...ch.profession_picks, s.id]
|
||||
return (
|
||||
<button key={s.id} className={`chip ${on ? 'on' : ''}`} disabled={!on && ch.profession_picks.length >= prof.picks}
|
||||
onClick={() => setCh(setProfessionPicks(ch, rules, next))}>{s.name}</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const stepSkills = (
|
||||
<>
|
||||
<h2>Skills</h2>
|
||||
<p className="muted">
|
||||
At most half the attribute per skill. Advanced skills must be unlocked first for {c.unlockCost} points from the pool
|
||||
(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>
|
||||
<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>
|
||||
</div>
|
||||
<p className="small muted">
|
||||
Proficiency adds +{cr.proficiency_bonus}. Two picks on the same skill make it Expertise (+{cr.expertise_bonus}); a profession proficiency counts as one of them.
|
||||
Proficiency in an advanced skill unlocks it for free. The talent "Versatile Training" grants 2 more picks.
|
||||
</p>
|
||||
{rules.skill_groups.map((g) => (
|
||||
<div key={g.id}>
|
||||
<h3>{g.name}</h3>
|
||||
<div className="skills">
|
||||
{c.skills.filter((s) => s.group === g.id).map((s) => {
|
||||
const setPts = (n) => set({ skill_points: { ...ch.skill_points, [s.id]: Math.max(0, Math.min(n, s.max)) } })
|
||||
return (
|
||||
<div key={s.id} className={`skill ${s.usable ? '' : 'locked'}`} title={s.desc}>
|
||||
<div>
|
||||
<strong>{s.name}</strong> {s.placeholder && <Badge />}
|
||||
<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'}
|
||||
</div>
|
||||
<div className="pips" title={`Proficiency: ${s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : 'none'}`}>
|
||||
<span className="small muted">Training</span>
|
||||
{[1, 2].map((k) => (
|
||||
<button key={k} className={`pip ${s.pips >= k ? 'on' : ''} ${s.profPip >= k ? 'from-prof' : ''}`}
|
||||
disabled={s.profPip >= k} onClick={() => clickPip(s, k)}
|
||||
title={s.profPip >= k ? 'From your profession' : k === 2 ? 'Expertise (uses 2 picks in total)' : 'Proficiency'}
|
||||
aria-label={`${s.name} proficiency ${k}`}>{s.pips >= k ? '◆' : '◇'}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!s.usable ? (
|
||||
<button className="unlock" onClick={() => set({ unlocked: [...ch.unlocked, s.id] })} disabled={c.skillLeft < c.unlockCost}
|
||||
title={c.skillLeft < c.unlockCost ? 'Not enough points left' : 'Unlock this advanced skill'}>
|
||||
Unlock ({c.unlockCost} pts)
|
||||
</button>
|
||||
) : (
|
||||
<div className="stepper">
|
||||
{s.type === 'advanced' && !s.free && (
|
||||
<button className="ghost small-btn" title={`Lock again and get ${c.unlockCost} points back`}
|
||||
onClick={() => setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 } }))}>Lock</button>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
const stepTalents = (
|
||||
<>
|
||||
<h2>Talents</h2>
|
||||
<p className="muted">Choose {cr.talents_min}-{cr.talents_max} starting talents. Chosen: {ch.talents.length}</p>
|
||||
{rules.talent_categories.map((cat) => {
|
||||
const ts = rules.talents.filter((t) => t.category === cat.id)
|
||||
if (!ts.length) return null
|
||||
return (
|
||||
<div key={cat.id}><h3>{cat.name}</h3>
|
||||
<div className="cards">{ts.map((t) => (
|
||||
<button key={t.id} className={`card choice ${ch.talents.includes(t.id) ? 'on' : ''}`} onClick={() => toggle('talents', t.id, cr.talents_max)}>
|
||||
<strong>{t.name}</strong> {t.placeholder && <Badge />}
|
||||
<span className="small">{t.desc}</span>
|
||||
</button>
|
||||
))}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
|
||||
const over = c.capacityUsed > c.capacityMax
|
||||
const stepAugments = (
|
||||
<>
|
||||
<h2>Mutations and implants</h2>
|
||||
<p className="muted">Optional. Both share the Augmentation Capacity. Exceeding it risks corruption or rejection.</p>
|
||||
<div className={`pool ${over ? 'over' : ''}`}>Capacity: <strong>{c.capacityUsed}</strong> / {c.capacityMax}{over && ' – exceeded, talk to your GM'}</div>
|
||||
{['mutation', 'implant'].map((kind) => (
|
||||
<div key={kind}><h3>{kind === 'mutation' ? 'Mutations' : 'Implants'}</h3>
|
||||
<div className="cards">{rules.augments.filter((a) => a.kind === kind).map((a) => (
|
||||
<button key={a.id} className={`card choice ${ch.augments.includes(a.id) ? 'on' : ''}`} onClick={() => toggle('augments', a.id)}>
|
||||
<strong>{a.name}</strong> {a.placeholder && <Badge />}
|
||||
<span className="small muted">{SOURCE[a.source] ? `${SOURCE[a.source]} · ` : ''}Cost {a.cost}</span>
|
||||
<span className="small">{a.desc}</span>
|
||||
</button>
|
||||
))}</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
// --- Equipment ---
|
||||
const kitRule = cr.kit
|
||||
const items = rules.equipment
|
||||
const catName = (id) => rules.equipment_categories.find((x) => x.id === id).name
|
||||
const setKit = (patch) => set({ kit: { ...ch.kit, ...patch } })
|
||||
const weaponItems = items.filter((e) => kitRule.weapon_cats.includes(e.cat))
|
||||
const armorItems = items.filter((e) => e.cat === 'armor' && kitRule.armor_tiers.includes(e.tier))
|
||||
const resourceItems = items.filter((e) => e.cat === 'resource')
|
||||
const toolItems = items.filter((e) => e.cat === 'tool')
|
||||
|
||||
const ItemCard = ({ e, on, onClick, disabled }) => (
|
||||
<button className={`card choice ${on ? 'on' : ''}`} onClick={onClick} disabled={disabled}>
|
||||
<strong>{e.name}</strong>
|
||||
<span className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg</span>
|
||||
{e.note && <span className="small">{e.note}</span>}
|
||||
</button>
|
||||
)
|
||||
const SingleSlot = ({ title, list, slot }) => (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
<div className="cards">{list.map((e) => (
|
||||
<ItemCard key={e.id} e={e} on={ch.kit[slot] === e.id} onClick={() => setKit({ [slot]: ch.kit[slot] === e.id ? '' : e.id })} />
|
||||
))}</div>
|
||||
</>
|
||||
)
|
||||
const setExtra = (id, qty) => {
|
||||
const extras = { ...ch.kit.extras }
|
||||
if (qty <= 0) delete extras[id]; else extras[id] = qty
|
||||
setKit({ extras })
|
||||
}
|
||||
const overWeight = c.weight > c.carryMax
|
||||
|
||||
const stepGear = (
|
||||
<>
|
||||
<h2>Equipment</h2>
|
||||
<p className="muted">Starting gear: one weapon (melee or firearm), light armor or clothing, {kitRule.resources_min}-{kitRule.resources_max} resources and one tool. Everything else can be added as extra items below.</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>
|
||||
<SingleSlot title="Weapon" list={weaponItems} slot="weapon" />
|
||||
<SingleSlot title="Armor or clothing" list={armorItems} slot="armor" />
|
||||
<h3>Resources ({ch.kit.resources.length}/{kitRule.resources_max})</h3>
|
||||
<div className="cards">{resourceItems.map((e) => {
|
||||
const on = ch.kit.resources.includes(e.id)
|
||||
return <ItemCard key={e.id} e={e} on={on} disabled={!on && ch.kit.resources.length >= kitRule.resources_max}
|
||||
onClick={() => setKit({ resources: on ? ch.kit.resources.filter((x) => x !== e.id) : [...ch.kit.resources, e.id] })} />
|
||||
})}</div>
|
||||
<SingleSlot title="Tool or utility item" list={toolItems} slot="tool" />
|
||||
|
||||
<h3>Extra items</h3>
|
||||
<p className="small muted">Anything else from the document's item lists. Ask your GM what you may take along.</p>
|
||||
{rules.equipment_categories.map((cat) => (
|
||||
<details key={cat.id}>
|
||||
<summary>{cat.name} <span className="muted small">({items.filter((e) => e.cat === cat.id).reduce((n, e) => n + (ch.kit.extras[e.id] || 0), 0)} chosen)</span></summary>
|
||||
<div className="extras">{items.filter((e) => e.cat === cat.id).map((e) => (
|
||||
<div key={e.id} className="extra">
|
||||
<div><strong>{e.name}</strong><div className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg{e.note ? ` · ${e.note}` : ''}</div></div>
|
||||
<div className="stepper">
|
||||
<button onClick={() => setExtra(e.id, (ch.kit.extras[e.id] || 0) - 1)} aria-label={`${e.name} minus`}>−</button>
|
||||
<span className="val">{ch.kit.extras[e.id] || 0}</span>
|
||||
<button onClick={() => setExtra(e.id, (ch.kit.extras[e.id] || 0) + 1)} aria-label={`${e.name} plus`}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
))}</div>
|
||||
</details>
|
||||
))}
|
||||
<label>Other items (free text)<textarea rows="3" value={ch.gear.other} onChange={(e) => set({ gear: { ...ch.gear, other: e.target.value } })} placeholder="Anything not in the lists, e.g. a unique starter multi-tool" /></label>
|
||||
<label>Notes<textarea rows="4" value={ch.notes} onChange={(e) => set({ notes: e.target.value })} /></label>
|
||||
</>
|
||||
)
|
||||
|
||||
const warnings = []
|
||||
if (c.attrLeft < 0) warnings.push('Too many attribute points spent.')
|
||||
if (c.attrLeft > 0) warnings.push(`${c.attrLeft} attribute points left.`)
|
||||
if (!ch.profession) warnings.push('No profession chosen.')
|
||||
else {
|
||||
if (!ch.profession_bonus_attr) warnings.push('No attribute chosen for the profession bonus.')
|
||||
if ((prof?.picks || 0) > ch.profession_picks.length) warnings.push('Profession proficiencies not fully chosen.')
|
||||
}
|
||||
if (c.skillLeft < 0) warnings.push('More skill points spent than the pool allows.')
|
||||
if (c.profLeft < 0) warnings.push('More proficiency picks used than you have.')
|
||||
if (c.profLeft > 0) warnings.push(`${c.profLeft} proficiency pick${c.profLeft > 1 ? 's' : ''} left.`)
|
||||
if (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
|
||||
if (over) warnings.push('Augmentation Capacity exceeded.')
|
||||
if (!ch.kit.weapon) warnings.push('No starting weapon chosen.')
|
||||
if (!ch.kit.armor) warnings.push('No armor or clothing chosen.')
|
||||
if (ch.kit.resources.length < kitRule.resources_min) warnings.push('No starting resource chosen.')
|
||||
if (!ch.kit.tool) warnings.push('No tool or utility item chosen.')
|
||||
if (overWeight) warnings.push('Carrying more than the carry capacity.')
|
||||
|
||||
const gearLines = kitItems(ch, rules)
|
||||
const stepSummary = (
|
||||
<>
|
||||
<h2>Summary</h2>
|
||||
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
|
||||
<div className="sheet card">
|
||||
<h3 className="sheet-name">{ch.name || 'Nameless'}</h3>
|
||||
<p className="muted">{ch.player && `Player: ${ch.player} · `}Profession: {prof?.name || '–'}{cls && ` · Class: ${[cls.name, sub?.name].filter(Boolean).join(' / ')}`}</p>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Skills</h4>
|
||||
<table><tbody>
|
||||
{c.skills.filter((s) => s.points || s.prof || (s.unlocked && s.type === 'advanced')).map((s) => <tr key={s.id}><td>{s.name}</td><td>{s.total}</td><td className="muted">{s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : ''}</td></tr>)}
|
||||
{!c.skills.some((s) => s.points || s.prof || s.unlocked) && <tr><td className="muted">None</td></tr>}
|
||||
</tbody></table>
|
||||
<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>
|
||||
<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>
|
||||
<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>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions wrap">
|
||||
<button onClick={() => download('pdf')}>Download as PDF</button>
|
||||
<button onClick={() => download('docx')}>Download as Word</button>
|
||||
<button className="ghost" onClick={save}>{saved ? 'Save changes' : 'Save on server'}</button>
|
||||
<button className="ghost" onClick={exportJson}>JSON backup</button>
|
||||
<button className="ghost" onClick={() => fileRef.current.click()}>Import JSON</button>
|
||||
<input ref={fileRef} type="file" accept="application/json" hidden onChange={importJson} />
|
||||
</div>
|
||||
{saved && (
|
||||
<p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const views = [stepConcept, stepAttrs, stepProfession, stepSkills, stepTalents, stepAugments, stepGear, stepSummary]
|
||||
|
||||
return (
|
||||
<div className="page creator">
|
||||
<p className="small muted presetchip">Rules: <button className="linklike" onClick={() => go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}</button></p>
|
||||
<ol className="steps">
|
||||
{STEPS.map((s, i) => (
|
||||
<li key={s}><button className={`${i === step ? 'on' : ''} ${i < step ? 'done' : ''}`} onClick={() => go(i)}>
|
||||
<span className="n">{i + 1}</span><span className="t">{s}</span></button></li>
|
||||
))}
|
||||
</ol>
|
||||
<div className={`creator-body ${step === STEPS.length - 1 ? 'wide' : ''}`}>
|
||||
<section className="step">
|
||||
{views[step]}
|
||||
{msg && <p className="toast" role="status">{msg}</p>}
|
||||
<div className="nav">
|
||||
<button className="ghost" onClick={() => go(step - 1)} disabled={step === 0}>Back</button>
|
||||
<span className="resets">
|
||||
{step < STEPS.length - 1 && <button className="ghost danger" onClick={resetThisStep}>Reset this page</button>}
|
||||
<button className="ghost danger" onClick={resetAll}>Start over</button>
|
||||
</span>
|
||||
<button onClick={() => go(step + 1, true)} disabled={step === STEPS.length - 1}>{pending ? 'Apply & next' : 'Next'}</button>
|
||||
</div>
|
||||
</section>
|
||||
{step < STEPS.length - 1 && <SheetPanel ch={ch} committed={committed} baseRules={baseRules} onApply={apply} onDiscard={discard} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user