1 Commits

Author SHA1 Message Date
Ivan Fontosh 877fbaa8e7 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 <cursoragent@cursor.com>
2026-10-06 11:41:42 +08:00
43 changed files with 2086 additions and 72 deletions
+76 -1
View File
@@ -1,7 +1,13 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser'; 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 { function nowMs(): number {
return Date.now(); return Date.now();
@@ -23,6 +29,9 @@ export class EffectsStore {
tool: defaultTool(), tool: defaultTool(),
instances: [], instances: [],
}; };
/** Постоянные эффекты по карточке (graphNodeId или sceneId) на время показа. */
private cache = new Map<string, EffectInstance[]>();
private currentKey: string | null = null;
getState(): EffectsState { getState(): EffectsState {
// Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров. // Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров.
@@ -36,13 +45,70 @@ export class EffectsStore {
serverNowMs: nowMs(), serverNowMs: nowMs(),
instances: [], instances: [],
}; };
this.commitCurrent();
return this.state; 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<string, EffectInstance[]> {
this.commitCurrent();
const out: Record<string, EffectInstance[]> = {};
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<string, EffectInstance[]>): 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 { dispatch(event: EffectsEvent): EffectsState {
const s = this.state; const s = this.state;
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix)); const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
this.state = next; this.state = next;
this.commitCurrent();
return next; return next;
} }
@@ -78,9 +144,18 @@ export class EffectsStore {
serverNowMs: now, serverNowMs: now,
instances: kept, instances: kept,
}; };
this.commitCurrent();
return true; 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 { makeId(prefix: string): string {
return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`; return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`;
} }
+108
View File
@@ -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));
});
@@ -67,6 +67,66 @@ void test('SceneDarknessStore: draft синхронизируется и сбр
assert.equal(store.getState().strokes.length, 1); 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-штрих сохраняется в кэше сцены', () => { void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
const store = new SceneDarknessStore(); const store = new SceneDarknessStore();
store.switchScene('scene_a', true); store.switchScene('scene_a', true);
+44 -4
View File
@@ -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) { 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; this.currentKey = cacheKey;
const strokes = let strokes: SceneDarknessRevealStroke[] = [];
cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : []; 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 = { this.state = {
revision: this.state.revision + 1, revision: this.state.revision + 1,
enabled, enabled,
@@ -77,4 +89,32 @@ export class SceneDarknessStore {
} }
} }
} }
/** Кэш по карточкам, включая текущую. Пустые карточки не входят. */
exportByCard(): Record<string, SceneDarknessRevealStroke[]> {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
}
const out: Record<string, SceneDarknessRevealStroke[]> = {};
for (const [key, strokes] of this.cache) {
if (strokes.length === 0) continue;
out[key] = strokes.map(cloneStroke);
}
return out;
}
/** Кладёт кэш, не переключая карточку. Дальше вызывают switchScene. */
importByCard(byCard: Record<string, SceneDarknessRevealStroke[]>): 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 })) };
} }
+26
View File
@@ -105,4 +105,30 @@ export class SceneMarkerStore {
} }
} }
} }
exportByCard(): Record<string, SceneMarkerStroke[]> {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
}
const out: Record<string, SceneMarkerStroke[]> = {};
for (const [key, strokes] of this.cache) {
if (strokes.length === 0) continue;
out[key] = strokes.map(cloneStroke);
}
return out;
}
importByCard(byCard: Record<string, SceneMarkerStroke[]>): 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 })) };
} }
+1
View File
@@ -392,6 +392,7 @@ export async function buildProjectFromFoundryDocuments(
previewVideoAutostart: previewAssetType === 'video', previewVideoAutostart: previewAssetType === 'video',
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
darknessStrokes: [],
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [], npcTokens: [],
+84
View File
@@ -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<GameSaveListItem[]> {
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<GameSaveFile> {
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<GameSaveListItem> {
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<GameSaveFile> {
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;
}
}
+226 -14
View File
@@ -20,7 +20,9 @@ import {
stripProjectZipExtension, stripProjectZipExtension,
} from '../shared/project/projectZipExtension'; } from '../shared/project/projectZipExtension';
import type { Project } from '../shared/types'; 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 { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport'; import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport';
import { import {
@@ -28,7 +30,9 @@ import {
previewRotationStepsCw, previewRotationStepsCw,
} from '../shared/types/scenePreviewRotation'; } from '../shared/types/scenePreviewRotation';
import { getSessionTimerRemainingMs } from '../shared/types/sessionTimer';
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore'; import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
import { GameSavesStore } from './gameSave/gameSavesStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore'; import { SceneDarknessStore } from './effects/sceneDarknessStore';
import { SceneMarkerStore } from './effects/sceneMarkerStore'; import { SceneMarkerStore } from './effects/sceneMarkerStore';
import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore'; import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore';
@@ -204,6 +208,9 @@ const tokenPathSessionStore = new TokenPathSessionStore();
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore(); const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore(); const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore(); const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
let gameSavesStore: GameSavesStore | null = null;
/** Канонический слепок последнего сохранения этого показа. null — ещё не сохраняли. */
let lastSaveCanonical: string | null = null;
let tokensStore: TokensStore | null = null; let tokensStore: TokensStore | null = null;
let playersStore: PlayersStore | null = null; let playersStore: PlayersStore | null = null;
let gameSystemsStore: GameSystemsStore | null = null; let gameSystemsStore: GameSystemsStore | null = null;
@@ -514,7 +521,168 @@ function syncSceneDarknessForProject(project: Project): void {
const enabled = const enabled =
Boolean(scene?.darkenScene) && Boolean(scene?.darkenScene) &&
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video'); (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<Project> {
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 { function syncSceneMarkerForProject(project: Project): void {
@@ -667,6 +835,7 @@ async function main() {
await playersStore.ensureLoaded(); await playersStore.ensureLoaded();
} }
gameSystemsStore = new GameSystemsStore(app.getPath('userData')); gameSystemsStore = new GameSystemsStore(app.getPath('userData'));
gameSavesStore = new GameSavesStore(app.getPath('userData'));
await gameSystemsStore.ensureLoaded(); await gameSystemsStore.ensureLoaded();
playlistsStore = new PlaylistsStore(app.getPath('userData')); playlistsStore = new PlaylistsStore(app.getPath('userData'));
await playlistsStore.ensureLoaded(); await playlistsStore.ensureLoaded();
@@ -701,7 +870,7 @@ async function main() {
registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token)); registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken()); registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version)); registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
registerHandler(ipcChannels.windows.openMultiWindow, (req) => { registerHandler(ipcChannels.windows.openMultiWindow, async (req) => {
sceneDarknessStore.resetSession(); sceneDarknessStore.resetSession();
sceneMarkerStore.resetSession(); sceneMarkerStore.resetSession();
sceneTrapsStore.resetSession(); sceneTrapsStore.resetSession();
@@ -714,23 +883,38 @@ async function main() {
timerSessionStore.clear(); timerSessionStore.clear();
initiativeSessionStore.clear(); initiativeSessionStore.clear();
diceRollSessionStore.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) : []; const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
if (playerIds.length > 0) { if (playerIds.length > 0) {
scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds }); scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds });
} }
} }
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
openMultiWindow(); openMultiWindow();
const project = projectStore.getOpenProject(); project = projectStore.getOpenProject();
if (project) { if (project && !saveId) {
syncSceneDarknessForProject(project); syncSceneDarknessForProject(project);
syncSceneMarkerForProject(project); syncSceneMarkerForProject(project);
syncSceneTrapsForProject(project); syncSceneTrapsForProject(project);
syncSceneEffectsForProject(project);
seedTokenPathPlaybackForProject(project); seedTokenPathPlaybackForProject(project);
} else { } else if (!project) {
emitTokenPathSessionState(); emitTokenPathSessionState();
} }
if (project && saveId) {
lastSaveCanonical = canonicalGameSave(captureGameSaveBody(project));
}
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneMarkerState(); emitSceneMarkerState();
emitSceneTrapsState(); emitSceneTrapsState();
@@ -739,11 +923,31 @@ async function main() {
emitScenePlayerTokensSessionState(); emitScenePlayerTokensSessionState();
emitTokenGridSnapState(); emitTokenGridSnapState();
emitEffectsState(); emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneViewState();
emitSessionTimerState(); emitSessionTimerState();
syncInitiativePresenceFromSession(); if (!saveId) syncInitiativePresenceFromSession();
emitInitiativeState(); emitInitiativeState();
emitDiceRollState(); 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, () => { registerHandler(ipcChannels.windows.closeMultiWindow, () => {
closeMultiWindow(); closeMultiWindow();
@@ -961,6 +1165,8 @@ async function main() {
sceneNpcTokensSessionStore.reset(); sceneNpcTokensSessionStore.reset();
clearNpcMapSpawnDrag(); clearNpcMapSpawnDrag();
scenePlayerTokensSessionStore.reset(); scenePlayerTokensSessionStore.reset();
effectsStore.resetSession();
lastSaveCanonical = null;
syncSceneMarkerForProject(project); syncSceneMarkerForProject(project);
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
@@ -974,7 +1180,8 @@ async function main() {
registerHandler(ipcChannels.project.close, async () => { registerHandler(ipcChannels.project.close, async () => {
closeNpcsEditorWindow(); closeNpcsEditorWindow();
await projectStore.closeOpenProject(); await projectStore.closeOpenProject();
effectsStore.clear(); effectsStore.resetSession();
lastSaveCanonical = null;
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
timerSessionStore.clear(); timerSessionStore.clear();
@@ -1015,7 +1222,8 @@ async function main() {
}); });
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => { registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null })); await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
effectsStore.clear(); const sceneProject = projectStore.getOpenProject();
if (sceneProject) syncSceneEffectsForProject(sceneProject);
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneViewStore.reset(); sceneViewStore.reset();
@@ -1046,7 +1254,8 @@ async function main() {
if (!open) throw new Error('No open project'); if (!open) throw new Error('No open project');
const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null; const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null;
projectStore.setSessionPointer(graphNodeId, gn ? gn.sceneId : null); projectStore.setSessionPointer(graphNodeId, gn ? gn.sceneId : null);
effectsStore.clear(); const switched = projectStore.getOpenProject();
if (switched) syncSceneEffectsForProject(switched);
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneViewStore.reset(); sceneViewStore.reset();
@@ -1080,7 +1289,10 @@ async function main() {
const before = projectStore.getOpenProject()?.scenes[sceneId]; const before = projectStore.getOpenProject()?.scenes[sceneId];
const next = await projectStore.updateScene(sceneId, patch); const next = await projectStore.updateScene(sceneId, patch);
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project?.currentSceneId === sceneId && patch.darkenScene !== undefined) { if (
project?.currentSceneId === sceneId &&
(patch.darkenScene !== undefined || patch.darknessStrokes !== undefined)
) {
syncSceneDarknessForProject(project); syncSceneDarknessForProject(project);
emitSceneDarknessState(); emitSceneDarknessState();
} }
@@ -315,4 +315,16 @@ export class InitiativeSessionStore {
this.state = next; this.state = next;
return this.state; return this.state;
} }
load(next: Omit<InitiativeState, 'revision'>): 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;
}
} }
@@ -225,4 +225,16 @@ export class MaterialsOverlayStore {
}; };
return this.state; return this.state;
} }
load(next: Omit<MaterialsOverlayState, 'revision' | 'zoomTool'>): 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;
}
} }
+13
View File
@@ -256,4 +256,17 @@ export class NpcsOverlayStore {
}; };
return this.state; return this.state;
} }
load(next: Pick<NpcsOverlayState, 'activeNpcIds' | 'layouts' | 'focusNpcId'>): 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;
}
} }
@@ -310,4 +310,14 @@ export class SceneNpcTokensSessionStore {
} }
} }
} }
load(next: Omit<SceneNpcTokensSessionState, 'revision'>): SceneNpcTokensSessionState {
this.state = {
revision: this.state.revision + 1,
byPlacementId: { ...next.byPlacementId },
scale: clampNpcTokenSessionScale(next.scale),
spawned: { ...next.spawned },
};
return this.state;
}
} }
@@ -179,4 +179,14 @@ export class ScenePlayerTokensSessionStore {
} }
} }
} }
load(next: Omit<ScenePlayerTokensSessionState, 'revision'>): ScenePlayerTokensSessionState {
this.state = {
revision: this.state.revision + 1,
selectedPlayerIds: [...next.selectedPlayerIds],
visible: next.visible,
byPlayerId: { ...next.byPlayerId },
};
return this.state;
}
} }
+9
View File
@@ -52,6 +52,7 @@ import type {
SceneTrap, SceneTrap,
} from '../../shared/types'; } from '../../shared/types';
import { normalizeSceneAudioRef, PROJECT_SCHEMA_VERSION } from '../../shared/types'; import { normalizeSceneAudioRef, PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeSceneDarknessStrokes } from '../../shared/types/sceneDarkness';
import { normalizeMaterialLegend } from '../../shared/types/materialLegend'; import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens'; import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
import { import {
@@ -772,6 +773,7 @@ export class ZipProjectStore {
previewVideoAutostart: false, previewVideoAutostart: false,
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
darknessStrokes: [],
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [], npcTokens: [],
@@ -798,6 +800,9 @@ export class ZipProjectStore {
? { previewRotationDeg: patch.previewRotationDeg } ? { previewRotationDeg: patch.previewRotationDeg }
: null), : null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.darknessStrokes !== undefined
? { darknessStrokes: normalizeSceneDarknessStrokes(patch.darknessStrokes) }
: null),
...(patch.traps !== undefined ...(patch.traps !== undefined
? { ? {
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)), 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 = const previewThumbAssetId =
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null; (s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene); 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 rawTraps = (s as unknown as { traps?: unknown[] }).traps;
const traps = (Array.isArray(rawTraps) ? rawTraps : []) const traps = (Array.isArray(rawTraps) ? rawTraps : [])
.map((t) => normalizeSceneTrap(t)) .map((t) => normalizeSceneTrap(t))
@@ -2666,6 +2674,7 @@ function normalizeScene(s: Scene): Scene {
previewVideoAutostart, previewVideoAutostart,
previewRotationDeg, previewRotationDeg,
darkenScene, darkenScene,
darknessStrokes,
traps, traps,
tokens, tokens,
npcTokens, npcTokens,
+25
View File
@@ -131,4 +131,29 @@ export class SceneTrapsStore {
} }
} }
} }
exportByCard(): Record<string, Record<string, SceneTrapRuntime>> {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, cloneById(this.state.byId));
}
const out: Record<string, Record<string, SceneTrapRuntime>> = {};
for (const [key, byId] of this.cache) {
const kept: Record<string, SceneTrapRuntime> = {};
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<string, Record<string, SceneTrapRuntime>>): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
for (const [key, byId] of Object.entries(byCard)) {
this.cache.set(key, cloneById(byId));
}
}
} }
@@ -128,4 +128,20 @@ export class TimerSessionStore {
}; };
return this.state; return this.state;
} }
/** Слепок: таймер на паузе, без абсолютного момента конца. */
loadPaused(next: Pick<SessionTimerState, 'durationMs' | 'remainingMs' | 'visibleToPlayers' | 'layout'>): 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;
}
} }
@@ -112,4 +112,12 @@ export class SceneTokensSessionStore {
} }
} }
} }
load(byPlacementId: SceneTokensSessionState['byPlacementId']): SceneTokensSessionState {
this.state = {
revision: this.state.revision + 1,
byPlacementId: { ...byPlacementId },
};
return this.state;
}
} }
+13
View File
@@ -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 }; export type { TokenPathTargetRef };
@@ -160,6 +160,50 @@
margin-top: 4px; 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 { .descriptionViewBody {
min-height: 360px; min-height: 360px;
max-height: min(560px, calc(100vh - 200px)); max-height: min(560px, calc(100vh - 200px));
+197 -14
View File
@@ -5,6 +5,7 @@ import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { fitAspectRect } from '../../shared/geometry/fitAspectRect'; import { fitAspectRect } from '../../shared/geometry/fitAspectRect';
import { ipcChannels } from '../../shared/ipc/contracts'; import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } 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 { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import { import {
isNodeInMainStoryline, isNodeInMainStoryline,
@@ -204,6 +205,14 @@ export function ControlApp() {
placementId: string; placementId: string;
} | null>(null); } | null>(null);
const [stopConfirmOpen, setStopConfirmOpen] = useState(false); const [stopConfirmOpen, setStopConfirmOpen] = useState(false);
const [stopDirty, setStopDirty] = useState(false);
const [saveOpen, setSaveOpen] = useState(false);
const [saveEndAfter, setSaveEndAfter] = useState(false);
const [saveItems, setSaveItems] = useState<GameSaveListItem[]>([]);
const [saveName, setSaveName] = useState('');
const [saveSelectedId, setSaveSelectedId] = useState<string | null>(null);
const [saveError, setSaveError] = useState('');
const [saveBusy, setSaveBusy] = useState(false);
const [sceneView, sceneViewApi] = useSceneViewState(); const [sceneView, sceneViewApi] = useSceneViewState();
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null); const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
const [materialsOverlay, materialsApi] = useMaterialsOverlayState(); const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
@@ -1156,26 +1165,74 @@ export function ControlApp() {
}; };
}, [npcMapSpawnDrag.dragging, npcMapSpawnDragApi, snapNormActive]); }, [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(() => { useEffect(() => {
return api.on(ipcChannels.windows.closeMultiWindowConfirmRequested, () => { return api.on(ipcChannels.windows.closeMultiWindowConfirmRequested, () => {
setStopConfirmOpen(true); openStopConfirm();
}); });
}, [api]); }, [api, openStopConfirm]);
useEffect(() => { useEffect(() => {
if (npcMapSpawnDrag.dragging) return; if (npcMapSpawnDrag.dragging) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
e.preventDefault(); e.preventDefault();
if (saveOpen) {
setSaveOpen(false);
return;
}
if (stopConfirmOpen) { if (stopConfirmOpen) {
setStopConfirmOpen(false); setStopConfirmOpen(false);
return; return;
} }
setStopConfirmOpen(true); openStopConfirm();
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [npcMapSpawnDrag.dragging, stopConfirmOpen]); }, [npcMapSpawnDrag.dragging, openStopConfirm, saveOpen, stopConfirmOpen]);
useEffect(() => { useEffect(() => {
if (!markersInteractive) { if (!markersInteractive) {
@@ -2142,6 +2199,21 @@ export function ControlApp() {
<span className={styles.iconGlyph}>⬛</span> <span className={styles.iconGlyph}>⬛</span>
</Button> </Button>
</div> </div>
{session?.currentSceneId ? (
<Button
onClick={() => {
const sceneId = session.currentSceneId;
if (!sceneId) return;
void api.invoke(ipcChannels.project.updateScene, {
sceneId,
patch: { darknessStrokes: sdState.strokes },
});
}}
data-testid="bake-darkness-to-scene"
>
{t('control.bakeDarknessToScene')}
</Button>
) : null}
</div> </div>
) : null} ) : null}
<div className={styles.radiusRow}> <div className={styles.radiusRow}>
@@ -2514,7 +2586,24 @@ export function ControlApp() {
: t('control.showPlayers')} : t('control.showPlayers')}
</Button> </Button>
) : null} ) : null}
<Button onClick={() => setStopConfirmOpen(true)}>{t('control.stopPresentation')}</Button> <Button onClick={openStopConfirm}>{t('control.stopPresentation')}</Button>
<Button
variant="ghost"
iconOnly
title={t('control.saveGame')}
ariaLabel={t('control.saveGame')}
data-testid="control-save-game"
onClick={() => openSaveModal(false)}
>
<span className={styles.iconGlyph} aria-hidden>
<svg viewBox="0 0 24 24" width="18" height="18">
<path
fill="currentColor"
d="M4 3h11.2L20 7.8V20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm2 2v5h9V6.6L13.4 5H6zm1 9v5h10v-5H7zm2 1h6v3H9v-3z"
/>
</svg>
</span>
</Button>
</div> </div>
</div> </div>
<div className={styles.spacer10} /> <div className={styles.spacer10} />
@@ -3138,7 +3227,7 @@ export function ControlApp() {
<Button <Button
variant="primary" variant="primary"
disabled={!session?.project?.currentGraphNodeId} disabled={!session?.project?.currentGraphNodeId}
onClick={() => setStopConfirmOpen(true)} onClick={openStopConfirm}
> >
{t('control.stopPresentation')} {t('control.stopPresentation')}
</Button> </Button>
@@ -3749,18 +3838,112 @@ export function ControlApp() {
× ×
</button> </button>
</div> </div>
<div>{t('control.stopConfirm.body')}</div> <div>{stopDirty ? t('control.stopConfirm.dirtyBody') : t('control.stopConfirm.body')}</div>
<div className={styles.modalFooter}> <div className={[styles.modalFooter, stopDirty ? styles.modalFooterWrap : ''].filter(Boolean).join(' ')}>
<Button onClick={() => setStopConfirmOpen(false)}>{t('common.cancel')}</Button> <Button onClick={() => setStopConfirmOpen(false)}>{t('common.cancel')}</Button>
<Button {stopDirty ? (
variant="primary" <Button
data-testid="stop-confirm-accept" data-testid="stop-confirm-discard"
onClick={() => {
setStopConfirmOpen(false);
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
}}
>
{t('control.stopConfirm.discard')}
</Button>
) : null}
{stopDirty ? (
<Button
variant="primary"
data-testid="stop-confirm-save-and-end"
onClick={() => {
setStopConfirmOpen(false);
openSaveModal(true);
}}
>
{t('control.stopConfirm.saveAndEnd')}
</Button>
) : (
<Button
variant="primary"
data-testid="stop-confirm-accept"
onClick={() => {
setStopConfirmOpen(false);
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
}}
>
{t('control.stopConfirm.confirm')}
</Button>
)}
</div>
</div>
</>,
document.body,
)
: null}
{saveOpen
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => {
if (!saveBusy) setSaveOpen(false);
}}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog} data-testid="save-game-modal">
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('control.saveGame.title')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => { onClick={() => {
setStopConfirmOpen(false); if (!saveBusy) setSaveOpen(false);
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
}} }}
> >
{t('control.stopConfirm.confirm')} ×
</button>
</div>
<input
className={styles.saveNameInput}
value={saveName}
placeholder={t('control.saveGame.name')}
aria-label={t('control.saveGame.name')}
onChange={(e) => {
setSaveName(e.currentTarget.value);
setSaveSelectedId(null);
}}
/>
<div className={styles.saveList}>
{saveItems.length === 0 ? <div>{t('control.saveGame.empty')}</div> : null}
{saveItems.map((item) => (
<button
key={item.id}
type="button"
className={[
styles.saveRow,
saveSelectedId === item.id ? styles.saveRowSelected : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => {
setSaveSelectedId(item.id);
setSaveName(item.name);
}}
>
{item.name}
</button>
))}
</div>
{saveError ? <div className={styles.saveError}>{saveError}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={() => setSaveOpen(false)} disabled={saveBusy}>
{t('common.cancel')}
</Button>
<Button variant="primary" data-testid="save-game-commit" disabled={saveBusy} onClick={commitSave}>
{t('control.saveGame.commit')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -97,6 +97,8 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
assert.ok(src.includes("t('control.darknessControl')")); assert.ok(src.includes("t('control.darknessControl')"));
assert.ok(src.includes("t('control.explorerBrush')")); assert.ok(src.includes("t('control.explorerBrush')"));
assert.ok(src.includes("t('control.closerBrush')")); 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("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')"));
assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'")); assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'"));
assert.ok(src.includes('SceneDarknessOverlay')); assert.ok(src.includes('SceneDarknessOverlay'));
@@ -326,7 +328,10 @@ void test('ControlApp: Escape → своя модалка подтвержден
assert.ok(src.includes('data-testid="control-help-tool"')); 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.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.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('showMessageBox'));
assert.ok(!src.includes('window.confirm')); assert.ok(!src.includes('window.confirm'));
}); });
+34
View File
@@ -385,6 +385,11 @@
font: inherit; font: inherit;
} }
.fileMenuItem:disabled {
opacity: 0.45;
cursor: default;
}
.fileMenuItemDanger { .fileMenuItemDanger {
text-align: left; text-align: left;
padding: 10px; padding: 10px;
@@ -525,6 +530,30 @@
box-sizing: border-box; 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 { .licenseBlockTitle {
font-weight: 900; font-weight: 900;
font-size: var(--text-lg); font-size: var(--text-lg);
@@ -921,6 +950,11 @@
width: 100%; width: 100%;
} }
.actionsRowHalf > button {
min-width: 0;
width: 100%;
}
.actionsRowHalf > span { .actionsRowHalf > span {
display: flex; display: flex;
min-width: 0; min-width: 0;
+128 -23
View File
@@ -16,6 +16,7 @@ import {
} from '../../shared/ipc/contracts'; } from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion'; import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot'; import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import type { GameSaveListItem } from '../../shared/types/gameSave';
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension'; import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
import type { import type {
AssetId, AssetId,
@@ -162,6 +163,10 @@ export function EditorApp() {
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false); const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = useState(false); const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false); const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
const [gameSaves, setGameSaves] = useState<GameSaveListItem[]>([]);
const [loadSaveOpen, setLoadSaveOpen] = useState(false);
const [loadSaveId, setLoadSaveId] = useState<string | null>(null);
const [loadSaveError, setLoadSaveError] = useState('');
const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false); const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false);
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false); const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
const [gameSystemsOpen, setGameSystemsOpen] = 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( const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId, playerIds?: string[]) => { (graphNodeId: GraphNodeId, playerIds?: string[]) => {
if (!licenseActive || launching) return; if (!licenseActive || launching) return;
@@ -943,6 +989,7 @@ export function EditorApp() {
data-testid="run-menu-btn" data-testid="run-menu-btn"
onClick={() => { onClick={() => {
if (runDisabled || launching) return; if (runDisabled || launching) return;
refreshGameSaves();
setRunMenuOpen((v) => !v); setRunMenuOpen((v) => !v);
}} }}
> >
@@ -1219,7 +1266,6 @@ export function EditorApp() {
previewVideoAutostart={sc?.previewVideoAutostart ?? false} previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewVideoLoop={sc?.settings.loopVideo ?? false} previewVideoLoop={sc?.settings.loopVideo ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0} previewRotationDeg={sc?.previewRotationDeg ?? 0}
darkenScene={sc?.darkenScene ?? false}
previewBusy={previewBusy} previewBusy={previewBusy}
previewBusyText={previewBusyText} previewBusyText={previewBusyText}
mediaAssets={sceneMediaAssets} mediaAssets={sceneMediaAssets}
@@ -1233,7 +1279,6 @@ export function EditorApp() {
onPreviewVideoLoopChange={(next) => onPreviewVideoLoopChange={(next) =>
void actions.updateScene(sid, { settings: { loopVideo: next } }) void actions.updateScene(sid, { settings: { loopVideo: next } })
} }
onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
onTitleChange={(title) => void actions.updateScene(sid, { title })} onTitleChange={(title) => void actions.updateScene(sid, { title })}
onDescriptionChange={(description) => onDescriptionChange={(description) =>
void actions.updateScene(sid, { description }) void actions.updateScene(sid, { description })
@@ -1692,6 +1737,69 @@ export function EditorApp() {
launchFromGraphNode(graphStartGraphNodeId, playerIds); launchFromGraphNode(graphStartGraphNodeId, playerIds);
}} }}
/> />
{loadSaveOpen
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => {
if (!launching) setLoadSaveOpen(false);
}}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog} data-testid="load-save-modal">
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('top.loadSave.title')}</div>
<button
type="button"
className={styles.modalClose}
aria-label={t('common.close')}
onClick={() => {
if (!launching) setLoadSaveOpen(false);
}}
>
×
</button>
</div>
<div className={styles.saveList}>
{gameSaves.map((item) => (
<button
key={item.id}
type="button"
className={[
styles.saveRow,
loadSaveId === item.id ? styles.saveRowSelected : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => setLoadSaveId(item.id)}
>
{item.name}
</button>
))}
</div>
{loadSaveError ? <div className={styles.fieldError}>{loadSaveError}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={() => setLoadSaveOpen(false)} disabled={launching}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
data-testid="load-save-commit"
disabled={!loadSaveId || launching}
onClick={() => {
if (loadSaveId) launchFromSave(loadSaveId);
}}
>
{t('top.loadSave.action')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</> </>
) : null} ) : null}
{USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos {USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos
@@ -1714,6 +1822,22 @@ export function EditorApp() {
> >
{t('top.runWithPlayers')} {t('top.runWithPlayers')}
</button> </button>
<button
type="button"
className={styles.fileMenuItem}
role="menuitem"
data-testid="run-load-save-item"
disabled={gameSaves.length === 0}
onClick={() => {
if (gameSaves.length === 0) return;
setRunMenuOpen(false);
setLoadSaveId(null);
setLoadSaveError('');
setLoadSaveOpen(true);
}}
>
{t('top.loadSave')}
</button>
</div>, </div>,
document.body, document.body,
) )
@@ -2494,7 +2618,6 @@ type SceneInspectorProps = {
previewVideoAutostart: boolean; previewVideoAutostart: boolean;
previewVideoLoop: boolean; previewVideoLoop: boolean;
previewRotationDeg: 0 | 90 | 180 | 270; previewRotationDeg: 0 | 90 | 180 | 270;
darkenScene: boolean;
previewBusy: boolean; previewBusy: boolean;
previewBusyText: string; previewBusyText: string;
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
@@ -2502,7 +2625,6 @@ type SceneInspectorProps = {
onAudioRefsChange: (next: SceneAudioRef[]) => void; onAudioRefsChange: (next: SceneAudioRef[]) => void;
onPreviewVideoAutostartChange: (next: boolean) => void; onPreviewVideoAutostartChange: (next: boolean) => void;
onPreviewVideoLoopChange: (next: boolean) => void; onPreviewVideoLoopChange: (next: boolean) => void;
onDarkenSceneChange: (next: boolean) => void;
onTitleChange: (v: string) => void; onTitleChange: (v: string) => void;
onDescriptionChange: (v: string) => void; onDescriptionChange: (v: string) => void;
onImportPreview: () => void; onImportPreview: () => void;
@@ -2575,7 +2697,6 @@ function SceneInspector({
previewVideoAutostart, previewVideoAutostart,
previewVideoLoop, previewVideoLoop,
previewRotationDeg, previewRotationDeg,
darkenScene,
previewBusy, previewBusy,
previewBusyText, previewBusyText,
mediaAssets, mediaAssets,
@@ -2583,7 +2704,6 @@ function SceneInspector({
onAudioRefsChange, onAudioRefsChange,
onPreviewVideoAutostartChange, onPreviewVideoAutostartChange,
onPreviewVideoLoopChange, onPreviewVideoLoopChange,
onDarkenSceneChange,
onTitleChange, onTitleChange,
onDescriptionChange, onDescriptionChange,
onImportPreview, onImportPreview,
@@ -2737,8 +2857,7 @@ function SceneInspector({
</div> </div>
) : null} ) : null}
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? ( {previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
<> <div className={styles.actionsRowHalf}>
<div className={styles.spacer6} />
<Button <Button
onClick={() => { onClick={() => {
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270; const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
@@ -2747,20 +2866,6 @@ function SceneInspector({
> >
{t('scene.rotate')} {t('scene.rotate')}
</Button> </Button>
</>
) : null}
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
<>
<div className={styles.spacer6} />
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={darkenScene}
onChange={(e) => onDarkenSceneChange(e.target.checked)}
/>
<span className={styles.spanSm}>{t('scene.darkenScene')}</span>
</label>
<div className={styles.spacer6} />
<Button <Button
onClick={() => { onClick={() => {
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {}); void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
@@ -2768,7 +2873,7 @@ function SceneInspector({
> >
{t('sceneEditor.open')} {t('sceneEditor.open')}
</Button> </Button>
</> </div>
) : null} ) : null}
<div className={styles.spacer6} /> <div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.audio')}</div> <div className={styles.labelSm}>{t('scene.audio')}</div>
+1
View File
@@ -8,6 +8,7 @@ export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly s
effects: ['Эффекты', 'Effects'], effects: ['Эффекты', 'Effects'],
presentation: ['Презентация', 'Presentation'], presentation: ['Презентация', 'Presentation'],
grid: ['Сетка', 'Grid'], grid: ['Сетка', 'Grid'],
sceneEditor: ['Затемнение сцены', 'Scene darkness', 'Редактор сцены', 'Scene editor'],
controlPanel: ['Пульт'], controlPanel: ['Пульт'],
players: ['Игроки', 'Players'], players: ['Игроки', 'Players'],
tokens: ['Токены', 'Tokens'], tokens: ['Токены', 'Tokens'],
File diff suppressed because one or more lines are too long
+14
View File
@@ -43,6 +43,7 @@ type Actions = {
createProject: (name: string) => Promise<void>; createProject: (name: string) => Promise<void>;
openProject: (id: ProjectId) => Promise<void>; openProject: (id: ProjectId) => Promise<void>;
closeProject: () => Promise<void>; closeProject: () => Promise<void>;
noteProject: (project: Project) => void;
createScene: () => Promise<void>; createScene: () => Promise<void>;
createScenesFromMediaPaths: ( createScenesFromMediaPaths: (
items: { filePath: string; title: string }[], items: { filePath: string; title: string }[],
@@ -71,6 +72,7 @@ type Actions = {
previewVideoAutostart?: boolean; previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270; previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean; darkenScene?: boolean;
darknessStrokes?: Scene['darknessStrokes'];
settings?: Partial<Scene['settings']>; settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>; media?: Partial<Scene['media']>;
layout?: { x: number; y: number }; layout?: { x: number; y: number };
@@ -354,6 +356,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewVideoAutostart: false, previewVideoAutostart: false,
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
darknessStrokes: [],
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [], npcTokens: [],
@@ -564,6 +567,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewVideoAutostart?: boolean; previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270; previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean; darkenScene?: boolean;
darknessStrokes?: Scene['darknessStrokes'];
traps?: import('../../../shared/types').SceneTrap[]; traps?: import('../../../shared/types').SceneTrap[];
tokens?: import('../../../shared/types').SceneToken[]; tokens?: import('../../../shared/types').SceneToken[];
npcTokens?: import('../../../shared/types').SceneNpcToken[]; npcTokens?: import('../../../shared/types').SceneNpcToken[];
@@ -594,6 +598,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
? { previewRotationDeg: patch.previewRotationDeg } ? { previewRotationDeg: patch.previewRotationDeg }
: null), : null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.darknessStrokes !== undefined ? { darknessStrokes: patch.darknessStrokes } : null),
...(patch.traps !== undefined ? { traps: patch.traps } : null), ...(patch.traps !== undefined ? { traps: patch.traps } : null),
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null), ...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : 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) => { const deleteProject = async (projectId: ProjectId) => {
projectDataEpochRef.current += 1; projectDataEpochRef.current += 1;
await api.invoke(ipcChannels.project.deleteProject, { projectId }); await api.invoke(ipcChannels.project.deleteProject, { projectId });
@@ -936,6 +949,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
createProject, createProject,
openProject, openProject,
closeProject, closeProject,
noteProject,
createScene, createScene,
createScenesFromMediaPaths, createScenesFromMediaPaths,
selectScene, selectScene,
@@ -86,6 +86,39 @@
user-select: none; 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 { .field {
display: grid; display: grid;
gap: 6px; gap: 6px;
+402
View File
@@ -7,14 +7,21 @@ import type {
NpcDisposition, NpcDisposition,
NpcId, NpcId,
ProjectNpc, ProjectNpc,
Scene,
SceneDarknessBrushShape,
SceneDarknessDraft,
SceneDarknessRevealStroke,
SceneDarknessState,
SceneGrid, SceneGrid,
SceneGridTemplate, SceneGridTemplate,
SceneId,
SceneNpcToken, SceneNpcToken,
SceneToken, SceneToken,
SceneTrap, SceneTrap,
SceneTrapType, SceneTrapType,
TokenId, TokenId,
} from '../../shared/types'; } from '../../shared/types';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { import {
asSceneNpcTokenId, asSceneNpcTokenId,
clampSceneNpcTokenSizeN, 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 }) { function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
const imageUrl = useAssetUrl(npc.avatarAssetId); const imageUrl = useAssetUrl(npc.avatarAssetId);
const disposition = normalizeNpcDisposition(npc.disposition); const disposition = normalizeNpcDisposition(npc.disposition);
@@ -270,8 +295,28 @@ export function SceneEditorApp() {
const [session, setSession] = useState<SessionState | null>(null); const [session, setSession] = useState<SessionState | null>(null);
const [trapsOpen, setTrapsOpen] = useState(false); const [trapsOpen, setTrapsOpen] = useState(false);
const [gridOpen, setGridOpen] = useState(false); const [gridOpen, setGridOpen] = useState(false);
const [darknessOpen, setDarknessOpen] = useState(false);
const [tokensOpen, setTokensOpen] = useState(false); const [tokensOpen, setTokensOpen] = useState(false);
const [npcsOpen, setNpcsOpen] = useState(false); const [npcsOpen, setNpcsOpen] = useState(false);
const [localDarken, setLocalDarken] = useState(false);
const [localDarknessStrokes, setLocalDarknessStrokes] = useState<SceneDarknessRevealStroke[]>([]);
const [darknessDraft, setDarknessDraft] = useState<SceneDarknessDraft | null>(null);
const [darknessTool, setDarknessTool] = useState<'none' | 'explore' | 'close'>('none');
const [darknessRadiusN, setDarknessRadiusN] = useState(0.08);
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
const darknessBrushRef = useRef<{
points: { x: number; y: number; tMs: number }[];
createdAtMs: number;
seed: number;
} | null>(null);
const brushCursorElRef = useRef<HTMLDivElement | null>(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 [tokenSearch, setTokenSearch] = useState('');
const [npcSearch, setNpcSearch] = useState(''); const [npcSearch, setNpcSearch] = useState('');
const [tokenModal, setTokenModal] = useState< const [tokenModal, setTokenModal] = useState<
@@ -291,16 +336,23 @@ export function SceneEditorApp() {
const [selected, setSelected] = useState<Selection>(null); const [selected, setSelected] = useState<Selection>(null);
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 }); const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const contentRectRef = useRef(contentRect);
contentRectRef.current = contentRect;
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragMode>(null); const dragRef = useRef<DragMode>(null);
const saveTrapsTimerRef = useRef(0); const saveTrapsTimerRef = useRef(0);
const saveTokensTimerRef = useRef(0); const saveTokensTimerRef = useRef(0);
const saveNpcTokensTimerRef = useRef(0); const saveNpcTokensTimerRef = useRef(0);
const saveGridTimerRef = useRef(0); const saveGridTimerRef = useRef(0);
const saveDarknessTimerRef = useRef(0);
const pendingDarknessStrokesRef = useRef<SceneDarknessRevealStroke[] | null>(null);
const darknessDirtyRef = useRef(false);
const spaceDownRef = useRef(false); const spaceDownRef = useRef(false);
const sceneIdRef = useRef<SceneId | null>(null);
const project = session?.project ?? null; const project = session?.project ?? null;
const sceneId = project?.currentSceneId ?? null; const sceneId = project?.currentSceneId ?? null;
sceneIdRef.current = sceneId;
const scene = sceneId && project ? project.scenes[sceneId] : undefined; const scene = sceneId && project ? project.scenes[sceneId] : undefined;
const url = useAssetUrl(scene?.previewAssetId ?? null); const url = useAssetUrl(scene?.previewAssetId ?? null);
const rot = scene?.previewRotationDeg ?? 0; const rot = scene?.previewRotationDeg ?? 0;
@@ -316,6 +368,7 @@ export function SceneEditorApp() {
const trapsRef = useRef<SceneTrap[]>([]); const trapsRef = useRef<SceneTrap[]>([]);
const tokensRef = useRef<SceneToken[]>([]); const tokensRef = useRef<SceneToken[]>([]);
const npcTokensRef = useRef<SceneNpcToken[]>([]); const npcTokensRef = useRef<SceneNpcToken[]>([]);
const localDarknessStrokesRef = useRef<SceneDarknessRevealStroke[]>([]);
useEffect(() => { useEffect(() => {
trapsRef.current = localTraps; trapsRef.current = localTraps;
@@ -324,12 +377,28 @@ export function SceneEditorApp() {
}, [localNpcTokens, localTokens, localTraps]); }, [localNpcTokens, localTokens, localTraps]);
useEffect(() => { 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 ?? []); setLocalTraps(scene?.traps ?? []);
setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId))); setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId)));
setLocalNpcTokens( setLocalNpcTokens(
(scene?.npcTokens ?? []).filter((t) => project?.npcs.some((npc) => npc.id === t.npcId)), (scene?.npcTokens ?? []).filter((t) => project?.npcs.some((npc) => npc.id === t.npcId)),
); );
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); 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); setSelected(null);
setView({ scale: 1, ox: 0.5, oy: 0.5 }); setView({ scale: 1, ox: 0.5, oy: 0.5 });
}, [sceneId, scene?.previewAssetId]); }, [sceneId, scene?.previewAssetId]);
@@ -356,6 +425,16 @@ export function SceneEditorApp() {
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
}, [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(() => { useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => { void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null }); setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
@@ -404,6 +483,99 @@ export function SceneEditorApp() {
[api, sceneId], [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( const persistGrid = useCallback(
(next: SceneGrid) => { (next: SceneGrid) => {
if (!sceneId) return; 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]); const viewCamera = useMemo(() => view, [view]);
useEffect(() => { useEffect(() => {
@@ -632,6 +832,17 @@ export function SceneEditorApp() {
const isVideo = scene?.previewAssetType === 'video' && Boolean(url); const isVideo = scene?.previewAssetType === 'video' && Boolean(url);
const hasMapMedia = isImage || isVideo; 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 ( return (
<div className={styles.page}> <div className={styles.page}>
<aside className={styles.sidebar}> <aside className={styles.sidebar}>
@@ -828,6 +1039,98 @@ export function SceneEditorApp() {
) : null} ) : null}
</div> </div>
<div className={styles.accordion} data-testid="scene-darkness-accordion">
<button
type="button"
className={styles.accordionHead}
onClick={() => setDarknessOpen((v) => !v)}
>
{t('sceneEditor.darkness')} {darknessOpen ? '▾' : '▸'}
</button>
{darknessOpen ? (
<div className={styles.gridPanel}>
<label className={styles.checkRow}>
<input
type="checkbox"
checked={localDarken}
disabled={!hasMapMedia}
onChange={(e) => persistDarken(e.target.checked)}
data-testid="scene-darken-toggle"
/>
<span>{t('scene.darkenScene')}</span>
</label>
{localDarken && hasMapMedia ? (
<>
<div className={styles.darknessTools}>
<Button
variant={darknessTool === 'explore' ? 'primary' : 'ghost'}
iconOnly
title={t('control.explorerBrush')}
ariaLabel={t('control.explorerBrush')}
onClick={() =>
setDarknessTool((prev) => (prev === 'explore' ? 'none' : 'explore'))
}
>
<span aria-hidden>🔦</span>
</Button>
<Button
variant={darknessTool === 'close' ? 'primary' : 'ghost'}
iconOnly
title={t('control.closerBrush')}
ariaLabel={t('control.closerBrush')}
onClick={() =>
setDarknessTool((prev) => (prev === 'close' ? 'none' : 'close'))
}
>
<span aria-hidden>⬛</span>
</Button>
</div>
<label className={styles.field}>
<span className={styles.fieldLabel}>
{t('control.brushRadius')}{' '}
<span className={styles.fieldValue}>
{Math.max(1, Math.min(36, Math.round(darknessRadiusN / 0.005)))}
</span>
</span>
<input
type="range"
className={styles.range}
min={1}
max={36}
step={1}
value={Math.max(1, Math.min(36, Math.round(darknessRadiusN / 0.005)))}
onChange={(e) => {
const ui = Number(e.currentTarget.value);
setDarknessRadiusN(Math.max(1, Math.min(36, Math.round(ui))) * 0.005);
}}
/>
</label>
<div className={styles.darknessTools}>
<Button
variant={darknessBrushShape === 'circle' ? 'primary' : 'ghost'}
iconOnly
title={t('control.brushShapeCircle')}
ariaLabel={t('control.brushShapeCircle')}
onClick={() => setDarknessBrushShape('circle')}
>
<span aria-hidden>○</span>
</Button>
<Button
variant={darknessBrushShape === 'square' ? 'primary' : 'ghost'}
iconOnly
title={t('control.brushShapeSquare')}
ariaLabel={t('control.brushShapeSquare')}
onClick={() => setDarknessBrushShape('square')}
>
<span aria-hidden>□</span>
</Button>
</div>
</>
) : null}
</div>
) : null}
</div>
{USERS_BRANCH_FEATURES_ENABLED ? ( {USERS_BRANCH_FEATURES_ENABLED ? (
<div className={styles.accordion} data-testid="scene-npc-accordion"> <div className={styles.accordion} data-testid="scene-npc-accordion">
<button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}> <button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}>
@@ -1071,6 +1374,105 @@ export function SceneEditorApp() {
/> />
)} )}
<SceneGridOverlay grid={localGrid} viewport={contentRect} /> <SceneGridOverlay grid={localGrid} viewport={contentRect} />
{localDarken && contentRect ? (
<SceneDarknessOverlay
state={darknessOverlayState}
overlayAlpha={0.55}
viewport={contentRect}
style={{ zIndex: 28 }}
/>
) : null}
{localDarken && darknessTool !== 'none' && contentRect ? (
<>
<div
ref={brushCursorElRef}
className={styles.brushCursor}
style={{ visibility: 'hidden' }}
aria-hidden
/>
<div
className={styles.darknessBrushLayer}
style={{
left: contentRect.x,
top: contentRect.y,
width: contentRect.w,
height: contentRect.h,
}}
onPointerEnter={(e) => {
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
darknessCursorPosRef.current = p;
layoutDarknessBrushCursor();
}}
onPointerLeave={() => {
darknessCursorPosRef.current = null;
layoutDarknessBrushCursor();
}}
onPointerDown={(e) => {
if (e.button !== 0 || spaceDownRef.current) return;
e.preventDefault();
e.stopPropagation();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
darknessCursorPosRef.current = p;
layoutDarknessBrushCursor();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
const createdAtMs = Date.now();
const seed = Math.floor(Math.random() * 1_000_000_000);
const pt = { x: p.x, y: p.y, tMs: createdAtMs };
darknessBrushRef.current = { points: [pt], createdAtMs, seed };
setDarknessDraft({
points: [pt],
radiusN: darknessRadiusN,
mode: darknessTool === 'close' ? 'cover' : 'reveal',
shape: darknessBrushShape,
});
}}
onPointerMove={(e) => {
const p = hostToNorm(e.clientX, e.clientY);
if (p) {
darknessCursorPosRef.current = p;
layoutDarknessBrushCursor();
}
const brush = darknessBrushRef.current;
if (!brush || (e.buttons & 1) === 0) return;
if (!p) return;
const last = brush.points[brush.points.length - 1];
if (last && Math.hypot(p.x - last.x, p.y - last.y) < 0.002) return;
const pt = { x: p.x, y: p.y, tMs: Date.now() };
brush.points.push(pt);
setDarknessDraft({
points: [...brush.points],
radiusN: darknessRadiusN,
mode: darknessTool === 'close' ? 'cover' : 'reveal',
shape: darknessBrushShape,
});
}}
onPointerUp={() => {
const brush = darknessBrushRef.current;
darknessBrushRef.current = null;
setDarknessDraft(null);
if (!brush || brush.points.length === 0) return;
const stroke: SceneDarknessRevealStroke = {
id: `sd_${String(brush.createdAtMs)}_${String(brush.seed)}`,
seed: brush.seed,
createdAtMs: brush.createdAtMs,
points: brush.points,
radiusN: darknessRadiusN,
mode: darknessTool === 'close' ? 'cover' : 'reveal',
shape: darknessBrushShape,
};
persistDarknessStrokes([...localDarknessStrokesRef.current, stroke]);
}}
onPointerCancel={() => {
darknessBrushRef.current = null;
setDarknessDraft(null);
darknessCursorPosRef.current = null;
layoutDarknessBrushCursor();
}}
/>
</>
) : null}
<TokenPathsOverlay <TokenPathsOverlay
tokens={localTokens} tokens={localTokens}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? localNpcTokens : []} npcTokens={USERS_BRANCH_FEATURES_ENABLED ? localNpcTokens : []}
+104
View File
@@ -0,0 +1,104 @@
import { computePathPlaybackSample } from '../types/tokenPathPlayback';
import type { GameSaveBody } from '../types/gameSave';
import { GAME_SAVE_SCHEMA_VERSION } from '../types/gameSave';
import type { Project } from '../types/domain';
import type { TokenPath } from '../types/tokenPath';
import type { TokenPathPlaybackEntry } from '../types/tokenPathSession';
export function defaultGameSaveName(now = new Date()): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${p(now.getDate())}.${p(now.getMonth() + 1)}.${now.getFullYear()} ${p(now.getHours())}:${p(now.getMinutes())}`;
}
export function canonicalGameSave(body: GameSaveBody): string {
return JSON.stringify(sortValue(body));
}
function sortValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortValue);
if (value && typeof value === 'object') {
const src = value as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const key of Object.keys(src).sort()) out[key] = sortValue(src[key]);
return out;
}
return value;
}
export function pathForPlacement(project: Project, kind: 'token' | 'npcToken', placementId: string): TokenPath | null {
for (const scene of Object.values(project.scenes)) {
const list = kind === 'token' ? scene.tokens : scene.npcTokens;
const found = (list ?? []).find((token) => String(token.id) === placementId);
if (found?.path && found.path.points.length >= 2) return found.path;
}
return null;
}
/** Движение и задержка становятся паузой в текущей точке пути. */
export function freezeTokenPathPlayback(
playback: Record<string, TokenPathPlaybackEntry>,
pathFor: (entry: TokenPathPlaybackEntry) => TokenPath | null,
nowMs: number,
): Record<string, TokenPathPlaybackEntry> {
const out: Record<string, TokenPathPlaybackEntry> = {};
for (const [key, entry] of Object.entries(playback)) {
out[key] = freezeEntry(entry, pathFor(entry), nowMs);
}
return out;
}
function freezeEntry(entry: TokenPathPlaybackEntry, path: TokenPath | null, nowMs: number): TokenPathPlaybackEntry {
if (entry.phase === 'stopped' || entry.phase === 'done' || !path) {
const phase = entry.phase === 'moving' || entry.phase === 'delay' ? 'stopped' : entry.phase;
return { ...entry, phase, rejoinDist: null, segmentStartedAtMs: 0 };
}
const sample = computePathPlaybackSample({ path, entry, nowMs });
if (!sample) {
return { ...entry, phase: 'stopped', rejoinDist: null, segmentStartedAtMs: 0 };
}
const phase = sample.markDone || sample.phase === 'done' ? 'done' : 'stopped';
return {
...entry,
phase,
baseDist: sample.sample.dist,
rejoinDist: null,
segmentStartedAtMs: 0,
direction: 1,
};
}
export function emptyGameSaveBody(projectId: string): GameSaveBody {
return {
schemaVersion: GAME_SAVE_SCHEMA_VERSION,
projectId,
graphNodeId: null,
sceneId: null,
darknessByCard: {},
markersByCard: {},
trapsByCard: {},
effectsByCard: {},
sceneTokensByPlacementId: {},
npcTokens: { byPlacementId: {}, scale: 1, spawned: {} },
tokenPaths: { presentationVisible: {}, playback: {} },
timer: {
durationMs: 0,
remainingMs: 0,
visibleToPlayers: false,
layout: { cx: 0, cy: 0, scale: 1, rotationDeg: 0 },
},
playerTokens: { selectedPlayerIds: [], visible: false, byPlayerId: {} },
camera: { scale: 1, ox: 0.5, oy: 0.5 },
materials: { activeMaterialIds: [], layouts: {}, legendLayouts: {}, focusMaterialId: null },
npcAvatars: { activeNpcIds: [], layouts: {}, focusNpcId: null },
initiative: {
enabled: false,
entries: {},
order: [],
reserve: [],
activeId: null,
conditions: {},
playersVisible: false,
},
tokenGridSnap: false,
};
}
+1
View File
@@ -22,6 +22,7 @@ function scene(id: string): Scene {
previewVideoAutostart: false, previewVideoAutostart: false,
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
darknessStrokes: [],
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [], npcTokens: [],
@@ -43,6 +43,7 @@ function scene(id: string, title: string): Scene {
previewVideoAutostart: false, previewVideoAutostart: false,
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
darknessStrokes: [],
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [], npcTokens: [],
+21 -2
View File
@@ -34,6 +34,7 @@ import type {
DisplayInfo, DisplayInfo,
Scene, Scene,
SceneDarknessEvent, SceneDarknessEvent,
SceneDarknessRevealStroke,
SceneDarknessState, SceneDarknessState,
SceneMarkerEvent, SceneMarkerEvent,
SceneMarkerState, SceneMarkerState,
@@ -166,6 +167,11 @@ export const ipcChannels = {
materialUpsertProgress: 'project.materialUpsertProgress', materialUpsertProgress: 'project.materialUpsertProgress',
npcUpsertProgress: 'project.npcUpsertProgress', npcUpsertProgress: 'project.npcUpsertProgress',
}, },
gameSave: {
list: 'gameSave.list',
write: 'gameSave.write',
isDirty: 'gameSave.isDirty',
},
windows: { windows: {
openMultiWindow: 'windows.openMultiWindow', openMultiWindow: 'windows.openMultiWindow',
closeMultiWindow: 'windows.closeMultiWindow', closeMultiWindow: 'windows.closeMultiWindow',
@@ -787,8 +793,20 @@ export type IpcInvokeMap = {
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.windows.openMultiWindow]: { [ipcChannels.windows.openMultiWindow]: {
req: { playerIds?: string[] }; req: { playerIds?: string[]; saveId?: string };
res: { ok: true }; res: { ok: true; project: Project | null };
};
[ipcChannels.gameSave.list]: {
req: Record<string, never>;
res: { saves: { id: string; name: string; savedAt: string }[] };
};
[ipcChannels.gameSave.write]: {
req: { id: string | null; name: string };
res: { save: { id: string; name: string; savedAt: string } };
};
[ipcChannels.gameSave.isDirty]: {
req: Record<string, never>;
res: { dirty: boolean };
}; };
[ipcChannels.windows.closeMultiWindow]: { [ipcChannels.windows.closeMultiWindow]: {
req: Record<string, never>; req: Record<string, never>;
@@ -1268,6 +1286,7 @@ export type ScenePatch = {
previewVideoAutostart?: boolean; previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270; previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean; darkenScene?: boolean;
darknessStrokes?: SceneDarknessRevealStroke[];
traps?: SceneTrap[]; traps?: SceneTrap[];
tokens?: SceneToken[]; tokens?: SceneToken[];
npcTokens?: SceneNpcToken[]; npcTokens?: SceneNpcToken[];
+6
View File
@@ -13,6 +13,7 @@ import type { SceneToken } from './appTokens';
import type { SceneNpcToken, PlayerImageOffset } from './appPlayers'; import type { SceneNpcToken, PlayerImageOffset } from './appPlayers';
import type { NpcDisposition } from './npcDisposition'; import type { NpcDisposition } from './npcDisposition';
import type { SceneGrid, SceneGridTemplate } from './sceneGrid'; import type { SceneGrid, SceneGridTemplate } from './sceneGrid';
import type { SceneDarknessRevealStroke } from './sceneDarkness';
import type { SceneTrap } from './sceneTraps'; import type { SceneTrap } from './sceneTraps';
export const PROJECT_SCHEMA_VERSION = 12 as const; export const PROJECT_SCHEMA_VERSION = 12 as const;
@@ -185,6 +186,11 @@ export type Scene = {
previewRotationDeg: 0 | 90 | 180 | 270; previewRotationDeg: 0 | 90 | 180 | 270;
/** В режиме показа: сцена начинается полностью затемнённой; мастер «раскрывает» кистью. */ /** В режиме показа: сцена начинается полностью затемнённой; мастер «раскрывает» кистью. */
darkenScene: boolean; darkenScene: boolean;
/**
* Стартовые штрихи затемнения (база в файле партии).
* При входе на карточку без сессионного кэша поднимаются в показ.
*/
darknessStrokes: SceneDarknessRevealStroke[];
/** Ловушки на карте (только для image-превью); расстановка в проекте. */ /** Ловушки на карте (только для image-превью); расстановка в проекте. */
traps: SceneTrap[]; traps: SceneTrap[];
/** Неигровые токены на карте (ссылки на app-local пул). */ /** Неигровые токены на карте (ссылки на app-local пул). */
+12
View File
@@ -199,3 +199,15 @@ export type EffectsEvent =
*/ */
hitMode?: 'inclusive' | 'brush'; hitMode?: 'inclusive' | 'brush';
}; };
/** Туман, огонь, дождь, вода и следы льда/тьмы живут на карточке, пока идёт показ. */
export function isPersistentFieldEffect(type: EffectInstanceType): boolean {
return (
type === 'fog' ||
type === 'fire' ||
type === 'rain' ||
type === 'water' ||
type === 'ice' ||
type === 'shadow'
);
}
+74
View File
@@ -0,0 +1,74 @@
import type { SceneNpcSessionSpawn, SceneNpcTokensSessionPlacement, ScenePlayerTokenPlacement } from './appPlayers';
import type { SceneTokensSessionPlacement } from './appTokens';
import type { EffectInstance } from './effects';
import type { InitiativeState } from './initiative';
import type { MaterialsOverlayLayout } from './materials';
import type { NpcsOverlayLayout } from './npcs';
import type { SceneDarknessRevealStroke } from './sceneDarkness';
import type { SceneMarkerStroke } from './sceneMarker';
import type { SceneTrapRuntime } from './sceneTraps';
import type { SceneViewCamera } from './sceneView';
import type { SessionTimerLayout } from './sessionTimer';
import type { TokenPathPlaybackEntry } from './tokenPathSession';
export const GAME_SAVE_SCHEMA_VERSION = 1;
export type GameSaveListItem = {
id: string;
name: string;
savedAt: string;
};
/** Тело слепка без id/имени/времени записи: по нему сравнивают «стол изменился». */
export type GameSaveBody = {
schemaVersion: typeof GAME_SAVE_SCHEMA_VERSION;
projectId: string;
graphNodeId: string | null;
sceneId: string | null;
darknessByCard: Record<string, SceneDarknessRevealStroke[]>;
markersByCard: Record<string, SceneMarkerStroke[]>;
trapsByCard: Record<string, Record<string, SceneTrapRuntime>>;
effectsByCard: Record<string, EffectInstance[]>;
sceneTokensByPlacementId: Record<string, SceneTokensSessionPlacement>;
npcTokens: {
byPlacementId: Record<string, SceneNpcTokensSessionPlacement>;
scale: number;
spawned: Record<string, SceneNpcSessionSpawn>;
};
tokenPaths: {
presentationVisible: Record<string, true>;
playback: Record<string, TokenPathPlaybackEntry>;
};
timer: {
durationMs: number;
remainingMs: number;
visibleToPlayers: boolean;
layout: SessionTimerLayout;
};
/** null — токены игроков в момент сохранения не были показаны. */
playerTokens: {
selectedPlayerIds: string[];
visible: boolean;
byPlayerId: Record<string, ScenePlayerTokenPlacement>;
};
camera: SceneViewCamera;
materials: {
activeMaterialIds: string[];
layouts: Record<string, MaterialsOverlayLayout>;
legendLayouts: Record<string, MaterialsOverlayLayout>;
focusMaterialId: string | null;
};
npcAvatars: {
activeNpcIds: string[];
layouts: Record<string, NpcsOverlayLayout>;
focusNpcId: string | null;
};
initiative: Omit<InitiativeState, 'revision'>;
tokenGridSnap: boolean;
};
export type GameSaveFile = GameSaveBody & {
id: string;
name: string;
savedAt: string;
};
+1
View File
@@ -3,6 +3,7 @@ export * from './appPlaylists';
export * from './appTokens'; export * from './appTokens';
export * from './domain'; export * from './domain';
export * from './effects'; export * from './effects';
export * from './gameSave';
export * from './ids'; export * from './ids';
export * from './materialLegend'; export * from './materialLegend';
export * from './materials'; export * from './materials';
+43
View File
@@ -3,7 +3,9 @@ import test from 'node:test';
import { import {
normalizeSceneDarknessBrushShape, normalizeSceneDarknessBrushShape,
normalizeSceneDarknessRevealStroke,
normalizeSceneDarknessStrokeMode, normalizeSceneDarknessStrokeMode,
normalizeSceneDarknessStrokes,
} from './sceneDarkness'; } from './sceneDarkness';
void test('normalizeSceneDarknessStrokeMode: reveal по умолчанию', () => { void test('normalizeSceneDarknessStrokeMode: reveal по умолчанию', () => {
@@ -18,3 +20,44 @@ void test('normalizeSceneDarknessBrushShape: circle по умолчанию', ()
assert.equal(normalizeSceneDarknessBrushShape('square'), 'square'); assert.equal(normalizeSceneDarknessBrushShape('square'), 'square');
assert.equal(normalizeSceneDarknessBrushShape('triangle'), 'circle'); assert.equal(normalizeSceneDarknessBrushShape('triangle'), 'circle');
}); });
void test('normalizeSceneDarknessStrokes: отбрасывает мусор, нормализует точки', () => {
assert.deepEqual(normalizeSceneDarknessStrokes(null), []);
assert.deepEqual(normalizeSceneDarknessStrokes('x'), []);
const out = normalizeSceneDarknessStrokes([
{ id: 'bad' },
{
id: 'ok',
seed: 1,
createdAtMs: 10,
radiusN: 2,
mode: 'cover',
shape: 'square',
points: [
{ x: -1, y: 2, tMs: 10 },
{ x: 0.5, y: 0.5 },
],
},
]);
assert.equal(out.length, 1);
assert.equal(out[0]?.id, 'ok');
assert.equal(out[0]?.mode, 'cover');
assert.equal(out[0]?.shape, 'square');
assert.equal(out[0]?.radiusN, 1);
assert.equal(out[0]?.points[0]?.x, 0);
assert.equal(out[0]?.points[0]?.y, 1);
assert.equal(out[0]?.points[1]?.tMs, 0);
});
void test('normalizeSceneDarknessRevealStroke: null без точек', () => {
assert.equal(
normalizeSceneDarknessRevealStroke({
id: 'x',
seed: 1,
createdAtMs: 1,
radiusN: 0.1,
points: [],
}),
null,
);
});
+40
View File
@@ -44,3 +44,43 @@ export function normalizeSceneDarknessStrokeMode(raw: unknown): SceneDarknessStr
export function normalizeSceneDarknessBrushShape(raw: unknown): SceneDarknessBrushShape { export function normalizeSceneDarknessBrushShape(raw: unknown): SceneDarknessBrushShape {
return raw === 'square' ? 'square' : 'circle'; return raw === 'square' ? 'square' : 'circle';
} }
export function normalizeSceneDarknessRevealStroke(raw: unknown): SceneDarknessRevealStroke | null {
if (!raw || typeof raw !== 'object') return null;
const o = raw as Record<string, unknown>;
if (typeof o.id !== 'string' || !o.id) return null;
if (typeof o.seed !== 'number' || !Number.isFinite(o.seed)) return null;
if (typeof o.createdAtMs !== 'number' || !Number.isFinite(o.createdAtMs)) return null;
if (typeof o.radiusN !== 'number' || !Number.isFinite(o.radiusN)) return null;
if (!Array.isArray(o.points) || o.points.length === 0) return null;
const points: NPoint[] = [];
for (const p of o.points) {
if (!p || typeof p !== 'object') continue;
const pt = p as Record<string, unknown>;
if (typeof pt.x !== 'number' || typeof pt.y !== 'number') continue;
const tMs = typeof pt.tMs === 'number' && Number.isFinite(pt.tMs) ? pt.tMs : 0;
points.push({
x: Math.max(0, Math.min(1, pt.x)),
y: Math.max(0, Math.min(1, pt.y)),
tMs,
...(typeof pt.pressure === 'number' ? { pressure: pt.pressure } : {}),
});
}
if (points.length === 0) return null;
return {
id: o.id,
seed: o.seed,
createdAtMs: o.createdAtMs,
points,
radiusN: Math.max(0.001, Math.min(1, o.radiusN)),
mode: normalizeSceneDarknessStrokeMode(o.mode),
shape: normalizeSceneDarknessBrushShape(o.shape),
};
}
export function normalizeSceneDarknessStrokes(raw: unknown): SceneDarknessRevealStroke[] {
if (!Array.isArray(raw)) return [];
return raw
.map((s) => normalizeSceneDarknessRevealStroke(s))
.filter((s): s is SceneDarknessRevealStroke => Boolean(s));
}
+57
View File
@@ -0,0 +1,57 @@
# Сохранение игры
Слепок показа по явной кнопке мастера. Файл партии не меняется. Новый «Запустить» по-прежнему открывает пустую сессию.
## Как мастер этим пользуется
- На пульте, в ряду над «Предпросмотр экрана», сразу после текстовой кнопки «Завершить» — кнопка-иконка с дискетой. Подпись на кнопке нет. Тултип и имя для чтения с экрана: «Сохранить» (EN: Save). Высота как у соседней «Завершить», вид как у иконок инструментов (`iconOnly`).
- Вторая «Завершить» в пустой ветке («Нет вариантов перехода») без дискеты. Сохранение одно, над предпросмотром.
- Клик по дискете открывает модалку в стиле остальных подтверждений пульта. Заголовок «Сохранить игру». Список — только сохранения открытой партии. Клик по строке не пишет файл: он подставляет имя и помечает эту запись на перезапись. Новое сохранение — другое имя в поле и ни одна строка не выбрана. Пишет файл кнопка «Сохранить» внизу. Рядом «Отмена». Второго подтверждения нет. Показ не закрывается. Пустое имя подставляется датой и временем.
- Имя хранится внутри файла. На диске у сохранения свой id, чтобы два одинаковых имени не затирали друг друга.
- «Загрузить сохранение» — пункт меню кнопки «Запустить». Он открывает отдельное окно со списком, в том же стиле, что выбор игроков у «Запустить с игроками», а не подменю имён. Клик по строке её выбирает, показ открывает кнопка «Загрузить». «Отмена» оставляет редактор. Если сохранений этой партии нет, пункт меню неактивен и окно не открывается. Пункт «Запустить с этой сцены» на графе сохранение не грузит: слепок сам открывает карточку, на которой остановились.
- Все пути закрытия показа (обе кнопки «Завершить», Escape, крестик презентации и пульта) открывают «Завершить показ?». Если в этом показе ещё не сохранялись или стол изменился после последнего сохранения, в окне три действия: «Сохранить и завершить», «Завершить без сохранения», «Отмена». «Сохранить и завершить» закрывает этот вопрос и открывает модалку дискеты; после успешной записи показ закрывается. «Отмена» в модалке сохранения оставляет показ открытым. «Завершить без сохранения» закрывает показ сразу. Если изменений нет, окно как сейчас: «Отмена» и «Завершить». Английские подписи: Save and end, End without saving, Cancel.
- Экспорта и импорта сохранений нет. Файл лежит в данных приложения и привязан к `id` партии на этом компьютере.
- Копия архива партии на другой компьютер исследование не увозит. Повторный импорт той же партии на этот компьютер получает новый `id`, старые сохранения остаются у исходного файла.
## Что попадает в слепок
Уже помнится весь показ и пишется по всем посещённым карточкам графа:
- карточка, на которой остановились;
- штрихи затемнения и маркеры;
- состояние ловушек;
- сдвиги токенов сцены и НПС, токены НПС, вызванные на сессию, скрытие от игроков, общий масштаб НПС;
- путь токена как пауза: точка на пути, без привязки к часам;
- таймер как пауза: остаток времени, без абсолютного момента конца.
Эффекты поля (туман, огонь, дождь, вода) и следы, которые сами не истекают (лёд после заморозки, тёмное пятно после тьмы-действия), пишутся по всем посещённым карточкам. При смене карточки они остаются на той, где их нарисовали, и возвращаются вместе с ней. Ластик и «Очистить эффекты» снимают их с текущей карточки. В сохранение попадает этот же кэш. Ключ карточки тот же, что у затемнения: узел графа, иначе id сцены.
Токены игроков — только слепок карточки в момент «Сохранить игру», и только если они были показаны. В слепок входят выбранные игроки, флаг показа и их позиции на этой карточке. На уже покинутых карточках позиции игроков не пишутся. После загрузки смена карточки по-прежнему убирает токены игроков с карты. Логика показа, скрытия и сброса при переходах не меняется.
На момент сохранения, без памяти по прошлым карточкам, пишутся ещё камера, материалы поверх сцены, крупные аватары НПС и трекер инициативы. Это то, что показ и так держит только для текущей карточки.
Не пишутся черновик незавершённого штриха, идущий бросок костей и сами разовые эффекты действий (молния, луч света, облако яда, взрыв, вспышка заморозки, тьма-действие). Лёд и тёмное пятно, которые после них остаются на карте, пишутся.
## Справка
Тексты на русском и английском, в тех же разделах, что уже есть. Ниже русский смысл; английский повторяет его.
«Запуск сессии» — после абзаца про «Запустить с игроками»:
Стрелка справа на «Запустить» → «Загрузить сохранение». Открывается отдельное окно со списком сохранений этой партии на этом компьютере. Строку выбирают кликом, показ открывает кнопка «Загрузить»: карточка, где нажали «Сохранить», открытые участки, эффекты поля, токены и остальное из файла. Обычный «Запустить» сохранение не подхватывает. Если сохранений нет, пункт неактивен. «Запустить с этой сцены» по-прежнему открывает выбранную карточку с пустой сессией.
В шагах «Вернуться к подготовке»: если с последнего сохранения стол изменился или в этом показе ещё не сохранялись, «Завершить показ?» даёт три действия. «Сохранить и завершить» открывает список сохранений и после записи закрывает показ. «Завершить без сохранения» закрывает показ сразу. «Отмена» оставляет показ. Если изменений нет, в окне по-прежнему только «Отмена» и «Завершить».
«Пульт управления» — рядом с описанием «Завершить»:
Сразу после «Завершить» над предпросмотром — дискета, подсказка «Сохранить». Она открывает список сохранений этой партии. Клик по строке подставляет её имя для перезаписи, файл пишет кнопка «Сохранить». Новое имя без выбранной строки создаёт ещё одну запись. Показ остаётся открытым. В файл входят исследование карты, эффекты поля на посещённых карточках, лёд и тёмные пятна, маркеры, ловушки, токены сцены и НПС, а также токены игроков на текущей карточке, если они были показаны. Файл остаётся на этом компьютере и не попадает в архив партии. Если закрывать показ с несохранёнными изменениями, «Завершить показ?» предлагает «Сохранить и завершить», «Завершить без сохранения» и «Отмена».
«Эффекты поля и действий» — после списка полевых кистей:
Туман, дождь, огонь, вода, а также лёд после заморозки и тёмное пятно после тьмы-действия остаются на карточке, пока идёт показ. Переход на другую сцену их прячет, возврат на ту же карточку показывает снова. Ластик и «Очистить эффекты» стирают их с текущей карточки до конца этого показа. В сохранение через дискету они входят вместе с остальным слепком. Сами эффекты действий при смене сцены по-прежнему пропадают.
## Чего сохранение не делает
- Не встраивается в `.ttrpg.zip` и не едет вместе с экспортом партии.
- Не продолжает сессию само при обычном «Запустить».
- Не начинает помнить позиции игроков на каждой посещённой карточке.
+35
View File
@@ -0,0 +1,35 @@
# Затемнение сцены в редакторе
Согласовано. Стартовый рисунок тьмы живёт в файле партии; показ и сохранение игры рисуют поверх.
## Интерфейс
- Галочку «Затемнить сцену» убрать из свойств сцены в инспекторе редактора кампании.
- В «Редактор сцены» — аккордеон «Затемнение сцены» (не вкладка).
- Внутри: переключатель «Затемнить сцену». При включении превью заливается 100% тьмой; доступны «Кисть Открытия» и «Кисть Закрытия», форма и радиус как на пульте.
- При выключенном затемнении кисти недоступны.
## Данные в партии
- `darkenScene: boolean` — как сейчас.
- Новое поле на **сцене**: стартовые штрихи (reveal/cover, точки, радиус, форма). Не на карточке графа.
- Две карточки одной сцены делят одну базу.
## Показ
- Вход на карточку с `darkenScene`: кэш затемнения сначала заполняется штрихами из сцены, поверх — то, что нарисовали в этом показе или подняли из сохранения игры.
- Обычный «Запустить» = база из сцены.
- «Загрузить сохранение» = полный рисунок из слепка.
- Кисти на пульте меняют только сессию, сцену в проекте сами не правят.
## Запись итога вечера в сцену (5b)
- Действие «Записать затемнение в сцену»: текущие штрихи активной карточки пишутся в поле сцены как новая база.
- После этого экспорт/копирование партии увозит «полуоткрытую» карту без отдельного файла сохранения.
- Сохранение игры по-прежнему хранит полный текущий рисунок по карточкам и базу сцены само не затирает.
## Вне объёма этого шага
- Пожар, туман, маркеры, ловушки в редакторе.
- Отдельный импорт «сцена + состояние» между партиями.
- Разная база затемнения для двух карточек одной сцены.
Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

+36
View File
@@ -0,0 +1,36 @@
# ВК: общий пост релиза 1.1.0
Обложка: `docs/vk-covers/vk-cover-release-1-1-0.png` (1:1, без текста на картинке).
Запасной вариант с версией на кадре: `docs/vk-covers/vk-cover-1-1-0.png`.
---
## Черновик поста
НРИ Плеер 1.1.0 — инструменты за столом, которые раньше жили в головах и на бумажках.
Что нового:
• 3D-кубики в отдельном окне — бросок с анимацией на экране игроков или только у мастера
• Трекер инициативы — очередь хода, состояния, подсветка текущего на карте
• Таймер сессии поверх сцены — пресеты, пауза, показ игрокам
• Маркер на области видимости — рисовать прямо на превью, игроки видят сразу
• Библиотека плейлистов — в редакторе и на пульте; громкость в проекте и параллельное воспроизведение
• Шаблоны сетки кампании — сохранили стиль один раз, применили на любую сцену
• Аккордеон «Сцены» на пульте — вся линия сюжета под рукой, не только история визитов
• Выбор монитора презентации при запуске сессии
• Окно инструкций и мелочи за столом: токены игроков не пропадают при смене сцены, кисти затемнения с формой и радиусом
Скачать бесплатно: https://ttrpgplayer.ru
Windows · macOS · Linux
#НРИплеер #TTRPG #DnD #релиз #настолки #мастер
---
## Короткая версия
НРИ Плеер 1.1.0: 3D-кубики, инициатива, таймер, маркер, плейлисты, шаблоны сетки и удобнее пульт за столом.
https://ttrpgplayer.ru
#НРИплеер #TTRPG #релиз
+1 -1
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate", "build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit", "typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/shared/fieldEffectStampPoints.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/presentation/PresentationApp.escapeConfirm.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/ensureParentDir.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/gameSystems/gameSystemsStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/shared/fieldEffectStampPoints.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/presentation/PresentationApp.escapeConfirm.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/gameSave.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/ensureParentDir.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/gameSystems/gameSystemsStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
"format": "prettier . --check", "format": "prettier . --check",
"format:write": "prettier . --write", "format:write": "prettier . --write",
"postinstall": "patch-package", "postinstall": "patch-package",