Initial TTRPG character meta-builder with visual sheet editor.

Offline Electron app seeds 10 system presets, supports create-from-scratch designer with palette/drag/resize/bricks, and exports .ttrpg-system.json for the player.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-12 14:49:31 +08:00
commit d1f180ee0a
44 changed files with 10548 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
import type { SystemDefinition, CharacterValues, SheetWidget } from '../shared/types.ts';
import { computeDerived } from '../shared/derived.ts';
type Props = {
system: SystemDefinition;
values: CharacterValues;
mode: 'design' | 'fill' | 'preview';
selectedWidgetId?: string | null;
onSelectWidget?: (id: string) => void;
onChangeValue?: (path: string, value: unknown) => void;
};
function widgetContent(
widget: SheetWidget,
system: SystemDefinition,
values: CharacterValues,
mode: Props['mode'],
onChangeValue?: Props['onChangeValue'],
) {
const bind = widget.bind ?? '';
const [scope, key] = bind.split('.');
const editable = mode === 'fill';
if (widget.type === 'section') {
return <strong>{String(widget.props?.title ?? widget.id)}</strong>;
}
if (widget.type === 'modBadge' || widget.type === 'number' || bind.startsWith('derived.')) {
const derived = values.derived ?? {};
let display: string | number = '—';
if (scope === 'attr') display = (values.attributes?.[key!] as number) ?? '—';
if (scope === 'resource') display = values.resources?.[key!] ?? '—';
if (scope === 'derived') display = derived[key!] ?? '—';
if (editable && scope === 'attr' && typeof display === 'number') {
return (
<label>
<div className="muted">{key}</div>
<input
data-testid={`field-${bind}`}
type="number"
value={display}
onChange={(e) => onChangeValue?.(bind, Number(e.target.value))}
/>
</label>
);
}
return (
<div data-testid={`field-${bind}`}>
<div className="muted">{String(widget.props?.label ?? key ?? widget.type)}</div>
<div style={{ fontSize: 22, fontWeight: 700 }}>{display}</div>
</div>
);
}
if (widget.type === 'dots') {
const max = Number(widget.props?.max ?? 5);
const current =
scope === 'attr'
? Number(values.attributes?.[key!] ?? 0)
: scope === 'skill'
? Number(values.skills?.[key!] ?? 0)
: 0;
return (
<div>
<div className="muted">{String(widget.props?.label ?? key)}</div>
<div className="row" data-testid={`field-${bind}`}>
{Array.from({ length: max }, (_, i) => (
<button
key={i}
type="button"
disabled={!editable}
style={{
width: 18,
height: 18,
borderRadius: '50%',
padding: 0,
background: i < current ? 'var(--accent2)' : 'transparent',
}}
onClick={() => onChangeValue?.(bind, i + 1 === current ? i : i + 1)}
/>
))}
</div>
</div>
);
}
if (widget.type === 'track' || widget.type === 'resourceBar') {
const v = values.resources?.[key!] ?? 0;
const max = Number(widget.props?.max ?? 10);
return (
<label>
<div className="muted">{String(widget.props?.label ?? key)}</div>
<input
data-testid={`field-${bind}`}
type="number"
disabled={!editable}
min={0}
max={max}
value={v}
onChange={(e) => onChangeValue?.(bind, Number(e.target.value))}
/>
<div
style={{
marginTop: 6,
height: 8,
background: 'var(--stroke)',
borderRadius: 4,
overflow: 'hidden',
}}
>
<div
style={{
width: `${Math.min(100, (v / max) * 100)}%`,
height: '100%',
background: 'var(--accent)',
}}
/>
</div>
</label>
);
}
if (widget.type === 'skillList') {
const skills = system.bricks.skills ?? [];
return (
<div data-testid="skill-list">
{skills.map((sk) => (
<div key={sk.key} className="row" style={{ justifyContent: 'space-between' }}>
<span>{sk.label}</span>
<input
data-testid={`field-skill.${sk.key}`}
type="number"
disabled={!editable}
style={{ width: 72 }}
value={Number(values.skills?.[sk.key] ?? sk.default ?? 0)}
onChange={(e) => onChangeValue?.(`skill.${sk.key}`, Number(e.target.value))}
/>
</div>
))}
</div>
);
}
if (widget.type === 'notes' || widget.type === 'text') {
const metaKey = key || 'notes';
const text = String(values.meta?.[metaKey] ?? '');
return (
<textarea
data-testid={`field-meta.${metaKey}`}
disabled={!editable}
value={text}
style={{ width: '100%', height: '100%', resize: 'none' }}
onChange={(e) => onChangeValue?.(`meta.${metaKey}`, e.target.value)}
/>
);
}
if (widget.type === 'locationBar') {
const zones = (widget.props?.zones as string[]) ?? ['head', 'body', 'arms', 'legs'];
return (
<div className="row" data-testid="location-bar">
{zones.map((z) => (
<label key={z}>
<div className="muted">{z}</div>
<input
type="number"
disabled={!editable}
style={{ width: 64 }}
value={Number((values.meta?.armor as Record<string, number> | undefined)?.[z] ?? 0)}
onChange={(e) => {
const armor = {
...((values.meta?.armor as Record<string, number>) ?? {}),
[z]: Number(e.target.value),
};
onChangeValue?.('meta.armor', armor);
}}
/>
</label>
))}
</div>
);
}
return <div className="muted">{widget.type}</div>;
}
export function SheetRenderer({
system,
values,
mode,
selectedWidgetId,
onSelectWidget,
onChangeValue,
}: Props) {
const page = system.sheetLayout.pages[0];
const derived = computeDerived(system.bricks.derived ?? [], values);
const merged: CharacterValues = { ...values, derived };
if (!page) return <div className="muted">No pages</div>;
return (
<div className="sheetCanvas" data-testid="sheet-canvas">
{page.widgets.map((w) => (
<div
key={w.id}
className={`widget${selectedWidgetId === w.id ? ' selected' : ''}`}
data-testid={`widget-${w.id}`}
style={{ left: w.frame.x, top: w.frame.y, width: w.frame.w, height: w.frame.h }}
onClick={() => onSelectWidget?.(w.id)}
>
{widgetContent(w, system, merged, mode, onChangeValue)}
</div>
))}
</div>
);
}