commit d1f180ee0a5d6208a76cb80df237a83ab084d546 Author: Ivan Fontosh Date: Sat Sep 12 14:49:31 2026 +0800 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f9862a --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +release/ +.cursor/ +*.log +.DS_Store +Thumbs.db +coverage/ +playwright-report/ +test-results/ +*.db +.env +.env.* diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 0000000..206cfb0 --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,17 @@ +# TTRPG Character Builder — Project + +## Vision + +Offline Electron meta-builder for TTRPG system schemas and character sheets, with export into TTRPG Player (`meta_builder` branch only). + +## Constraints + +- Electron offline; same pack targets as TTRPG Player +- Player changes only on git branch `meta_builder` (never merge to main in this workstream) +- Portable `app/shared` contract for player + future mobile +- Functional tests + e2e (not smoke-only) +- Depth B sheets; 10 presets; spell compendium UI; React Flow progression; no ICE combat / hit-location roller + +## Current status + +Scaffold live: shared engine + SQLite builder + UI shell + 10 preset files + player GameSystemsStore/IPC/modal. diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 0000000..b0278e4 --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,11 @@ +# Roadmap + +1. [x] Scaffold Electron/React/Vite + shared core + tests +2. [x] SQLite + IPC + basic Admin/Designer/Fill UI +3. [x] 10 preset scaffolds + preset smoke +4. [x] Player `meta_builder`: GameSystems store/IPC/settings modal +5. [ ] Visual designer DnD polish + React Flow progression editor +6. [ ] Spell compendium CRUD + dnd.su PHB import script +7. [ ] Player: system field on players + sheet fill window + persist +8. [ ] Full functional e2e (Playwright Electron) builder + player +9. [ ] Deepen presets to depth B (inventory, spells UI, armor SVG) diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 0000000..815772e --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,6 @@ +# State + +- Branch builder: local git master (new repo) +- Player branch: `meta_builder` +- GSD installer: requires Node >=24 and may need `GSD_ALLOW_SYMLINKED_DEST=1` on this machine +- Next: React Flow editor, player sheet window, e2e suite diff --git a/README.md b/README.md new file mode 100644 index 0000000..4eccafe --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# TTRPG Character Builder + +Offline Electron meta-builder for TTRPG system schemas and character sheets. +Designed to export `.ttrpg-system.json` for import into **TTRPG Player** (`meta_builder` branch). + +## Stack + +- Electron 41 + React 19 + TypeScript + Vite +- SQLite (`better-sqlite3`) +- Shared portable contract in `app/shared` (formula DSL, creation rules, snapshots) + +## Scripts + +```bash +npm install +npm test +npm run dev +npm run build +``` + +## Presets + +`presets/` — start with Shadowdark + D&D 5e SRD scaffold. More systems follow the roadmap. + +## Legal + +Fan mechanical scaffolds. D&D spell text may be imported from third-party aggregators at the product owner's responsibility — see `docs/adr/`. diff --git a/app/main/index.ts b/app/main/index.ts new file mode 100644 index 0000000..a010a9a --- /dev/null +++ b/app/main/index.ts @@ -0,0 +1,156 @@ +import { app, BrowserWindow, dialog, ipcMain } from 'electron'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { CharacterSheet, SystemDefinition } from '../shared/types.ts'; +import { AppStore } from './store.ts'; + +let mainWindow: BrowserWindow | null = null; +let store: AppStore; + +function preloadPath(): string { + return path.join(__dirname, '../preload/index.cjs'); +} + +function presetsDirs(): string[] { + const candidates = [ + path.join(app.getAppPath(), 'presets'), + path.join(process.cwd(), 'presets'), + path.join(__dirname, '../../presets'), + ]; + if (app.isPackaged) { + candidates.unshift(path.join(process.resourcesPath, 'presets')); + } + return candidates.filter((d) => fs.existsSync(d)); +} + +function listPresetFiles(): string[] { + const files: string[] = []; + for (const dir of presetsDirs()) { + for (const f of fs.readdirSync(dir)) { + if (f.endsWith('.json') && !f.includes('sample')) { + files.push(path.join(dir, f)); + } + } + } + // unique by basename + const byName = new Map(); + for (const f of files) byName.set(path.basename(f), f); + return [...byName.values()].sort(); +} + +function seedPresetsIfEmpty(): number { + if (store.listSystems().length > 0) return 0; + let n = 0; + for (const file of listPresetFiles()) { + try { + const system = JSON.parse(fs.readFileSync(file, 'utf8')) as SystemDefinition; + store.upsertSystem(system); + n += 1; + } catch (e) { + console.error('Failed to seed preset', file, e); + } + } + return n; +} + +function createWindow(): void { + mainWindow = new BrowserWindow({ + width: 1480, + height: 920, + minWidth: 1100, + minHeight: 700, + backgroundColor: '#09090b', + title: 'TTRPG Character Builder', + webPreferences: { + preload: preloadPath(), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + const devUrl = process.env.VITE_DEV_SERVER_URL; + if (devUrl) { + void mainWindow.loadURL(devUrl); + } else { + void mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')); + } +} + +function registerIpc(): void { + ipcMain.handle('systems:list', () => store.listSystems()); + ipcMain.handle('systems:get', (_e, id: string) => store.getSystem(id)); + ipcMain.handle('systems:upsert', (_e, system: SystemDefinition) => store.upsertSystem(system)); + ipcMain.handle('systems:delete', (_e, id: string) => { + store.deleteSystem(id); + return true; + }); + + ipcMain.handle('characters:list', (_e, systemId?: string) => store.listCharacters(systemId)); + ipcMain.handle('characters:get', (_e, id: string) => store.getCharacter(id)); + ipcMain.handle('characters:upsert', (_e, character: CharacterSheet) => store.upsertCharacter(character)); + ipcMain.handle('characters:delete', (_e, id: string) => { + store.deleteCharacter(id); + return true; + }); + + ipcMain.handle('systems:export', async (_e, id: string) => { + const system = store.getSystem(id); + if (!system || !mainWindow) return null; + const result = await dialog.showSaveDialog(mainWindow, { + title: 'Export system', + defaultPath: `${system.id}.ttrpg-system.json`, + filters: [{ name: 'TTRPG System', extensions: ['json'] }], + }); + if (result.canceled || !result.filePath) return null; + fs.writeFileSync(result.filePath, JSON.stringify(system, null, 2), 'utf8'); + return result.filePath; + }); + + ipcMain.handle('systems:import', async () => { + if (!mainWindow) return null; + const result = await dialog.showOpenDialog(mainWindow, { + title: 'Import system', + filters: [{ name: 'TTRPG System', extensions: ['json'] }], + properties: ['openFile'], + }); + if (result.canceled || !result.filePaths[0]) return null; + const raw = fs.readFileSync(result.filePaths[0], 'utf8'); + const system = JSON.parse(raw) as SystemDefinition; + return store.upsertSystem(system); + }); + + ipcMain.handle('presets:list', () => listPresetFiles()); + ipcMain.handle('presets:load', (_e, filePath: string) => { + const raw = fs.readFileSync(filePath, 'utf8'); + const system = JSON.parse(raw) as SystemDefinition; + return store.upsertSystem(system); + }); + ipcMain.handle('presets:seed', () => { + // Force re-seed missing presets by id + let n = 0; + for (const file of listPresetFiles()) { + const system = JSON.parse(fs.readFileSync(file, 'utf8')) as SystemDefinition; + if (!store.getSystem(system.id)) { + store.upsertSystem(system); + n += 1; + } + } + if (store.listSystems().length === 0) n = seedPresetsIfEmpty(); + return n; + }); +} + +app.whenReady().then(() => { + const dbPath = path.join(app.getPath('userData'), 'builder.sqlite'); + store = new AppStore(dbPath); + const seeded = seedPresetsIfEmpty(); + if (seeded) console.log(`Seeded ${seeded} presets`); + registerIpc(); + createWindow(); +}); + +app.on('window-all-closed', () => { + store?.close(); + if (process.platform !== 'darwin') app.quit(); +}); diff --git a/app/main/store.ts b/app/main/store.ts new file mode 100644 index 0000000..f64efdc --- /dev/null +++ b/app/main/store.ts @@ -0,0 +1,137 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import Database from 'better-sqlite3'; + +import type { CharacterSheet, SystemDefinition } from '../shared/types.ts'; +import { validateSystemDefinition } from '../shared/systemValidate.ts'; + +export class AppStore { + private readonly db: Database.Database; + + constructor(dbPath: string) { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + this.db = new Database(dbPath); + this.db.exec(` + CREATE TABLE IF NOT EXISTS systems ( + id TEXT PRIMARY KEY, + revision INTEGER NOT NULL, + schema_version INTEGER NOT NULL, + name TEXT NOT NULL, + json TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS characters ( + id TEXT PRIMARY KEY, + system_id TEXT NOT NULL, + name TEXT NOT NULL, + json TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + } + + listSystems(): Array<{ id: string; name: string; revision: number; schemaVersion: number }> { + return this.db + .prepare('SELECT id, name, revision, schema_version AS schemaVersion FROM systems ORDER BY name') + .all() as Array<{ id: string; name: string; revision: number; schemaVersion: number }>; + } + + getSystem(id: string): SystemDefinition | null { + const row = this.db.prepare('SELECT json FROM systems WHERE id = ?').get(id) as + | { json: string } + | undefined; + return row ? (JSON.parse(row.json) as SystemDefinition) : null; + } + + upsertSystem(system: SystemDefinition): SystemDefinition { + const check = validateSystemDefinition(system); + if (!check.ok) { + throw new Error(check.errors.map((e) => e.message).join('; ')); + } + const existing = this.getSystem(system.id); + const next: SystemDefinition = { + ...system, + revision: existing ? existing.revision + 1 : system.revision || 1, + }; + const now = new Date().toISOString(); + this.db + .prepare( + `INSERT INTO systems (id, revision, schema_version, name, json, updated_at) + VALUES (@id, @revision, @schemaVersion, @name, @json, @updatedAt) + ON CONFLICT(id) DO UPDATE SET + revision=excluded.revision, + schema_version=excluded.schema_version, + name=excluded.name, + json=excluded.json, + updated_at=excluded.updated_at`, + ) + .run({ + id: next.id, + revision: next.revision, + schemaVersion: next.schemaVersion, + name: next.name, + json: JSON.stringify(next), + updatedAt: now, + }); + return next; + } + + deleteSystem(id: string): void { + const tx = this.db.transaction(() => { + this.db.prepare('DELETE FROM characters WHERE system_id = ?').run(id); + this.db.prepare('DELETE FROM systems WHERE id = ?').run(id); + }); + tx(); + } + + listCharacters(systemId?: string): Array<{ id: string; name: string; systemId: string }> { + if (systemId) { + return this.db + .prepare( + 'SELECT id, name, system_id AS systemId FROM characters WHERE system_id = ? ORDER BY name', + ) + .all(systemId) as Array<{ id: string; name: string; systemId: string }>; + } + return this.db + .prepare('SELECT id, name, system_id AS systemId FROM characters ORDER BY name') + .all() as Array<{ id: string; name: string; systemId: string }>; + } + + getCharacter(id: string): CharacterSheet | null { + const row = this.db.prepare('SELECT json FROM characters WHERE id = ?').get(id) as + | { json: string } + | undefined; + return row ? (JSON.parse(row.json) as CharacterSheet) : null; + } + + upsertCharacter(character: CharacterSheet): CharacterSheet { + const now = new Date().toISOString(); + this.db + .prepare( + `INSERT INTO characters (id, system_id, name, json, updated_at) + VALUES (@id, @systemId, @name, @json, @updatedAt) + ON CONFLICT(id) DO UPDATE SET + system_id=excluded.system_id, + name=excluded.name, + json=excluded.json, + updated_at=excluded.updated_at`, + ) + .run({ + id: character.id, + systemId: character.systemId, + name: character.name, + json: JSON.stringify(character), + updatedAt: now, + }); + return character; + } + + deleteCharacter(id: string): void { + this.db.prepare('DELETE FROM characters WHERE id = ?').run(id); + } + + close(): void { + this.db.close(); + } +} diff --git a/app/preload/index.ts b/app/preload/index.ts new file mode 100644 index 0000000..e7be5e9 --- /dev/null +++ b/app/preload/index.ts @@ -0,0 +1,32 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +import type { CharacterSheet, SystemDefinition } from '../shared/types.ts'; + +const api = { + listSystems: () => ipcRenderer.invoke('systems:list') as Promise< + Array<{ id: string; name: string; revision: number; schemaVersion: number }> + >, + getSystem: (id: string) => ipcRenderer.invoke('systems:get', id) as Promise, + upsertSystem: (system: SystemDefinition) => + ipcRenderer.invoke('systems:upsert', system) as Promise, + deleteSystem: (id: string) => ipcRenderer.invoke('systems:delete', id) as Promise, + exportSystem: (id: string) => ipcRenderer.invoke('systems:export', id) as Promise, + importSystem: () => ipcRenderer.invoke('systems:import') as Promise, + listPresets: () => ipcRenderer.invoke('presets:list') as Promise, + loadPreset: (filePath: string) => + ipcRenderer.invoke('presets:load', filePath) as Promise, + seedPresets: () => ipcRenderer.invoke('presets:seed') as Promise, + listCharacters: (systemId?: string) => + ipcRenderer.invoke('characters:list', systemId) as Promise< + Array<{ id: string; name: string; systemId: string }> + >, + getCharacter: (id: string) => + ipcRenderer.invoke('characters:get', id) as Promise, + upsertCharacter: (character: CharacterSheet) => + ipcRenderer.invoke('characters:upsert', character) as Promise, + deleteCharacter: (id: string) => ipcRenderer.invoke('characters:delete', id) as Promise, +}; + +contextBridge.exposeInMainWorld('ttrpgBuilder', api); + +export type TtrpgBuilderApi = typeof api; diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx new file mode 100644 index 0000000..c2b16f9 --- /dev/null +++ b/app/renderer/App.tsx @@ -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>; + getSystem: (id: string) => Promise; + upsertSystem: (system: SystemDefinition) => Promise; + deleteSystem: (id: string) => Promise; + exportSystem: (id: string) => Promise; + importSystem: () => Promise; + listPresets: () => Promise; + loadPreset: (filePath: string) => Promise; + seedPresets: () => Promise; + listCharacters: (systemId?: string) => Promise>; + getCharacter: (id: string) => Promise; + upsertCharacter: (character: CharacterSheet) => Promise; + deleteCharacter: (id: string) => Promise; + }; + } +} + +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 = {}; + for (const a of system.bricks.attributes ?? []) { + attributes[a.key] = typeof a.default === 'number' ? a.default : 10; + } + const resources: Record = {}; + for (const r of system.bricks.resources ?? []) { + resources[r.key] = r.default ?? 0; + } + const skills: Record = {}; + for (const s of system.bricks.skills ?? []) { + skills[s.key] = typeof s.default === 'number' ? s.default : 0; + } + return { attributes, resources, skills, meta: {} as Record }; +} + +export function App() { + const [locale, setLocale] = useState('ru'); + const [tab, setTab] = useState('systems'); + const [systems, setSystems] = useState< + Array<{ id: string; name: string; revision: number; schemaVersion: number }> + >([]); + const [current, setCurrent] = useState(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)[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 ( +
+ + +
+ {tab === 'systems' && ( +
+
+

{locale === 'ru' ? 'Системы и пресеты' : 'Systems & presets'}

+

+ {locale === 'ru' + ? 'Откройте любой из 10 пресетов в визуальном редакторе или создайте свою схему с нуля.' + : 'Open any of the 10 presets in the visual editor, or create a schema from scratch.'} +

+
+
+ + + + + + {systems.length} / 10+ + +
+
+ {booting &&
{locale === 'ru' ? 'Загрузка…' : 'Loading…'}
} + {!booting && systems.length === 0 && ( +
{t(locale, 'noSystem')}
+ )} + {systems.map((s) => ( +
+
+ {s.name} +
+ {s.id} · r{s.revision} +
+
+
+ + + + +
+
+ ))} +
+
+ )} + + {tab === 'designer' && current && ( +
+
+ setCurrent({ ...current, name: e.target.value })} + /> + + +
+ { + setCurrent(next); + setValues(defaultValues(next)); + }} + /> +
+ )} + + {tab === 'fill' && current && ( +
+
+ setCharacterName(e.target.value)} + placeholder={t(locale, 'characterName')} + /> + + + {validation && ( + + {validation.ok + ? t(locale, 'validationOk') + : `${t(locale, 'validationFail')}: ${validation.errors[0]?.message}`} + + )} +
+
+
{t(locale, 'derived')}
+
{JSON.stringify(derived, null, 2)}
+
+ onChangeValue(path, value)} + /> +
+ )} +
+
+ ); +} diff --git a/app/renderer/DesignerWorkspace.tsx b/app/renderer/DesignerWorkspace.tsx new file mode 100644 index 0000000..370faed --- /dev/null +++ b/app/renderer/DesignerWorkspace.tsx @@ -0,0 +1,388 @@ +import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; + +import type { SheetWidget, SystemDefinition, WidgetType } from '../shared/types.ts'; +import { WidgetPreview } from './WidgetPreview.tsx'; + +const PALETTE: Array<{ type: WidgetType; label: string; defaultBind?: string; w: number; h: number }> = [ + { type: 'section', label: 'Section', w: 220, h: 40 }, + { type: 'number', label: 'Number', defaultBind: 'attr.str', w: 110, h: 72 }, + { type: 'modBadge', label: 'Mod badge', defaultBind: 'derived.strMod', w: 110, h: 72 }, + { type: 'dots', label: 'Dots', defaultBind: 'attr.str', w: 160, h: 72 }, + { type: 'track', label: 'Track / HP', defaultBind: 'resource.hp', w: 220, h: 80 }, + { type: 'skillList', label: 'Skill list', w: 280, h: 240 }, + { type: 'notes', label: 'Notes', defaultBind: 'meta.notes', w: 280, h: 140 }, + { type: 'locationBar', label: 'Armor zones', w: 360, h: 90 }, + { type: 'text', label: 'Text', defaultBind: 'meta.title', w: 200, h: 60 }, +]; + +type Props = { + system: SystemDefinition; + onChange: (next: SystemDefinition) => void; + locale: 'ru' | 'en'; +}; + +function snap(n: number, grid = 8): number { + return Math.round(n / grid) * grid; +} + +export function DesignerWorkspace({ system, onChange, locale }: Props) { + const [selectedId, setSelectedId] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const dragRef = useRef<{ + id: string; + mode: 'move' | 'resize'; + startX: number; + startY: number; + orig: { x: number; y: number; w: number; h: number }; + } | null>(null); + + const page = system.sheetLayout.pages[pageIndex] ?? system.sheetLayout.pages[0]; + const selected = page?.widgets.find((w) => w.id === selectedId) ?? null; + + function updateWidgets(mutator: (widgets: SheetWidget[]) => SheetWidget[]) { + if (!page) return; + const pages = system.sheetLayout.pages.map((p, i) => + i === pageIndex ? { ...p, widgets: mutator(p.widgets) } : p, + ); + onChange({ ...system, sheetLayout: { ...system.sheetLayout, pages } }); + } + + function addWidget(item: (typeof PALETTE)[number]) { + if (!page) return; + const id = `w_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + const widget: SheetWidget = { + id, + type: item.type, + bind: item.defaultBind, + frame: { + x: snap(24 + (page.widgets.length % 5) * 16), + y: snap(24 + (page.widgets.length % 7) * 16), + w: item.w, + h: item.h, + }, + props: { + label: item.label, + title: item.label, + max: item.type === 'dots' ? 5 : 20, + }, + }; + updateWidgets((ws) => [...ws, widget]); + setSelectedId(id); + } + + function onPointerDownWidget(e: ReactPointerEvent, id: string, mode: 'move' | 'resize') { + e.stopPropagation(); + e.preventDefault(); + const w = page?.widgets.find((x) => x.id === id); + if (!w) return; + setSelectedId(id); + dragRef.current = { + id, + mode, + startX: e.clientX, + startY: e.clientY, + orig: { ...w.frame }, + }; + (e.target as HTMLElement).setPointerCapture?.(e.pointerId); + } + + function onPointerMove(e: ReactPointerEvent) { + const d = dragRef.current; + if (!d) return; + const dx = e.clientX - d.startX; + const dy = e.clientY - d.startY; + updateWidgets((ws) => + ws.map((w) => { + if (w.id !== d.id) return w; + if (d.mode === 'move') { + return { + ...w, + frame: { + ...w.frame, + x: snap(Math.max(0, d.orig.x + dx)), + y: snap(Math.max(0, d.orig.y + dy)), + }, + }; + } + return { + ...w, + frame: { + ...w.frame, + w: snap(Math.max(48, d.orig.w + dx)), + h: snap(Math.max(36, d.orig.h + dy)), + }, + }; + }), + ); + } + + function onPointerUp() { + dragRef.current = null; + } + + function patchSelected(patch: Partial & { props?: Record }) { + if (!selected) return; + updateWidgets((ws) => + ws.map((w) => + w.id === selected.id + ? { + ...w, + ...patch, + props: { ...w.props, ...patch.props }, + } + : w, + ), + ); + } + + function removeSelected() { + if (!selected) return; + updateWidgets((ws) => ws.filter((w) => w.id !== selected.id)); + setSelectedId(null); + } + + function addBrick(kind: 'attributes' | 'skills' | 'resources' | 'derived') { + const key = `new_${Date.now().toString(36).slice(-4)}`; + const bricks = { ...system.bricks }; + if (kind === 'attributes') { + bricks.attributes = [ + ...(bricks.attributes ?? []), + { key, label: key.toUpperCase(), scale: 'numeric', min: 1, max: 20, default: 10 }, + ]; + } else if (kind === 'skills') { + bricks.skills = [ + ...(bricks.skills ?? []), + { key, label: key, scale: 'numeric', default: 0 }, + ]; + } else if (kind === 'resources') { + bricks.resources = [ + ...(bricks.resources ?? []), + { key, label: key, min: 0, default: 0, track: true }, + ]; + } else { + bricks.derived = [ + ...(bricks.derived ?? []), + { + key, + label: key, + formula: 'floor((attr.str - 10) / 2)', + dependsOn: ['attr.str'], + }, + ]; + } + onChange({ ...system, bricks }); + } + + function removeBrick(kind: 'attributes' | 'skills' | 'resources' | 'derived', key: string) { + const bricks = { ...system.bricks }; + if (kind === 'attributes') bricks.attributes = (bricks.attributes ?? []).filter((a) => a.key !== key); + if (kind === 'skills') bricks.skills = (bricks.skills ?? []).filter((a) => a.key !== key); + if (kind === 'resources') bricks.resources = (bricks.resources ?? []).filter((a) => a.key !== key); + if (kind === 'derived') bricks.derived = (bricks.derived ?? []).filter((a) => a.key !== key); + onChange({ ...system, bricks }); + } + + const bindOptions: string[] = []; + for (const a of system.bricks.attributes ?? []) bindOptions.push(`attr.${a.key}`); + for (const s of system.bricks.skills ?? []) bindOptions.push(`skill.${s.key}`); + for (const r of system.bricks.resources ?? []) bindOptions.push(`resource.${r.key}`); + for (const d of system.bricks.derived ?? []) bindOptions.push(`derived.${d.key}`); + bindOptions.push('meta.notes', 'meta.title'); + + return ( +
+
+
{locale === 'ru' ? 'Палитра' : 'Palette'}
+
+ {PALETTE.map((item) => ( + + ))} +
+ +
{locale === 'ru' ? 'Кирпичики' : 'Bricks'}
+
+ + + + +
+
+ {(system.bricks.attributes ?? []).map((a) => ( +
+ attr.{a.key} + +
+ ))} + {(system.bricks.skills ?? []).map((a) => ( +
+ skill.{a.key} + +
+ ))} + {(system.bricks.resources ?? []).map((a) => ( +
+ resource.{a.key} + +
+ ))} + {(system.bricks.derived ?? []).map((a) => ( +
+ derived.{a.key} + +
+ ))} +
+
+ +
setSelectedId(null)} + > +
+ {system.sheetLayout.pages.map((p, i) => ( + + ))} + +
+ +
+ {(page?.widgets ?? []).map((w) => ( +
{ + e.stopPropagation(); + setSelectedId(w.id); + }} + onPointerDown={(e) => onPointerDownWidget(e, w.id, 'move')} + > +
{w.type}{w.bind ? ` · ${w.bind}` : ''}
+
+ +
+
onPointerDownWidget(e, w.id, 'resize')} + /> +
+ ))} +
+
+ +
+
{locale === 'ru' ? 'Свойства' : 'Properties'}
+ {!selected ? ( +
{locale === 'ru' ? 'Выберите виджет' : 'Select a widget'}
+ ) : ( +
+ + + + + +
+ )} + +
+ System +
+ + +