Files

502 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}