feat(npcs): drop binding; select NPCs on storyline export

Remove storyline/scene NPC binding and export chosen NPCs with relations/groups. Harden export modal so a missing npcs payload no longer blacks out the editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-30 10:05:09 +08:00
parent 04c75cd725
commit e08f5ef550
17 changed files with 418 additions and 522 deletions
+8 -85
View File
@@ -39,7 +39,6 @@ import type {
MaterialLegend,
MediaAsset,
MediaAssetType,
NpcBinding,
Project,
ProjectId,
ProjectNpc,
@@ -66,12 +65,6 @@ import {
asNpcRelationId,
asProjectId,
} from '../../shared/types/ids';
import {
clearNpcBindingsForDeletedScene,
clearNpcBindingsForRemovedStoryline,
noneBinding,
normalizeNpcBinding,
} from '../../shared/npcs/npcBinding';
import {
DEFAULT_NPC_GROUP_COLOR,
normalizeHexColor,
@@ -712,25 +705,9 @@ export class ZipProjectStore {
currentSceneId = ids[0] ?? null;
}
const removedSideStarts = p.sceneGraphNodes.filter(
(n) => n.sceneId === sceneId && n.isSideStoryStart,
);
let npcs = clearNpcBindingsForDeletedScene(p.npcs ?? [], sceneId);
for (const side of removedSideStarts) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
kind: 'side',
startGraphNodeId: side.id,
});
}
const hadMainOnScene = p.sceneGraphNodes.some((n) => n.sceneId === sceneId && n.isStartScene);
if (hadMainOnScene) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
}
return {
...withGraph,
scenes: nextScenes,
npcs,
sceneListOrder: removeFromSceneListOrder(
reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder),
sceneId,
@@ -792,25 +769,9 @@ export class ZipProjectStore {
if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
throw new Error('Graph node not found');
}
const prevMain = open.project.sceneGraphNodes.find((n) => n.isStartScene);
const clearingMain = graphNodeId === null || (prevMain && prevMain.id !== graphNodeId);
await this.updateProject((p) => {
let npcs = p.npcs ?? [];
if (clearingMain && prevMain) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
}
const demotedSides = p.sceneGraphNodes.filter(
(n) => n.isSideStoryStart && graphNodeId !== null && n.id === graphNodeId,
);
for (const side of demotedSides) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
kind: 'side',
startGraphNodeId: side.id,
});
}
return {
...p,
npcs,
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
const isMain = graphNodeId !== null && n.id === graphNodeId;
if (isMain) {
@@ -841,19 +802,8 @@ export class ZipProjectStore {
return open.project;
}
await this.updateProject((p) => {
let npcs = p.npcs ?? [];
if (!enabling) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
kind: 'side',
startGraphNodeId: graphNodeId,
});
}
if (enabling && node.isStartScene) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
}
return {
...p,
npcs,
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
if (n.id !== graphNodeId) return n;
if (enabling) {
@@ -916,23 +866,7 @@ export class ZipProjectStore {
await this.updateProject((p) => {
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
const npcs = (p.npcs ?? []).map((n) => {
if (
n.binding?.kind === 'storyline' &&
n.binding.storyline.kind === 'side' &&
n.binding.storyline.startGraphNodeId === nodeId
) {
return {
...n,
binding: {
kind: 'storyline' as const,
storyline: { kind: 'side' as const, startGraphNodeId: newStartId },
},
};
}
return n;
});
return { ...withGraph, npcs, scenes: applyConnectionSets(withGraph.scenes, out) };
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
@@ -1309,7 +1243,6 @@ export class ZipProjectStore {
description?: string;
filePath?: string;
groupId?: NpcGroupId | null;
binding?: NpcBinding;
},
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
): Promise<Project> {
@@ -1384,7 +1317,6 @@ export class ZipProjectStore {
description:
typeof input.description === 'string' ? input.description : prev.description,
groupId: resolveGroup(input.groupId, prev.groupId),
binding: input.binding !== undefined ? input.binding : prev.binding,
};
} else {
if (!nextAssetId) throw new Error('NPC avatar is required');
@@ -1397,7 +1329,6 @@ export class ZipProjectStore {
x: 80 + (count % 4) * 220,
y: 80 + Math.floor(count / 4) * 200,
groupId: resolveGroup(input.groupId, null),
binding: input.binding ?? noneBinding(),
});
}
return { ...p, assets, npcs };
@@ -1415,7 +1346,6 @@ export class ZipProjectStore {
name?: string;
description?: string;
groupId?: NpcGroupId | null;
binding?: NpcBinding;
},
): Promise<Project> {
const open = this.openProject;
@@ -1447,7 +1377,6 @@ export class ZipProjectStore {
...(name !== undefined ? { name } : {}),
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
groupId,
...(patch.binding !== undefined ? { binding: patch.binding } : {}),
};
});
return { ...p, npcs };
@@ -2000,10 +1929,13 @@ export class ZipProjectStore {
async getProjectStorylines(
projectId: ProjectId,
labels: StorylineLabels,
): Promise<StorylineListItem[]> {
): Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }> {
const snap = await this.loadProjectSnapshot(projectId);
try {
return listExportableStorylines(snap.project, labels);
return {
storylines: listExportableStorylines(snap.project, labels),
npcs: (snap.project.npcs ?? []).map((n) => ({ id: n.id, name: n.name })),
};
} finally {
if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
@@ -2014,6 +1946,7 @@ export class ZipProjectStore {
async exportStorylinesZipToPath(
projectId: ProjectId,
selections: StorylineSelection[],
npcIds: string[],
destinationPath: string,
labels: StorylineLabels,
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
@@ -2030,6 +1963,7 @@ export class ZipProjectStore {
newProjectId: newExportBundleProjectId(),
exportTitle: entry?.name ?? snap.project.meta.name,
labels,
npcIds,
});
await fs.mkdir(path.join(exportCache, 'assets'), { recursive: true });
const assetIds = Object.keys(partial.assets) as AssetId[];
@@ -2483,11 +2417,6 @@ function normalizeProject(p: Project): Project {
);
const npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups);
const groupIdSet = new Set(npcGroups.map((g) => g.id));
const sceneIdSet = new Set(Object.keys(scenes) as SceneId[]);
const sideStartIds = new Set(
sceneGraphNodes.filter((n) => n.isSideStoryStart).map((n) => n.id),
);
const hasMainStart = sceneGraphNodes.some((n) => n.isStartScene);
const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs;
const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : [])
.map((n, index) => {
@@ -2500,7 +2429,6 @@ function normalizeProject(p: Project): Project {
x?: number;
y?: number;
groupId?: string | null;
binding?: unknown;
};
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
const name = obj.name.trim();
@@ -2516,11 +2444,6 @@ function normalizeProject(p: Project): Project {
x,
y,
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
binding: normalizeNpcBinding(obj.binding, {
sceneIds: sceneIdSet,
sideStartIds,
hasMainStart,
}),
};
})
.filter((x): x is ProjectNpc => Boolean(x));