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>
This commit is contained in:
Ivan Fontosh
2026-10-06 11:41:42 +08:00
parent f6c301a12b
commit 877fbaa8e7
43 changed files with 2086 additions and 72 deletions
+76 -1
View File
@@ -1,7 +1,13 @@
import crypto from 'node:crypto';
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser';
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
import {
isPersistentFieldEffect,
type EffectInstance,
type EffectsEvent,
type EffectsState,
type EffectToolState,
} from '../../shared/types';
function nowMs(): number {
return Date.now();
@@ -23,6 +29,9 @@ export class EffectsStore {
tool: defaultTool(),
instances: [],
};
/** Постоянные эффекты по карточке (graphNodeId или sceneId) на время показа. */
private cache = new Map<string, EffectInstance[]>();
private currentKey: string | null = null;
getState(): EffectsState {
// Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров.
@@ -36,13 +45,70 @@ export class EffectsStore {
serverNowMs: nowMs(),
instances: [],
};
this.commitCurrent();
return this.state;
}
/** Новый показ: и текущая карточка, и кэш остальных. */
resetSession(): EffectsState {
this.cache.clear();
this.currentKey = null;
this.state = {
revision: this.state.revision + 1,
serverNowMs: nowMs(),
tool: this.state.tool,
instances: [],
};
return this.getState();
}
/**
* Смена карточки: туман/огонь/дождь/вода/лёд/пятно остаются на ушедшей
* и поднимаются на новой. Разовые эффекты действий сбрасываются.
*/
switchScene(cacheKey: string | null): EffectsState {
this.commitCurrent();
this.currentKey = cacheKey;
const instances = cacheKey ? [...(this.cache.get(cacheKey) ?? [])] : [];
this.state = {
...this.state,
revision: this.state.revision + 1,
serverNowMs: nowMs(),
instances,
};
return this.getState();
}
exportByCard(): Record<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 {
const s = this.state;
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
this.state = next;
this.commitCurrent();
return next;
}
@@ -78,9 +144,18 @@ export class EffectsStore {
serverNowMs: now,
instances: kept,
};
this.commitCurrent();
return true;
}
private commitCurrent(): void {
if (this.currentKey === null) return;
this.cache.set(
this.currentKey,
this.state.instances.filter((i) => isPersistentFieldEffect(i.type)).map((i) => ({ ...i })),
);
}
makeId(prefix: string): string {
return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`;
}
+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);
});
void test('SceneDarknessStore: cache miss берёт seedStrokes из сцены', () => {
const store = new SceneDarknessStore();
const seed = [
{
id: 'base1',
seed: 7,
createdAtMs: 50,
points: [{ x: 0.3, y: 0.4, tMs: 50 }],
radiusN: 0.06,
mode: 'reveal' as const,
},
];
store.switchScene('card_a', true, seed);
assert.equal(store.getState().strokes.length, 1);
assert.equal(store.getState().strokes[0]?.id, 'base1');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'session1',
seed: 2,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
mode: 'cover',
},
});
store.switchScene('card_b', true, []);
store.switchScene('card_a', true, seed);
assert.equal(store.getState().strokes.length, 2);
assert.equal(store.getState().strokes[1]?.id, 'session1');
});
void test('SceneDarknessStore: resetSession снова берёт seed', () => {
const store = new SceneDarknessStore();
const seed = [
{
id: 'base1',
seed: 1,
createdAtMs: 10,
points: [{ x: 0.1, y: 0.1, tMs: 10 }],
radiusN: 0.05,
},
];
store.switchScene('card_a', true, seed);
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 's1',
seed: 2,
createdAtMs: 20,
points: [{ x: 0.2, y: 0.2, tMs: 20 }],
radiusN: 0.05,
},
});
store.resetSession();
store.switchScene('card_a', true, seed);
assert.equal(store.getState().strokes.length, 1);
assert.equal(store.getState().strokes[0]?.id, 'base1');
});
void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
const store = new SceneDarknessStore();
store.switchScene('scene_a', true);
+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) {
this.cache.set(this.currentKey, [...this.state.strokes]);
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
}
this.currentKey = cacheKey;
const strokes =
cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : [];
let strokes: SceneDarknessRevealStroke[] = [];
if (cacheKey && enabled) {
if (this.cache.has(cacheKey)) {
strokes = (this.cache.get(cacheKey) ?? []).map(cloneStroke);
} else {
strokes = seedStrokes.map(cloneStroke);
this.cache.set(cacheKey, strokes.map(cloneStroke));
}
}
this.state = {
revision: this.state.revision + 1,
enabled,
@@ -77,4 +89,32 @@ export class SceneDarknessStore {
}
}
}
/** Кэш по карточкам, включая текущую. Пустые карточки не входят. */
exportByCard(): Record<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',
previewRotationDeg: 0,
darkenScene: false,
darknessStrokes: [],
traps: [],
tokens: [],
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,
} from '../shared/project/projectZipExtension';
import type { Project } from '../shared/types';
import { asNpcId } from '../shared/types/ids';
import { canonicalGameSave, freezeTokenPathPlayback, pathForPlacement } from '../shared/gameSave/buildSnapshot';
import { GAME_SAVE_SCHEMA_VERSION, type GameSaveBody, type GameSaveFile } from '../shared/types/gameSave';
import { asNpcId, asSceneId, type MaterialId } from '../shared/types/ids';
import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport';
import {
@@ -28,7 +30,9 @@ import {
previewRotationStepsCw,
} from '../shared/types/scenePreviewRotation';
import { getSessionTimerRemainingMs } from '../shared/types/sessionTimer';
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
import { GameSavesStore } from './gameSave/gameSavesStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore';
import { SceneMarkerStore } from './effects/sceneMarkerStore';
import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore';
@@ -204,6 +208,9 @@ const tokenPathSessionStore = new TokenPathSessionStore();
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
let gameSavesStore: GameSavesStore | null = null;
/** Канонический слепок последнего сохранения этого показа. null — ещё не сохраняли. */
let lastSaveCanonical: string | null = null;
let tokensStore: TokensStore | null = null;
let playersStore: PlayersStore | null = null;
let gameSystemsStore: GameSystemsStore | null = null;
@@ -514,7 +521,168 @@ function syncSceneDarknessForProject(project: Project): void {
const enabled =
Boolean(scene?.darkenScene) &&
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video');
sceneDarknessStore.switchScene(cacheKey, enabled);
sceneDarknessStore.switchScene(cacheKey, enabled, scene?.darknessStrokes ?? []);
}
function syncSceneEffectsForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
effectsStore.switchScene(cacheKey);
}
function captureGameSaveBody(project: Project): GameSaveBody {
const now = Date.now();
const timer = timerSessionStore.getState();
const players = scenePlayerTokensSessionStore.getState();
const initiative = initiativeSessionStore.getState();
const materials = materialsOverlayStore.getState();
const npcs = npcsOverlayStore.getState();
const camera = sceneViewStore.getState();
const npcTokens = sceneNpcTokensSessionStore.getState();
const paths = tokenPathSessionStore.getState();
return {
schemaVersion: GAME_SAVE_SCHEMA_VERSION,
projectId: String(project.id),
graphNodeId: project.currentGraphNodeId ? String(project.currentGraphNodeId) : null,
sceneId: project.currentSceneId ? String(project.currentSceneId) : null,
darknessByCard: sceneDarknessStore.exportByCard(),
markersByCard: sceneMarkerStore.exportByCard(),
trapsByCard: sceneTrapsStore.exportByCard(),
effectsByCard: effectsStore.exportByCard(),
sceneTokensByPlacementId: { ...sceneTokensSessionStore.getState().byPlacementId },
npcTokens: {
byPlacementId: { ...npcTokens.byPlacementId },
scale: npcTokens.scale,
spawned: { ...npcTokens.spawned },
},
tokenPaths: {
presentationVisible: { ...paths.presentationVisible },
playback: freezeTokenPathPlayback(
paths.playback,
(entry) => pathForPlacement(project, entry.kind, entry.placementId),
now,
),
},
timer: {
durationMs: timer.durationMs,
remainingMs: getSessionTimerRemainingMs(timer, now),
visibleToPlayers: timer.visibleToPlayers,
layout: { ...timer.layout },
},
playerTokens: {
selectedPlayerIds: [...players.selectedPlayerIds],
visible: players.visible,
byPlayerId: players.visible ? { ...players.byPlayerId } : {},
},
camera: { scale: camera.scale, ox: camera.ox, oy: camera.oy },
materials: {
activeMaterialIds: materials.activeMaterialIds.map(String),
layouts: { ...materials.layouts },
legendLayouts: { ...materials.legendLayouts },
focusMaterialId: materials.focusMaterialId ? String(materials.focusMaterialId) : null,
},
npcAvatars: {
activeNpcIds: npcs.activeNpcIds.map(String),
layouts: { ...npcs.layouts },
focusNpcId: npcs.focusNpcId ? String(npcs.focusNpcId) : null,
},
initiative: {
enabled: initiative.enabled,
entries: { ...initiative.entries },
order: [...initiative.order],
reserve: [...initiative.reserve],
activeId: initiative.activeId,
conditions: { ...initiative.conditions },
playersVisible: initiative.playersVisible,
},
tokenGridSnap: tokenGridSnapSessionStore.getState().enabled,
};
}
function sessionIsDirty(project: Project | null): boolean {
if (!project) return false;
if (lastSaveCanonical === null) return true;
return canonicalGameSave(captureGameSaveBody(project)) !== lastSaveCanonical;
}
async function applyGameSave(project: Project, file: GameSaveFile): Promise<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 {
@@ -667,6 +835,7 @@ async function main() {
await playersStore.ensureLoaded();
}
gameSystemsStore = new GameSystemsStore(app.getPath('userData'));
gameSavesStore = new GameSavesStore(app.getPath('userData'));
await gameSystemsStore.ensureLoaded();
playlistsStore = new PlaylistsStore(app.getPath('userData'));
await playlistsStore.ensureLoaded();
@@ -701,7 +870,7 @@ async function main() {
registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
registerHandler(ipcChannels.windows.openMultiWindow, (req) => {
registerHandler(ipcChannels.windows.openMultiWindow, async (req) => {
sceneDarknessStore.resetSession();
sceneMarkerStore.resetSession();
sceneTrapsStore.resetSession();
@@ -714,23 +883,38 @@ async function main() {
timerSessionStore.clear();
initiativeSessionStore.clear();
diceRollSessionStore.clear();
if (USERS_BRANCH_FEATURES_ENABLED) {
materialsOverlayStore.clear();
npcsOverlayStore.clear();
sceneViewStore.reset();
effectsStore.resetSession();
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
lastSaveCanonical = null;
const saveId = typeof req?.saveId === 'string' ? req.saveId.trim() : '';
let project = projectStore.getOpenProject();
if (saveId) {
if (!project || !gameSavesStore) throw new Error('GAME_SAVE_NOT_FOUND');
const file = await gameSavesStore.read(String(project.id), saveId);
project = await applyGameSave(project, file);
} else if (USERS_BRANCH_FEATURES_ENABLED) {
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
if (playerIds.length > 0) {
scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds });
}
}
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
openMultiWindow();
const project = projectStore.getOpenProject();
if (project) {
project = projectStore.getOpenProject();
if (project && !saveId) {
syncSceneDarknessForProject(project);
syncSceneMarkerForProject(project);
syncSceneTrapsForProject(project);
syncSceneEffectsForProject(project);
seedTokenPathPlaybackForProject(project);
} else {
} else if (!project) {
emitTokenPathSessionState();
}
if (project && saveId) {
lastSaveCanonical = canonicalGameSave(captureGameSaveBody(project));
}
emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState();
@@ -739,11 +923,31 @@ async function main() {
emitScenePlayerTokensSessionState();
emitTokenGridSnapState();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneViewState();
emitSessionTimerState();
syncInitiativePresenceFromSession();
if (!saveId) syncInitiativePresenceFromSession();
emitInitiativeState();
emitDiceRollState();
return { ok: true };
emitTokenPathSessionState();
return { ok: true as const, project: projectStore.getOpenProject() };
});
registerHandler(ipcChannels.gameSave.list, async () => {
const project = projectStore.getOpenProject();
if (!project || !gameSavesStore) return { saves: [] };
return { saves: await gameSavesStore.list(String(project.id)) };
});
registerHandler(ipcChannels.gameSave.write, async ({ id, name }) => {
const project = projectStore.getOpenProject();
if (!project || !gameSavesStore) throw new Error('GAME_SAVE_NOT_FOUND');
const body = captureGameSaveBody(project);
const save = await gameSavesStore.write(String(project.id), body, { id, name });
lastSaveCanonical = canonicalGameSave(body);
return { save };
});
registerHandler(ipcChannels.gameSave.isDirty, () => {
return { dirty: sessionIsDirty(projectStore.getOpenProject()) };
});
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
closeMultiWindow();
@@ -961,6 +1165,8 @@ async function main() {
sceneNpcTokensSessionStore.reset();
clearNpcMapSpawnDrag();
scenePlayerTokensSessionStore.reset();
effectsStore.resetSession();
lastSaveCanonical = null;
syncSceneMarkerForProject(project);
emitSceneViewState();
emitSceneTokensSessionState();
@@ -974,7 +1180,8 @@ async function main() {
registerHandler(ipcChannels.project.close, async () => {
closeNpcsEditorWindow();
await projectStore.closeOpenProject();
effectsStore.clear();
effectsStore.resetSession();
lastSaveCanonical = null;
materialsOverlayStore.clear();
npcsOverlayStore.clear();
timerSessionStore.clear();
@@ -1015,7 +1222,8 @@ async function main() {
});
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
effectsStore.clear();
const sceneProject = projectStore.getOpenProject();
if (sceneProject) syncSceneEffectsForProject(sceneProject);
materialsOverlayStore.clear();
npcsOverlayStore.clear();
sceneViewStore.reset();
@@ -1046,7 +1254,8 @@ async function main() {
if (!open) throw new Error('No open project');
const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null;
projectStore.setSessionPointer(graphNodeId, gn ? gn.sceneId : null);
effectsStore.clear();
const switched = projectStore.getOpenProject();
if (switched) syncSceneEffectsForProject(switched);
materialsOverlayStore.clear();
npcsOverlayStore.clear();
sceneViewStore.reset();
@@ -1080,7 +1289,10 @@ async function main() {
const before = projectStore.getOpenProject()?.scenes[sceneId];
const next = await projectStore.updateScene(sceneId, patch);
const project = projectStore.getOpenProject();
if (project?.currentSceneId === sceneId && patch.darkenScene !== undefined) {
if (
project?.currentSceneId === sceneId &&
(patch.darkenScene !== undefined || patch.darknessStrokes !== undefined)
) {
syncSceneDarknessForProject(project);
emitSceneDarknessState();
}
@@ -315,4 +315,16 @@ export class InitiativeSessionStore {
this.state = next;
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;
}
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;
}
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,
} from '../../shared/types';
import { normalizeSceneAudioRef, PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeSceneDarknessStrokes } from '../../shared/types/sceneDarkness';
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
import {
@@ -772,6 +773,7 @@ export class ZipProjectStore {
previewVideoAutostart: false,
previewRotationDeg: 0,
darkenScene: false,
darknessStrokes: [],
traps: [],
tokens: [],
npcTokens: [],
@@ -798,6 +800,9 @@ export class ZipProjectStore {
? { previewRotationDeg: patch.previewRotationDeg }
: null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.darknessStrokes !== undefined
? { darknessStrokes: normalizeSceneDarknessStrokes(patch.darknessStrokes) }
: null),
...(patch.traps !== undefined
? {
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
@@ -2619,6 +2624,9 @@ function normalizeScene(s: Scene): Scene {
const previewThumbAssetId =
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene);
const darknessStrokes = normalizeSceneDarknessStrokes(
(s as unknown as { darknessStrokes?: unknown }).darknessStrokes,
);
const rawTraps = (s as unknown as { traps?: unknown[] }).traps;
const traps = (Array.isArray(rawTraps) ? rawTraps : [])
.map((t) => normalizeSceneTrap(t))
@@ -2666,6 +2674,7 @@ function normalizeScene(s: Scene): Scene {
previewVideoAutostart,
previewRotationDeg,
darkenScene,
darknessStrokes,
traps,
tokens,
npcTokens,
+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;
}
/** Слепок: таймер на паузе, без абсолютного момента конца. */
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 };