fix: game audio persistence and editor perf

- Keep game/campaign audio assets referenced (no prune)
- Flush pending project save on quit/switch/export to avoid losing campaignAudios
- Control: prevent game music restarts on scene changes; allow always-on controls; handle autoplay-after-scene-audio
- Editor: reduce ReactFlow churn with stable scene card map; lazy/async image decode
- Add contract/unit tests and update test script

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-22 19:06:16 +08:00
parent f823a7c05f
commit 1d051f8bf9
19 changed files with 1164 additions and 115 deletions
+45
View File
@@ -172,6 +172,27 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
async function main() {
await app.whenReady();
/**
* `ZipProjectStore` пишет `project.json` в кэш синхронно с мутациями, а упаковку `.dnd.zip`
* откладывает (`queueSave`, ~250 мс). Без финального `saveNow()` при выходе на диске
* остаётся старый zip — при следующем открытии кэш пересоздаётся из него и теряются
* недавние поля (в т.ч. `campaignAudios`).
*/
let appQuittingAfterPendingSave = false;
app.on('before-quit', (e) => {
if (appQuittingAfterPendingSave) return;
e.preventDefault();
appQuittingAfterPendingSave = true;
void projectStore
.saveNow()
.catch((err: unknown) => {
console.error('[before-quit] saveNow failed', err);
})
.finally(() => {
app.quit();
});
});
const licenseService = new LicenseService(app.getPath('userData'));
setLicenseAssert(() => {
licenseService.assertForIpc();
@@ -286,6 +307,30 @@ async function main() {
emitSessionState();
return result;
});
registerHandler(ipcChannels.project.importCampaignAudio, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
filters: [
{
name: 'Аудио',
extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
});
if (canceled || filePaths.length === 0) {
const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project');
return { canceled: true as const, project, imported: [] };
}
const result = await projectStore.importCampaignAudioFiles(filePaths);
emitSessionState();
return { canceled: false as const, ...result };
});
registerHandler(ipcChannels.project.updateCampaignAudios, async ({ audios }) => {
const project = await projectStore.setCampaignAudios(audios);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId }) => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],