From 877fbaa8e723048d25485f102898b75d16e422bb Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Tue, 6 Oct 2026 11:41:42 +0800 Subject: [PATCH] feat: save games and edit scene darkness Persist show state to local saves; author map darkness in scene editor with bake-to-scene and help updates. Co-authored-by: Cursor --- app/main/effects/effectsStore.ts | 77 +++- app/main/effects/gameSave.test.ts | 108 +++++ app/main/effects/sceneDarknessStore.test.ts | 60 +++ app/main/effects/sceneDarknessStore.ts | 48 ++- app/main/effects/sceneMarkerStore.ts | 26 ++ app/main/foundry/foundryImport.ts | 1 + app/main/gameSave/gameSavesStore.ts | 84 ++++ app/main/index.ts | 240 ++++++++++- app/main/initiative/initiativeSessionStore.ts | 12 + app/main/materials/materialsOverlayStore.ts | 12 + app/main/npcs/npcsOverlayStore.ts | 13 + .../players/sceneNpcTokensSessionStore.ts | 10 + .../players/scenePlayerTokensSessionStore.ts | 10 + app/main/project/zipStore.ts | 9 + app/main/sceneTraps/sceneTrapsStore.ts | 25 ++ app/main/sessionTimer/timerSessionStore.ts | 16 + app/main/tokens/sceneTokensSessionStore.ts | 8 + app/main/tokens/tokenPathSessionStore.ts | 13 + app/renderer/control/ControlApp.module.css | 44 ++ app/renderer/control/ControlApp.tsx | 211 ++++++++- .../control/controlApp.effectsPanel.test.ts | 7 +- app/renderer/editor/EditorApp.module.css | 34 ++ app/renderer/editor/EditorApp.tsx | 151 ++++++- app/renderer/editor/help/helpLinkify.ts | 1 + app/renderer/editor/i18n/editorMessages.ts | 54 ++- app/renderer/editor/state/projectState.ts | 14 + .../sceneEditor/SceneEditorApp.module.css | 33 ++ app/renderer/sceneEditor/SceneEditorApp.tsx | 402 ++++++++++++++++++ app/shared/gameSave/buildSnapshot.ts | 104 +++++ app/shared/graph/sceneListOrder.test.ts | 1 + .../graph/storylineExportImport.test.ts | 1 + app/shared/ipc/contracts.ts | 23 +- app/shared/types/domain.ts | 6 + app/shared/types/effects.ts | 12 + app/shared/types/gameSave.ts | 74 ++++ app/shared/types/index.ts | 1 + app/shared/types/sceneDarkness.test.ts | 43 ++ app/shared/types/sceneDarkness.ts | 40 ++ docs/game-save-plan.md | 57 +++ docs/scene-darkness-editor-plan.md | 35 ++ docs/vk-covers/vk-cover-release-1-1-0.png | Bin 0 -> 664106 bytes docs/vk-news-1.1.0-release.md | 36 ++ package.json | 2 +- 43 files changed, 2086 insertions(+), 72 deletions(-) create mode 100644 app/main/effects/gameSave.test.ts create mode 100644 app/main/gameSave/gameSavesStore.ts create mode 100644 app/shared/gameSave/buildSnapshot.ts create mode 100644 app/shared/types/gameSave.ts create mode 100644 docs/game-save-plan.md create mode 100644 docs/scene-darkness-editor-plan.md create mode 100644 docs/vk-covers/vk-cover-release-1-1-0.png create mode 100644 docs/vk-news-1.1.0-release.md diff --git a/app/main/effects/effectsStore.ts b/app/main/effects/effectsStore.ts index 60d30fa..ddfa69f 100644 --- a/app/main/effects/effectsStore.ts +++ b/app/main/effects/effectsStore.ts @@ -1,7 +1,13 @@ import crypto from 'node:crypto'; import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser'; -import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types'; +import { + isPersistentFieldEffect, + type EffectInstance, + type EffectsEvent, + type EffectsState, + type EffectToolState, +} from '../../shared/types'; function nowMs(): number { return Date.now(); @@ -23,6 +29,9 @@ export class EffectsStore { tool: defaultTool(), instances: [], }; + /** Постоянные эффекты по карточке (graphNodeId или sceneId) на время показа. */ + private cache = new Map(); + private currentKey: string | null = null; getState(): EffectsState { // Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров. @@ -36,13 +45,70 @@ export class EffectsStore { serverNowMs: nowMs(), instances: [], }; + this.commitCurrent(); return this.state; } + /** Новый показ: и текущая карточка, и кэш остальных. */ + resetSession(): EffectsState { + this.cache.clear(); + this.currentKey = null; + this.state = { + revision: this.state.revision + 1, + serverNowMs: nowMs(), + tool: this.state.tool, + instances: [], + }; + return this.getState(); + } + + /** + * Смена карточки: туман/огонь/дождь/вода/лёд/пятно остаются на ушедшей + * и поднимаются на новой. Разовые эффекты действий сбрасываются. + */ + switchScene(cacheKey: string | null): EffectsState { + this.commitCurrent(); + this.currentKey = cacheKey; + const instances = cacheKey ? [...(this.cache.get(cacheKey) ?? [])] : []; + this.state = { + ...this.state, + revision: this.state.revision + 1, + serverNowMs: nowMs(), + instances, + }; + return this.getState(); + } + + exportByCard(): Record { + this.commitCurrent(); + const out: Record = {}; + for (const [key, instances] of this.cache) { + const kept = instances.filter((i) => isPersistentFieldEffect(i.type)); + if (kept.length > 0) out[key] = kept.map((i) => ({ ...i })); + } + return out; + } + + importByCard(byCard: Record): void { + this.cache.clear(); + this.currentKey = null; + this.state = { + ...this.state, + revision: this.state.revision + 1, + serverNowMs: nowMs(), + instances: [], + }; + for (const [key, instances] of Object.entries(byCard)) { + const kept = instances.filter((i) => isPersistentFieldEffect(i.type)); + if (kept.length > 0) this.cache.set(key, kept.map((i) => ({ ...i }))); + } + } + dispatch(event: EffectsEvent): EffectsState { const s = this.state; const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix)); this.state = next; + this.commitCurrent(); return next; } @@ -78,9 +144,18 @@ export class EffectsStore { serverNowMs: now, instances: kept, }; + this.commitCurrent(); return true; } + private commitCurrent(): void { + if (this.currentKey === null) return; + this.cache.set( + this.currentKey, + this.state.instances.filter((i) => isPersistentFieldEffect(i.type)).map((i) => ({ ...i })), + ); + } + makeId(prefix: string): string { return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`; } diff --git a/app/main/effects/gameSave.test.ts b/app/main/effects/gameSave.test.ts new file mode 100644 index 0000000..19419a5 --- /dev/null +++ b/app/main/effects/gameSave.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { EffectsStore } from './effectsStore'; +import { GameSavesStore } from '../gameSave/gameSavesStore'; +import { canonicalGameSave, emptyGameSaveBody, freezeTokenPathPlayback } from '../../shared/gameSave/buildSnapshot'; +import type { EffectInstance } from '../../shared/types'; +import type { TokenPath } from '../../shared/types/tokenPath'; +import type { TokenPathPlaybackEntry } from '../../shared/types/tokenPathSession'; + +function fog(): EffectInstance { + return { + id: 'fog1', + type: 'fog', + seed: 1, + createdAtMs: 1, + points: [{ x: 0.2, y: 0.2, tMs: 1 }], + radiusN: 0.1, + opacity: 0.4, + lifetimeMs: null, + }; +} + +function bolt(): EffectInstance { + return { + id: 'lt1', + type: 'lightning', + seed: 2, + createdAtMs: 1, + start: { x: 0, y: 0 }, + end: { x: 1, y: 1 }, + widthN: 0.02, + intensity: 1, + lifetimeMs: 800, + }; +} + +void test('effects stay on the card and action effects do not', () => { + const store = new EffectsStore(); + store.switchScene('card-a'); + store.dispatch({ kind: 'instance.add', instance: fog() }); + store.dispatch({ kind: 'instance.add', instance: bolt() }); + store.switchScene('card-b'); + assert.equal(store.getState().instances.length, 0); + store.switchScene('card-a'); + assert.deepEqual( + store.getState().instances.map((i) => i.type), + ['fog'], + ); + store.dispatch({ kind: 'instances.clear' }); + store.switchScene('card-b'); + store.switchScene('card-a'); + assert.equal(store.getState().instances.length, 0); +}); + +void test('freezeTokenPathPlayback parks a moving token', () => { + const pathLine: TokenPath = { + points: [ + { nx: 0, ny: 0.5 }, + { nx: 1, ny: 0.5 }, + ], + closed: false, + loopMode: 'once', + durationSec: 2, + startMode: 'onEnter', + delaySec: 0, + facingMode: 'tangentSmooth', + fixedRotationDeg: 0, + }; + const entry: TokenPathPlaybackEntry = { + kind: 'token', + placementId: 't1', + phase: 'moving', + segmentStartedAtMs: 0, + baseDist: 0, + direction: 1, + rejoinDist: null, + durationSec: 2, + pathLength: 1, + }; + const frozen = freezeTokenPathPlayback({ 'token:t1': entry }, () => pathLine, 1000); + const next = frozen['token:t1']; + assert.ok(next); + assert.equal(next.phase, 'stopped'); + assert.ok(next.baseDist > 0.4 && next.baseDist < 0.6); +}); + +void test('game save file keeps id on overwrite and lists one campaign', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-saves-')); + const store = new GameSavesStore(root); + const body = emptyGameSaveBody('p_one'); + const first = await store.write('p_one', body, { id: null, name: 'Вечер' }); + const again = await store.write('p_one', body, { id: first.id, name: 'Вечер 2' }); + assert.equal(again.id, first.id); + assert.equal(again.name, 'Вечер 2'); + const list = await store.list('p_one'); + assert.equal(list.length, 1); + assert.equal(list[0]?.name, 'Вечер 2'); + const other = emptyGameSaveBody('p_two'); + await store.write('p_two', other, { id: null, name: 'Другая' }); + assert.equal((await store.list('p_one')).length, 1); + const loaded = await store.read('p_one', first.id); + const { id: _id, name: _name, savedAt: _savedAt, ...rest } = loaded; + assert.equal(canonicalGameSave(rest), canonicalGameSave(body)); +}); diff --git a/app/main/effects/sceneDarknessStore.test.ts b/app/main/effects/sceneDarknessStore.test.ts index 4ddaacd..6491db5 100644 --- a/app/main/effects/sceneDarknessStore.test.ts +++ b/app/main/effects/sceneDarknessStore.test.ts @@ -67,6 +67,66 @@ void test('SceneDarknessStore: draft синхронизируется и сбр assert.equal(store.getState().strokes.length, 1); }); +void test('SceneDarknessStore: cache miss берёт seedStrokes из сцены', () => { + const store = new SceneDarknessStore(); + const seed = [ + { + id: 'base1', + seed: 7, + createdAtMs: 50, + points: [{ x: 0.3, y: 0.4, tMs: 50 }], + radiusN: 0.06, + mode: 'reveal' as const, + }, + ]; + store.switchScene('card_a', true, seed); + assert.equal(store.getState().strokes.length, 1); + assert.equal(store.getState().strokes[0]?.id, 'base1'); + store.dispatch({ + kind: 'stroke.add', + stroke: { + id: 'session1', + seed: 2, + createdAtMs: 100, + points: [{ x: 0.5, y: 0.5, tMs: 100 }], + radiusN: 0.08, + mode: 'cover', + }, + }); + store.switchScene('card_b', true, []); + store.switchScene('card_a', true, seed); + assert.equal(store.getState().strokes.length, 2); + assert.equal(store.getState().strokes[1]?.id, 'session1'); +}); + +void test('SceneDarknessStore: resetSession снова берёт seed', () => { + const store = new SceneDarknessStore(); + const seed = [ + { + id: 'base1', + seed: 1, + createdAtMs: 10, + points: [{ x: 0.1, y: 0.1, tMs: 10 }], + radiusN: 0.05, + }, + ]; + store.switchScene('card_a', true, seed); + store.dispatch({ + kind: 'stroke.add', + stroke: { + id: 's1', + seed: 2, + createdAtMs: 20, + points: [{ x: 0.2, y: 0.2, tMs: 20 }], + radiusN: 0.05, + }, + }); + store.resetSession(); + store.switchScene('card_a', true, seed); + assert.equal(store.getState().strokes.length, 1); + assert.equal(store.getState().strokes[0]?.id, 'base1'); +}); + void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => { const store = new SceneDarknessStore(); store.switchScene('scene_a', true); diff --git a/app/main/effects/sceneDarknessStore.ts b/app/main/effects/sceneDarknessStore.ts index 6e9647c..ade8a99 100644 --- a/app/main/effects/sceneDarknessStore.ts +++ b/app/main/effects/sceneDarknessStore.ts @@ -29,14 +29,26 @@ export class SceneDarknessStore { /** * Переключение сцены: сохраняем текущие штрихи в кэш и загружаем состояние новой сцены. + * Если ключа ещё не было в сессии — берём seed (база из файла партии). */ - switchScene(cacheKey: string | null, enabled: boolean): SceneDarknessState { + switchScene( + cacheKey: string | null, + enabled: boolean, + seedStrokes: readonly SceneDarknessRevealStroke[] = [], + ): SceneDarknessState { if (this.currentKey !== null) { - this.cache.set(this.currentKey, [...this.state.strokes]); + this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke)); } this.currentKey = cacheKey; - const strokes = - cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : []; + let strokes: SceneDarknessRevealStroke[] = []; + if (cacheKey && enabled) { + if (this.cache.has(cacheKey)) { + strokes = (this.cache.get(cacheKey) ?? []).map(cloneStroke); + } else { + strokes = seedStrokes.map(cloneStroke); + this.cache.set(cacheKey, strokes.map(cloneStroke)); + } + } this.state = { revision: this.state.revision + 1, enabled, @@ -77,4 +89,32 @@ export class SceneDarknessStore { } } } + + /** Кэш по карточкам, включая текущую. Пустые карточки не входят. */ + exportByCard(): Record { + if (this.currentKey !== null) { + this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke)); + } + const out: Record = {}; + for (const [key, strokes] of this.cache) { + if (strokes.length === 0) continue; + out[key] = strokes.map(cloneStroke); + } + return out; + } + + /** Кладёт кэш, не переключая карточку. Дальше вызывают switchScene. */ + importByCard(byCard: Record): void { + this.cache.clear(); + this.currentKey = null; + this.state = emptyState(); + for (const [key, strokes] of Object.entries(byCard)) { + if (!Array.isArray(strokes) || strokes.length === 0) continue; + this.cache.set(key, strokes.map(cloneStroke)); + } + } +} + +function cloneStroke(stroke: SceneDarknessRevealStroke): SceneDarknessRevealStroke { + return { ...stroke, points: stroke.points.map((p) => ({ ...p })) }; } diff --git a/app/main/effects/sceneMarkerStore.ts b/app/main/effects/sceneMarkerStore.ts index 36ca9cb..5a25390 100644 --- a/app/main/effects/sceneMarkerStore.ts +++ b/app/main/effects/sceneMarkerStore.ts @@ -105,4 +105,30 @@ export class SceneMarkerStore { } } } + + exportByCard(): Record { + if (this.currentKey !== null) { + this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke)); + } + const out: Record = {}; + for (const [key, strokes] of this.cache) { + if (strokes.length === 0) continue; + out[key] = strokes.map(cloneStroke); + } + return out; + } + + importByCard(byCard: Record): void { + this.cache.clear(); + this.currentKey = null; + this.state = emptyState(); + for (const [key, strokes] of Object.entries(byCard)) { + if (!Array.isArray(strokes) || strokes.length === 0) continue; + this.cache.set(key, strokes.map(cloneStroke)); + } + } +} + +function cloneStroke(stroke: SceneMarkerStroke): SceneMarkerStroke { + return { ...stroke, points: stroke.points.map((p) => ({ ...p })) }; } diff --git a/app/main/foundry/foundryImport.ts b/app/main/foundry/foundryImport.ts index 12fab9c..d1e72a1 100644 --- a/app/main/foundry/foundryImport.ts +++ b/app/main/foundry/foundryImport.ts @@ -392,6 +392,7 @@ export async function buildProjectFromFoundryDocuments( previewVideoAutostart: previewAssetType === 'video', previewRotationDeg: 0, darkenScene: false, + darknessStrokes: [], traps: [], tokens: [], npcTokens: [], diff --git a/app/main/gameSave/gameSavesStore.ts b/app/main/gameSave/gameSavesStore.ts new file mode 100644 index 0000000..4963662 --- /dev/null +++ b/app/main/gameSave/gameSavesStore.ts @@ -0,0 +1,84 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { defaultGameSaveName } from '../../shared/gameSave/buildSnapshot'; +import { GAME_SAVE_SCHEMA_VERSION, type GameSaveBody, type GameSaveFile, type GameSaveListItem } from '../../shared/types/gameSave'; + +function safeSegment(raw: string): string { + const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, ''); + if (!cleaned || cleaned === '.' || cleaned === '..') throw new Error('GAME_SAVE_BAD_ID'); + return cleaned; +} + +export class GameSavesStore { + private readonly rootDir: string; + + constructor(userData: string) { + this.rootDir = path.join(userData, 'game-saves'); + } + + async list(projectId: string): Promise { + const dir = this.projectDir(projectId); + let names: string[] = []; + try { + names = await fs.readdir(dir); + } catch { + return []; + } + const items: GameSaveListItem[] = []; + for (const name of names) { + if (!name.endsWith('.json')) continue; + try { + const file = await this.readFile(path.join(dir, name)); + if (file.projectId !== projectId) continue; + items.push({ id: file.id, name: file.name, savedAt: file.savedAt }); + } catch { + continue; + } + } + items.sort((a, b) => (a.savedAt < b.savedAt ? 1 : a.savedAt > b.savedAt ? -1 : 0)); + return items; + } + + async read(projectId: string, id: string): Promise { + const filePath = this.filePath(projectId, id); + try { + const file = await this.readFile(filePath); + if (file.projectId !== projectId || file.id !== id) throw new Error('GAME_SAVE_NOT_FOUND'); + return file; + } catch (err) { + if (err instanceof Error && err.message.startsWith('GAME_SAVE_')) throw err; + throw new Error('GAME_SAVE_NOT_FOUND'); + } + } + + async write(projectId: string, body: GameSaveBody, opts: { id: string | null; name: string }): Promise { + if (body.projectId !== projectId) throw new Error('GAME_SAVE_PROJECT_MISMATCH'); + const id = opts.id ? safeSegment(opts.id) : `gs_${crypto.randomBytes(8).toString('hex')}`; + const name = opts.name.trim() || defaultGameSaveName(); + const savedAt = new Date().toISOString(); + const file: GameSaveFile = { ...body, id, name, savedAt, schemaVersion: GAME_SAVE_SCHEMA_VERSION }; + const filePath = this.filePath(projectId, id); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(file), 'utf8'); + return { id, name, savedAt }; + } + + private projectDir(projectId: string): string { + return path.join(this.rootDir, safeSegment(projectId)); + } + + private filePath(projectId: string, id: string): string { + return path.join(this.projectDir(projectId), `${safeSegment(id)}.json`); + } + + private async readFile(filePath: string): Promise { + const raw = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw) as GameSaveFile; + if (!parsed || parsed.schemaVersion !== GAME_SAVE_SCHEMA_VERSION || typeof parsed.id !== 'string') { + throw new Error('GAME_SAVE_NOT_FOUND'); + } + return parsed; + } +} diff --git a/app/main/index.ts b/app/main/index.ts index c1a0d36..2a16b38 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -20,7 +20,9 @@ import { stripProjectZipExtension, } from '../shared/project/projectZipExtension'; import type { Project } from '../shared/types'; -import { asNpcId } from '../shared/types/ids'; +import { canonicalGameSave, freezeTokenPathPlayback, pathForPlacement } from '../shared/gameSave/buildSnapshot'; +import { GAME_SAVE_SCHEMA_VERSION, type GameSaveBody, type GameSaveFile } from '../shared/types/gameSave'; +import { asNpcId, asSceneId, type MaterialId } from '../shared/types/ids'; import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers'; import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport'; import { @@ -28,7 +30,9 @@ import { previewRotationStepsCw, } from '../shared/types/scenePreviewRotation'; +import { getSessionTimerRemainingMs } from '../shared/types/sessionTimer'; import { EffectsStore, effectsDefaultTool } from './effects/effectsStore'; +import { GameSavesStore } from './gameSave/gameSavesStore'; import { SceneDarknessStore } from './effects/sceneDarknessStore'; import { SceneMarkerStore } from './effects/sceneMarkerStore'; import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore'; @@ -204,6 +208,9 @@ const tokenPathSessionStore = new TokenPathSessionStore(); const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore(); const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore(); const tokenGridSnapSessionStore = new TokenGridSnapSessionStore(); +let gameSavesStore: GameSavesStore | null = null; +/** Канонический слепок последнего сохранения этого показа. null — ещё не сохраняли. */ +let lastSaveCanonical: string | null = null; let tokensStore: TokensStore | null = null; let playersStore: PlayersStore | null = null; let gameSystemsStore: GameSystemsStore | null = null; @@ -514,7 +521,168 @@ function syncSceneDarknessForProject(project: Project): void { const enabled = Boolean(scene?.darkenScene) && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video'); - sceneDarknessStore.switchScene(cacheKey, enabled); + sceneDarknessStore.switchScene(cacheKey, enabled, scene?.darknessStrokes ?? []); +} + +function syncSceneEffectsForProject(project: Project): void { + const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null; + effectsStore.switchScene(cacheKey); +} + +function captureGameSaveBody(project: Project): GameSaveBody { + const now = Date.now(); + const timer = timerSessionStore.getState(); + const players = scenePlayerTokensSessionStore.getState(); + const initiative = initiativeSessionStore.getState(); + const materials = materialsOverlayStore.getState(); + const npcs = npcsOverlayStore.getState(); + const camera = sceneViewStore.getState(); + const npcTokens = sceneNpcTokensSessionStore.getState(); + const paths = tokenPathSessionStore.getState(); + return { + schemaVersion: GAME_SAVE_SCHEMA_VERSION, + projectId: String(project.id), + graphNodeId: project.currentGraphNodeId ? String(project.currentGraphNodeId) : null, + sceneId: project.currentSceneId ? String(project.currentSceneId) : null, + darknessByCard: sceneDarknessStore.exportByCard(), + markersByCard: sceneMarkerStore.exportByCard(), + trapsByCard: sceneTrapsStore.exportByCard(), + effectsByCard: effectsStore.exportByCard(), + sceneTokensByPlacementId: { ...sceneTokensSessionStore.getState().byPlacementId }, + npcTokens: { + byPlacementId: { ...npcTokens.byPlacementId }, + scale: npcTokens.scale, + spawned: { ...npcTokens.spawned }, + }, + tokenPaths: { + presentationVisible: { ...paths.presentationVisible }, + playback: freezeTokenPathPlayback( + paths.playback, + (entry) => pathForPlacement(project, entry.kind, entry.placementId), + now, + ), + }, + timer: { + durationMs: timer.durationMs, + remainingMs: getSessionTimerRemainingMs(timer, now), + visibleToPlayers: timer.visibleToPlayers, + layout: { ...timer.layout }, + }, + playerTokens: { + selectedPlayerIds: [...players.selectedPlayerIds], + visible: players.visible, + byPlayerId: players.visible ? { ...players.byPlayerId } : {}, + }, + camera: { scale: camera.scale, ox: camera.ox, oy: camera.oy }, + materials: { + activeMaterialIds: materials.activeMaterialIds.map(String), + layouts: { ...materials.layouts }, + legendLayouts: { ...materials.legendLayouts }, + focusMaterialId: materials.focusMaterialId ? String(materials.focusMaterialId) : null, + }, + npcAvatars: { + activeNpcIds: npcs.activeNpcIds.map(String), + layouts: { ...npcs.layouts }, + focusNpcId: npcs.focusNpcId ? String(npcs.focusNpcId) : null, + }, + initiative: { + enabled: initiative.enabled, + entries: { ...initiative.entries }, + order: [...initiative.order], + reserve: [...initiative.reserve], + activeId: initiative.activeId, + conditions: { ...initiative.conditions }, + playersVisible: initiative.playersVisible, + }, + tokenGridSnap: tokenGridSnapSessionStore.getState().enabled, + }; +} + +function sessionIsDirty(project: Project | null): boolean { + if (!project) return false; + if (lastSaveCanonical === null) return true; + return canonicalGameSave(captureGameSaveBody(project)) !== lastSaveCanonical; +} + +async function applyGameSave(project: Project, file: GameSaveFile): Promise { + if (file.projectId !== String(project.id)) throw new Error('GAME_SAVE_PROJECT_MISMATCH'); + let next = project; + if (file.graphNodeId) { + const gn = project.sceneGraphNodes.find((n) => String(n.id) === file.graphNodeId); + if (!gn) throw new Error('GAME_SAVE_CARD_MISSING'); + next = projectStore.setSessionPointer(gn.id, gn.sceneId); + } else if (file.sceneId && project.scenes[asSceneId(file.sceneId)]) { + next = projectStore.setSessionPointer(null, asSceneId(file.sceneId)); + } else if (file.sceneId) { + throw new Error('GAME_SAVE_CARD_MISSING'); + } + + sceneDarknessStore.importByCard(file.darknessByCard ?? {}); + sceneMarkerStore.importByCard(file.markersByCard ?? {}); + sceneTrapsStore.importByCard(file.trapsByCard ?? {}); + effectsStore.importByCard(file.effectsByCard ?? {}); + syncSceneDarknessForProject(next); + syncSceneMarkerForProject(next); + syncSceneTrapsForProject(next); + syncSceneEffectsForProject(next); + + sceneTokensSessionStore.load(file.sceneTokensByPlacementId ?? {}); + const npcIds = new Set((next.npcs ?? []).map((n) => String(n.id))); + const spawned: GameSaveBody['npcTokens']['spawned'] = {}; + for (const [id, spawn] of Object.entries(file.npcTokens?.spawned ?? {})) { + if (npcIds.has(String(spawn.npcId))) spawned[id] = spawn; + } + sceneNpcTokensSessionStore.load({ + byPlacementId: file.npcTokens?.byPlacementId ?? {}, + scale: file.npcTokens?.scale ?? 1, + spawned, + }); + + let selected = [...(file.playerTokens?.selectedPlayerIds ?? [])]; + if (playersStore) { + const known = new Set(playersStore.listPlayers().map((p) => String(p.id))); + selected = selected.filter((id) => known.has(id)); + } + const byPlayerId: GameSaveBody['playerTokens']['byPlayerId'] = {}; + if (file.playerTokens?.visible) { + for (const id of selected) { + const placement = file.playerTokens.byPlayerId?.[id]; + if (placement) byPlayerId[id] = placement; + } + } + scenePlayerTokensSessionStore.load({ + selectedPlayerIds: selected, + visible: Boolean(file.playerTokens?.visible) && selected.length > 0, + byPlayerId, + }); + + tokenPathSessionStore.load(file.tokenPaths?.playback ?? {}, file.tokenPaths?.presentationVisible ?? {}); + timerSessionStore.loadPaused(file.timer); + sceneViewStore.dispatch({ kind: 'set', camera: file.camera }); + + const materialIds = new Set((next.materials ?? []).map((m) => String(m.id))); + const activeMaterials = (file.materials?.activeMaterialIds ?? []).filter((id) => materialIds.has(id)); + materialsOverlayStore.load({ + activeMaterialIds: activeMaterials as MaterialId[], + layouts: file.materials?.layouts ?? {}, + legendLayouts: file.materials?.legendLayouts ?? {}, + focusMaterialId: + file.materials?.focusMaterialId && materialIds.has(file.materials.focusMaterialId) + ? (file.materials.focusMaterialId as MaterialId) + : null, + }); + const activeNpcs = (file.npcAvatars?.activeNpcIds ?? []).filter((id) => npcIds.has(id)); + npcsOverlayStore.load({ + activeNpcIds: activeNpcs.map((id) => asNpcId(id)), + layouts: file.npcAvatars?.layouts ?? {}, + focusNpcId: + file.npcAvatars?.focusNpcId && npcIds.has(file.npcAvatars.focusNpcId) + ? asNpcId(file.npcAvatars.focusNpcId) + : null, + }); + tokenGridSnapSessionStore.setEnabled(Boolean(file.tokenGridSnap)); + if (file.initiative) initiativeSessionStore.load(file.initiative); + return next; } function syncSceneMarkerForProject(project: Project): void { @@ -667,6 +835,7 @@ async function main() { await playersStore.ensureLoaded(); } gameSystemsStore = new GameSystemsStore(app.getPath('userData')); + gameSavesStore = new GameSavesStore(app.getPath('userData')); await gameSystemsStore.ensureLoaded(); playlistsStore = new PlaylistsStore(app.getPath('userData')); await playlistsStore.ensureLoaded(); @@ -701,7 +870,7 @@ async function main() { registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token)); registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken()); registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version)); - registerHandler(ipcChannels.windows.openMultiWindow, (req) => { + registerHandler(ipcChannels.windows.openMultiWindow, async (req) => { sceneDarknessStore.resetSession(); sceneMarkerStore.resetSession(); sceneTrapsStore.resetSession(); @@ -714,23 +883,38 @@ async function main() { timerSessionStore.clear(); initiativeSessionStore.clear(); diceRollSessionStore.clear(); - if (USERS_BRANCH_FEATURES_ENABLED) { + materialsOverlayStore.clear(); + npcsOverlayStore.clear(); + sceneViewStore.reset(); + effectsStore.resetSession(); + effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() }); + lastSaveCanonical = null; + const saveId = typeof req?.saveId === 'string' ? req.saveId.trim() : ''; + let project = projectStore.getOpenProject(); + if (saveId) { + if (!project || !gameSavesStore) throw new Error('GAME_SAVE_NOT_FOUND'); + const file = await gameSavesStore.read(String(project.id), saveId); + project = await applyGameSave(project, file); + } else if (USERS_BRANCH_FEATURES_ENABLED) { const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : []; if (playerIds.length > 0) { scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds }); } } - effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() }); openMultiWindow(); - const project = projectStore.getOpenProject(); - if (project) { + project = projectStore.getOpenProject(); + if (project && !saveId) { syncSceneDarknessForProject(project); syncSceneMarkerForProject(project); syncSceneTrapsForProject(project); + syncSceneEffectsForProject(project); seedTokenPathPlaybackForProject(project); - } else { + } else if (!project) { emitTokenPathSessionState(); } + if (project && saveId) { + lastSaveCanonical = canonicalGameSave(captureGameSaveBody(project)); + } emitSceneDarknessState(); emitSceneMarkerState(); emitSceneTrapsState(); @@ -739,11 +923,31 @@ async function main() { emitScenePlayerTokensSessionState(); emitTokenGridSnapState(); emitEffectsState(); + emitMaterialsOverlayState(); + emitNpcsOverlayState(); + emitSceneViewState(); emitSessionTimerState(); - syncInitiativePresenceFromSession(); + if (!saveId) syncInitiativePresenceFromSession(); emitInitiativeState(); emitDiceRollState(); - return { ok: true }; + emitTokenPathSessionState(); + return { ok: true as const, project: projectStore.getOpenProject() }; + }); + registerHandler(ipcChannels.gameSave.list, async () => { + const project = projectStore.getOpenProject(); + if (!project || !gameSavesStore) return { saves: [] }; + return { saves: await gameSavesStore.list(String(project.id)) }; + }); + registerHandler(ipcChannels.gameSave.write, async ({ id, name }) => { + const project = projectStore.getOpenProject(); + if (!project || !gameSavesStore) throw new Error('GAME_SAVE_NOT_FOUND'); + const body = captureGameSaveBody(project); + const save = await gameSavesStore.write(String(project.id), body, { id, name }); + lastSaveCanonical = canonicalGameSave(body); + return { save }; + }); + registerHandler(ipcChannels.gameSave.isDirty, () => { + return { dirty: sessionIsDirty(projectStore.getOpenProject()) }; }); registerHandler(ipcChannels.windows.closeMultiWindow, () => { closeMultiWindow(); @@ -961,6 +1165,8 @@ async function main() { sceneNpcTokensSessionStore.reset(); clearNpcMapSpawnDrag(); scenePlayerTokensSessionStore.reset(); + effectsStore.resetSession(); + lastSaveCanonical = null; syncSceneMarkerForProject(project); emitSceneViewState(); emitSceneTokensSessionState(); @@ -974,7 +1180,8 @@ async function main() { registerHandler(ipcChannels.project.close, async () => { closeNpcsEditorWindow(); await projectStore.closeOpenProject(); - effectsStore.clear(); + effectsStore.resetSession(); + lastSaveCanonical = null; materialsOverlayStore.clear(); npcsOverlayStore.clear(); timerSessionStore.clear(); @@ -1015,7 +1222,8 @@ async function main() { }); registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => { await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null })); - effectsStore.clear(); + const sceneProject = projectStore.getOpenProject(); + if (sceneProject) syncSceneEffectsForProject(sceneProject); materialsOverlayStore.clear(); npcsOverlayStore.clear(); sceneViewStore.reset(); @@ -1046,7 +1254,8 @@ async function main() { if (!open) throw new Error('No open project'); const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null; projectStore.setSessionPointer(graphNodeId, gn ? gn.sceneId : null); - effectsStore.clear(); + const switched = projectStore.getOpenProject(); + if (switched) syncSceneEffectsForProject(switched); materialsOverlayStore.clear(); npcsOverlayStore.clear(); sceneViewStore.reset(); @@ -1080,7 +1289,10 @@ async function main() { const before = projectStore.getOpenProject()?.scenes[sceneId]; const next = await projectStore.updateScene(sceneId, patch); const project = projectStore.getOpenProject(); - if (project?.currentSceneId === sceneId && patch.darkenScene !== undefined) { + if ( + project?.currentSceneId === sceneId && + (patch.darkenScene !== undefined || patch.darknessStrokes !== undefined) + ) { syncSceneDarknessForProject(project); emitSceneDarknessState(); } diff --git a/app/main/initiative/initiativeSessionStore.ts b/app/main/initiative/initiativeSessionStore.ts index e6b050f..559a218 100644 --- a/app/main/initiative/initiativeSessionStore.ts +++ b/app/main/initiative/initiativeSessionStore.ts @@ -315,4 +315,16 @@ export class InitiativeSessionStore { this.state = next; return this.state; } + + load(next: Omit): InitiativeState { + this.state = ensureActiveValid({ + ...next, + revision: this.state.revision + 1, + entries: { ...next.entries }, + order: [...next.order], + reserve: [...next.reserve], + conditions: { ...next.conditions }, + }); + return this.state; + } } diff --git a/app/main/materials/materialsOverlayStore.ts b/app/main/materials/materialsOverlayStore.ts index 439c676..facc2e0 100644 --- a/app/main/materials/materialsOverlayStore.ts +++ b/app/main/materials/materialsOverlayStore.ts @@ -225,4 +225,16 @@ export class MaterialsOverlayStore { }; return this.state; } + + load(next: Omit): MaterialsOverlayState { + this.state = { + revision: this.state.revision + 1, + activeMaterialIds: [...next.activeMaterialIds], + layouts: { ...next.layouts }, + legendLayouts: { ...next.legendLayouts }, + focusMaterialId: next.focusMaterialId, + zoomTool: null, + }; + return this.state; + } } diff --git a/app/main/npcs/npcsOverlayStore.ts b/app/main/npcs/npcsOverlayStore.ts index a7d7c1a..57b0fc4 100644 --- a/app/main/npcs/npcsOverlayStore.ts +++ b/app/main/npcs/npcsOverlayStore.ts @@ -256,4 +256,17 @@ export class NpcsOverlayStore { }; return this.state; } + + load(next: Pick): NpcsOverlayState { + this.state = { + revision: this.state.revision + 1, + activeNpcIds: [...next.activeNpcIds], + layouts: { ...next.layouts }, + focusNpcId: next.focusNpcId, + zoomTool: null, + inspectNpcIds: [], + inspectFocusNpcId: null, + }; + return this.state; + } } diff --git a/app/main/players/sceneNpcTokensSessionStore.ts b/app/main/players/sceneNpcTokensSessionStore.ts index 8f44b8e..55a11ee 100644 --- a/app/main/players/sceneNpcTokensSessionStore.ts +++ b/app/main/players/sceneNpcTokensSessionStore.ts @@ -310,4 +310,14 @@ export class SceneNpcTokensSessionStore { } } } + + load(next: Omit): SceneNpcTokensSessionState { + this.state = { + revision: this.state.revision + 1, + byPlacementId: { ...next.byPlacementId }, + scale: clampNpcTokenSessionScale(next.scale), + spawned: { ...next.spawned }, + }; + return this.state; + } } diff --git a/app/main/players/scenePlayerTokensSessionStore.ts b/app/main/players/scenePlayerTokensSessionStore.ts index 91ce1c0..4886697 100644 --- a/app/main/players/scenePlayerTokensSessionStore.ts +++ b/app/main/players/scenePlayerTokensSessionStore.ts @@ -179,4 +179,14 @@ export class ScenePlayerTokensSessionStore { } } } + + load(next: Omit): ScenePlayerTokensSessionState { + this.state = { + revision: this.state.revision + 1, + selectedPlayerIds: [...next.selectedPlayerIds], + visible: next.visible, + byPlayerId: { ...next.byPlayerId }, + }; + return this.state; + } } diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 222f8b3..04a8e48 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -52,6 +52,7 @@ import type { SceneTrap, } from '../../shared/types'; import { normalizeSceneAudioRef, PROJECT_SCHEMA_VERSION } from '../../shared/types'; +import { normalizeSceneDarknessStrokes } from '../../shared/types/sceneDarkness'; import { normalizeMaterialLegend } from '../../shared/types/materialLegend'; import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens'; import { @@ -772,6 +773,7 @@ export class ZipProjectStore { previewVideoAutostart: false, previewRotationDeg: 0, darkenScene: false, + darknessStrokes: [], traps: [], tokens: [], npcTokens: [], @@ -798,6 +800,9 @@ export class ZipProjectStore { ? { previewRotationDeg: patch.previewRotationDeg } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), + ...(patch.darknessStrokes !== undefined + ? { darknessStrokes: normalizeSceneDarknessStrokes(patch.darknessStrokes) } + : null), ...(patch.traps !== undefined ? { traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)), @@ -2619,6 +2624,9 @@ function normalizeScene(s: Scene): Scene { const previewThumbAssetId = (s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null; const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene); + const darknessStrokes = normalizeSceneDarknessStrokes( + (s as unknown as { darknessStrokes?: unknown }).darknessStrokes, + ); const rawTraps = (s as unknown as { traps?: unknown[] }).traps; const traps = (Array.isArray(rawTraps) ? rawTraps : []) .map((t) => normalizeSceneTrap(t)) @@ -2666,6 +2674,7 @@ function normalizeScene(s: Scene): Scene { previewVideoAutostart, previewRotationDeg, darkenScene, + darknessStrokes, traps, tokens, npcTokens, diff --git a/app/main/sceneTraps/sceneTrapsStore.ts b/app/main/sceneTraps/sceneTrapsStore.ts index 31e8cb4..1f62b22 100644 --- a/app/main/sceneTraps/sceneTrapsStore.ts +++ b/app/main/sceneTraps/sceneTrapsStore.ts @@ -131,4 +131,29 @@ export class SceneTrapsStore { } } } + + exportByCard(): Record> { + if (this.currentKey !== null) { + this.cache.set(this.currentKey, cloneById(this.state.byId)); + } + const out: Record> = {}; + for (const [key, byId] of this.cache) { + const kept: Record = {}; + for (const [id, runtime] of Object.entries(byId)) { + if (runtime.status === 'inactive' && !runtime.revealed) continue; + kept[id] = { ...runtime }; + } + if (Object.keys(kept).length > 0) out[key] = kept; + } + return out; + } + + importByCard(byCard: Record>): void { + this.cache.clear(); + this.currentKey = null; + this.state = emptyState(); + for (const [key, byId] of Object.entries(byCard)) { + this.cache.set(key, cloneById(byId)); + } + } } diff --git a/app/main/sessionTimer/timerSessionStore.ts b/app/main/sessionTimer/timerSessionStore.ts index 9d3fae6..48642fc 100644 --- a/app/main/sessionTimer/timerSessionStore.ts +++ b/app/main/sessionTimer/timerSessionStore.ts @@ -128,4 +128,20 @@ export class TimerSessionStore { }; return this.state; } + + /** Слепок: таймер на паузе, без абсолютного момента конца. */ + loadPaused(next: Pick): SessionTimerState { + const durationMs = clampSessionTimerDurationMs(next.durationMs); + const remainingMs = Math.max(0, Math.min(durationMs, Math.round(next.remainingMs))); + this.state = { + revision: this.state.revision + 1, + durationMs, + remainingMs, + running: false, + endsAt: null, + visibleToPlayers: Boolean(next.visibleToPlayers), + layout: clampSessionTimerLayout(next.layout), + }; + return this.state; + } } diff --git a/app/main/tokens/sceneTokensSessionStore.ts b/app/main/tokens/sceneTokensSessionStore.ts index 6577e9b..38d026a 100644 --- a/app/main/tokens/sceneTokensSessionStore.ts +++ b/app/main/tokens/sceneTokensSessionStore.ts @@ -112,4 +112,12 @@ export class SceneTokensSessionStore { } } } + + load(byPlacementId: SceneTokensSessionState['byPlacementId']): SceneTokensSessionState { + this.state = { + revision: this.state.revision + 1, + byPlacementId: { ...byPlacementId }, + }; + return this.state; + } } diff --git a/app/main/tokens/tokenPathSessionStore.ts b/app/main/tokens/tokenPathSessionStore.ts index 28d9714..2f97596 100644 --- a/app/main/tokens/tokenPathSessionStore.ts +++ b/app/main/tokens/tokenPathSessionStore.ts @@ -166,6 +166,19 @@ export class TokenPathSessionStore { } } } + + load( + playback: TokenPathSessionState['playback'], + presentationVisible: TokenPathSessionState['presentationVisible'], + ): TokenPathSessionState { + this.state = { + revision: this.state.revision + 1, + playback: { ...playback }, + presentationVisible: { ...presentationVisible }, + serverNowMs: Date.now(), + }; + return this.state; + } } export type { TokenPathTargetRef }; diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index dd53c4f..97e5c05 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -160,6 +160,50 @@ margin-top: 4px; } +.modalFooterWrap { + flex-wrap: wrap; +} + +.saveList { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 240px; + overflow: auto; +} + +.saveRow { + text-align: left; + padding: 8px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--stroke); + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; +} + +.saveRowSelected { + border-color: var(--scene-list-selected-border); + background: var(--scene-list-selected-bg); +} + +.saveNameInput { + width: 100%; + box-sizing: border-box; + padding: 8px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--stroke); + background: var(--color-surface); + color: inherit; + font: inherit; +} + +.saveError { + color: var(--color-danger); + font-size: 13px; +} + .descriptionViewBody { min-height: 360px; max-height: min(560px, calc(100vh - 200px)); diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 7c951c3..555aaaa 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -5,6 +5,7 @@ import { pickEraseTargetId } from '../../shared/effectEraserHitTest'; import { fitAspectRect } from '../../shared/geometry/fitAspectRect'; import { ipcChannels } from '../../shared/ipc/contracts'; import type { SessionState } from '../../shared/ipc/contracts'; +import type { GameSaveListItem } from '../../shared/types/gameSave'; import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures'; import { isNodeInMainStoryline, @@ -204,6 +205,14 @@ export function ControlApp() { placementId: string; } | null>(null); const [stopConfirmOpen, setStopConfirmOpen] = useState(false); + const [stopDirty, setStopDirty] = useState(false); + const [saveOpen, setSaveOpen] = useState(false); + const [saveEndAfter, setSaveEndAfter] = useState(false); + const [saveItems, setSaveItems] = useState([]); + const [saveName, setSaveName] = useState(''); + const [saveSelectedId, setSaveSelectedId] = useState(null); + const [saveError, setSaveError] = useState(''); + const [saveBusy, setSaveBusy] = useState(false); const [sceneView, sceneViewApi] = useSceneViewState(); const [sceneViewDraft, setSceneViewDraft] = useState(null); const [materialsOverlay, materialsApi] = useMaterialsOverlayState(); @@ -1156,26 +1165,74 @@ export function ControlApp() { }; }, [npcMapSpawnDrag.dragging, npcMapSpawnDragApi, snapNormActive]); + const openStopConfirm = useCallback(() => { + void api.invoke(ipcChannels.gameSave.isDirty, {}).then( + ({ dirty }) => { + setStopDirty(dirty); + setStopConfirmOpen(true); + }, + () => { + setStopDirty(true); + setStopConfirmOpen(true); + }, + ); + }, [api]); + + const openSaveModal = useCallback((endAfter: boolean) => { + setSaveEndAfter(endAfter); + setSaveName(''); + setSaveSelectedId(null); + setSaveError(''); + setSaveOpen(true); + void api.invoke(ipcChannels.gameSave.list, {}).then( + ({ saves }) => setSaveItems(saves), + () => setSaveItems([]), + ); + }, [api]); + + const commitSave = useCallback(() => { + if (saveBusy) return; + setSaveBusy(true); + setSaveError(''); + void api + .invoke(ipcChannels.gameSave.write, { id: saveSelectedId, name: saveName }) + .then(() => { + setSaveOpen(false); + setSaveBusy(false); + if (saveEndAfter) { + void api.invoke(ipcChannels.windows.closeMultiWindow, {}); + } + }) + .catch((err: unknown) => { + setSaveBusy(false); + setSaveError(err instanceof Error ? err.message : String(err)); + }); + }, [api, saveBusy, saveEndAfter, saveName, saveSelectedId]); + useEffect(() => { return api.on(ipcChannels.windows.closeMultiWindowConfirmRequested, () => { - setStopConfirmOpen(true); + openStopConfirm(); }); - }, [api]); + }, [api, openStopConfirm]); useEffect(() => { if (npcMapSpawnDrag.dragging) return; const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; e.preventDefault(); + if (saveOpen) { + setSaveOpen(false); + return; + } if (stopConfirmOpen) { setStopConfirmOpen(false); return; } - setStopConfirmOpen(true); + openStopConfirm(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [npcMapSpawnDrag.dragging, stopConfirmOpen]); + }, [npcMapSpawnDrag.dragging, openStopConfirm, saveOpen, stopConfirmOpen]); useEffect(() => { if (!markersInteractive) { @@ -2142,6 +2199,21 @@ export function ControlApp() { ⬛ + {session?.currentSceneId ? ( + + ) : null} ) : null}
@@ -2514,7 +2586,24 @@ export function ControlApp() { : t('control.showPlayers')} ) : null} - + +
@@ -3138,7 +3227,7 @@ export function ControlApp() { @@ -3749,18 +3838,112 @@ export function ControlApp() { ×
-
{t('control.stopConfirm.body')}
-
+
{stopDirty ? t('control.stopConfirm.dirtyBody') : t('control.stopConfirm.body')}
+
- + ) : null} + {stopDirty ? ( + + ) : ( + + )} +
+
+ , + document.body, + ) + : null} + {saveOpen + ? createPortal( + <> + + + { + setSaveName(e.currentTarget.value); + setSaveSelectedId(null); + }} + /> +
+ {saveItems.length === 0 ?
{t('control.saveGame.empty')}
: null} + {saveItems.map((item) => ( + + ))} +
+ {saveError ?
{saveError}
: null} +
+ +
diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts index cbdc747..4acf363 100644 --- a/app/renderer/control/controlApp.effectsPanel.test.ts +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -97,6 +97,8 @@ void test('ControlApp: эффекты в пульте, иконки с тулт assert.ok(src.includes("t('control.darknessControl')")); assert.ok(src.includes("t('control.explorerBrush')")); assert.ok(src.includes("t('control.closerBrush')")); + assert.ok(src.includes("t('control.bakeDarknessToScene')")); + assert.ok(src.includes('bake-darkness-to-scene')); assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')")); assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'")); assert.ok(src.includes('SceneDarknessOverlay')); @@ -326,7 +328,10 @@ void test('ControlApp: Escape → своя модалка подтвержден assert.ok(src.includes('data-testid="control-help-tool"')); assert.match(src, /data-testid="control-help-tool"[\s\S]{0,180}iconOnly|iconOnly[\s\S]{0,180}data-testid="control-help-tool"/); assert.ok(src.includes("section: 'controlPanel'")); - assert.equal((src.match(/windows\.closeMultiWindow,/g) ?? []).length, 1); + assert.equal((src.match(/windows\.closeMultiWindow,/g) ?? []).length, 3); + assert.ok(src.includes('data-testid="control-save-game"')); + assert.ok(src.includes('data-testid="stop-confirm-save-and-end"')); + assert.ok(src.includes('data-testid="stop-confirm-discard"')); assert.ok(!src.includes('showMessageBox')); assert.ok(!src.includes('window.confirm')); }); diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index a3617e1..fa9deee 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -385,6 +385,11 @@ font: inherit; } +.fileMenuItem:disabled { + opacity: 0.45; + cursor: default; +} + .fileMenuItemDanger { text-align: left; padding: 10px; @@ -525,6 +530,30 @@ box-sizing: border-box; } +.saveList { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 240px; + overflow: auto; +} + +.saveRow { + text-align: left; + padding: 8px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--stroke); + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; +} + +.saveRowSelected { + border-color: var(--scene-list-selected-border); + background: var(--scene-list-selected-bg); +} + .licenseBlockTitle { font-weight: 900; font-size: var(--text-lg); @@ -921,6 +950,11 @@ width: 100%; } +.actionsRowHalf > button { + min-width: 0; + width: 100%; +} + .actionsRowHalf > span { display: flex; min-width: 0; diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index baa7344..80401b9 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -16,6 +16,7 @@ import { } from '../../shared/ipc/contracts'; import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion'; import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot'; +import type { GameSaveListItem } from '../../shared/types/gameSave'; import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension'; import type { AssetId, @@ -162,6 +163,10 @@ export function EditorApp() { const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false); const [playersManagerOpen, setPlayersManagerOpen] = useState(false); const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false); + const [gameSaves, setGameSaves] = useState([]); + const [loadSaveOpen, setLoadSaveOpen] = useState(false); + const [loadSaveId, setLoadSaveId] = useState(null); + const [loadSaveError, setLoadSaveError] = useState(''); const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false); const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false); const [gameSystemsOpen, setGameSystemsOpen] = useState(false); @@ -387,6 +392,47 @@ export function EditorApp() { }); }, []); + const refreshGameSaves = useCallback(() => { + if (!state.project) { + setGameSaves([]); + return; + } + void getDndApi() + .invoke(ipcChannels.gameSave.list, {}) + .then( + ({ saves }) => setGameSaves(saves), + () => setGameSaves([]), + ); + }, [state.project]); + + useEffect(() => { + refreshGameSaves(); + }, [refreshGameSaves]); + + const launchFromSave = useCallback( + (saveId: string) => { + if (!licenseActive || launching) return; + stopAllEditorAudio(); + setLaunching(true); + setRunMenuOpen(false); + setLoadSaveError(''); + void (async () => { + try { + const res = await getDndApi().invoke(ipcChannels.windows.openMultiWindow, { saveId }); + if (res.project) actions.noteProject(res.project); + setLoadSaveOpen(false); + } catch (err) { + setLaunching(false); + const message = err instanceof Error ? err.message : String(err); + setLoadSaveError( + message.includes('GAME_SAVE_CARD_MISSING') ? t('top.loadSave.cardMissing') : message, + ); + } + })(); + }, + [licenseActive, launching, t, actions], + ); + const launchFromGraphNode = useCallback( (graphNodeId: GraphNodeId, playerIds?: string[]) => { if (!licenseActive || launching) return; @@ -943,6 +989,7 @@ export function EditorApp() { data-testid="run-menu-btn" onClick={() => { if (runDisabled || launching) return; + refreshGameSaves(); setRunMenuOpen((v) => !v); }} > @@ -1219,7 +1266,6 @@ export function EditorApp() { previewVideoAutostart={sc?.previewVideoAutostart ?? false} previewVideoLoop={sc?.settings.loopVideo ?? false} previewRotationDeg={sc?.previewRotationDeg ?? 0} - darkenScene={sc?.darkenScene ?? false} previewBusy={previewBusy} previewBusyText={previewBusyText} mediaAssets={sceneMediaAssets} @@ -1233,7 +1279,6 @@ export function EditorApp() { onPreviewVideoLoopChange={(next) => void actions.updateScene(sid, { settings: { loopVideo: next } }) } - onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })} onTitleChange={(title) => void actions.updateScene(sid, { title })} onDescriptionChange={(description) => void actions.updateScene(sid, { description }) @@ -1692,6 +1737,69 @@ export function EditorApp() { launchFromGraphNode(graphStartGraphNodeId, playerIds); }} /> + {loadSaveOpen + ? createPortal( + <> + + +
+ {gameSaves.map((item) => ( + + ))} +
+ {loadSaveError ?
{loadSaveError}
: null} +
+ + +
+ + , + document.body, + ) + : null} ) : null} {USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos @@ -1714,6 +1822,22 @@ export function EditorApp() { > {t('top.runWithPlayers')} + , document.body, ) @@ -2494,7 +2618,6 @@ type SceneInspectorProps = { previewVideoAutostart: boolean; previewVideoLoop: boolean; previewRotationDeg: 0 | 90 | 180 | 270; - darkenScene: boolean; previewBusy: boolean; previewBusyText: string; mediaAssets: MediaAsset[]; @@ -2502,7 +2625,6 @@ type SceneInspectorProps = { onAudioRefsChange: (next: SceneAudioRef[]) => void; onPreviewVideoAutostartChange: (next: boolean) => void; onPreviewVideoLoopChange: (next: boolean) => void; - onDarkenSceneChange: (next: boolean) => void; onTitleChange: (v: string) => void; onDescriptionChange: (v: string) => void; onImportPreview: () => void; @@ -2575,7 +2697,6 @@ function SceneInspector({ previewVideoAutostart, previewVideoLoop, previewRotationDeg, - darkenScene, previewBusy, previewBusyText, mediaAssets, @@ -2583,7 +2704,6 @@ function SceneInspector({ onAudioRefsChange, onPreviewVideoAutostartChange, onPreviewVideoLoopChange, - onDarkenSceneChange, onTitleChange, onDescriptionChange, onImportPreview, @@ -2737,8 +2857,7 @@ function SceneInspector({ ) : null} {previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? ( - <> -
+
- - ) : null} - {previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? ( - <> -
- -
- +
) : null}
{t('scene.audio')}
diff --git a/app/renderer/editor/help/helpLinkify.ts b/app/renderer/editor/help/helpLinkify.ts index 4b936d7..5df0f87 100644 --- a/app/renderer/editor/help/helpLinkify.ts +++ b/app/renderer/editor/help/helpLinkify.ts @@ -8,6 +8,7 @@ export const HELP_SECTION_LINK_ALIASES: Partial> = { 'top.runWithPlayersPlayers': 'Игроки', 'top.runWithPlayersEmpty': 'Нет игроков в библиотеке.', 'top.runWithPlayersConfirm': 'Запустить', + 'top.loadSave': 'Загрузить сохранение', + 'top.loadSave.title': 'Загрузить сохранение', + 'top.loadSave.action': 'Загрузить', + 'top.loadSave.cardMissing': 'Карточка из сохранения больше не в этой партии.', 'top.launching': 'Запуск…', 'top.afterLicense': 'Доступно после активации лицензии', 'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)', @@ -191,11 +195,11 @@ export const EDITOR_MESSAGES: Record> = { 'help.section.sceneProps.title': 'Свойства сцены', 'help.section.sceneProps.body': - 'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» — для мастера. «Описание» — заметки мастера с форматированием: рядом с подписью нажмите карандаш, откроется редактор (жирный, курсив, заголовки, списки, ссылки). Под подписью видно фрагмент текста или «описание отсутствует», если поле пустое. Во время сессии описание открывается с пульта в отдельном окне (см. «Пульт управления»), а не в блоке «История».\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF, видео и др.).\n\n3) Для картинки и видео можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки и видео можно включить «Затемнить сцену»: при показе игроки сначала увидят кадр в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\n5) Кнопка «Редактор сцены» доступна и для картинки, и для видео — сетка, ловушки, неигровые токены и токены НПС поверх превью (см. «Редактор сцены», «Генератор сетки», «Ловушки», «Неигровые токены», «НПС»).\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.sceneEditor.title': 'Редактор сцены', 'help.section.sceneEditor.body': - '«Редактор сцены» — отдельное окно для подготовки карты: сетка боя, ловушки, неигровые токены и круглые токены НПС. Работает для сцен с изображением и с видео (оверлеи поверх ролика).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку или видео, если превью ещё нет.\n\n3) Нажмите «Редактор сцены».\n\nСлева — аккордеоны «Сетка», «Неигровые токены», «НПС» и «Ловушки»; справа — карта сцены. Перетащите НПС на карту, чтобы добавить его круглый токен в проект (это не то же самое, что сессионный токен из окна НПС во время игры). ПКМ по токену — «Указать движение»: отдельное окно маршрута, который сохранится в проекте (см. «Неигровые токены»).\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида. Delete / Backspace убирает выделенный маркер на карте.\n\nПод аккордеонами кнопка «Очистить сцену» убирает с текущей карты все ловушки и токены (пул токенов приложения не трогает).\n\nПодробнее: разделы «Генератор сетки», «Ловушки», «Неигровые токены» и «НПС».', + '«Редактор сцены» — отдельное окно для подготовки карты: сетка боя, затемнение, ловушки, неигровые токены и круглые токены НПС. Работает для сцен с изображением и с видео (оверлеи поверх ролика).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку или видео, если превью ещё нет.\n\n3) Нажмите «Редактор сцены» (в одном ряду с «Повернуть», если превью уже есть).\n\nСлева — аккордеоны «Сетка», «Затемнение сцены», «Неигровые токены», «НПС» и «Ловушки»; справа — карта сцены.\n\nЗатемнение сцены:\n\n1) Раскройте аккордеон «Затемнение сцены».\n\n2) Включите «Затемнить сцену» — превью заливается тьмой. Пока галочка выключена, кисти недоступны.\n\n3) «Кисть Открытия» снимает тьму, «Кисть Закрытия» снова накрывает участки. Радиус и форма (круг / квадрат) — как на пульте; на карте виден контур кисти.\n\n4) Рисунок пишется в файл партии как стартовая база этой сцены (не карточки графа). Две карточки одной сцены делят одну базу.\n\n5) При обычном «Запустить» показ подставляет эту базу; кисти на пульте рисуют поверх и сами сцену не меняют (см. «Эффекты»). Чтобы итог вечера стал новой базой в партии, на пульте нажмите «Записать затемнение в сцену».\n\n«Очистить сцену» убирает с карты ловушки и токены, затемнение не трогает.\n\nПеретащите НПС на карту, чтобы добавить его круглый токен в проект (это не то же самое, что сессионный токен из окна НПС во время игры). ПКМ по токену — «Указать движение»: отдельное окно маршрута, который сохранится в проекте (см. «Неигровые токены»).\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида. Delete / Backspace убирает выделенный маркер на карте.\n\nПодробнее: разделы «Генератор сетки», «Ловушки», «Неигровые токены», «НПС» и «Эффекты».', 'help.section.grid.title': 'Генератор сетки', 'help.section.grid.body': @@ -211,6 +215,7 @@ export const EDITOR_MESSAGES: Record> = { 'sceneEditor.gridColor': 'Цвет', 'sceneEditor.gridColorAria': 'Цвет сетки', 'sceneEditor.gridSize': 'Размер', + 'sceneEditor.darkness': 'Затемнение сцены', 'sceneEditor.npcs': 'НПС', 'sceneEditor.npcSearch': 'Поиск НПС…', 'sceneEditor.noNpcs': 'Нет НПС', @@ -286,11 +291,11 @@ export const EDITOR_MESSAGES: Record> = { 'help.section.session.title': 'Запуск сессии', 'help.section.session.body': - 'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nЭкран для игроков можно заранее выбрать в «Настройки» → «Экран презентации»: монитор и полноэкранный режим. При «Запустить» окно презентации откроется на выбранном экране автоматически.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Завершить» (у предпросмотра или в блоке ветвления, если переходов нет). Тот же вопрос задают Escape и закрытие окна презентации или пульта.\n\n2) В окне «Завершить показ?» нажмите «Завершить» и дождитесь закрытия обоих окон.', + 'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\n\nЗагрузить сохранение: стрелка справа на кнопке «Запустить» → «Загрузить сохранение». Открывается отдельное окно со списком сохранений этой партии на этом компьютере. Строку выбирают кликом, показ открывает кнопка «Загрузить»: карточка, где нажали «Сохранить», открытые участки затемнения, эффекты поля, токены и остальное из файла. Обычный «Запустить» сохранение не подхватывает и для затемнённых сцен берёт стартовую базу из файла партии (нарисованную в «Редакторе сцены», см. «Эффекты»). Если сохранений нет, пункт неактивен. «Запустить с этой сцены» по-прежнему открывает выбранную карточку с пустой сессией.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nЭкран для игроков можно заранее выбрать в «Настройки» → «Экран презентации»: монитор и полноэкранный режим. При «Запустить» окно презентации откроется на выбранном экране автоматически.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Завершить» (у предпросмотра или в блоке ветвления, если переходов нет). Тот же вопрос задают Escape и закрытие окна презентации или пульта.\n\n2) Если показ ещё не записан или стол изменился после последнего сохранения, в окне «Завершить показ?» три действия. «Сохранить и завершить» открывает список сохранений и после записи закрывает показ. «Завершить без сохранения» закрывает показ сразу. «Отмена» оставляет показ. Если изменений нет, в окне по-прежнему только «Отмена» и «Завершить».', 'help.section.controlPanel.title': 'Пульт управления', 'help.section.controlPanel.body': - 'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты, «История» и «Сцены»; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты»: маркер (рисунок на карте), книга (описание текущей сцены; если описания нет, кнопка неактивна и подсказка «Описание отсутствует»), материалы (см. «Материалы»), плейлисты, таймер (обратный отсчёт: «Показать игрокам» выводит виджет на предпросмотр и презентацию), инициатива (очередь хода), кубики, НПС (карточки, чекбокс «Показать» для аватара и постановка токена на карту — см. «НПС») и «?» («Справка»: открывает этот раздел, не закрывая показ).\n\n«Предпросмотр экрана» показывает то же, что видят игроки. Здесь же рисуют эффекты — они сразу появляются на большом экране (и на картинке, и на видео). Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Ловушки»). Неигровые токены, токены НПС и игроков тоже видны на предпросмотре: их можно двигать и менять размер за жёлтый уголок до конца сессии. Линии путей движения видны вам всегда; игрокам — только после «Показать путь» в меню токена. На превью можно перетащить плитку из окна НПС — появится сессионный токен. ПКМ по неигровому токену — показать / скрыть путь, остановить / продолжить движение, сброс на старт. ПКМ по токену НПС — скрыть от игроков или показать игрокам (на пульте токен остаётся), активировать / открыть информацию / сменить тип / сделать неактивным / убрать с карты / путь (см. «НПС» и «Неигровые токены»). Аватары с чекбокса «Показать» лежат поверх сцены отдельно от токенов на карте: их двигают, масштабируют лупами из окна НПС и закрывают «Закрыть всех». На видео-сценах внизу превью остаются кнопки воспроизведения и полоса перемотки.\n\nНад превью:\n• «Привязка токенов к сетке» — если на сцене включена сетка, перетаскиваемые токены (неигровые, НПС, игроки) встают по клеткам;\n• «Размер токенов игроков» — общий масштаб круглых токенов игроков и НПС на предпросмотре и презентации;\n• «Показать игроков» / «Скрыть игроков» — после запуска с игроками.\n\n«История» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю. «Сцены» — сцены текущей линии, чтобы перейти к любой из них.\n\n«Завершить» у предпросмотра и в конце ветки (когда переходов нет) спрашивает подтверждение и после него закрывает показ. Escape на пульте и на презентации, а также закрытие этих окон, задают тот же вопрос.', + 'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты, «История» и «Сцены»; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты»: маркер (рисунок на карте), книга (описание текущей сцены; если описания нет, кнопка неактивна и подсказка «Описание отсутствует»), материалы (см. «Материалы»), плейлисты, таймер (обратный отсчёт: «Показать игрокам» выводит виджет на предпросмотр и презентацию), инициатива (очередь хода), кубики, НПС (карточки, чекбокс «Показать» для аватара и постановка токена на карту — см. «НПС») и «?» («Справка»: открывает этот раздел, не закрывая показ).\n\n«Предпросмотр экрана» показывает то же, что видят игроки. Здесь же рисуют эффекты — они сразу появляются на большом экране (и на картинке, и на видео). Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Ловушки»). Неигровые токены, токены НПС и игроков тоже видны на предпросмотре: их можно двигать и менять размер за жёлтый уголок до конца сессии. Линии путей движения видны вам всегда; игрокам — только после «Показать путь» в меню токена. На превью можно перетащить плитку из окна НПС — появится сессионный токен. ПКМ по неигровому токену — показать / скрыть путь, остановить / продолжить движение, сброс на старт. ПКМ по токену НПС — скрыть от игроков или показать игрокам (на пульте токен остаётся), активировать / открыть информацию / сменить тип / сделать неактивным / убрать с карты / путь (см. «НПС» и «Неигровые токены»). Аватары с чекбокса «Показать» лежат поверх сцены отдельно от токенов на карте: их двигают, масштабируют лупами из окна НПС и закрывают «Закрыть всех». На видео-сценах внизу превью остаются кнопки воспроизведения и полоса перемотки.\n\nНад превью:\n• «Привязка токенов к сетке» — если на сцене включена сетка, перетаскиваемые токены (неигровые, НПС, игроки) встают по клеткам;\n• «Размер токенов игроков» — общий масштаб круглых токенов игроков и НПС на предпросмотре и презентации;\n• «Показать игроков» / «Скрыть игроков» — после запуска с игроками.\n\n«История» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю. «Сцены» — сцены текущей линии, чтобы перейти к любой из них.\n\n«Завершить» у предпросмотра и в конце ветки (когда переходов нет) спрашивает подтверждение и после него закрывает показ. Сразу после «Завершить» над предпросмотром — дискета, подсказка «Сохранить». Она открывает список сохранений этой партии. Клик по строке подставляет её имя для перезаписи, файл пишет кнопка «Сохранить». Новое имя без выбранной строки создаёт ещё одну запись. Показ остаётся открытым. В файл входят исследование карты, эффекты поля на посещённых карточках, лёд и тёмные пятна, маркеры, ловушки, токены сцены и НПС, а также токены игроков на текущей карточке, если они были показаны. Файл остаётся на этом компьютере и не попадает в архив партии. Если закрывать показ с несохранёнными изменениями, «Завершить показ?» предлагает «Сохранить и завершить», «Завершить без сохранения» и «Отмена». Escape на пульте и на презентации, а также закрытие этих окон, задают тот же вопрос.', 'help.section.transitions.title': 'Переходы между сценами', 'help.section.transitions.body': @@ -302,11 +307,11 @@ export const EDITOR_MESSAGES: Record> = { 'help.section.effects.title': 'Эффекты поля и действий', 'help.section.effects.body': - 'Эффекты работают на сценах с картинкой и с видео. Рисуйте в «Предпросмотр экрана» — игроки увидят то же на презентации.\n\nВыберите инструмент слева:\n• Эффекты поля (туман, дождь, огонь, вода) — зажмите левую кнопку и ведите по карте.\n• Эффекты действий (молния, луч света, заморозка, тьма, облако яда, взрыв) — короткий клик или штрих; у некоторых есть звук.\n\nЕсли у сцены в свойствах включено «Затемнить сцену», появится блок «Управление затемнением» с «Кистью Открытия» 🔦 и «Кистью Закрытия» ⬛. «Кисть Открытия» снимает тьму на обоих экранах сразу; «Кисть Закрытия» снова накрывает уже открытые участки. У игроков нераскрытое остаётся чёрным, у вас на пульте — полузатемнённым. Состояние сохраняется, пока идёт показ и вы снова попадаете на ту же карточку сцены на карте. Это не то же самое, что эффект «Тьма» 🌑 в блоке действий.\n\nЛастик 🧹 — для эффектов поля (туман, дождь, огонь, вода) водите кистью, как «Кистью Открытия» для затемнения: стирается только пройденный участок. Эффекты действий (молния, луч и т.д.) убираются целиком при клике или проведении по ним. «Очистить эффекты» — снять всё сразу.\n\n«Радиус кисти» под панелью — чем больше число, тем шире мазок.', + 'Эффекты работают на сценах с картинкой и с видео. Рисуйте в «Предпросмотр экрана» — игроки увидят то же на презентации.\n\nВыберите инструмент слева:\n• Эффекты поля (туман, дождь, огонь, вода) — зажмите левую кнопку и ведите по карте. Туман, дождь, огонь, вода, а также лёд после заморозки и тёмное пятно после тьмы-действия остаются на карточке, пока идёт показ. Переход на другую сцену их прячет, возврат на ту же карточку показывает снова. Ластик и «Очистить эффекты» стирают их с текущей карточки до конца этого показа. В сохранение через дискету они входят вместе с остальным слепком. Сами эффекты действий при смене сцены по-прежнему пропадают.\n• Эффекты действий (молния, луч света, заморозка, тьма, облако яда, взрыв) — короткий клик или штрих; у некоторых есть звук.\n\nЗатемнение карты (не путать с эффектом «Тьма»):\n\n• Включается в «Редакторе сцены» → «Затемнение сцены» → «Затемнить сцену». Там же можно заранее нарисовать стартовые открытые участки; они сохраняются в файле партии.\n• На пульте появляется блок «Управление затемнением» с «Кистью Открытия» и «Кистью Закрытия», радиусом и формой кисти.\n• Обычный «Запустить» подставляет базу из файла партии. «Загрузить сохранение» поднимает полный рисунок из слепка (база сцены при этом сама не затирается).\n• Кисти на пульте меняют только текущий показ; в файл партии сами не пишут.\n• «Записать затемнение в сцену» записывает текущие штрихи активной карточки в партию как новую базу — удобно увезти «полуоткрытую» карту без отдельного файла сохранения.\n• У игроков нераскрытое остаётся чёрным, у вас на пульте — полузатемнённым. Состояние помнится, пока идёт показ и вы снова попадаете на ту же карточку.\n\nЛастик — для эффектов поля (туман, дождь, огонь, вода) водите кистью, как «Кистью Открытия» для затемнения: стирается только пройденный участок. Эффекты действий (молния, луч и т.д.) убираются целиком при клике или проведении по ним. «Очистить эффекты» — снять всё сразу.\n\n«Радиус кисти» под панелью — чем больше число, тем шире мазок.', 'help.section.presentation.title': 'Экран презентации', 'help.section.presentation.body': - '«Презентация» — то, что видят игроки: картинка или видео сцены (с учётом поворота из редактора) по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны. Токены на карте и большие аватары НПС с чекбокса «Показать» видны, но не интерактивны. Линию пути игроки видят только если мастер включил «Показать путь».\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» и при необходимости снова закрывает участки «Кистью Закрытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Монитор и полноэкранный режим задаются в «Настройки» → «Экран презентации»; при запуске сессии окно открывается на выбранном экране.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.', + '«Презентация» — то, что видят игроки: картинка или видео сцены (с учётом поворота из редактора) по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны. Токены на карте и большие аватары НПС с чекбокса «Показать» видны, но не интерактивны. Линию пути игроки видят только если мастер включил «Показать путь».\n\nЕсли в «Редакторе сцены» включено «Затемнить сцену», игроки видят карту под тьмой. Стартовые открытые участки из редактора (база в файле партии) уже могут быть видны; дальше мастер открывает и закрывает карту «Кистью Открытия» и «Кистью Закрытия» на пульте (см. «Эффекты»).\n\nПри смене сцены с пульта картинка обновляется сама. Монитор и полноэкранный режим задаются в «Настройки» → «Экран презентации»; при запуске сессии окно открывается на выбранном экране.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.', 'help.section.importExport.title': 'Импорт и экспорт', 'help.section.importExport.body': @@ -792,6 +797,7 @@ export const EDITOR_MESSAGES: Record> = { 'control.water': 'Вода', 'control.darkness': 'Тьма', 'control.darknessControl': 'Управление затемнением', + 'control.bakeDarknessToScene': 'Записать затемнение в сцену', 'control.explorerBrush': 'Кисть Открытия', 'control.closerBrush': 'Кисть Закрытия', 'control.lightning': 'Молния', @@ -819,6 +825,15 @@ export const EDITOR_MESSAGES: Record> = { 'control.stopConfirm.title': 'Завершить показ?', 'control.stopConfirm.body': 'Закрыть пульт и презентацию и вернуться в редактор?', 'control.stopConfirm.confirm': 'Завершить', + 'control.stopConfirm.dirtyBody': + 'Показ ещё не записан или стол изменился после последнего сохранения. Записать игру перед закрытием?', + 'control.stopConfirm.saveAndEnd': 'Сохранить и завершить', + 'control.stopConfirm.discard': 'Завершить без сохранения', + 'control.saveGame': 'Сохранить', + 'control.saveGame.title': 'Сохранить игру', + 'control.saveGame.name': 'Имя сохранения', + 'control.saveGame.commit': 'Сохранить', + 'control.saveGame.empty': 'Сохранений этой партии пока нет.', 'control.showPlayers': 'Показать игроков', 'control.hidePlayers': 'Скрыть игроков', 'control.videoBrushHint': @@ -935,6 +950,10 @@ export const EDITOR_MESSAGES: Record> = { 'top.runWithPlayersPlayers': 'Players', 'top.runWithPlayersEmpty': 'No players in the library.', 'top.runWithPlayersConfirm': 'Launch', + 'top.loadSave': 'Load save', + 'top.loadSave.title': 'Load save', + 'top.loadSave.action': 'Load', + 'top.loadSave.cardMissing': 'The card from this save is no longer in this campaign.', 'top.launching': 'Starting…', 'top.afterLicense': 'Available after license activation', 'top.setStartScene': 'Set a start scene on the graph (right‑click a node)', @@ -1003,11 +1022,11 @@ export const EDITOR_MESSAGES: Record> = { '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 is for the GM. Description is GM notes with formatting: click the pencil next to the label to open the editor (bold, italic, headings, lists, links). Below the label you see a text preview, or “no description” when empty. During a session, open the description from the control panel in a separate window (see Control panel) — it is not shown inside the History list.\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, video, etc.).\n\n3) For images and video, use Rotate (90° steps). Clear removes the preview.\n\n4) For images and video, enable Darken scene so players start in full darkness and you reveal the frame with the Opening brush on the control panel (see Effects).\n\n5) Scene editor works for both images and video — battle grid, traps, non-player tokens, and NPC tokens over the preview (see Scene editor, Grid generator, Traps, Non-player tokens, and NPCs).\n\nFor video, enable Autostart if the clip should start on its own on the player screen, and Loop if needed.\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 is for the GM. Description is GM notes with formatting: click the pencil next to the label to open the editor (bold, italic, headings, lists, links). Below the label you see a text preview, or “no description” when empty. During a session, open the description from the control panel in a separate window (see Control panel) — it is not shown inside the History list.\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, video, etc.).\n\n3) For images and video: Rotate (90° steps) and Scene editor sit in one row. Clear removes the preview.\n\n4) In Scene editor you set the battle grid, map darkness, traps, non-player tokens, and NPC tokens (see Scene editor, Grid generator, Traps, Non-player tokens, and NPCs). There is no Darken scene checkbox in scene properties anymore — only the Scene darkness accordion in the scene editor.\n\nFor video, enable Autostart if the clip should start on its own on the player screen, and Loop if needed.\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.sceneEditor.title': 'Scene editor', 'help.section.sceneEditor.body': - 'Scene editor is a separate window for preparing the map: battle grid, traps, non-player tokens, and circular NPC tokens. It works for image and video scenes (overlays sit on top of the clip).\n\nOpen it:\n\n1) Select a scene in the left list.\n\n2) In Scene properties, upload an image or video if the scene has none yet.\n\n3) Click Scene editor.\n\nOn the left are the Grid, Non-player tokens, NPCs, and Traps accordions; on the right is the scene map. Drag an NPC onto the map to add its circular token to the project (this is not the same as a session token from the NPCs window during play). Right-click a token → Set movement to open the path editor; the route is saved in the project (see Non-player tokens).\n\nMap navigation: mouse wheel zooms; middle mouse button or Space+left-drag pans the view. Delete / Backspace removes the selected marker on the map.\n\nUnder the accordions, Clear scene removes every trap and token from the current map (it does not delete tokens from the app library).\n\nFor details, see Grid generator, Traps, Non-player tokens, and NPCs.', + 'Scene editor is a separate window for preparing the map: battle grid, darkness, traps, non-player tokens, and circular NPC tokens. It works for image and video scenes (overlays sit on top of the clip).\n\nOpen it:\n\n1) Select a scene in the left list.\n\n2) In Scene properties, upload an image or video if the scene has none yet.\n\n3) Click Scene editor (same row as Rotate when a preview already exists).\n\nOn the left are the Grid, Scene darkness, Non-player tokens, NPCs, and Traps accordions; on the right is the scene map.\n\nScene darkness:\n\n1) Expand the Scene darkness accordion.\n\n2) Enable Darken scene — the preview fills with darkness. While it is off, brushes stay unavailable.\n\n3) Opening brush clears darkness; Closing brush covers areas again. Radius and shape (circle / square) match the control panel; a brush outline follows the pointer on the map.\n\n4) The painting is stored in the campaign file as the starting base for this scene (not per graph card). Two cards that share a scene share one base.\n\n5) A normal Start loads that base; control-panel brushes paint on top and do not change the scene file by themselves (see Effects). To make the evening result the new campaign base, press Bake darkness into scene on the control panel.\n\nClear scene removes traps and tokens from the map; it does not clear darkness.\n\nDrag an NPC onto the map to add its circular token to the project (this is not the same as a session token from the NPCs window during play). Right-click a token → Set movement to open the path editor; the route is saved in the project (see Non-player tokens).\n\nMap navigation: mouse wheel zooms; middle mouse button or Space+left-drag pans the view. Delete / Backspace removes the selected marker on the map.\n\nFor details, see Grid generator, Traps, Non-player tokens, NPCs, and Effects.', 'help.section.grid.title': 'Grid generator', 'help.section.grid.body': @@ -1023,6 +1042,7 @@ export const EDITOR_MESSAGES: Record> = { 'sceneEditor.gridColor': 'Color', 'sceneEditor.gridColorAria': 'Grid color', 'sceneEditor.gridSize': 'Size', + 'sceneEditor.darkness': 'Scene darkness', 'sceneEditor.npcs': 'NPCs', 'sceneEditor.npcSearch': 'Search NPCs…', 'sceneEditor.noNpcs': 'No NPCs', @@ -1098,11 +1118,11 @@ export const EDITOR_MESSAGES: Record> = { 'help.section.session.title': 'Starting a session', 'help.section.session.body': - 'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nChoose the player screen ahead of time in Settings → Presentation display: pick a monitor and fullscreen mode. When you click Run, the presentation window opens on that display automatically.\n\nReturn to prep:\n\n1) On the control panel click End (by the preview, or in the branch block when there is nowhere left to go). Escape and closing the presentation or control window ask the same question.\n\n2) In the End presentation? dialog click End and wait until both windows close.', + 'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\n\nLoad a save: use the chevron on the right of Run → Load save. A separate window lists saves of this campaign on this computer. Click a row, then Load opens the show on the card where you pressed Save, with darkness reveals, field effects, tokens, and the rest of the file. A normal Run does not pick up a save and for darkened scenes loads the starting base from the campaign file (painted in Scene editor; see Effects). If there are no saves, the item is disabled. Start from this scene still opens the chosen card with an empty session.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nChoose the player screen ahead of time in Settings → Presentation display: pick a monitor and fullscreen mode. When you click Run, the presentation window opens on that display automatically.\n\nReturn to prep:\n\n1) On the control panel click End (by the preview, or in the branch block when there is nowhere left to go). Escape and closing the presentation or control window ask the same question.\n\n2) If the show is not saved yet, or the table changed since the last save, the End presentation? dialog has three actions. Save and end opens the save list and closes the show after the file is written. End without saving closes the show immediately. Cancel leaves the show open. If nothing changed, the dialog still has only Cancel and End.', 'help.section.controlPanel.title': 'Control panel', 'help.section.controlPanel.body': - 'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects, History, and Scenes. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools: marker (draw on the map), book (current scene description; if there is none the button is disabled and the tooltip reads “No description”), materials (see Materials), playlists, timer (countdown: Show to players places the widget on the preview and presentation), initiative (turn order), dice, NPCs (cards, the Show checkbox for an avatar, and placing a token on the map — see NPCs), and “?” (Help: opens this section without ending the show).\n\nScreen preview shows what players see. Paint effects here — they appear on the big screen right away for both image and video scenes. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player, NPC, and player tokens are also visible on the preview: you can move them and resize them from the yellow corner until the session ends. Path lines are always visible to you; players see a line only after Show path in the token menu. You can drag a tile from the NPCs window onto the preview to spawn a session token. Right-click a non-player token to show / hide path, stop / resume movement, or reset to start. Right-click an NPC token to hide it from players or show it to players (it stays on the control panel), activate / open information / change type / make inactive / remove from map / set a path (see NPCs and Non-player tokens). Avatars from the Show checkbox sit over the scene separately from map tokens: drag them, zoom with the NPCs window magnifiers, and clear them with Close all. On video scenes, transport controls and the scrub bar stay at the bottom of the preview.\n\nAbove the preview:\n• Snap tokens to grid — when the scene grid is on, dragged tokens (non-player, NPC, players) snap to cells;\n• Player token size — shared scale for circular player and NPC tokens on the preview and presentation;\n• Show players / Hide players — after Run with players.\n\nHistory lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry. Scenes lists scenes on the current line so you can jump to any of them.\n\nEnd by the preview and at the end of a branch (when there is nowhere left to go) asks for confirmation and then closes the show. Escape on the control panel and on presentation, and closing those windows, ask the same question.', + 'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects, History, and Scenes. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools: marker (draw on the map), book (current scene description; if there is none the button is disabled and the tooltip reads “No description”), materials (see Materials), playlists, timer (countdown: Show to players places the widget on the preview and presentation), initiative (turn order), dice, NPCs (cards, the Show checkbox for an avatar, and placing a token on the map — see NPCs), and “?” (Help: opens this section without ending the show).\n\nScreen preview shows what players see. Paint effects here — they appear on the big screen right away for both image and video scenes. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player, NPC, and player tokens are also visible on the preview: you can move them and resize them from the yellow corner until the session ends. Path lines are always visible to you; players see a line only after Show path in the token menu. You can drag a tile from the NPCs window onto the preview to spawn a session token. Right-click a non-player token to show / hide path, stop / resume movement, or reset to start. Right-click an NPC token to hide it from players or show it to players (it stays on the control panel), activate / open information / change type / make inactive / remove from map / set a path (see NPCs and Non-player tokens). Avatars from the Show checkbox sit over the scene separately from map tokens: drag them, zoom with the NPCs window magnifiers, and clear them with Close all. On video scenes, transport controls and the scrub bar stay at the bottom of the preview.\n\nAbove the preview:\n• Snap tokens to grid — when the scene grid is on, dragged tokens (non-player, NPC, players) snap to cells;\n• Player token size — shared scale for circular player and NPC tokens on the preview and presentation;\n• Show players / Hide players — after Run with players.\n\nHistory lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry. Scenes lists scenes on the current line so you can jump to any of them.\n\nEnd by the preview and at the end of a branch (when there is nowhere left to go) asks for confirmation and then closes the show. Right after End above the preview is a floppy-disk button, tooltip Save. It opens the save list for this campaign. Clicking a row fills its name for overwrite; the Save button writes the file. A different name with no row selected creates another save. The show stays open. The file includes map exploration, field effects on visited cards, ice and dark patches, markers, traps, scene and NPC tokens, and player tokens on the current card if they were shown. The file stays on this computer and is not part of the campaign archive. If you close the show with unsaved changes, End presentation? offers Save and end, End without saving, and Cancel. Escape on the control panel and on presentation, and closing those windows, ask the same question.', 'help.section.transitions.title': 'Scene transitions', 'help.section.transitions.body': @@ -1114,11 +1134,11 @@ export const EDITOR_MESSAGES: Record> = { 'help.section.effects.title': 'Field and action effects', 'help.section.effects.body': - 'Effects work on image and video scenes. Paint in Screen preview — players see the same on presentation.\n\nPick a tool on the left:\n• Field effects (fog, rain, fire, water) — hold the left button and brush on the map.\n• Action effects (lightning, sunbeam, freeze, darkness, poison cloud, explosion) — click or short stroke; some include sound.\n\nIf Darken scene is enabled in scene properties, a Darkness control section appears with the Opening brush 🔦 and Closing brush ⬛. Opening brush clears darkness on both screens at once; Closing brush covers revealed areas again. Unrevealed areas stay fully black for players and half-dark on your preview. The state is remembered while the show runs and you return to the same graph card. This is not the same as the Darkness 🌑 action effect.\n\nEraser 🧹 — for field effects (fog, rain, fire, water), brush like the Opening brush for darkness: only the stroke area is erased. Action effects (lightning, sunbeam, etc.) are removed whole when you click or drag over them. Clear effects removes everything at once.\n\nBrush radius under the panel — higher values mean a wider stroke.', + 'Effects work on image and video scenes. Paint in Screen preview — players see the same on presentation.\n\nPick a tool on the left:\n• Field effects (fog, rain, fire, water) — hold the left button and brush on the map. Fog, rain, fire, water, plus ice left by freeze and the dark patch left by the darkness action, stay on the card while the show runs. Leaving the card hides them; coming back to the same card shows them again. The eraser and Clear effects remove them from the current card for the rest of this show. The floppy-disk save includes them with the rest of the snapshot. Action effects still disappear when you change scenes.\n• Action effects (lightning, sunbeam, freeze, darkness, poison cloud, explosion) — click or short stroke; some include sound.\n\nMap darkness (not the same as the Darkness action effect):\n\n• Turn it on in Scene editor → Scene darkness → Darken scene. There you can also paint starting revealed areas; they are stored in the campaign file.\n• On the control panel a Darkness control section appears with Opening brush and Closing brush, plus radius and brush shape.\n• A normal Start loads the base from the campaign file. Load save restores the full reveal from the snapshot (it does not overwrite the scene base by itself).\n• Control-panel brushes change only the current show; they do not write into the campaign file on their own.\n• Bake darkness into scene writes the current card strokes into the campaign as the new base — useful for shipping a partly revealed map without a separate save file.\n• Unrevealed areas stay fully black for players and half-dark on your preview. The state is remembered while the show runs and you return to the same graph card.\n\nEraser — for field effects (fog, rain, fire, water), brush like the Opening brush for darkness: only the stroke area is erased. Action effects (lightning, sunbeam, etc.) are removed whole when you click or drag over them. Clear effects removes everything at once.\n\nBrush radius under the panel — higher values mean a wider stroke.', 'help.section.presentation.title': 'Presentation screen', 'help.section.presentation.body': - 'Presentation is what players see: the scene image or video (with rotation from the editor) according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens. Map tokens and large NPC avatars from the Show checkbox are visible but not interactive. Players see a path line only if the GM turned on Show path.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush and can cover areas again with the Closing brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Monitor and fullscreen mode are set in Settings → Presentation display; when you start a session, the window opens on that display.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.', + 'Presentation is what players see: the scene image or video (with rotation from the editor) according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens. Map tokens and large NPC avatars from the Show checkbox are visible but not interactive. Players see a path line only if the GM turned on Show path.\n\nIf Darken scene is enabled in the Scene editor, players see the map under darkness. Starting revealed areas painted in the editor (the campaign-file base) may already show; the GM then opens and covers the map with the Opening brush and Closing brush on the control panel (see Effects).\n\nWhen you switch scenes from the control panel, the image updates automatically. Monitor and fullscreen mode are set in Settings → Presentation display; when you start a session, the window opens on that display.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.', 'help.section.importExport.title': 'Import and export', 'help.section.importExport.body': @@ -1606,6 +1626,7 @@ export const EDITOR_MESSAGES: Record> = { 'control.water': 'Water', 'control.darkness': 'Darkness', 'control.darknessControl': 'Darkness control', + 'control.bakeDarknessToScene': 'Bake darkness into scene', 'control.explorerBrush': 'Opening brush', 'control.closerBrush': 'Closing brush', 'control.lightning': 'Lightning', @@ -1633,6 +1654,15 @@ export const EDITOR_MESSAGES: Record> = { 'control.stopConfirm.title': 'End presentation?', 'control.stopConfirm.body': 'Close the control panel and presentation and return to the editor?', 'control.stopConfirm.confirm': 'End', + 'control.stopConfirm.dirtyBody': + 'This show is not saved yet, or the table changed since the last save. Save the game before closing?', + 'control.stopConfirm.saveAndEnd': 'Save and end', + 'control.stopConfirm.discard': 'End without saving', + 'control.saveGame': 'Save', + 'control.saveGame.title': 'Save game', + 'control.saveGame.name': 'Save name', + 'control.saveGame.commit': 'Save', + 'control.saveGame.empty': 'No saves for this campaign yet.', 'control.showPlayers': 'Show players', 'control.hidePlayers': 'Hide players', 'control.videoBrushHint': diff --git a/app/renderer/editor/state/projectState.ts b/app/renderer/editor/state/projectState.ts index 667749c..dcc8997 100644 --- a/app/renderer/editor/state/projectState.ts +++ b/app/renderer/editor/state/projectState.ts @@ -43,6 +43,7 @@ type Actions = { createProject: (name: string) => Promise; openProject: (id: ProjectId) => Promise; closeProject: () => Promise; + noteProject: (project: Project) => void; createScene: () => Promise; createScenesFromMediaPaths: ( items: { filePath: string; title: string }[], @@ -71,6 +72,7 @@ type Actions = { previewVideoAutostart?: boolean; previewRotationDeg?: 0 | 90 | 180 | 270; darkenScene?: boolean; + darknessStrokes?: Scene['darknessStrokes']; settings?: Partial; media?: Partial; layout?: { x: number; y: number }; @@ -354,6 +356,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) previewVideoAutostart: false, previewRotationDeg: 0, darkenScene: false, + darknessStrokes: [], traps: [], tokens: [], npcTokens: [], @@ -564,6 +567,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) previewVideoAutostart?: boolean; previewRotationDeg?: 0 | 90 | 180 | 270; darkenScene?: boolean; + darknessStrokes?: Scene['darknessStrokes']; traps?: import('../../../shared/types').SceneTrap[]; tokens?: import('../../../shared/types').SceneToken[]; npcTokens?: import('../../../shared/types').SceneNpcToken[]; @@ -594,6 +598,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) ? { previewRotationDeg: patch.previewRotationDeg } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), + ...(patch.darknessStrokes !== undefined ? { darknessStrokes: patch.darknessStrokes } : null), ...(patch.traps !== undefined ? { traps: patch.traps } : null), ...(patch.tokens !== undefined ? { tokens: patch.tokens } : null), ...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : null), @@ -918,6 +923,14 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) } }; + const noteProject = (project: Project) => { + setState((s) => ({ + ...s, + project, + selectedSceneId: project.currentSceneId ?? s.selectedSceneId, + })); + }; + const deleteProject = async (projectId: ProjectId) => { projectDataEpochRef.current += 1; await api.invoke(ipcChannels.project.deleteProject, { projectId }); @@ -936,6 +949,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) createProject, openProject, closeProject, + noteProject, createScene, createScenesFromMediaPaths, selectScene, diff --git a/app/renderer/sceneEditor/SceneEditorApp.module.css b/app/renderer/sceneEditor/SceneEditorApp.module.css index e67fced..7ebb619 100644 --- a/app/renderer/sceneEditor/SceneEditorApp.module.css +++ b/app/renderer/sceneEditor/SceneEditorApp.module.css @@ -86,6 +86,39 @@ user-select: none; } +.darknessTools { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.darknessBrushLayer { + position: absolute; + z-index: 29; + cursor: none; + touch-action: none; +} + +.brushCursor { + position: absolute; + z-index: 30; + will-change: left, top; + transform: translate(-50%, -50%); + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.55); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.3), + 0 0 18px rgba(255, 140, 40, 0.12), + inset 0 0 18px rgba(255, 140, 40, 0.1); + background: rgba(255, 160, 60, 0.03); + pointer-events: none; +} + +.brushCursorSquare { + border-radius: 2px; +} + .field { display: grid; gap: 6px; diff --git a/app/renderer/sceneEditor/SceneEditorApp.tsx b/app/renderer/sceneEditor/SceneEditorApp.tsx index fbf9324..023cec9 100644 --- a/app/renderer/sceneEditor/SceneEditorApp.tsx +++ b/app/renderer/sceneEditor/SceneEditorApp.tsx @@ -7,14 +7,21 @@ import type { NpcDisposition, NpcId, ProjectNpc, + Scene, + SceneDarknessBrushShape, + SceneDarknessDraft, + SceneDarknessRevealStroke, + SceneDarknessState, SceneGrid, SceneGridTemplate, + SceneId, SceneNpcToken, SceneToken, SceneTrap, SceneTrapType, TokenId, } from '../../shared/types'; +import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay'; import { asSceneNpcTokenId, clampSceneNpcTokenSizeN, @@ -237,6 +244,24 @@ function SceneNpcMarker({ ); } +function sameDarknessStrokes( + a: readonly SceneDarknessRevealStroke[], + b: readonly SceneDarknessRevealStroke[], +): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + const left = a[i]; + const right = b[i]; + if (!left || !right) return false; + if (left.id !== right.id || left.points.length !== right.points.length) return false; + if (left.radiusN !== right.radiusN || left.mode !== right.mode || left.shape !== right.shape) { + return false; + } + } + return true; +} + function NpcPaletteTile({ npc }: { npc: ProjectNpc }) { const imageUrl = useAssetUrl(npc.avatarAssetId); const disposition = normalizeNpcDisposition(npc.disposition); @@ -270,8 +295,28 @@ export function SceneEditorApp() { const [session, setSession] = useState(null); const [trapsOpen, setTrapsOpen] = useState(false); const [gridOpen, setGridOpen] = useState(false); + const [darknessOpen, setDarknessOpen] = useState(false); const [tokensOpen, setTokensOpen] = useState(false); const [npcsOpen, setNpcsOpen] = useState(false); + const [localDarken, setLocalDarken] = useState(false); + const [localDarknessStrokes, setLocalDarknessStrokes] = useState([]); + const [darknessDraft, setDarknessDraft] = useState(null); + const [darknessTool, setDarknessTool] = useState<'none' | 'explore' | 'close'>('none'); + const [darknessRadiusN, setDarknessRadiusN] = useState(0.08); + const [darknessBrushShape, setDarknessBrushShape] = useState('circle'); + const darknessBrushRef = useRef<{ + points: { x: number; y: number; tMs: number }[]; + createdAtMs: number; + seed: number; + } | null>(null); + const brushCursorElRef = useRef(null); + const darknessCursorPosRef = useRef<{ x: number; y: number } | null>(null); + const darknessToolRef = useRef(darknessTool); + const darknessRadiusNRef = useRef(darknessRadiusN); + const darknessBrushShapeRef = useRef(darknessBrushShape); + darknessToolRef.current = darknessTool; + darknessRadiusNRef.current = darknessRadiusN; + darknessBrushShapeRef.current = darknessBrushShape; const [tokenSearch, setTokenSearch] = useState(''); const [npcSearch, setNpcSearch] = useState(''); const [tokenModal, setTokenModal] = useState< @@ -291,16 +336,23 @@ export function SceneEditorApp() { const [selected, setSelected] = useState(null); const [view, setView] = useState({ scale: 1, ox: 0.5, oy: 0.5 }); const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + const contentRectRef = useRef(contentRect); + contentRectRef.current = contentRect; const hostRef = useRef(null); const dragRef = useRef(null); const saveTrapsTimerRef = useRef(0); const saveTokensTimerRef = useRef(0); const saveNpcTokensTimerRef = useRef(0); const saveGridTimerRef = useRef(0); + const saveDarknessTimerRef = useRef(0); + const pendingDarknessStrokesRef = useRef(null); + const darknessDirtyRef = useRef(false); const spaceDownRef = useRef(false); + const sceneIdRef = useRef(null); const project = session?.project ?? null; const sceneId = project?.currentSceneId ?? null; + sceneIdRef.current = sceneId; const scene = sceneId && project ? project.scenes[sceneId] : undefined; const url = useAssetUrl(scene?.previewAssetId ?? null); const rot = scene?.previewRotationDeg ?? 0; @@ -316,6 +368,7 @@ export function SceneEditorApp() { const trapsRef = useRef([]); const tokensRef = useRef([]); const npcTokensRef = useRef([]); + const localDarknessStrokesRef = useRef([]); useEffect(() => { trapsRef.current = localTraps; @@ -324,12 +377,28 @@ export function SceneEditorApp() { }, [localNpcTokens, localTokens, localTraps]); useEffect(() => { + localDarknessStrokesRef.current = localDarknessStrokes; + }, [localDarknessStrokes]); + + useEffect(() => { + darknessDirtyRef.current = false; + pendingDarknessStrokesRef.current = null; + if (saveDarknessTimerRef.current) { + window.clearTimeout(saveDarknessTimerRef.current); + saveDarknessTimerRef.current = 0; + } setLocalTraps(scene?.traps ?? []); setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId))); setLocalNpcTokens( (scene?.npcTokens ?? []).filter((t) => project?.npcs.some((npc) => npc.id === t.npcId)), ); setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); + setLocalDarken(Boolean(scene?.darkenScene)); + const strokes = scene?.darknessStrokes ?? []; + localDarknessStrokesRef.current = strokes; + setLocalDarknessStrokes(strokes); + setDarknessDraft(null); + setDarknessTool('none'); setSelected(null); setView({ scale: 1, ox: 0.5, oy: 0.5 }); }, [sceneId, scene?.previewAssetId]); @@ -356,6 +425,16 @@ export function SceneEditorApp() { setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); }, [scene?.grid]); + useEffect(() => { + // IPC session clones arrays — do not clobber unsaved local darkness edits. + if (darknessBrushRef.current || darknessDirtyRef.current || saveDarknessTimerRef.current) return; + setLocalDarken(Boolean(scene?.darkenScene)); + const incoming = scene?.darknessStrokes ?? []; + if (sameDarknessStrokes(incoming, localDarknessStrokesRef.current)) return; + localDarknessStrokesRef.current = incoming; + setLocalDarknessStrokes(incoming); + }, [scene?.darkenScene, scene?.darknessStrokes]); + useEffect(() => { void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => { setSession({ project: p, currentSceneId: p?.currentSceneId ?? null }); @@ -404,6 +483,99 @@ export function SceneEditorApp() { [api, sceneId], ); + const mergeSceneIntoSession = useCallback((updated: Scene) => { + setSession((prev) => { + if (!prev?.project?.scenes[updated.id]) return prev; + return { + ...prev, + project: { + ...prev.project, + scenes: { ...prev.project.scenes, [updated.id]: updated }, + }, + }; + }); + }, []); + + const flushDarknessStrokes = useCallback(async () => { + const sid = sceneIdRef.current; + const payload = pendingDarknessStrokesRef.current; + if (saveDarknessTimerRef.current) { + window.clearTimeout(saveDarknessTimerRef.current); + saveDarknessTimerRef.current = 0; + } + if (!sid || !payload) return; + pendingDarknessStrokesRef.current = null; + try { + const res = await api.invoke(ipcChannels.project.updateScene, { + sceneId: sid, + patch: { darknessStrokes: payload }, + }); + darknessDirtyRef.current = false; + mergeSceneIntoSession(res.scene); + } catch { + // Keep dirty so a later flush / session sync can retry from local ref. + pendingDarknessStrokesRef.current = payload; + darknessDirtyRef.current = true; + } + }, [api, mergeSceneIntoSession]); + + const persistDarken = useCallback( + (next: boolean) => { + const sid = sceneIdRef.current; + if (!sid) return; + setLocalDarken(next); + if (!next) setDarknessTool('none'); + darknessDirtyRef.current = true; + void (async () => { + await flushDarknessStrokes(); + try { + const res = await api.invoke(ipcChannels.project.updateScene, { + sceneId: sid, + patch: { darkenScene: next }, + }); + darknessDirtyRef.current = false; + mergeSceneIntoSession(res.scene); + } catch { + darknessDirtyRef.current = true; + } + })(); + }, + [api, flushDarknessStrokes, mergeSceneIntoSession], + ); + + const persistDarknessStrokes = useCallback( + (next: SceneDarknessRevealStroke[]) => { + if (!sceneIdRef.current) return; + localDarknessStrokesRef.current = next; + setLocalDarknessStrokes(next); + pendingDarknessStrokesRef.current = next; + darknessDirtyRef.current = true; + if (saveDarknessTimerRef.current) window.clearTimeout(saveDarknessTimerRef.current); + saveDarknessTimerRef.current = window.setTimeout(() => { + void flushDarknessStrokes(); + }, 120); + }, + [flushDarknessStrokes], + ); + + const flushDarknessStrokesRef = useRef(flushDarknessStrokes); + flushDarknessStrokesRef.current = flushDarknessStrokes; + useEffect(() => { + const flush = () => { + void flushDarknessStrokesRef.current(); + }; + const onVis = () => { + if (document.hidden) flush(); + }; + window.addEventListener('blur', flush); + document.addEventListener('visibilitychange', onVis); + return () => { + window.removeEventListener('blur', flush); + document.removeEventListener('visibilitychange', onVis); + flush(); + }; + }, []); + const persistGrid = useCallback( (next: SceneGrid) => { if (!sceneId) return; @@ -504,6 +676,34 @@ export function SceneEditorApp() { }; }; + const layoutDarknessBrushCursor = () => { + const el = brushCursorElRef.current; + const p = darknessCursorPosRef.current; + const cr = contentRectRef.current; + if (!el) return; + if (!p || !cr || darknessToolRef.current === 'none') { + el.style.visibility = 'hidden'; + return; + } + el.style.visibility = 'visible'; + el.className = [ + styles.brushCursor, + darknessBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '', + ] + .filter(Boolean) + .join(' '); + const minDim = Math.min(cr.w, cr.h); + const size = Math.max(1, darknessRadiusNRef.current * minDim * 2); + el.style.left = `${String(cr.x + p.x * cr.w)}px`; + el.style.top = `${String(cr.y + p.y * cr.h)}px`; + el.style.width = `${String(size)}px`; + el.style.height = `${String(size)}px`; + }; + + useEffect(() => { + layoutDarknessBrushCursor(); + }, [darknessBrushShape, darknessRadiusN, darknessTool, contentRect]); + const viewCamera = useMemo(() => view, [view]); useEffect(() => { @@ -632,6 +832,17 @@ export function SceneEditorApp() { const isVideo = scene?.previewAssetType === 'video' && Boolean(url); const hasMapMedia = isImage || isVideo; + const darknessOverlayState = useMemo((): SceneDarknessState | null => { + if (!localDarken) return null; + return { + revision: 1, + enabled: true, + cacheKey: sceneId, + strokes: localDarknessStrokes, + draft: darknessDraft, + }; + }, [darknessDraft, localDarken, localDarknessStrokes, sceneId]); + return (
+
+ + {darknessOpen ? ( +
+ + {localDarken && hasMapMedia ? ( + <> +
+ + +
+ +
+ + +
+ + ) : null} +
+ ) : null} +
+ {USERS_BRANCH_FEATURES_ENABLED ? (