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:
+226
-14
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user