feat: кубики/таймер/кисть/спонсоры и задел игровых систем

Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-13 15:37:44 +08:00
parent 3d0474fe97
commit 712086ddca
32 changed files with 1712 additions and 34 deletions
+7
View File
@@ -688,6 +688,13 @@
margin-top: 4px;
}
.aboutSponsorsList {
display: flex;
flex-direction: column;
gap: 4px;
line-height: 1.45;
}
.aboutLink {
color: var(--color-accent, #a78bfa);
text-decoration: none;
+47
View File
@@ -62,6 +62,7 @@ import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals';
import { LaunchPlayersModal } from './LaunchPlayersModal';
import { PresentationSettingsModal } from './PresentationSettingsModal';
import { GameSystemsModal } from './GameSystemsModal';
import { PlaylistsManagerModal } from './PlaylistsModal';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal';
@@ -166,10 +167,28 @@ export function EditorApp() {
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false);
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
const [gameSystemsOpen, setGameSystemsOpen] = useState(false);
const [runMenuOpen, setRunMenuOpen] = useState(false);
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
const runSplitRef = useRef<HTMLDivElement | null>(null);
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
const listGameSystems = useCallback(async () => {
const res = await getDndApi().invoke(ipcChannels.gameSystems.list, {});
return res.systems;
}, []);
const importGameSystem = useCallback(async () => {
const res = await getDndApi().invoke(ipcChannels.gameSystems.importFromFile, {});
return res.canceled ? null : res.system;
}, []);
const deleteGameSystem = useCallback(async (id: string) => {
await getDndApi().invoke(ipcChannels.gameSystems.delete, { id });
}, []);
const previewGameSystem = useCallback(async (id: string) => {
console.log('[gameSystems] preview stub', id);
window.alert(`Preview (stub): ${id}`);
}, []);
const onProjectNotice = useCallback(
(code: ProjectNoticeCode) => {
const handlers: Record<ProjectNoticeCode, () => void> = {
@@ -1360,6 +1379,18 @@ export function EditorApp() {
>
{t('menu.presentationSettings')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setGameSystemsOpen(true);
}}
>
{t('menu.gameSystems')}
</button>
<div className={styles.fileMenuSubHost} role="presentation">
<button
type="button"
@@ -1682,6 +1713,22 @@ export function EditorApp() {
open={presentationSettingsOpen}
onClose={() => setPresentationSettingsOpen(false)}
/>
<GameSystemsModal
open={gameSystemsOpen}
onClose={() => setGameSystemsOpen(false)}
labels={{
title: t('gameSystems.title'),
import: t('gameSystems.import'),
preview: t('gameSystems.preview'),
remove: t('gameSystems.remove'),
empty: t('gameSystems.empty'),
close: t('gameSystems.close'),
}}
listSystems={listGameSystems}
importSystem={importGameSystem}
deleteSystem={deleteGameSystem}
previewSystem={previewGameSystem}
/>
<PlaylistsManagerModal
open={playlistsManagerOpen}
onClose={() => setPlaylistsManagerOpen(false)}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import type { GameSystemListItem } from '../../shared/ipc/contracts';
export type { GameSystemListItem };
type Props = {
open: boolean;
onClose: () => void;
labels: {
title: string;
import: string;
preview: string;
remove: string;
empty: string;
close: string;
};
listSystems: () => Promise<GameSystemListItem[]>;
importSystem: () => Promise<GameSystemListItem | null>;
deleteSystem: (id: string) => Promise<void>;
previewSystem: (id: string) => Promise<void>;
};
export function GameSystemsModal({
open,
onClose,
labels,
listSystems,
importSystem,
deleteSystem,
previewSystem,
}: Props) {
const [items, setItems] = useState<GameSystemListItem[]>([]);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!open) return;
void (async () => {
setItems(await listSystems());
})();
}, [open, listSystems]);
if (!open) return null;
return (
<div
data-testid="game-systems-modal"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
display: 'grid',
placeItems: 'center',
zIndex: 10000,
}}
>
<div
style={{
width: 560,
maxHeight: '80vh',
overflow: 'auto',
background: 'rgba(10,10,14,0.98)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 12,
padding: 16,
color: 'rgba(255,255,255,0.92)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<strong>{labels.title}</strong>
<button type="button" data-testid="game-systems-close" onClick={onClose}>
{labels.close}
</button>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<button
type="button"
data-testid="game-systems-import"
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await importSystem();
setItems(await listSystems());
} finally {
setBusy(false);
}
}}
>
{labels.import}
</button>
</div>
{items.length === 0 ? (
<div style={{ opacity: 0.6 }}>{labels.empty}</div>
) : (
items.map((s) => (
<div
key={s.id}
data-testid={`game-system-${s.id}`}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 8,
padding: '10px 0',
borderBottom: '1px solid rgba(255,255,255,0.08)',
}}
>
<div>
<div>{s.name}</div>
<div style={{ opacity: 0.55, fontSize: 12 }}>
{s.id} · r{s.revision}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={() => void previewSystem(s.id)}>
{labels.preview}
</button>
<button
type="button"
onClick={async () => {
await deleteSystem(s.id);
setItems(await listSystems());
}}
>
{labels.remove}
</button>
</div>
</div>
))
)}
</div>
</div>
);
}
@@ -13,6 +13,15 @@ import {
} from '../help/helpSections';
import { useEditorI18n } from '../i18n/EditorI18nContext';
const ABOUT_SPONSORS = [
{ kind: 'text', name: 'Исаков Артур' },
{ kind: 'text', name: 'Подчуфарова Евгения' },
{ kind: 'text', name: 'Никитин Виктор' },
{ kind: 'text', name: 'Майер С.В. (Клуб «Серый Май»)' },
{ kind: 'link', name: 'true_rus(КАВ)', url: 'https://truecorp.su/' },
{ kind: 'text', name: 'Макс LonelyFlame Чурсин' },
] as const;
type AppAboutModalProps = {
open: boolean;
onClose: () => void;
@@ -80,6 +89,24 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
{t('app.about.websiteUrl')}
</a>
</div>
<div className={styles.fieldLabel}>{t('app.about.sponsorsLabel')}</div>
<div className={styles.aboutSponsorsList}>
{ABOUT_SPONSORS.map((sponsor) =>
sponsor.kind === 'link' ? (
<a
key={sponsor.name}
className={styles.aboutLink}
href={sponsor.url}
target="_blank"
rel="noreferrer"
>
{sponsor.name}
</a>
) : (
<div key={sponsor.name}>{sponsor.name}</div>
),
)}
</div>
</div>
</div>
<div className={styles.modalFooter}>
@@ -132,12 +132,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'menu.aboutLicense': 'О лицензии',
'menu.checkUpdates': 'Проверить обновления',
'menu.presentationSettings': 'Экран презентации',
'menu.gameSystems': 'Игровые системы',
'menu.language': 'Язык',
'menu.langRu': 'Русский',
'menu.langEn': 'English',
'menu.aboutProgram': 'О программе',
'menu.instructions': 'Инструкция',
'gameSystems.title': 'Игровые системы',
'gameSystems.import': 'Импорт…',
'gameSystems.preview': 'Просмотр',
'gameSystems.remove': 'Удалить',
'gameSystems.empty': 'Нет импортированных систем',
'gameSystems.close': 'Закрыть',
'top.aboutApp': 'О приложении',
'app.about.title': 'О программе',
@@ -152,6 +160,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'app.about.supportEmail': 'player.ttrpg@gmail.com',
'app.about.websiteLabel': 'САЙТ И ОБНОВЛЕНИЯ',
'app.about.websiteUrl': 'https://ttrpgplayer.ru/',
'app.about.sponsorsLabel': 'СПОНСОРЫ',
'help.title': 'Инструкция',
'help.navAria': 'Разделы инструкции',
@@ -676,6 +685,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'dice.noResult': 'Пока нет броска',
'dice.inc': 'Больше',
'dice.dec': 'Меньше',
'dice.showAnimationToPlayers': 'Показать анимацию игрокам',
'sessionTimer.duration': 'Длительность (MM:SS)',
'sessionTimer.start': 'Старт',
@@ -877,12 +887,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'menu.aboutLicense': 'About license',
'menu.checkUpdates': 'Check for updates',
'menu.presentationSettings': 'Presentation display',
'menu.gameSystems': 'Game systems',
'menu.language': 'Language',
'menu.langRu': 'Русский',
'menu.langEn': 'English',
'menu.aboutProgram': 'About',
'menu.instructions': 'Instructions',
'gameSystems.title': 'Game systems',
'gameSystems.import': 'Import…',
'gameSystems.preview': 'Preview',
'gameSystems.remove': 'Remove',
'gameSystems.empty': 'No imported systems',
'gameSystems.close': 'Close',
'top.aboutApp': 'About',
'app.about.title': 'About',
@@ -897,6 +915,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'app.about.supportEmail': 'player.ttrpg@gmail.com',
'app.about.websiteLabel': 'WEBSITE & UPDATES',
'app.about.websiteUrl': 'https://ttrpgplayer.ru/',
'app.about.sponsorsLabel': 'SPONSORS',
'help.title': 'Instructions',
'help.navAria': 'Instruction sections',
@@ -1423,6 +1442,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'dice.noResult': 'No roll yet',
'dice.inc': 'More',
'dice.dec': 'Fewer',
'dice.showAnimationToPlayers': 'Show animation to players',
'sessionTimer.duration': 'Duration (MM:SS)',
'sessionTimer.start': 'Start',