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
+388
View File
@@ -0,0 +1,388 @@
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type { SheetWidget, SystemDefinition, WidgetType } from '../shared/types.ts';
import { WidgetPreview } from './WidgetPreview.tsx';
const PALETTE: Array<{ type: WidgetType; label: string; defaultBind?: string; w: number; h: number }> = [
{ type: 'section', label: 'Section', w: 220, h: 40 },
{ type: 'number', label: 'Number', defaultBind: 'attr.str', w: 110, h: 72 },
{ type: 'modBadge', label: 'Mod badge', defaultBind: 'derived.strMod', w: 110, h: 72 },
{ type: 'dots', label: 'Dots', defaultBind: 'attr.str', w: 160, h: 72 },
{ type: 'track', label: 'Track / HP', defaultBind: 'resource.hp', w: 220, h: 80 },
{ type: 'skillList', label: 'Skill list', w: 280, h: 240 },
{ type: 'notes', label: 'Notes', defaultBind: 'meta.notes', w: 280, h: 140 },
{ type: 'locationBar', label: 'Armor zones', w: 360, h: 90 },
{ type: 'text', label: 'Text', defaultBind: 'meta.title', w: 200, h: 60 },
];
type Props = {
system: SystemDefinition;
onChange: (next: SystemDefinition) => void;
locale: 'ru' | 'en';
};
function snap(n: number, grid = 8): number {
return Math.round(n / grid) * grid;
}
export function DesignerWorkspace({ system, onChange, locale }: Props) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const dragRef = useRef<{
id: string;
mode: 'move' | 'resize';
startX: number;
startY: number;
orig: { x: number; y: number; w: number; h: number };
} | null>(null);
const page = system.sheetLayout.pages[pageIndex] ?? system.sheetLayout.pages[0];
const selected = page?.widgets.find((w) => w.id === selectedId) ?? null;
function updateWidgets(mutator: (widgets: SheetWidget[]) => SheetWidget[]) {
if (!page) return;
const pages = system.sheetLayout.pages.map((p, i) =>
i === pageIndex ? { ...p, widgets: mutator(p.widgets) } : p,
);
onChange({ ...system, sheetLayout: { ...system.sheetLayout, pages } });
}
function addWidget(item: (typeof PALETTE)[number]) {
if (!page) return;
const id = `w_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const widget: SheetWidget = {
id,
type: item.type,
bind: item.defaultBind,
frame: {
x: snap(24 + (page.widgets.length % 5) * 16),
y: snap(24 + (page.widgets.length % 7) * 16),
w: item.w,
h: item.h,
},
props: {
label: item.label,
title: item.label,
max: item.type === 'dots' ? 5 : 20,
},
};
updateWidgets((ws) => [...ws, widget]);
setSelectedId(id);
}
function onPointerDownWidget(e: ReactPointerEvent, id: string, mode: 'move' | 'resize') {
e.stopPropagation();
e.preventDefault();
const w = page?.widgets.find((x) => x.id === id);
if (!w) return;
setSelectedId(id);
dragRef.current = {
id,
mode,
startX: e.clientX,
startY: e.clientY,
orig: { ...w.frame },
};
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
}
function onPointerMove(e: ReactPointerEvent) {
const d = dragRef.current;
if (!d) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
updateWidgets((ws) =>
ws.map((w) => {
if (w.id !== d.id) return w;
if (d.mode === 'move') {
return {
...w,
frame: {
...w.frame,
x: snap(Math.max(0, d.orig.x + dx)),
y: snap(Math.max(0, d.orig.y + dy)),
},
};
}
return {
...w,
frame: {
...w.frame,
w: snap(Math.max(48, d.orig.w + dx)),
h: snap(Math.max(36, d.orig.h + dy)),
},
};
}),
);
}
function onPointerUp() {
dragRef.current = null;
}
function patchSelected(patch: Partial<SheetWidget> & { props?: Record<string, unknown> }) {
if (!selected) return;
updateWidgets((ws) =>
ws.map((w) =>
w.id === selected.id
? {
...w,
...patch,
props: { ...w.props, ...patch.props },
}
: w,
),
);
}
function removeSelected() {
if (!selected) return;
updateWidgets((ws) => ws.filter((w) => w.id !== selected.id));
setSelectedId(null);
}
function addBrick(kind: 'attributes' | 'skills' | 'resources' | 'derived') {
const key = `new_${Date.now().toString(36).slice(-4)}`;
const bricks = { ...system.bricks };
if (kind === 'attributes') {
bricks.attributes = [
...(bricks.attributes ?? []),
{ key, label: key.toUpperCase(), scale: 'numeric', min: 1, max: 20, default: 10 },
];
} else if (kind === 'skills') {
bricks.skills = [
...(bricks.skills ?? []),
{ key, label: key, scale: 'numeric', default: 0 },
];
} else if (kind === 'resources') {
bricks.resources = [
...(bricks.resources ?? []),
{ key, label: key, min: 0, default: 0, track: true },
];
} else {
bricks.derived = [
...(bricks.derived ?? []),
{
key,
label: key,
formula: 'floor((attr.str - 10) / 2)',
dependsOn: ['attr.str'],
},
];
}
onChange({ ...system, bricks });
}
function removeBrick(kind: 'attributes' | 'skills' | 'resources' | 'derived', key: string) {
const bricks = { ...system.bricks };
if (kind === 'attributes') bricks.attributes = (bricks.attributes ?? []).filter((a) => a.key !== key);
if (kind === 'skills') bricks.skills = (bricks.skills ?? []).filter((a) => a.key !== key);
if (kind === 'resources') bricks.resources = (bricks.resources ?? []).filter((a) => a.key !== key);
if (kind === 'derived') bricks.derived = (bricks.derived ?? []).filter((a) => a.key !== key);
onChange({ ...system, bricks });
}
const bindOptions: string[] = [];
for (const a of system.bricks.attributes ?? []) bindOptions.push(`attr.${a.key}`);
for (const s of system.bricks.skills ?? []) bindOptions.push(`skill.${s.key}`);
for (const r of system.bricks.resources ?? []) bindOptions.push(`resource.${r.key}`);
for (const d of system.bricks.derived ?? []) bindOptions.push(`derived.${d.key}`);
bindOptions.push('meta.notes', 'meta.title');
return (
<div className="designerGrid" data-testid="designer-workspace">
<div className="panel designerSide">
<div className="sideTitle">{locale === 'ru' ? 'Палитра' : 'Palette'}</div>
<div className="palette">
{PALETTE.map((item) => (
<button
key={item.type + item.label}
type="button"
className="paletteItem"
data-testid={`palette-${item.type}`}
onClick={() => addWidget(item)}
>
{item.label}
</button>
))}
</div>
<div className="sideTitle">{locale === 'ru' ? 'Кирпичики' : 'Bricks'}</div>
<div className="row wrap">
<button type="button" onClick={() => addBrick('attributes')}>+ Attr</button>
<button type="button" onClick={() => addBrick('skills')}>+ Skill</button>
<button type="button" onClick={() => addBrick('resources')}>+ Resource</button>
<button type="button" onClick={() => addBrick('derived')}>+ Derived</button>
</div>
<div className="brickList">
{(system.bricks.attributes ?? []).map((a) => (
<div key={a.key} className="brickRow">
<code>attr.{a.key}</code>
<button type="button" onClick={() => removeBrick('attributes', a.key)}>×</button>
</div>
))}
{(system.bricks.skills ?? []).map((a) => (
<div key={a.key} className="brickRow">
<code>skill.{a.key}</code>
<button type="button" onClick={() => removeBrick('skills', a.key)}>×</button>
</div>
))}
{(system.bricks.resources ?? []).map((a) => (
<div key={a.key} className="brickRow">
<code>resource.{a.key}</code>
<button type="button" onClick={() => removeBrick('resources', a.key)}>×</button>
</div>
))}
{(system.bricks.derived ?? []).map((a) => (
<div key={a.key} className="brickRow">
<code>derived.{a.key}</code>
<button type="button" onClick={() => removeBrick('derived', a.key)}>×</button>
</div>
))}
</div>
</div>
<div
className="designerCanvasHost"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={() => setSelectedId(null)}
>
<div className="row" style={{ marginBottom: 8 }}>
{system.sheetLayout.pages.map((p, i) => (
<button
key={p.id}
type="button"
className={i === pageIndex ? 'primary' : ''}
onClick={(e) => {
e.stopPropagation();
setPageIndex(i);
}}
>
{p.label}
</button>
))}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const id = `page_${Date.now().toString(36)}`;
onChange({
...system,
sheetLayout: {
...system.sheetLayout,
pages: [
...system.sheetLayout.pages,
{ id, label: `Page ${system.sheetLayout.pages.length + 1}`, widgets: [] },
],
},
});
setPageIndex(system.sheetLayout.pages.length);
}}
>
+ Page
</button>
</div>
<div className="sheetCanvas designMode" data-testid="sheet-canvas">
{(page?.widgets ?? []).map((w) => (
<div
key={w.id}
className={`widget${selectedId === 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={(e) => {
e.stopPropagation();
setSelectedId(w.id);
}}
onPointerDown={(e) => onPointerDownWidget(e, w.id, 'move')}
>
<div className="widgetChrome">{w.type}{w.bind ? ` · ${w.bind}` : ''}</div>
<div className="widgetBody">
<WidgetPreview widget={w} system={system} />
</div>
<div
className="resizeHandle"
onPointerDown={(e) => onPointerDownWidget(e, w.id, 'resize')}
/>
</div>
))}
</div>
</div>
<div className="panel designerSide">
<div className="sideTitle">{locale === 'ru' ? 'Свойства' : 'Properties'}</div>
{!selected ? (
<div className="muted">{locale === 'ru' ? 'Выберите виджет' : 'Select a widget'}</div>
) : (
<div className="propsForm">
<label>
Type
<input value={selected.type} disabled />
</label>
<label>
Bind
<select
data-testid="widget-bind"
value={selected.bind ?? ''}
onChange={(e) => patchSelected({ bind: e.target.value || undefined })}
>
<option value="">—</option>
{bindOptions.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</label>
<label>
Label
<input
value={String(selected.props?.label ?? selected.props?.title ?? '')}
onChange={(e) => patchSelected({ props: { label: e.target.value, title: e.target.value } })}
/>
</label>
<label>
Max
<input
type="number"
value={Number(selected.props?.max ?? 0)}
onChange={(e) => patchSelected({ props: { max: Number(e.target.value) } })}
/>
</label>
<button type="button" data-testid="remove-widget" onClick={removeSelected}>
{locale === 'ru' ? 'Удалить виджет' : 'Remove widget'}
</button>
</div>
)}
<div className="sideTitle" style={{ marginTop: 16 }}>
System
</div>
<label className="propsForm">
ID
<input
data-testid="system-id"
value={system.id}
onChange={(e) => onChange({ ...system, id: e.target.value.trim() || system.id })}
/>
</label>
<label className="propsForm">
Edition
<input
value={system.edition ?? ''}
onChange={(e) => onChange({ ...system, edition: e.target.value })}
/>
</label>
<label className="propsForm">
Description
<textarea
rows={4}
value={system.description ?? ''}
onChange={(e) => onChange({ ...system, description: e.target.value })}
/>
</label>
</div>
</div>
);
}