NPC editor, NPC tokens from library, admin management
- NPC library (own + shared) built on the character wizard with a free NPC preset - NPC tokens in map editor and lobby, DM sheet on token click, DM checks for NPCs - Admins can promote/demote other users Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
+40
-17
@@ -14,9 +14,13 @@ function readDraft(key) {
|
||||
try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
|
||||
}
|
||||
|
||||
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName }) {
|
||||
const DRAFT = `aw_draft:${user}` // drafts are kept per account
|
||||
const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
|
||||
const NPC_KINDS = [['npc', 'NPC'], ['enemy', 'Enemy'], ['monster', 'Monster'], ['creature', 'Creature'], ['animal', 'Animal'], ['robot', 'Robot / machine']]
|
||||
|
||||
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName, npc = false }) {
|
||||
const DRAFT = `${npc ? 'aw_draft_npc' : 'aw_draft'}:${user}` // drafts are kept per account
|
||||
const freshCharacter = () => npc
|
||||
? { ...applyPreset(emptyCharacter(baseRules), baseRules, 'npc'), npc: true, npc_kind: 'enemy', token_size: 1, abilities: '', loot: '' }
|
||||
: applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
|
||||
const [ch, setCh] = useState(() => {
|
||||
const d = readDraft(DRAFT)
|
||||
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
|
||||
@@ -58,7 +62,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
if (!loadRequest) return
|
||||
if (loadRequest.new) { // "New character" button on the My characters page
|
||||
const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved
|
||||
if (untouched || window.confirm('Start a new character? Your current draft is discarded unless you saved it.')) {
|
||||
if (untouched || window.confirm('Start a new ' + (npc ? 'NPC' : 'character') + '? Your current draft is discarded unless you saved it.')) {
|
||||
setBoth(freshCharacter()); setSaved(null); setStep(0)
|
||||
}
|
||||
clearLoad()
|
||||
@@ -74,7 +78,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
// Direct link #creator/<id> (read only; saving creates a copy)
|
||||
useEffect(() => {
|
||||
const id = window.location.hash.split('/')[1]
|
||||
if (!id || loadRequest) return
|
||||
if (!id || loadRequest || npc) 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('Character not found, or it is not shared with you.'))
|
||||
@@ -94,7 +98,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
|
||||
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)
|
||||
setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation, ...(npc ? { npc: true, npc_kind: ch.npc_kind, token_size: ch.token_size, abilities: '', loot: '' } : {}) }); setSaved(null); setStep(0)
|
||||
}
|
||||
const resetThisStep = () => {
|
||||
if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return
|
||||
@@ -154,10 +158,25 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
const stepConcept = (
|
||||
<>
|
||||
<h2>Concept</h2>
|
||||
<div className="card presetbox">
|
||||
{npc && (
|
||||
<div className="card presetbox">
|
||||
<div className="grid2">
|
||||
<label>Type
|
||||
<select value={ch.npc_kind || 'npc'} onChange={(e) => set({ npc_kind: e.target.value })}>
|
||||
{NPC_KINDS.map(([id, label]) => <option key={id} value={id}>{label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Token size (grid squares)
|
||||
<input type="number" min="1" max="8" value={ch.token_size || 1} onChange={(e) => set({ token_size: Math.max(1, Math.min(8, Number(e.target.value) || 1)) })} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="small muted">NPCs use generous pools (attributes, skills, talents), so you can build anything from a rat to a boss.</p>
|
||||
</div>
|
||||
)}
|
||||
{!npc && <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>)}
|
||||
{(baseRules.presets || []).filter((p) => !p.npc).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
{ch.preset === 'custom' && <option value="custom">Custom</option>}
|
||||
</select>
|
||||
</label>
|
||||
@@ -167,11 +186,11 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
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>}
|
||||
<div className="card portraitbox"><PortraitEditor value={ch.portrait} onChange={(p) => set({ portrait: 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>
|
||||
<label>{npc ? 'NPC name' : 'Character name'}<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
|
||||
{!npc && <label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>}
|
||||
</div>
|
||||
<div className="grid2">
|
||||
<label>Class (optional)
|
||||
@@ -189,7 +208,9 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
</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>
|
||||
<label>{npc ? 'Description' : 'Background'}<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
|
||||
{npc && <label>Special abilities and attacks<textarea rows="5" value={ch.abilities || ''} onChange={(e) => set({ abilities: e.target.value })} placeholder="e.g. Bite 2d6, poison spit (range 10, VIT check or poisoned), regenerates 2 HP per round" /></label>}
|
||||
{npc && <label>Loot and drops<textarea rows="3" value={ch.loot || ''} onChange={(e) => set({ loot: e.target.value })} placeholder="e.g. 2 scrap, hide, rusty knife" /></label>}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -419,6 +440,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
)
|
||||
|
||||
const warnings = []
|
||||
if (npc) { if (c.attrLeft < 0) warnings.push('Too many attribute points spent.'); if (c.skillLeft < 0) warnings.push('More skill points spent than the pool allows.') } else {
|
||||
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.')
|
||||
@@ -432,6 +454,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
if (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
|
||||
if (over) warnings.push('Augmentation Capacity exceeded.')
|
||||
if (overWeight) warnings.push('Carrying more than the carry capacity.')
|
||||
}
|
||||
|
||||
const gearLines = kitItems(ch, rules)
|
||||
const stepSummary = (
|
||||
@@ -440,14 +463,14 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
|
||||
<PaperSheet data={ch} baseRules={baseRules} />
|
||||
<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>
|
||||
{!npc && <button onClick={() => download('pdf')}>Download as PDF</button>}
|
||||
{!npc && <button onClick={() => download('docx')}>Download as Word</button>}
|
||||
<button className="ghost" onClick={save}>{saved ? 'Save changes' : npc ? 'Save NPC' : '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 && (
|
||||
{saved && !npc && (
|
||||
<p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p>
|
||||
)}
|
||||
</>
|
||||
@@ -457,7 +480,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
|
||||
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>
|
||||
{!npc && <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)}>
|
||||
|
||||
Reference in New Issue
Block a user