df50b6b680
Co-authored-by: Cursor <cursoragent@cursor.com>
502 lines
17 KiB
TypeScript
502 lines
17 KiB
TypeScript
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);
|
||
setStatus(
|
||
locale === 'ru'
|
||
? 'Нет связи с Electron preload (перезапустите npm run dev)'
|
||
: 'Electron preload missing (restart npm run dev)',
|
||
);
|
||
return;
|
||
}
|
||
try {
|
||
await window.ttrpgBuilder.seedPresets();
|
||
await refresh();
|
||
setStatus(locale === 'ru' ? 'Пресеты готовы' : 'Presets ready');
|
||
} catch (e) {
|
||
setStatus(String(e));
|
||
} finally {
|
||
setBooting(false);
|
||
}
|
||
})();
|
||
}, [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>
|
||
);
|
||
}
|