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
+491
View File
@@ -0,0 +1,491 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { CharacterSheet, SystemDefinition } from '../shared/types.ts';
import { FORMAT_VERSION, SYSTEM_FORMAT, CHARACTER_FORMAT } from '../shared/types.ts';
import { computeDerived } from '../shared/derived.ts';
import { validateCreationRules } from '../shared/creation.ts';
import { SheetRenderer } from './SheetRenderer.tsx';
import { DesignerWorkspace } from './DesignerWorkspace.tsx';
import { t, type Locale } from './i18n.ts';
type Tab = 'systems' | 'designer' | 'fill';
declare global {
interface Window {
ttrpgBuilder: {
listSystems: () => Promise<Array<{ id: string; name: string; revision: number; schemaVersion: number }>>;
getSystem: (id: string) => Promise<SystemDefinition | null>;
upsertSystem: (system: SystemDefinition) => Promise<SystemDefinition>;
deleteSystem: (id: string) => Promise<boolean>;
exportSystem: (id: string) => Promise<string | null>;
importSystem: () => Promise<SystemDefinition | null>;
listPresets: () => Promise<string[]>;
loadPreset: (filePath: string) => Promise<SystemDefinition>;
seedPresets: () => Promise<number>;
listCharacters: (systemId?: string) => Promise<Array<{ id: string; name: string; systemId: string }>>;
getCharacter: (id: string) => Promise<CharacterSheet | null>;
upsertCharacter: (character: CharacterSheet) => Promise<CharacterSheet>;
deleteCharacter: (id: string) => Promise<boolean>;
};
}
}
function blankSystem(): SystemDefinition {
return {
format: SYSTEM_FORMAT,
formatVersion: FORMAT_VERSION,
id: `custom_${Date.now().toString(36)}`,
name: 'Новая система',
description: 'Система, созданная с нуля в визуальном редакторе.',
edition: 'custom',
schemaVersion: 1,
revision: 1,
bricks: {
attributes: [
{ key: 'str', label: 'STR', scale: 'numeric', min: 3, max: 18, default: 10, category: 'physical' },
{ key: 'dex', label: 'DEX', scale: 'numeric', min: 3, max: 18, default: 10, category: 'physical' },
{ key: 'con', label: 'CON', scale: 'numeric', min: 3, max: 18, default: 10, category: 'physical' },
{ key: 'int', label: 'INT', scale: 'numeric', min: 3, max: 18, default: 10, category: 'mental' },
{ key: 'wis', label: 'WIS', scale: 'numeric', min: 3, max: 18, default: 10, category: 'mental' },
{ key: 'cha', label: 'CHA', scale: 'numeric', min: 3, max: 18, default: 10, category: 'social' },
],
skills: [],
resources: [{ key: 'hp', label: 'HP', min: 0, default: 10, track: true }],
derived: [
{
key: 'strMod',
label: 'STR Mod',
formula: 'floor((attr.str - 10) / 2)',
dependsOn: ['attr.str'],
},
],
traits: [],
},
creationRules: [
{
id: 'pb',
type: 'point_buy',
appliesTo: 'attribute',
params: { budget: 27, keys: ['str', 'dex', 'con', 'int', 'wis', 'cha'] },
},
],
sheetLayout: {
pageSize: 'fluid',
grid: { size: 8 },
pages: [
{
id: 'main',
label: 'Main',
widgets: [
{
id: 'sec',
type: 'section',
frame: { x: 16, y: 16, w: 240, h: 40 },
props: { title: 'Attributes' },
},
{
id: 'w-str',
type: 'number',
bind: 'attr.str',
frame: { x: 16, y: 72, w: 100, h: 72 },
props: { label: 'STR' },
},
{
id: 'w-dex',
type: 'number',
bind: 'attr.dex',
frame: { x: 128, y: 72, w: 100, h: 72 },
props: { label: 'DEX' },
},
{
id: 'w-mod',
type: 'modBadge',
bind: 'derived.strMod',
frame: { x: 240, y: 72, w: 100, h: 72 },
props: { label: 'STR Mod' },
},
{
id: 'w-hp',
type: 'track',
bind: 'resource.hp',
frame: { x: 16, y: 168, w: 240, h: 80 },
props: { label: 'HP', max: 20 },
},
],
},
],
},
};
}
function defaultValues(system: SystemDefinition) {
const attributes: Record<string, number | string | boolean> = {};
for (const a of system.bricks.attributes ?? []) {
attributes[a.key] = typeof a.default === 'number' ? a.default : 10;
}
const resources: Record<string, number> = {};
for (const r of system.bricks.resources ?? []) {
resources[r.key] = r.default ?? 0;
}
const skills: Record<string, number | string | boolean> = {};
for (const s of system.bricks.skills ?? []) {
skills[s.key] = typeof s.default === 'number' ? s.default : 0;
}
return { attributes, resources, skills, meta: {} as Record<string, unknown> };
}
export function App() {
const [locale, setLocale] = useState<Locale>('ru');
const [tab, setTab] = useState<Tab>('systems');
const [systems, setSystems] = useState<
Array<{ id: string; name: string; revision: number; schemaVersion: number }>
>([]);
const [current, setCurrent] = useState<SystemDefinition | null>(null);
const [characterName, setCharacterName] = useState('Герой');
const [values, setValues] = useState(defaultValues(blankSystem()));
const [status, setStatus] = useState('');
const [booting, setBooting] = useState(true);
const refresh = useCallback(async () => {
if (!window.ttrpgBuilder) return;
setSystems(await window.ttrpgBuilder.listSystems());
}, []);
useEffect(() => {
void (async () => {
if (!window.ttrpgBuilder) {
setBooting(false);
return;
}
await window.ttrpgBuilder.seedPresets();
await refresh();
setBooting(false);
setStatus(locale === 'ru' ? 'Пресеты готовы' : 'Presets ready');
})();
}, [refresh, locale]);
const validation = useMemo(() => {
if (!current) return null;
const strategy = current.creationRules[0]?.type;
return validateCreationRules(current, values, strategy);
}, [current, values]);
const derived = useMemo(() => {
if (!current) return {};
try {
return computeDerived(current.bricks.derived ?? [], values);
} catch {
return {};
}
}, [current, values]);
async function openSystem(id: string) {
const s = await window.ttrpgBuilder.getSystem(id);
if (!s) return;
setCurrent(s);
setValues(defaultValues(s));
setTab('designer');
setStatus(`${s.name}`);
}
async function saveSystem() {
if (!current) return;
const saved = await window.ttrpgBuilder.upsertSystem(current);
setCurrent(saved);
setStatus(
locale === 'ru'
? `Сохранено: ${saved.name} (r${saved.revision})`
: `Saved: ${saved.name} (r${saved.revision})`,
);
await refresh();
}
function onChangeValue(path: string, value: unknown) {
setValues((prev) => {
const next = {
...prev,
attributes: { ...prev.attributes },
skills: { ...prev.skills },
resources: { ...prev.resources },
meta: { ...prev.meta },
};
const [scope, key] = path.split('.');
if (scope === 'attr' && key) next.attributes![key] = value as number | string | boolean;
if (scope === 'skill' && key) next.skills![key] = value as number | string | boolean;
if (scope === 'resource' && key) next.resources![key] = value as number;
if (scope === 'meta' && key) (next.meta as Record<string, unknown>)[key] = value;
return next;
});
}
async function saveCharacter() {
if (!current) return;
const sheet: CharacterSheet = {
format: CHARACTER_FORMAT,
formatVersion: FORMAT_VERSION,
id: `char_${Date.now().toString(36)}`,
name: characterName,
systemId: current.id,
schemaVersion: current.schemaVersion,
revisionAtCreate: current.revision,
values: { ...values, derived },
creation: { strategy: current.creationRules[0]?.type ?? 'point_buy' },
};
if (validation && !validation.ok) {
setStatus(t(locale, 'validationFail'));
return;
}
await window.ttrpgBuilder.upsertCharacter(sheet);
setStatus(
locale === 'ru' ? `Персонаж сохранён: ${sheet.name}` : `Character saved: ${sheet.name}`,
);
}
function createFromScratch() {
const s = blankSystem();
setCurrent(s);
setValues(defaultValues(s));
setTab('designer');
setStatus(locale === 'ru' ? 'Новая система — сохраните, когда будете готовы' : 'New system — save when ready');
}
return (
<div className="appShell">
<aside className="sidebar">
<div className="brand" data-testid="brand">
<span>TTRPG</span> Builder
</div>
<div className="row">
<button
type="button"
data-testid="locale-ru"
className={locale === 'ru' ? 'primary' : ''}
onClick={() => setLocale('ru')}
>
RU
</button>
<button
type="button"
data-testid="locale-en"
className={locale === 'en' ? 'primary' : ''}
onClick={() => setLocale('en')}
>
EN
</button>
</div>
<button
type="button"
className={`navBtn${tab === 'systems' ? ' active' : ''}`}
data-testid="tab-systems"
onClick={() => setTab('systems')}
>
{t(locale, 'systems')}
</button>
<button
type="button"
className={`navBtn${tab === 'designer' ? ' active' : ''}`}
data-testid="tab-designer"
onClick={() => setTab('designer')}
disabled={!current}
>
{t(locale, 'designer')}
</button>
<button
type="button"
className={`navBtn${tab === 'fill' ? ' active' : ''}`}
data-testid="tab-fill"
onClick={() => setTab('fill')}
disabled={!current}
>
{t(locale, 'fill')}
</button>
<div className="muted statusLine" data-testid="status">
{booting ? (locale === 'ru' ? 'Загрузка…' : 'Loading…') : status}
</div>
</aside>
<main className="main">
{tab === 'systems' && (
<div>
<div className="heroPanel">
<h1>{locale === 'ru' ? 'Системы и пресеты' : 'Systems & presets'}</h1>
<p className="muted">
{locale === 'ru'
? 'Откройте любой из 10 пресетов в визуальном редакторе или создайте свою схему с нуля.'
: 'Open any of the 10 presets in the visual editor, or create a schema from scratch.'}
</p>
</div>
<div className="panel row">
<button
type="button"
className="primary"
data-testid="new-system"
onClick={createFromScratch}
>
{t(locale, 'newSystem')}
</button>
<button
type="button"
data-testid="import-system"
onClick={async () => {
const s = await window.ttrpgBuilder.importSystem();
if (s) {
setCurrent(s);
setValues(defaultValues(s));
await refresh();
setTab('designer');
}
}}
>
{t(locale, 'import')}
</button>
<button
type="button"
data-testid="load-presets"
onClick={async () => {
const n = await window.ttrpgBuilder.seedPresets();
const files = await window.ttrpgBuilder.listPresets();
for (const f of files) await window.ttrpgBuilder.loadPreset(f);
await refresh();
setStatus(
locale === 'ru'
? `Пресеты обновлены (${files.length}, новых ${n})`
: `Presets refreshed (${files.length}, new ${n})`,
);
}}
>
{t(locale, 'loadPresets')}
</button>
<button type="button" onClick={() => void refresh()}>
{t(locale, 'refresh')}
</button>
<span className="muted" data-testid="systems-count">
{systems.length} / 10+
</span>
</div>
<div className="panel systemsGrid">
{booting && <div className="muted">{locale === 'ru' ? 'Загрузка…' : 'Loading…'}</div>}
{!booting && systems.length === 0 && (
<div className="muted">{t(locale, 'noSystem')}</div>
)}
{systems.map((s) => (
<div key={s.id} className="systemCard" data-testid={`system-${s.id}`}>
<div>
<strong>{s.name}</strong>
<div className="muted">
{s.id} · r{s.revision}
</div>
</div>
<div className="row">
<button type="button" className="primary" onClick={() => void openSystem(s.id)}>
{locale === 'ru' ? 'Редактор' : 'Editor'}
</button>
<button
type="button"
onClick={async () => {
await openSystem(s.id);
setTab('fill');
}}
>
{locale === 'ru' ? 'Лист' : 'Sheet'}
</button>
<button type="button" onClick={() => void window.ttrpgBuilder.exportSystem(s.id)}>
{t(locale, 'export')}
</button>
<button
type="button"
onClick={async () => {
await window.ttrpgBuilder.deleteSystem(s.id);
if (current?.id === s.id) setCurrent(null);
await refresh();
}}
>
{t(locale, 'delete')}
</button>
</div>
</div>
))}
</div>
</div>
)}
{tab === 'designer' && current && (
<div>
<div className="panel row stickyBar">
<input
data-testid="system-name"
className="nameInput"
value={current.name}
onChange={(e) => setCurrent({ ...current, name: e.target.value })}
/>
<button
type="button"
className="primary"
data-testid="save-system"
onClick={() => void saveSystem()}
>
{t(locale, 'save')}
</button>
<button type="button" onClick={() => setTab('fill')}>
{t(locale, 'fill')}
</button>
</div>
<DesignerWorkspace
system={current}
locale={locale}
onChange={(next) => {
setCurrent(next);
setValues(defaultValues(next));
}}
/>
</div>
)}
{tab === 'fill' && current && (
<div>
<div className="panel row stickyBar">
<input
data-testid="character-name"
className="nameInput"
value={characterName}
onChange={(e) => setCharacterName(e.target.value)}
placeholder={t(locale, 'characterName')}
/>
<button
type="button"
className="primary"
data-testid="save-character"
onClick={() => void saveCharacter()}
>
{t(locale, 'createCharacter')}
</button>
<button type="button" onClick={() => setTab('designer')}>
{t(locale, 'designer')}
</button>
{validation && (
<span
className={validation.ok ? 'muted' : 'error'}
data-testid="validation-status"
>
{validation.ok
? t(locale, 'validationOk')
: `${t(locale, 'validationFail')}: ${validation.errors[0]?.message}`}
</span>
)}
</div>
<div className="panel">
<div className="muted">{t(locale, 'derived')}</div>
<pre data-testid="derived-json">{JSON.stringify(derived, null, 2)}</pre>
</div>
<SheetRenderer
system={current}
values={values}
mode="fill"
onChangeValue={(path, value) => onChangeValue(path, value)}
/>
</div>
)}
</main>
</div>
);
}
+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>
);
}
+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>
);
}
+95
View File
@@ -0,0 +1,95 @@
import type { CharacterValues, SheetWidget, SystemDefinition } from '../shared/types.ts';
import { computeDerived } from '../shared/derived.ts';
type Props = {
widget: SheetWidget;
system: SystemDefinition;
};
/** Compact in-widget preview for design mode (no nested canvas). */
export function WidgetPreview({ widget, system }: Props) {
const values: CharacterValues = {
attributes: Object.fromEntries(
(system.bricks.attributes ?? []).map((a) => [a.key, typeof a.default === 'number' ? a.default : 10]),
),
skills: Object.fromEntries(
(system.bricks.skills ?? []).map((a) => [a.key, typeof a.default === 'number' ? a.default : 0]),
),
resources: Object.fromEntries((system.bricks.resources ?? []).map((a) => [a.key, a.default ?? 0])),
meta: { notes: '…', title: system.name },
};
const derived = computeDerived(system.bricks.derived ?? [], values);
const bind = widget.bind ?? '';
const [, key] = bind.split('.');
if (widget.type === 'section') {
return <strong>{String(widget.props?.title ?? 'Section')}</strong>;
}
if (widget.type === 'dots') {
const max = Number(widget.props?.max ?? 5);
const cur = Number(values.attributes?.[key!] ?? 2);
return (
<div>
<div className="muted">{String(widget.props?.label ?? key)}</div>
<div className="row">
{Array.from({ length: max }, (_, i) => (
<span
key={i}
style={{
width: 12,
height: 12,
borderRadius: '50%',
border: '1px solid var(--stroke2)',
background: i < cur ? 'var(--accent2)' : 'transparent',
}}
/>
))}
</div>
</div>
);
}
if (widget.type === 'track' || widget.type === 'resourceBar') {
const v = Number(values.resources?.[key!] ?? 5);
const max = Number(widget.props?.max ?? 10);
return (
<div>
<div className="muted">{String(widget.props?.label ?? key)}</div>
<div style={{ height: 8, background: 'var(--stroke)', borderRadius: 4 }}>
<div style={{ width: `${(v / max) * 100}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
);
}
if (widget.type === 'skillList') {
return (
<div className="muted">
{(system.bricks.skills ?? []).slice(0, 4).map((s) => (
<div key={s.key}>
{s.label}: {String(values.skills?.[s.key] ?? 0)}
</div>
))}
…
</div>
);
}
if (widget.type === 'notes' || widget.type === 'text') {
return <div className="muted">{String(values.meta?.notes ?? 'Notes')}</div>;
}
if (bind.startsWith('derived.')) {
return (
<div>
<div className="muted">{String(widget.props?.label ?? key)}</div>
<div style={{ fontSize: 20, fontWeight: 700 }}>{derived[key!] ?? 0}</div>
</div>
);
}
if (bind.startsWith('attr.')) {
return (
<div>
<div className="muted">{String(widget.props?.label ?? key)}</div>
<div style={{ fontSize: 20, fontWeight: 700 }}>{String(values.attributes?.[key!] ?? 10)}</div>
</div>
);
}
return <div className="muted">{widget.type}</div>;
}
+52
View File
@@ -0,0 +1,52 @@
export type Locale = 'ru' | 'en';
const messages = {
en: {
brand: 'TTRPG Character Builder',
systems: 'Systems',
characters: 'Characters',
designer: 'Sheet designer',
fill: 'Fill character',
import: 'Import',
export: 'Export',
loadPresets: 'Load presets',
newSystem: 'New system',
save: 'Save',
delete: 'Delete',
refresh: 'Refresh',
noSystem: 'Select or create a system',
attributes: 'Attributes',
derived: 'Derived',
validationOk: 'Validation passed',
validationFail: 'Validation failed',
createCharacter: 'Create character',
characterName: 'Character name',
},
ru: {
brand: 'Конструктор персонажей НРИ',
systems: 'Системы',
characters: 'Персонажи',
designer: 'Дизайнер листа',
fill: 'Заполнение',
import: 'Импорт',
export: 'Экспорт',
loadPresets: 'Загрузить пресеты',
newSystem: 'Новая система',
save: 'Сохранить',
delete: 'Удалить',
refresh: 'Обновить',
noSystem: 'Выберите или создайте систему',
attributes: 'Атрибуты',
derived: 'Производные',
validationOk: 'Валидация пройдена',
validationFail: 'Ошибки валидации',
createCharacter: 'Создать персонажа',
characterName: 'Имя персонажа',
},
} as const;
export type MessageKey = keyof typeof messages.en;
export function t(locale: Locale, key: MessageKey): string {
return messages[locale][key] ?? messages.en[key];
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TTRPG Character Builder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
import './styles.css';
createRoot(document.getElementById('root')!).render(<App />);
+188
View File
@@ -0,0 +1,188 @@
:root {
--bg0: #09090b;
--color-panel: rgba(255, 255, 255, 0.04);
--color-panel-2: rgba(255, 255, 255, 0.06);
--stroke: rgba(255, 255, 255, 0.08);
--stroke2: rgba(255, 255, 255, 0.12);
--text0: rgba(255, 255, 255, 0.92);
--text1: rgba(255, 255, 255, 0.72);
--text2: rgba(255, 255, 255, 0.52);
--accent: #7c3aed;
--accent2: #a78bfa;
--accent-border: rgba(124, 58, 237, 0.55);
--accent-fill-solid: rgba(124, 58, 237, 0.92);
--accent-fill-soft: rgba(124, 58, 237, 0.1);
--color-danger: rgba(248, 113, 113, 0.95);
--radius-sm: 8px;
--radius-md: 12px;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
font-family: 'Segoe UI', 'IBM Plex Sans', system-ui, sans-serif;
color: var(--text0);
background: var(--bg0);
}
* { box-sizing: border-box; }
html, body, #root { margin: 0; height: 100%; }
button {
height: 34px;
padding: 0 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-panel);
color: var(--text0);
cursor: pointer;
}
button.primary {
border: 1px solid var(--accent-border);
background: var(--accent-fill-solid);
}
button:disabled { opacity: 0.45; cursor: not-allowed; }
input, select, textarea {
background: var(--color-panel-2);
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
color: var(--text0);
padding: 8px 10px;
width: 100%;
}
.appShell {
display: grid;
grid-template-columns: 260px 1fr;
height: 100%;
}
.sidebar {
border-right: 1px solid var(--stroke);
background: #18181b;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.brand { font-size: 18px; font-weight: 700; }
.brand span { color: var(--accent2); }
.navBtn { text-align: left; width: 100%; }
.navBtn.active {
border-color: var(--accent-border);
background: var(--accent-fill-soft);
}
.statusLine { margin-top: auto; font-size: 12px; min-height: 2.5em; }
.main { padding: 20px; overflow: auto; }
.panel {
background: var(--color-panel);
border: 1px solid var(--stroke);
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 16px;
}
.heroPanel {
margin-bottom: 16px;
}
.heroPanel h1 {
margin: 0 0 8px;
font-size: 28px;
font-weight: 700;
}
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.row.wrap { flex-wrap: wrap; }
.muted { color: var(--text2); }
.error { color: var(--color-danger); font-size: 13px; }
.systemsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
}
.systemCard {
border: 1px solid var(--stroke);
border-radius: var(--radius-md);
background: var(--color-panel-2);
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.nameInput { max-width: 360px; }
.stickyBar { position: sticky; top: 0; z-index: 5; backdrop-filter: blur(8px); }
.sheetCanvas {
position: relative;
min-height: 720px;
background:
linear-gradient(var(--stroke) 1px, transparent 1px) 0 0 / 16px 16px,
linear-gradient(90deg, var(--stroke) 1px, transparent 1px) 0 0 / 16px 16px,
#0c0c10;
border: 1px solid var(--stroke2);
border-radius: var(--radius-md);
overflow: hidden;
}
.widget {
position: absolute;
border: 1px solid var(--stroke2);
background: rgba(10, 10, 14, 0.94);
border-radius: 8px;
padding: 8px;
overflow: hidden;
touch-action: none;
user-select: none;
}
.widget.selected {
border-color: var(--accent2);
box-shadow: 0 0 0 1px var(--accent-border);
}
.widgetChrome {
font-size: 10px;
color: var(--text2);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.widgetBody { pointer-events: none; }
.resizeHandle {
position: absolute;
right: 2px;
bottom: 2px;
width: 14px;
height: 14px;
border-radius: 3px;
background: var(--accent-fill-soft);
border: 1px solid var(--accent-border);
cursor: nwse-resize;
pointer-events: auto;
}
.designerGrid {
display: grid;
grid-template-columns: 220px 1fr 240px;
gap: 12px;
min-height: calc(100vh - 120px);
}
.designerSide {
max-height: calc(100vh - 120px);
overflow: auto;
}
.sideTitle {
font-weight: 700;
margin: 8px 0;
color: var(--text1);
}
.palette { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.paletteItem { width: 100%; text-align: left; }
.brickList { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
.brickRow {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
padding: 4px 6px;
border: 1px solid var(--stroke);
border-radius: 6px;
}
.brickRow button { height: 24px; padding: 0 8px; }
.propsForm { display: flex; flex-direction: column; gap: 10px; }
.propsForm label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text1); }
.designerCanvasHost { min-width: 0; }