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>
);
}