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
+78
View File
@@ -165,6 +165,7 @@ export class ZipProjectStore {
},
scenes: {},
assets: {},
campaignAudios: [],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
@@ -182,6 +183,11 @@ export class ZipProjectStore {
async openProjectById(projectId: ProjectId): Promise<Project> {
await this.ensureRoots();
// Mutations are persisted to cache immediately, but zip packing is debounced (queueSave).
// When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
if (this.openProject) {
await this.saveNow();
}
this.projectSession += 1;
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
@@ -589,6 +595,61 @@ export class ZipProjectStore {
return { project: latest, imported: staged };
}
/**
* Copies audio files into cache `assets/` and registers them on the project as campaign audio.
*/
async importCampaignAudioFiles(filePaths: string[]): Promise<{ project: Project; imported: MediaAsset[] }> {
const open = this.openProject;
if (!open) throw new Error('No open project');
if (filePaths.length === 0) {
return { project: open.project, imported: [] };
}
const staged: MediaAsset[] = [];
for (const filePath of filePaths) {
const kind = classifyMediaPath(filePath);
if (kind?.type !== 'audio') continue;
const buf = await fs.readFile(filePath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId());
const orig = path.basename(filePath);
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.copyFile(filePath, abs);
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
}
if (staged.length === 0) {
return { project: open.project, imported: [] };
}
await this.updateProject((p) => {
const assets = { ...p.assets };
const campaignAudios = [...p.campaignAudios];
for (const asset of staged) {
assets[asset.id] = asset;
if (asset.type !== 'audio') continue;
campaignAudios.push({ assetId: asset.id, autoplay: true, loop: true });
}
return { ...p, assets, campaignAudios };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return { project: latest, imported: staged };
}
async setCampaignAudios(audios: Project['campaignAudios']): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({ ...p, campaignAudios: Array.isArray(audios) ? audios : [] }));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async saveNow(): Promise<void> {
const open = this.openProject;
if (!open) return;
@@ -777,6 +838,10 @@ export class ZipProjectStore {
/** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */
async exportProjectZipToPath(projectId: ProjectId, destinationPath: string): Promise<void> {
await this.ensureRoots();
// If exporting the currently open project, make sure pending debounced pack is flushed.
if (this.openProject?.id === projectId) {
await this.saveNow();
}
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
@@ -982,6 +1047,18 @@ function normalizeProject(p: Project): Project {
}
sceneGraphNodes = normalizeSceneGraphNodeFlags(sceneGraphNodes);
const currentGraphNodeId = (p as { currentGraphNodeId?: GraphNodeId | null }).currentGraphNodeId ?? null;
const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios;
const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : [])
.map((a) => {
if (typeof a === 'string') return { assetId: a as AssetId, autoplay: false, loop: false };
if (a && typeof a === 'object') {
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
if (!obj.assetId) return null;
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
}
return null;
})
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string };
const createdWithAppVersion = (() => {
const c = metaRaw.createdWithAppVersion?.trim();
@@ -1001,6 +1078,7 @@ function normalizeProject(p: Project): Project {
schemaVersion: PROJECT_SCHEMA_VERSION,
},
scenes,
campaignAudios,
sceneGraphNodes,
sceneGraphEdges,
currentGraphNodeId,