Give NPCs their own editor section and unify the world-state boxes
NPCs are still part of stat_schema, but editing them inside the World State panel meant a nested dashed sub-box among four flat stat lists. They now get a top-level "Cast & NPCs" section: one card per NPC (avatar, name, npc.<id> address, triggers, description, its own stats) in a responsive grid, saved through the same schema path via the new NpcEditor/addNpc exports. The panel's real inconsistency was CSS, not layout: the global field rule keys off input[type="..."], which never matches the editor's typeless key and description inputs, so those rendered with browser defaults beside properly styled number boxes. One scoped base rule now covers the whole editor and every entry is the same tile with a caption over each control. Two bugs fell out of that: .se-band-n (0,1,0) always lost to input[type="number"] (0,1,1), so band bounds rendered full-width; and the exclusion has to be :not(:where([type="checkbox"])) because a plain :not() inherits its argument's specificity and swallows every override. In Play, NPCs group under a single "Cast" heading as compact plates instead of each becoming another top-level group in the rail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet
This commit is contained in:
co-authored by
Claude Opus 5
parent
d66fd1d6a2
commit
be6dbb0f24
+121
-64
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { npcInitials } from './components'
|
||||||
|
|
||||||
// Rebuild an object with one key renamed, preserving order. Returns null on a
|
// Rebuild an object with one key renamed, preserving order. Returns null on a
|
||||||
// no-op or a collision (so the caller keeps the old object).
|
// no-op or a collision (so the caller keeps the old object).
|
||||||
@@ -27,13 +28,24 @@ function KeyInput({ value, onCommit, placeholder }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every control in this editor wears the same tiny caption — that consistency
|
||||||
|
// is what keeps the mixed row types (stats, flags, milestones, NPCs) reading
|
||||||
|
// as one form rather than five.
|
||||||
|
function Field({ label, className = '', children }) {
|
||||||
|
return (
|
||||||
|
<label className={`se-field ${className}`}>
|
||||||
|
<span>{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Num({ label, value, onChange }) {
|
function Num({ label, value, onChange }) {
|
||||||
return (
|
return (
|
||||||
<label className="se-num">
|
<Field label={label} className="se-num">
|
||||||
<span>{label}</span>
|
|
||||||
<input type="number" value={value ?? ''}
|
<input type="number" value={value ?? ''}
|
||||||
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
|
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
|
||||||
</label>
|
</Field>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,18 +77,17 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
|
|||||||
onChange(next)
|
onChange(next)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="se-stat">
|
<div className="se-row">
|
||||||
<div className="se-row-top">
|
<div className="se-row-top">
|
||||||
<KeyInput value={statKey} onCommit={onRenameKey} placeholder="stat_name" />
|
<KeyInput value={statKey} onCommit={onRenameKey} placeholder="stat_name" />
|
||||||
<button type="button" className="se-remove" onClick={onRemove} title="Remove stat">✕</button>
|
<button type="button" className="se-remove" onClick={onRemove} title="Remove stat">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="se-fields">
|
<div className="se-fields">
|
||||||
{isText ? (
|
{isText ? (
|
||||||
<label className="se-num se-initial-text">
|
<Field label="initial" className="se-num se-initial-text">
|
||||||
<span>initial</span>
|
|
||||||
<input type="text" value={def.initial ?? ''}
|
<input type="text" value={def.initial ?? ''}
|
||||||
onChange={(e) => set('initial', e.target.value)} />
|
onChange={(e) => set('initial', e.target.value)} />
|
||||||
</label>
|
</Field>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Num label="min" value={def.min} onChange={(v) => set('min', v)} />
|
<Num label="min" value={def.min} onChange={(v) => set('min', v)} />
|
||||||
@@ -86,18 +97,19 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Num label="cooldown" value={def.cooldown} onChange={(v) => set('cooldown', v)} />
|
<Num label="cooldown" value={def.cooldown} onChange={(v) => set('cooldown', v)} />
|
||||||
<label className="se-num se-kind">
|
<Field label="kind" className="se-num se-kind">
|
||||||
<span>kind</span>
|
|
||||||
<select value={isText ? 'text' : (def.type === 'counter' ? 'counter' : 'number')}
|
<select value={isText ? 'text' : (def.type === 'counter' ? 'counter' : 'number')}
|
||||||
onChange={(e) => setType(e.target.value === 'number' ? undefined : e.target.value)}>
|
onChange={(e) => setType(e.target.value === 'number' ? undefined : e.target.value)}>
|
||||||
<option value="number">number</option>
|
<option value="number">number</option>
|
||||||
<option value="counter">counts up only</option>
|
<option value="counter">counts up only</option>
|
||||||
<option value="text">free text</option>
|
<option value="text">free text</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<input className="se-text" value={def.desc || ''} placeholder="description (shown to the AI)"
|
<Field label="description (shown to the AI)">
|
||||||
onChange={(e) => set('desc', e.target.value || undefined)} />
|
<input className="se-text" value={def.desc || ''} placeholder="e.g. how badly wounded you are"
|
||||||
|
onChange={(e) => set('desc', e.target.value || undefined)} />
|
||||||
|
</Field>
|
||||||
{!isText && (
|
{!isText && (
|
||||||
<div className="se-bands">
|
<div className="se-bands">
|
||||||
<div className="se-sub-head">Bands — low, high, label</div>
|
<div className="se-sub-head">Bands — low, high, label</div>
|
||||||
@@ -121,7 +133,7 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatSection({ title, defs, onChange, addLabel = '+ stat', nested }) {
|
function StatSection({ title, hint, defs, onChange, addLabel = '+ stat', nested }) {
|
||||||
const entries = Object.entries(defs || {})
|
const entries = Object.entries(defs || {})
|
||||||
const rename = (o, n) => { const x = withRenamedKey(defs, o, n); if (x) onChange(x) }
|
const rename = (o, n) => { const x = withRenamedKey(defs, o, n); if (x) onChange(x) }
|
||||||
const setDef = (k, val) => onChange({ ...defs, [k]: val })
|
const setDef = (k, val) => onChange({ ...defs, [k]: val })
|
||||||
@@ -137,6 +149,7 @@ function StatSection({ title, defs, onChange, addLabel = '+ stat', nested }) {
|
|||||||
<span className="se-section-title">{title}</span>
|
<span className="se-section-title">{title}</span>
|
||||||
<button type="button" className="se-add" onClick={add}>{addLabel}</button>
|
<button type="button" className="se-add" onClick={add}>{addLabel}</button>
|
||||||
</div>
|
</div>
|
||||||
|
{hint && <p className="se-hint">{hint}</p>}
|
||||||
{entries.length === 0 && <div className="se-empty">None yet.</div>}
|
{entries.length === 0 && <div className="se-empty">None yet.</div>}
|
||||||
{entries.map(([k, d]) => (
|
{entries.map(([k, d]) => (
|
||||||
<StatEditor key={k} statKey={k} def={d || {}}
|
<StatEditor key={k} statKey={k} def={d || {}}
|
||||||
@@ -148,44 +161,6 @@ function StatSection({ title, defs, onChange, addLabel = '+ stat', nested }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NpcSection({ npcs, onChange }) {
|
|
||||||
const entries = Object.entries(npcs || {})
|
|
||||||
const rename = (o, n) => { const x = withRenamedKey(npcs, o, n); if (x) onChange(x) }
|
|
||||||
const setNpc = (k, val) => onChange({ ...npcs, [k]: val })
|
|
||||||
const remove = (k) => { const x = { ...npcs }; delete x[k]; onChange(x) }
|
|
||||||
const add = () => {
|
|
||||||
let i = 1, key = 'npc'
|
|
||||||
while (npcs[key]) key = `npc${i++}`
|
|
||||||
onChange({ ...npcs, [key]: { name: '', keys: '', desc: '', stats: {} } })
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="se-section">
|
|
||||||
<div className="se-section-head">
|
|
||||||
<span className="se-section-title">NPCs</span>
|
|
||||||
<button type="button" className="se-add" onClick={add}>+ NPC</button>
|
|
||||||
</div>
|
|
||||||
{entries.length === 0 && <div className="se-empty">No NPCs.</div>}
|
|
||||||
{entries.map(([k, npc]) => (
|
|
||||||
<div key={k} className="se-npc">
|
|
||||||
<div className="se-row-top">
|
|
||||||
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="npc_id" />
|
|
||||||
<span className="schema-npc-id">npc.{k}</span>
|
|
||||||
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove NPC">✕</button>
|
|
||||||
</div>
|
|
||||||
<input className="se-text" value={npc.name || ''} placeholder="Display name"
|
|
||||||
onChange={(e) => setNpc(k, { ...npc, name: e.target.value })} />
|
|
||||||
<input className="se-text" value={npc.keys || ''} placeholder="trigger words, comma-separated (e.g. Gwen, ranger)"
|
|
||||||
onChange={(e) => setNpc(k, { ...npc, keys: e.target.value })} />
|
|
||||||
<input className="se-text" value={npc.desc || ''} placeholder="description (lore + shown to the AI)"
|
|
||||||
onChange={(e) => setNpc(k, { ...npc, desc: e.target.value })} />
|
|
||||||
<StatSection title="Stats" defs={npc.stats || {}} nested
|
|
||||||
onChange={(nd) => setNpc(k, { ...npc, stats: nd })} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FlagSection({ flags, onChange }) {
|
function FlagSection({ flags, onChange }) {
|
||||||
const entries = Object.entries(flags || {})
|
const entries = Object.entries(flags || {})
|
||||||
const rename = (o, n) => { const x = withRenamedKey(flags, o, n); if (x) onChange(x) }
|
const rename = (o, n) => { const x = withRenamedKey(flags, o, n); if (x) onChange(x) }
|
||||||
@@ -199,12 +174,13 @@ function FlagSection({ flags, onChange }) {
|
|||||||
return (
|
return (
|
||||||
<div className="se-section">
|
<div className="se-section">
|
||||||
<div className="se-section-head">
|
<div className="se-section-head">
|
||||||
<span className="se-section-title">Flags (on/off)</span>
|
<span className="se-section-title">Flags</span>
|
||||||
<button type="button" className="se-add" onClick={add}>+ flag</button>
|
<button type="button" className="se-add" onClick={add}>+ flag</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="se-hint">On/off switches the AI can flip either way — a door unlocked, an alarm raised.</p>
|
||||||
{entries.length === 0 && <div className="se-empty">No flags.</div>}
|
{entries.length === 0 && <div className="se-empty">No flags.</div>}
|
||||||
{entries.map(([k, f]) => (
|
{entries.map(([k, f]) => (
|
||||||
<div key={k} className="se-stat">
|
<div key={k} className="se-row">
|
||||||
<div className="se-row-top">
|
<div className="se-row-top">
|
||||||
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="flag_name" />
|
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="flag_name" />
|
||||||
<label className="se-check">
|
<label className="se-check">
|
||||||
@@ -214,8 +190,10 @@ function FlagSection({ flags, onChange }) {
|
|||||||
</label>
|
</label>
|
||||||
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove flag">✕</button>
|
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove flag">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<input className="se-text" value={f.desc || ''} placeholder="description"
|
<Field label="description (shown to the AI)">
|
||||||
onChange={(e) => setFlag(k, { ...f, desc: e.target.value })} />
|
<input className="se-text" value={f.desc || ''} placeholder="e.g. the cellar door is unlocked"
|
||||||
|
onChange={(e) => setFlag(k, { ...f, desc: e.target.value })} />
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -235,26 +213,104 @@ function MilestoneSection({ milestones, onChange }) {
|
|||||||
return (
|
return (
|
||||||
<div className="se-section">
|
<div className="se-section">
|
||||||
<div className="se-section-head">
|
<div className="se-section-head">
|
||||||
<span className="se-section-title">Milestones (sticky objectives)</span>
|
<span className="se-section-title">Milestones</span>
|
||||||
<button type="button" className="se-add" onClick={add}>+ milestone</button>
|
<button type="button" className="se-add" onClick={add}>+ milestone</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="se-hint">Objectives that stick once reached — they never un-tick on their own.</p>
|
||||||
{entries.length === 0 && <div className="se-empty">No milestones.</div>}
|
{entries.length === 0 && <div className="se-empty">No milestones.</div>}
|
||||||
{entries.map(([k, m]) => (
|
{entries.map(([k, m]) => (
|
||||||
<div key={k} className="se-stat">
|
<div key={k} className="se-row">
|
||||||
<div className="se-row-top">
|
<div className="se-row-top">
|
||||||
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="milestone_id" />
|
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="milestone_id" />
|
||||||
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove milestone">✕</button>
|
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove milestone">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<input className="se-text" value={m?.desc || ''} placeholder="objective description"
|
<Field label="objective">
|
||||||
onChange={(e) => setM(k, { ...m, desc: e.target.value })} />
|
<input className="se-text" value={m?.desc || ''} placeholder="e.g. escaped the bandit camp"
|
||||||
|
onChange={(e) => setM(k, { ...m, desc: e.target.value })} />
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form-based editor for a stat_schema. `schema` is the parsed object (or null);
|
// ---- NPCs ----------------------------------------------------------------
|
||||||
// `onChange(nextSchema)` fires on every edit. Empty sections are dropped.
|
// NPCs live inside the same stat_schema (`schema.npcs`) but get their own
|
||||||
|
// top-level section in the editor: each one is a small character sheet, which
|
||||||
|
// doesn't fit the flat stat rows the other sections use.
|
||||||
|
|
||||||
|
// Next `npcs` object with a fresh entry appended, and the id it used.
|
||||||
|
export function addNpc(npcs) {
|
||||||
|
let i = 1, key = 'npc'
|
||||||
|
while (npcs?.[key]) key = `npc${i++}`
|
||||||
|
return { ...(npcs || {}), [key]: { name: '', keys: '', desc: '', stats: {} } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function NpcCard({ npcId, npc, onRenameKey, onChange, onRemove }) {
|
||||||
|
const set = (field, val) => onChange({ ...npc, [field]: val })
|
||||||
|
const statCount = Object.keys(npc.stats || {}).length
|
||||||
|
return (
|
||||||
|
<div className="se-npc">
|
||||||
|
<div className="se-npc-head">
|
||||||
|
<span className="se-avatar" aria-hidden="true">{npcInitials(npc.name, npcId)}</span>
|
||||||
|
<div className="se-npc-ident">
|
||||||
|
<input className="se-npc-name" value={npc.name || ''} placeholder="Display name"
|
||||||
|
onChange={(e) => set('name', e.target.value)} />
|
||||||
|
<code className="se-npc-addr">npc.{npcId}</code>
|
||||||
|
</div>
|
||||||
|
<span className="se-npc-count">{statCount} {statCount === 1 ? 'stat' : 'stats'}</span>
|
||||||
|
<button type="button" className="se-remove" onClick={onRemove} title="Remove NPC">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="se-npc-body">
|
||||||
|
<Field label="id (how the AI addresses them)" className="se-npc-idfield">
|
||||||
|
<KeyInput value={npcId} onCommit={onRenameKey} placeholder="npc_id" />
|
||||||
|
</Field>
|
||||||
|
<Field label="trigger words, comma-separated">
|
||||||
|
<input className="se-text" value={npc.keys || ''} placeholder="Gwen, ranger, the scout"
|
||||||
|
onChange={(e) => set('keys', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="description (lore + shown to the AI)">
|
||||||
|
<textarea className="se-text se-area" rows={2} value={npc.desc || ''}
|
||||||
|
placeholder="A wary ranger who owes you a debt."
|
||||||
|
onChange={(e) => set('desc', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<StatSection title="Their stats" defs={npc.stats || {}} nested
|
||||||
|
onChange={(nd) => set('stats', nd)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The NPC roster. Takes/returns just the `npcs` slice of a stat_schema.
|
||||||
|
export function NpcEditor({ npcs, onChange }) {
|
||||||
|
const entries = Object.entries(npcs || {})
|
||||||
|
const rename = (o, n) => { const x = withRenamedKey(npcs, o, n); if (x) onChange(x) }
|
||||||
|
const setNpc = (k, val) => onChange({ ...npcs, [k]: val })
|
||||||
|
const remove = (k) => { const x = { ...npcs }; delete x[k]; onChange(x) }
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="empty" style={{ padding: '20px 0' }}>
|
||||||
|
No NPCs yet. Each one gets its own stats (trust, health, ferocity — whatever suits them),
|
||||||
|
and a story card is created automatically so they show up in context when mentioned.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="se se-npc-grid">
|
||||||
|
{entries.map(([k, npc]) => (
|
||||||
|
<NpcCard key={k} npcId={k} npc={npc || {}}
|
||||||
|
onRenameKey={(nk) => rename(k, nk)}
|
||||||
|
onChange={(n) => setNpc(k, n)}
|
||||||
|
onRemove={() => remove(k)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form-based editor for a stat_schema, minus the NPCs (see `NpcEditor`, which
|
||||||
|
// the scenario editor renders as its own page section). `schema` is the parsed
|
||||||
|
// object (or null); `onChange(nextSchema)` fires on every edit. Empty sections
|
||||||
|
// are dropped.
|
||||||
export default function SchemaEditor({ schema, onChange }) {
|
export default function SchemaEditor({ schema, onChange }) {
|
||||||
const s = schema && typeof schema === 'object' ? schema : {}
|
const s = schema && typeof schema === 'object' ? schema : {}
|
||||||
const setSection = (key, val) => {
|
const setSection = (key, val) => {
|
||||||
@@ -265,9 +321,10 @@ export default function SchemaEditor({ schema, onChange }) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="se">
|
<div className="se">
|
||||||
<StatSection title="World stats" defs={s.world || {}} onChange={(v) => setSection('world', v)} />
|
<StatSection title="World stats" defs={s.world || {}} onChange={(v) => setSection('world', v)}
|
||||||
<StatSection title="Player stats" defs={s.player || {}} onChange={(v) => setSection('player', v)} />
|
hint="Things about the situation, not any one character — time of day, the camp’s alert level." />
|
||||||
<NpcSection npcs={s.npcs || {}} onChange={(v) => setSection('npcs', v)} />
|
<StatSection title="Player stats" defs={s.player || {}} onChange={(v) => setSection('player', v)}
|
||||||
|
hint="The player’s own numbers — hp, gold, reputation — plus free-text ones like an outfit." />
|
||||||
<FlagSection flags={s.flags || {}} onChange={(v) => setSection('flags', v)} />
|
<FlagSection flags={s.flags || {}} onChange={(v) => setSection('flags', v)} />
|
||||||
<MilestoneSection milestones={s.milestones || {}} onChange={(v) => setSection('milestones', v)} />
|
<MilestoneSection milestones={s.milestones || {}} onChange={(v) => setSection('milestones', v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ function monogram(title) {
|
|||||||
return letters.join('').toUpperCase()
|
return letters.join('').toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Initials for an NPC's avatar disc — "Bandit Leader" → BL, "gwen" → GW.
|
||||||
|
*
|
||||||
|
* Deliberately not `monogram`: that one drops short words, which is right for
|
||||||
|
* scenario titles and wrong for names, and it has no id to fall back on.
|
||||||
|
*/
|
||||||
|
export function npcInitials(name, id) {
|
||||||
|
const src = String(name || id || '?').trim() || '?'
|
||||||
|
const words = src.split(/[\s_-]+/).filter(Boolean)
|
||||||
|
const letters = words.length > 1 ? words[0][0] + words[1][0] : src.slice(0, 2)
|
||||||
|
return letters.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
/** A scenario's plate: uploaded picture, else emoji, else generated art.
|
/** A scenario's plate: uploaded picture, else emoji, else generated art.
|
||||||
*
|
*
|
||||||
* `large` is for the Continue cards, where the plate carries more weight. The
|
* `large` is for the Continue cards, where the plate carries more weight. The
|
||||||
|
|||||||
+165
-30
@@ -641,6 +641,37 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
transition: width 0.35s ease;
|
transition: width 0.35s ease;
|
||||||
}
|
}
|
||||||
|
/* Cast — each NPC is a small plate inside the one "Cast" group, so a roster of
|
||||||
|
five doesn't read as five more unrelated sections in the rail. */
|
||||||
|
.ws-npc {
|
||||||
|
padding: 7px 9px;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 2px solid var(--accent-dim);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(255, 255, 255, 0.022);
|
||||||
|
}
|
||||||
|
.ws-npc:last-child { margin-bottom: 0; }
|
||||||
|
.ws-npc-head { display: flex; align-items: center; gap: 7px; margin-bottom: 3px; }
|
||||||
|
.ws-npc-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: #14100a;
|
||||||
|
background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim));
|
||||||
|
}
|
||||||
|
.ws-npc-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ws-none { font-size: 0.78rem; color: var(--text-dim); font-style: italic; }
|
||||||
.ws-milestones { list-style: none; margin: 0; padding: 0; }
|
.ws-milestones { list-style: none; margin: 0; padding: 0; }
|
||||||
.ws-milestones li {
|
.ws-milestones li {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -749,35 +780,47 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
|
|||||||
.schema-desc { color: var(--text-dim); font-size: 0.8rem; margin-top: 2px; }
|
.schema-desc { color: var(--text-dim); font-size: 0.8rem; margin-top: 2px; }
|
||||||
.schema-bands { color: var(--text-dim); font-size: 0.76rem; margin-top: 2px; font-style: italic; }
|
.schema-bands { color: var(--text-dim); font-size: 0.76rem; margin-top: 2px; font-style: italic; }
|
||||||
|
|
||||||
/* ---- Form-based schema editor ---- */
|
/* ---- Form-based schema editor ----
|
||||||
|
One shape for everything: a section is a panel, every entry inside it is a
|
||||||
|
`.se-row` tile, and every control wears a `.se-field` caption. Adding a new
|
||||||
|
kind of entry means reusing those three, not inventing a fourth box. */
|
||||||
.se-section {
|
.se-section {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
padding: 12px 14px;
|
padding: 13px 15px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
}
|
}
|
||||||
.se-section-nested {
|
.se-section-nested {
|
||||||
margin: 8px 0 0;
|
margin: 10px 0 0;
|
||||||
background: transparent;
|
padding: 11px 12px;
|
||||||
border-style: dashed;
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.se-section-head {
|
.se-section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.se-section-title {
|
.se-section-title {
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.se-hint {
|
||||||
|
margin: -2px 0 10px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.45;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
.se-empty { color: var(--text-dim); font-size: 0.82rem; padding: 4px 0; }
|
.se-empty { color: var(--text-dim); font-size: 0.82rem; padding: 4px 0; }
|
||||||
.se-add, .se-add-sm {
|
.se-add, .se-add-sm {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.se-add-sm { margin-top: 6px; }
|
.se-add-sm { margin-top: 6px; }
|
||||||
.se-remove {
|
.se-remove {
|
||||||
@@ -788,53 +831,145 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
|
|||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.se-remove:hover { color: var(--danger, #e5484d); }
|
.se-remove:hover { color: var(--danger, #e5484d); }
|
||||||
.se-stat, .se-npc {
|
|
||||||
padding: 8px 0;
|
/* Every control in the editor, one look. The global field rule keys off
|
||||||
border-bottom: 1px solid var(--border);
|
`input[type="…"]`, so the plain `<input>`s here (keys, descriptions) never
|
||||||
|
matched it and fell back to browser defaults — that mismatch is most of why
|
||||||
|
this panel used to read as a pile of unrelated boxes. Everything below is
|
||||||
|
qualified with `.se` so it outranks that global rule (0,1,1) too. */
|
||||||
|
/* `:where()` so the exclusion costs no specificity — a plain
|
||||||
|
`:not([type="checkbox"])` would rank this above the `.se .se-*` overrides
|
||||||
|
below and swallow every one of them. */
|
||||||
|
.se input:not(:where([type="checkbox"])), .se textarea, .se select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
.se-stat:last-child, .se-npc:last-child { border-bottom: none; }
|
|
||||||
.se-npc {
|
/* One entry — a stat, a flag, a milestone. */
|
||||||
|
.se-row {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.018);
|
||||||
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
.se-row-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
.se-row:last-child { margin-bottom: 0; }
|
||||||
.se-key {
|
.se-row:focus-within { border-color: var(--border-bright); }
|
||||||
|
/* Wraps because a phone-width row can't fit key + toggle + ✕ side by side, and
|
||||||
|
an input's default `min-width: auto` would push the ✕ off-screen instead. */
|
||||||
|
.se-row-top { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.se .se-key {
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
font-size: 0.85rem;
|
font-size: 0.82rem;
|
||||||
padding: 4px 8px;
|
min-width: 0;
|
||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
}
|
}
|
||||||
|
/* Only in the row context — `.se-field` is a *column* flex, where a basis of
|
||||||
|
140px would set the input's height instead of its width. */
|
||||||
|
.se .se-row-top .se-key { flex: 1 1 140px; }
|
||||||
|
.se-field { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.se-field > span {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
.se-fields {
|
.se-fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px 12px;
|
gap: 8px 12px;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.se-num { display: flex; flex-direction: column; gap: 2px; }
|
.se .se-num input, .se .se-num select { width: 72px; padding: 4px 6px; }
|
||||||
.se-num span { font-size: 0.7rem; color: var(--text-dim); }
|
.se .se-kind select { width: 132px; }
|
||||||
.se-num input, .se-num select { width: 72px; padding: 3px 6px; font-size: 0.82rem; border-radius: 6px; }
|
.se .se-initial-text input { width: 220px; max-width: 100%; }
|
||||||
.se-kind select { width: 118px; }
|
|
||||||
.se-initial-text input { width: 220px; }
|
|
||||||
.se-check {
|
.se-check {
|
||||||
display: flex; align-items: center; gap: 5px;
|
display: flex; align-items: center; gap: 5px;
|
||||||
font-size: 0.8rem; color: var(--text-dim);
|
font-size: 0.8rem; color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.se-check input { width: auto; }
|
.se-check input { width: auto; }
|
||||||
.se-text {
|
/* The global textarea rule also forces min-height:70px, which a 2-row inline
|
||||||
width: 100%;
|
field doesn't want. */
|
||||||
padding: 4px 8px;
|
.se .se-area { min-height: 0; line-height: 1.45; resize: vertical; }
|
||||||
font-size: 0.82rem;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.se-sub-head { font-size: 0.7rem; color: var(--text-dim); margin: 4px 0 4px; }
|
.se-sub-head { font-size: 0.7rem; color: var(--text-dim); margin: 4px 0 4px; }
|
||||||
.se-band { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; }
|
.se-band { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; }
|
||||||
.se-band-n { width: 64px; padding: 3px 6px; font-size: 0.82rem; }
|
/* Qualified so it outweighs the global input[type="number"] rule (0,1,1) —
|
||||||
.se-band-l { flex: 1; padding: 3px 6px; font-size: 0.82rem; }
|
as a bare class it lost, and the band bounds rendered full-width. */
|
||||||
|
.se .se-band-n { width: 64px; padding: 4px 6px; }
|
||||||
|
.se .se-band-l { flex: 1 1 60px; width: auto; min-width: 0; }
|
||||||
|
.se-bands { margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ---- NPC roster (its own page section) ---- */
|
||||||
|
.se-npc-grid {
|
||||||
|
display: grid;
|
||||||
|
/* min() so a 320px phone gets one column instead of a 340px overflow */
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.se-npc {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.se-npc:focus-within { border-color: var(--border-bright); }
|
||||||
|
.se-npc-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: linear-gradient(180deg, rgba(212, 169, 78, 0.07), transparent);
|
||||||
|
}
|
||||||
|
.se-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 34px; height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #14100a;
|
||||||
|
background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim));
|
||||||
|
}
|
||||||
|
.se-npc-ident { flex: 1; min-width: 0; }
|
||||||
|
.se .se-npc-name {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.se .se-npc-name:focus { background: var(--bg-input); border-color: var(--border-bright); }
|
||||||
|
.se-npc-addr {
|
||||||
|
display: block;
|
||||||
|
margin: 1px 0 0 6px;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.se-npc-count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.se-npc-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.se-npc-idfield .se-key { max-width: 100%; }
|
||||||
.ws-report { list-style: none; margin: 4px 0 0; padding: 0; font-size: 0.82rem; }
|
.ws-report { list-style: none; margin: 4px 0 0; padding: 0; font-size: 0.82rem; }
|
||||||
.ws-report li { padding: 2px 0; color: var(--text); }
|
.ws-report li { padding: 2px 0; color: var(--text); }
|
||||||
|
|
||||||
|
|||||||
+28
-10
@@ -2,7 +2,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'rea
|
|||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { AutoTextarea, Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
|
import { AutoTextarea, Field, StoryCardRow, downloadJSON, npcInitials, pickJSONFile, useToast } from '../components'
|
||||||
|
|
||||||
const MODES = ['do', 'say', 'story']
|
const MODES = ['do', 'say', 'story']
|
||||||
const PLAYER_TYPES = ['do', 'say', 'story']
|
const PLAYER_TYPES = ['do', 'say', 'story']
|
||||||
@@ -703,11 +703,16 @@ function StatRow({ name, def, value, editing, onChange }) {
|
|||||||
|
|
||||||
// `values`/`draft` are both plain {statName: value} maps — `draft` (edit mode)
|
// `values`/`draft` are both plain {statName: value} maps — `draft` (edit mode)
|
||||||
// is a slice of the drawer's flat path->value map for this group's prefix.
|
// is a slice of the drawer's flat path->value map for this group's prefix.
|
||||||
function StatGroup({ title, defs, values, desc, editing, draft, onEdit }) {
|
// `nested` = the caller already drew a heading (an NPC card), so this drops the
|
||||||
|
// group's own spacing and says "nothing here" rather than vanishing and leaving
|
||||||
|
// that heading dangling over empty space.
|
||||||
|
function StatGroup({ title, defs, values, desc, editing, draft, onEdit, nested }) {
|
||||||
const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
|
const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
|
||||||
if (entries.length === 0) return null
|
if (entries.length === 0) {
|
||||||
|
return nested ? <div className="ws-none">No tracked stats.</div> : null
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="ws-group">
|
<div className={nested ? 'ws-stats' : 'ws-group'}>
|
||||||
{title && <h3 className="ws-group-title" title={desc || undefined}>{title}</h3>}
|
{title && <h3 className="ws-group-title" title={desc || undefined}>{title}</h3>}
|
||||||
{entries.map(([name, def]) => (
|
{entries.map(([name, def]) => (
|
||||||
<StatRow key={name} name={name} def={def}
|
<StatRow key={name} name={name} def={def}
|
||||||
@@ -835,12 +840,25 @@ function WorldStateDrawer({ advId, refreshKey }) {
|
|||||||
<StatGroup title="You" defs={schema.player} values={state.player}
|
<StatGroup title="You" defs={schema.player} values={state.player}
|
||||||
editing={editing} draft={sliceDraft(draft, 'player')}
|
editing={editing} draft={sliceDraft(draft, 'player')}
|
||||||
onEdit={(name, v) => setPath(`player.${name}`, v)} />
|
onEdit={(name, v) => setPath(`player.${name}`, v)} />
|
||||||
{npcs.map(([id, def]) => (
|
{npcs.length > 0 && (
|
||||||
<StatGroup key={id} title={def.name || id} desc={def.desc}
|
<div className="ws-group">
|
||||||
defs={def.stats} values={npcState[id]}
|
<h3 className="ws-group-title">Cast</h3>
|
||||||
editing={editing} draft={sliceDraft(draft, `npc.${id}`)}
|
{npcs.map(([id, def]) => (
|
||||||
onEdit={(name, v) => setPath(`npc.${id}.${name}`, v)} />
|
<div key={id} className="ws-npc">
|
||||||
))}
|
<div className="ws-npc-head" title={def.desc || undefined}>
|
||||||
|
<span className="ws-npc-avatar" aria-hidden="true">
|
||||||
|
{npcInitials(def.name, id)}
|
||||||
|
</span>
|
||||||
|
<span className="ws-npc-name">{def.name || id}</span>
|
||||||
|
</div>
|
||||||
|
<StatGroup defs={def.stats} values={npcState[id]}
|
||||||
|
editing={editing} draft={sliceDraft(draft, `npc.${id}`)}
|
||||||
|
onEdit={(name, v) => setPath(`npc.${id}.${name}`, v)}
|
||||||
|
nested />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{flags.length > 0 && (
|
{flags.length > 0 && (
|
||||||
<div className="ws-group">
|
<div className="ws-group">
|
||||||
<h3 className="ws-group-title">Flags</h3>
|
<h3 className="ws-group-title">Flags</h3>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom'
|
|||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
|
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
|
||||||
import ArtPicker from '../ArtPicker'
|
import ArtPicker from '../ArtPicker'
|
||||||
import SchemaEditor from '../SchemaEditor'
|
import SchemaEditor, { NpcEditor, addNpc } from '../SchemaEditor'
|
||||||
|
|
||||||
export default function ScenarioEditor() {
|
export default function ScenarioEditor() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
@@ -106,6 +106,15 @@ export default function ScenarioEditor() {
|
|||||||
debounceSave('stat_schema', () => saveSchema(cleaned))
|
debounceSave('stat_schema', () => saveSchema(cleaned))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NPCs live in the same stat_schema, but are edited in their own page
|
||||||
|
// section — so this is just the `npcs` slice of the same save path.
|
||||||
|
const setNpcs = (npcs) => {
|
||||||
|
const next = { ...(parsedSchema || {}) }
|
||||||
|
if (npcs && Object.keys(npcs).length) next.npcs = npcs
|
||||||
|
else delete next.npcs
|
||||||
|
applySchema(next)
|
||||||
|
}
|
||||||
|
|
||||||
const addCard = async () => {
|
const addCard = async () => {
|
||||||
const card = await api.createStoryCard({ scenario_id: Number(id) })
|
const card = await api.createStoryCard({ scenario_id: Number(id) })
|
||||||
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
|
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
|
||||||
@@ -243,10 +252,10 @@ export default function ScenarioEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
|
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
|
||||||
Optional. Define stats (with bands and rules), NPCs, flags, and milestones, and
|
Optional. Define stats (with bands and rules), flags, and milestones, and the AI
|
||||||
the AI will track them each turn — HP, mana, an NPC’s trust, quest objectives.
|
will track them each turn — HP, mana, a raised alarm, quest objectives. NPCs are
|
||||||
Each NPC has its own name, trigger keys, description, and its own stats; a story
|
part of this same schema but have their own section below. Leave blank for a plain
|
||||||
card is created for it automatically. Leave blank for a plain narrative scenario.
|
narrative scenario.
|
||||||
</p>
|
</p>
|
||||||
{schemaView === 'form' ? (
|
{schemaView === 'form' ? (
|
||||||
<SchemaEditor schema={parsedSchema} onChange={applySchema} />
|
<SchemaEditor schema={parsedSchema} onChange={applySchema} />
|
||||||
@@ -272,6 +281,27 @@ export default function ScenarioEditor() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="page-header" style={{ marginTop: 28 }}>
|
||||||
|
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Cast & NPCs</h2>
|
||||||
|
{schemaView === 'form' && (
|
||||||
|
<button onClick={() => setNpcs(addNpc(parsedSchema?.npcs))}>+ Add NPC</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
|
||||||
|
Characters the AI tracks by name. Each has its own trigger words, description and
|
||||||
|
stats — a dragon’s <em>ferocity</em>, a ranger’s <em>trust</em> — addressed as
|
||||||
|
<code> npc.<id>.<stat></code>. Adding one here also creates a story card
|
||||||
|
for it when an adventure starts.
|
||||||
|
</p>
|
||||||
|
{schemaView === 'form' ? (
|
||||||
|
<NpcEditor npcs={parsedSchema?.npcs || {}} onChange={setNpcs} />
|
||||||
|
) : (
|
||||||
|
<div className="empty" style={{ padding: '20px 0' }}>
|
||||||
|
NPCs are part of the schema JSON above — switch to <strong>Editor</strong> to edit
|
||||||
|
them as cards.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="page-header" style={{ marginTop: 28 }}>
|
<div className="page-header" style={{ marginTop: 28 }}>
|
||||||
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Attached Scripts</h2>
|
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Attached Scripts</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user