feat(editor): побочные сюжетные линии и импорт/экспорт линий

Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на
пульте: визуальное выделение компонент графа, запрет недопустимых связей
между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий.

Реализован partial export/import сюжетных линий:
- экспорт выбранных линий в урезанный .ttrpg.zip с manifest в project.json;
- импорт в открытый проект с выбором линий, разрешением конфликтов названий
  сцен (одна модалка на операцию) и отчётом о результате;
- новое окно источника импорта: «Из проекта» (dropdown, без текущего) или
  «Из файла»; на главном экране — только полный импорт из файла.

Исправлены гонки при открытии/закрытии проекта (сериализация open/close в
main, сброс зависших состояний UI), залипание оверлея прогресса экспорта и
старт Electron в dev после готовности Vite.

Добавлены unit-тесты для lineage, export/import и контрактов zipStore.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-09 14:47:28 +08:00
parent de9190959c
commit c8ab9dd567
26 changed files with 3507 additions and 266 deletions
+99 -6
View File
@@ -160,11 +160,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.graph.title': 'Граф сцен',
'help.section.graph.body':
'Карта в центре показывает, как эпизоды связаны. Каждый прямоугольник — место на схеме; одна сцена может встретиться несколько раз (например, игроки снова возвращаются в таверну).\n\nДобавить сцену на карту:\n\n1) Возьмите сцену в левом списке.\n\n2) Перетащите на свободное место на карте.\n\nСделать переход между сценами:\n\n1) Наведите на нижнюю точку первой карточки.\n\n2) Потяните линию к верхней точке второй и отпустите — появится стрелка.\n\nИз одной сцены может выходить несколько стрелок — так вы делаете ветвление сюжета. Вторую стрелку к той же паре сцен провести нельзя.\n\nУдалить стрелку: правый клик по линии → «Удалить». Обычный клик по линии ничего не делает.\n\nС чего начинается игра: правый клик по карточке → «Начальная сцена» (появится метка «НАЧАЛО»), затем «Запустить» в шапке. Можно начать с любой карточки: ПКМ → «Запустить с этой сцены» — откроются презентация и пульт с выбранного места (метку «НАЧАЛО» ставить не обязательно).\n\nУбрать карточку с карты, не удаляя сцену из списка: ПКМ → «Удалить».\n\nМасштаб — кнопки внизу или колёсико мыши. «Показать всё» вместит всю схему на экран.',
'Карта в центре показывает, как эпизоды связаны. Каждый прямоугольник — место на схеме; одна сцена может встретиться несколько раз (например, игроки снова возвращаются в таверну).\n\nДобавить сцену на карту:\n\n1) Возьмите сцену в левом списке.\n\n2) Перетащите на свободное место на карте.\n\nСделать переход между сценами:\n\n1) Наведите на нижнюю точку первой карточки.\n\n2) Потяните линию к верхней точке второй и отпустите — появится стрелка.\n\nИз одной сцены может выходить несколько стрелок — так вы делаете ветвление сюжета. Вторую стрелку к той же паре сцен провести нельзя.\n\nУдалить стрелку: правый клик по линии → «Удалить». Обычный клик по линии ничего не делает.\n\nС чего начинается игра: правый клик по карточке → «Начальная сцена» (появится метка «НАЧАЛО»), затем «Запустить» в шапке. Можно начать с любой карточки основного сюжета: ПКМ → «Запустить с этой сцены» — откроются презентация и пульт с выбранного места (метку «НАЧАЛО» ставить не обязательно). Для карточек побочных линий этот пункт недоступен.\n\nУбрать карточку с карты, не удаляя сцену из списка: ПКМ → «Удалить».\n\nМасштаб — кнопки внизу или колёсико мыши. «Показать всё» вместит всю схему на экран.',
'help.section.sideStorylines.title': 'Побочные сюжетные линии',
'help.section.sideStorylines.body':
'Побочная линия — отдельная ветка сюжета, не связанная с основным сюжетом. Она нужна для ответвлений, флешбэков, побочных квестов и сцен «вне основного пути».\n\nСоздать побочную линию в редакторе:\n\n1) Добавьте сцены на карту и соедините их стрелками в отдельной группе — она не должна касаться основного сюжета (фиолетовые связи) и других побочных линий.\n\n2) Правый клик по стартовой карточке побочной ветки → «Начальная сцена побочной линии». Появится синяя метка «ПОБОЧНАЯ».\n\n3) В свойствах сцены задайте «Название побочной линии» — оно будет видно на пульте.\n\nПункт «Начальная сцена побочной линии» скрыт, если карточка уже связана с основным сюжетом (где есть фиолетовое «НАЧАЛО») или с другой побочной линией (где есть синее «ПОБОЧНАЯ»).\n\nСвязи внутри побочной линии и выделение её карточек — синего цвета (#0078d4). Между основным и побочным сюжетом, а также между разными побочными линиями, стрелки провести нельзя.\n\nСнять метку: ПКМ → «Снять метку «Начальная сцена побочной линии»». Название очистится, плитка исчезнет с пульта.\n\nУдалить стартовую карточку: если есть следующая сцена по стрелке — метка переносится на неё; если нет — вся побочная линия удаляется с карты.\n\nВо время игры на пульте под блоком «Музыка» появляется «Побочные сюжетные линии» — плитки с превью и названием. Клик переносит партию на первую сцену линии. Программа запоминает, с какой сцены основного сюжета вы ушли.\n\nПока идёт побочная линия, в «Варианты ветвления» первой опцией всегда «Вернуться в основной сюжет» — возврат на запомненную сцену. История «Сюжетная линия» продолжает записывать все шаги, включая побочную ветку.\n\nЗапустить побочную линию из редактора нельзя — только с пульта во время сессии.',
'help.section.sceneProps.title': 'Свойства сцены',
'help.section.sceneProps.body':
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» и «Описание» — для мастера. Описание видно на пульте в блоке сюжетной линии.\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF и др.).\n\n3) Для картинки можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки можно включить «Затемнить сцену»: при показе игроки сначала увидят карту в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков. На видео-сценах эффекты кистью недоступны.\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» и «Описание» — для мастера. Описание видно на пульте в блоке сюжетной линии.\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF и др.).\n\n3) Для картинки можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки можно включить «Затемнить сцену»: при показе игроки сначала увидят карту в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков. На видео-сценах эффекты кистью недоступны.\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
'help.section.campaignAudio.title': 'Аудио игры',
'help.section.campaignAudio.body':
@@ -247,10 +251,46 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'export.title': 'Экспорт проекта',
'export.project': 'ПРОЕКТ',
'export.hint':
'Далее откроется окно сохранения: укажите имя и папку для файла .ttrpg.zip — будет создана копия архива проекта.',
'Выберите сюжетные линии для экспорта. В архив попадут только выбранные линии, их сцены и материалы. Далее откроется окно сохранения .ttrpg.zip.',
'export.exporting': 'Экспорт…',
'export.saveAs': 'Сохранить как…',
'storyline.section': 'СЮЖЕТНАЯ ЛИНИЯ',
'storyline.main': 'Основная линия',
'storyline.loading': 'Загрузка линий…',
'storyline.empty': 'В проекте нет сюжетных линий с метками «НАЧАЛО» или «ПОБОЧНАЯ».',
'storyline.mainExistsHint': 'в проекте уже есть основная линия',
'importSource.title': 'Импорт',
'importSource.type': 'ТИП ИМПОРТА',
'importSource.fromProject': 'Из проекта',
'importSource.fromFile': 'Из файла',
'importSource.project': 'ПРОЕКТ',
'importSource.file': 'ФАЙЛ',
'importSource.chooseFile': 'Выбрать файл',
'importSource.noFileSelected': 'Файл не выбран',
'importSource.noOtherProjects': 'Нет других проектов для импорта.',
'importSource.fileOnlyHint': 'Выберите файл проекта (.ttrpg.zip) для полного импорта.',
'importStoryline.title': 'Импорт сюжетных линий',
'importStoryline.source': 'ИСТОЧНИК',
'importStoryline.continue': 'Далее',
'importStoryline.import': 'Импортировать',
'importStoryline.conflictsTitle': 'Совпадение названий сцен',
'importStoryline.conflictsHint':
'В импортируемых линиях есть сцены с такими же названиями, как в текущем проекте. Выберите действие для каждой.',
'importStoryline.createNewScene': 'Создать новую сцену',
'importStoryline.useExistingScene': 'Использовать «{title}»',
'importStoryline.reportTitle': 'Импорт завершён',
'importStoryline.reportLines': 'Импортировано линий: {count}',
'importStoryline.reportScenesCreated': 'Создано новых сцен: {count}',
'importStoryline.reportScenesReused': 'Использовано существующих сцен: {count}',
'importStoryline.reportNodes': 'Добавлено карточек на граф: {count}',
'importStoryline.reportEdges': 'Добавлено связей: {count}',
'importStoryline.reportAssetsCopied': 'Скопировано файлов материалов: {count}',
'importStoryline.reportAssetsReused': 'Повторно использовано материалов: {count}',
'importStoryline.reportRenamedSides': 'Переименованы побочные линии: {names}',
'confirmDelete.title': 'Удаление проекта',
'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.',
'confirmDelete.failedTitle': 'Не удалось удалить',
@@ -299,6 +339,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'sceneCard.menu': 'Меню сцены',
'graph.badgeStart': 'НАЧАЛО',
'graph.badgeSideStory': 'ПОБОЧНАЯ',
'graph.untitled': 'Без названия',
'graph.videoBadge': 'Видео',
'graph.audioBadge': 'Аудио',
@@ -312,8 +353,12 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'graph.fitAll': 'Показать всё',
'graph.startScene': 'Начальная сцена',
'graph.unsetStartScene': 'Снять метку «Начальная сцена»',
'graph.sideStoryStartScene': 'Начальная сцена побочной линии',
'graph.unsetSideStoryStartScene': 'Снять метку «Начальная сцена побочной линии»',
'graph.runFromScene': 'Запустить с этой сцены',
'scene.sideStoryLineTitle': 'Название побочной линии',
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
'control.effects': 'ЭФФЕКТЫ',
'control.tools': 'Инструменты',
@@ -343,6 +388,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.videoBrushHint':
'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).',
'control.branches': 'Варианты ветвления',
'control.returnToMainStory': 'Вернуться в основной сюжет',
'control.sideStoryLines': 'Побочные сюжетные линии',
'control.option': 'ОПЦИЯ {n}',
'control.unnamed': 'Без названия',
'control.switchScene': 'Переключить',
@@ -488,11 +535,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.graph.title': 'Scene graph',
'help.section.graph.body':
'The map in the center shows how episodes connect. Each box is a spot on your story; the same scene can appear more than once (for example, players return to the tavern).\n\nPlace a scene on the map:\n\n1) Grab a scene in the left list.\n\n2) Drag it onto empty space on the map.\n\nConnect two scenes:\n\n1) Point at the bottom dot on the first card.\n\n2) Drag a line to the top dot on the second and release — an arrow appears.\n\nOne scene can have several outgoing arrows — that is how you branch the story. You cannot draw a second arrow between the same pair.\n\nRemove an arrow: right-click the line → Delete. A normal click on the line does nothing.\n\nSet where the game starts: right-click a card → Start scene (a START badge appears), then click Run in the header. You can also start from any card: right-click → Start from this scene — presentation and the control panel open at that spot (no START badge required).\n\nRemove a card from the map without deleting the scene from the list: right-click → Delete.\n\nZoom with the buttons at the bottom or the mouse wheel. Fit view shows the whole map.',
'The map in the center shows how episodes connect. Each box is a spot on your story; the same scene can appear more than once (for example, players return to the tavern).\n\nPlace a scene on the map:\n\n1) Grab a scene in the left list.\n\n2) Drag it onto empty space on the map.\n\nConnect two scenes:\n\n1) Point at the bottom dot on the first card.\n\n2) Drag a line to the top dot on the second and release — an arrow appears.\n\nOne scene can have several outgoing arrows — that is how you branch the story. You cannot draw a second arrow between the same pair.\n\nRemove an arrow: right-click the line → Delete. A normal click on the line does nothing.\n\nSet where the game starts: right-click a card → Start scene (a START badge appears), then click Run in the header. You can also start from any main-story card: right-click → Start from this scene — presentation and the control panel open at that spot (no START badge required). Side-story cards do not offer this menu item.\n\nRemove a card from the map without deleting the scene from the list: right-click → Delete.\n\nZoom with the buttons at the bottom or the mouse wheel. Fit view shows the whole map.',
'help.section.sideStorylines.title': 'Side storylines',
'help.section.sideStorylines.body':
'A side storyline is a separate branch not connected to the main plot — for detours, flashbacks, side quests, and scenes off the main path.\n\nCreate one in the editor:\n\n1) Place scenes on the map and link them in an isolated group — it must not touch the main story (purple links) or other side storylines.\n\n2) Right-click the starting card → Side storyline start scene. A blue SIDE badge appears.\n\n3) In scene properties, set Side storyline title — it appears on the control panel.\n\nThe menu item is hidden if the card is already linked to the main story (purple START anywhere in the group) or another side storyline (blue SIDE in the group).\n\nLinks inside a side storyline and card selection use blue (#0078d4). You cannot link main to side, or one side storyline to another.\n\nClear the mark: right-click → Clear side storyline start mark. The title is cleared and the tile disappears from the control panel.\n\nDeleting the start card: if there is a next scene along an arrow, the mark moves to it; otherwise the whole side storyline is removed from the map.\n\nDuring play, Side storylines appears under Music on the control panel — tiles with preview and title. Clicking jumps to the first scene. The app remembers which main-story scene you left from.\n\nWhile in a side storyline, Branch options always lists Return to main story first — back to the remembered scene. Storyline history keeps recording all steps, including inside side branches.\n\nYou cannot launch a side storyline from the editor — only from the control panel during a session.',
'help.section.sceneProps.title': 'Scene properties',
'help.section.sceneProps.body':
'Select a scene in the left list — its properties open on the right.\n\nScene title and Description are for the GM. The description appears in the storyline on the control panel.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, etc.).\n\n3) For images, use Rotate (90° steps). Clear removes the preview.\n\n4) For images, enable Darken scene so players start in full darkness and you reveal the map with the Opening brush on the control panel (see Effects).\n\nFor video, enable Autostart if the clip should start on its own on the player screen. Brush effects are not available on video scenes.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
'Select a scene in the left list — its properties open on the right.\n\nScene title and Description are for the GM. The description appears in the storyline on the control panel.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, etc.).\n\n3) For images, use Rotate (90° steps). Clear removes the preview.\n\n4) For images, enable Darken scene so players start in full darkness and you reveal the map with the Opening brush on the control panel (see Effects).\n\nFor video, enable Autostart if the clip should start on its own on the player screen. Brush effects are not available on video scenes.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
'help.section.campaignAudio.title': 'Game audio',
'help.section.campaignAudio.body':
@@ -575,10 +626,46 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'export.title': 'Export project',
'export.project': 'PROJECT',
'export.hint':
'A save dialog will open: choose a name and folder for the .ttrpg.zip file — a copy of the project archive will be created.',
'Select storylines to export. The archive will include only the chosen lines, their scenes, and assets. Then choose where to save the .ttrpg.zip file.',
'export.exporting': 'Exporting…',
'export.saveAs': 'Save as…',
'storyline.section': 'STORYLINE',
'storyline.main': 'Main storyline',
'storyline.loading': 'Loading storylines…',
'storyline.empty': 'This project has no storylines marked with START or SIDE badges.',
'storyline.mainExistsHint': 'main storyline already exists in this project',
'importSource.title': 'Import',
'importSource.type': 'IMPORT TYPE',
'importSource.fromProject': 'From project',
'importSource.fromFile': 'From file',
'importSource.project': 'PROJECT',
'importSource.file': 'FILE',
'importSource.chooseFile': 'Choose file',
'importSource.noFileSelected': 'No file selected',
'importSource.noOtherProjects': 'No other projects available to import from.',
'importSource.fileOnlyHint': 'Choose a project file (.ttrpg.zip) for a full import.',
'importStoryline.title': 'Import storylines',
'importStoryline.source': 'SOURCE',
'importStoryline.continue': 'Continue',
'importStoryline.import': 'Import',
'importStoryline.conflictsTitle': 'Duplicate scene titles',
'importStoryline.conflictsHint':
'Imported storylines contain scenes with the same titles as in the current project. Choose what to do for each.',
'importStoryline.createNewScene': 'Create new scene',
'importStoryline.useExistingScene': 'Use existing «{title}»',
'importStoryline.reportTitle': 'Import complete',
'importStoryline.reportLines': 'Storylines imported: {count}',
'importStoryline.reportScenesCreated': 'New scenes created: {count}',
'importStoryline.reportScenesReused': 'Existing scenes reused: {count}',
'importStoryline.reportNodes': 'Graph cards added: {count}',
'importStoryline.reportEdges': 'Connections added: {count}',
'importStoryline.reportAssetsCopied': 'Asset files copied: {count}',
'importStoryline.reportAssetsReused': 'Assets reused: {count}',
'importStoryline.reportRenamedSides': 'Renamed side storylines: {names}',
'confirmDelete.title': 'Delete project',
'confirmDelete.body':
'Permanently delete project “{name}”? The file and cache will be removed from disk.',
@@ -619,6 +706,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'scene.darkenScene': 'Darken scene',
'scene.rotate': 'Rotate',
'scene.audio': 'SCENE AUDIO',
'scene.sideStoryLineTitle': 'Side storyline title',
'scene.removeTitle': 'Remove from scene',
'scene.branching': 'BRANCHING',
'scene.branchingHint':
@@ -628,6 +716,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'sceneCard.menu': 'Scene menu',
'graph.badgeStart': 'START',
'graph.badgeSideStory': 'SIDE',
'graph.untitled': 'Untitled',
'graph.videoBadge': 'Video',
'graph.audioBadge': 'Audio',
@@ -641,6 +730,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'graph.fitAll': 'Fit view',
'graph.startScene': 'Start scene',
'graph.unsetStartScene': 'Clear start scene mark',
'graph.sideStoryStartScene': 'Side storyline start scene',
'graph.unsetSideStoryStartScene': 'Clear side storyline start mark',
'graph.runFromScene': 'Start from this scene',
'control.remoteTitle': 'CONTROL PANEL',
@@ -672,6 +763,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.videoBrushHint':
'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).',
'control.branches': 'Branch options',
'control.returnToMainStory': 'Return to main story',
'control.sideStoryLines': 'Side storylines',
'control.option': 'OPTION {n}',
'control.unnamed': 'Untitled',
'control.switchScene': 'Switch',