feat: окно инструкций, свёрнутая история и обновление спонсоров

Инструкции вынесены в отдельное окно Electron; токены игроков не пропадают при смене сцены; предпросмотр и плейлисты адаптированы; история свёрнута по умолчанию.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-17 06:21:42 +08:00
parent 712086ddca
commit a29c72b307
24 changed files with 506 additions and 290 deletions
-107
View File
@@ -550,113 +550,6 @@
line-height: 1.45;
}
.instructionsDialog {
position: fixed;
z-index: var(--z-modal);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(920px, calc(100vw - 32px));
max-height: min(86vh, 720px);
border-radius: var(--radius-lg);
border: 1px solid var(--stroke);
background: var(--color-surface-elevated);
box-shadow: var(--shadow-xl);
padding: 16px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 12px;
}
.instructionsLayout {
display: grid;
grid-template-columns: 1fr 220px;
gap: 14px;
min-height: 0;
max-height: min(62vh, 560px);
}
.instructionsContent {
min-height: 0;
overflow: auto;
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--bg0);
}
.instructionsContentTitle {
font-weight: 800;
font-size: var(--text-md);
margin-bottom: 12px;
}
.instructionsParagraph {
margin: 0 0 12px;
color: var(--text1);
font-size: var(--text-sm);
line-height: 1.55;
white-space: pre-wrap;
}
.instructionsParagraph:last-child {
margin-bottom: 0;
}
.instructionsInlineLink {
display: inline;
margin: 0;
padding: 0;
border: none;
background: none;
color: var(--accent, #a78bfa);
font: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.instructionsInlineLink:hover {
color: var(--text0);
}
.instructionsNav {
min-height: 0;
overflow: auto;
display: grid;
gap: 4px;
align-content: start;
padding: 6px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--panel2);
}
.instructionsNavItem {
text-align: left;
padding: 9px 10px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text2);
cursor: pointer;
font: inherit;
font-size: var(--text-xs);
line-height: 1.35;
}
.instructionsNavItem:hover {
background: var(--scene-list-hover-bg);
color: var(--text0);
}
.instructionsNavItemActive {
background: rgba(167, 139, 250, 0.18);
color: var(--text0);
font-weight: 700;
}
.aboutBody {
display: grid;
gap: 10px;
+11 -12
View File
@@ -37,7 +37,7 @@ import { Button, Input } from '../shared/ui/controls';
import { LayoutShell } from '../shared/ui/LayoutShell';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
import { AppAboutModal } from './about/EditorAboutModals';
import styles from './EditorApp.module.css';
import { EditorAudioTrackList } from './EditorAudioTrackList';
import {
@@ -132,8 +132,6 @@ export function EditorApp() {
const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false);
const [aboutMenuOpen, setAboutMenuOpen] = useState(false);
const [appAboutOpen, setAppAboutOpen] = useState(false);
const [instructionsOpen, setInstructionsOpen] = useState(false);
const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview');
const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [importSourceOpen, setImportSourceOpen] = useState(false);
@@ -384,6 +382,14 @@ export function EditorApp() {
const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph';
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
const openInstructions = useCallback((section: HelpSectionId = 'overview') => {
void getDndApi()
.invoke(ipcChannels.windows.openInstructions, { section })
.catch((err) => {
console.error('[editor] openInstructions failed', err);
});
}, []);
const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
if (!licenseActive || launching) return;
@@ -990,8 +996,7 @@ export function EditorApp() {
title={runHelpTooltip}
tooltipPlacement="bottom-left"
onClick={() => {
setInstructionsSection(runHelpSection);
setInstructionsOpen(true);
openInstructions(runHelpSection);
}}
>
?
@@ -1472,11 +1477,6 @@ export function EditorApp() {
snapshot={licenseSnap}
/>
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
<InstructionsModal
open={instructionsOpen}
initialSection={instructionsSection}
onClose={() => setInstructionsOpen(false)}
/>
{aboutMenuOpen && aboutMenuPos
? createPortal(
<div
@@ -1502,8 +1502,7 @@ export function EditorApp() {
className={styles.fileMenuItem}
onClick={() => {
setAboutMenuOpen(false);
setInstructionsSection('overview');
setInstructionsOpen(true);
openInstructions('overview');
}}
>
{t('menu.instructions')}
+8 -133
View File
@@ -1,22 +1,19 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
import { Button } from '../../shared/ui/controls';
import styles from '../EditorApp.module.css';
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../help/helpLinkify';
import {
HELP_SECTION_IDS,
helpSectionBodyKey,
helpSectionTitleKey,
type HelpSectionId,
} 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: 'Клуб настольных и бизнес-игр "КвестИя" - Подчуфарова Евгения Александровна',
url: 'https://vk.ru/club210361604',
},
{ kind: 'link', name: 'Старые кости', url: 'https://boosty.to/tabletoppodcasts' },
{ kind: 'text', name: 'Майер С.В. (Клуб «Серый Май»)' },
{ kind: 'link', name: 'true_rus(КАВ)', url: 'https://truecorp.su/' },
{ kind: 'text', name: 'Макс LonelyFlame Чурсин' },
@@ -119,125 +116,3 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
document.body,
);
}
type InstructionsModalProps = {
open: boolean;
onClose: () => void;
initialSection?: HelpSectionId;
};
function InstructionsModalBody({
initialSection,
onClose,
}: {
initialSection: HelpSectionId;
onClose: () => void;
}) {
const { t } = useEditorI18n();
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
const contentRef = useRef<HTMLDivElement | null>(null);
const navRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
useEffect(() => {
contentRef.current?.scrollTo({ top: 0 });
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
activeNav?.scrollIntoView({ block: 'nearest' });
}, [activeId]);
const linkCatalog = useMemo(
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
[t],
);
const body = t(helpSectionBodyKey(activeId));
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
const goToSection = (id: HelpSectionId) => {
setActiveId(id);
};
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.instructionsDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('help.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.instructionsLayout}>
<div ref={contentRef} className={styles.instructionsContent}>
<div className={styles.instructionsContentTitle}>{t(helpSectionTitleKey(activeId))}</div>
{paragraphs.map((p, i) => (
<p key={i} className={styles.instructionsParagraph}>
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
part.type === 'text' ? (
<React.Fragment key={j}>{part.value}</React.Fragment>
) : (
<button
key={j}
type="button"
className={styles.instructionsInlineLink}
onClick={() => goToSection(part.id)}
>
{part.value}
</button>
),
)}
</p>
))}
</div>
<nav ref={navRef} className={styles.instructionsNav} aria-label={t('help.navAria')}>
{HELP_SECTION_IDS.map((id) => (
<button
key={id}
type="button"
className={[
styles.instructionsNavItem,
id === activeId ? styles.instructionsNavItemActive : '',
]
.filter(Boolean)
.join(' ')}
aria-current={id === activeId ? 'true' : undefined}
onClick={() => goToSection(id)}
>
{t(helpSectionTitleKey(id))}
</button>
))}
</nav>
</div>
<div className={styles.modalFooter}>
<Button variant="primary" onClick={onClose}>
{t('common.close')}
</Button>
</div>
</div>
</>,
document.body,
);
}
export function InstructionsModal({ open, onClose, initialSection }: InstructionsModalProps) {
if (!open) return null;
const section = initialSection ?? 'overview';
return <InstructionsModalBody key={section} initialSection={section} onClose={onClose} />;
}
+7 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { HelpSectionId } from './helpSections';
import { parseHelpSectionId, type HelpSectionId } from './helpSections';
import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify';
const RU_TITLES: Record<HelpSectionId, string> = {
@@ -80,3 +80,9 @@ void test('findHelpLinkRanges: полное «Пульт управления»
assert.equal(ranges[0]?.id, 'controlPanel');
assert.equal(ranges[0]?.text, '«Пульт управления»');
});
void test('parseHelpSectionId: unknown falls back to overview', () => {
assert.equal(parseHelpSectionId('grid'), 'grid');
assert.equal(parseHelpSectionId(''), 'overview');
assert.equal(parseHelpSectionId('not-a-section'), 'overview');
});
+6
View File
@@ -40,3 +40,9 @@ export function helpSectionTitleKey(id: HelpSectionId): string {
export function helpSectionBodyKey(id: HelpSectionId): string {
return `help.section.${id}.body`;
}
export function parseHelpSectionId(raw: unknown): HelpSectionId {
const s = String(raw ?? '').trim();
if ((HELP_SECTION_IDS as readonly string[]).includes(s)) return s as HelpSectionId;
return 'overview';
}