d1f180ee0a
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>
217 lines
6.5 KiB
TypeScript
217 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
}
|