d1f180ee0a
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>
492 lines
17 KiB
TypeScript
492 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);
|
|
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>
|
|
);
|
|
}
|