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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user