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
+13 -51
View File
@@ -1,8 +1,6 @@
import { noneBinding } from '../npcs/npcBinding';
import type {
ExportedStorylineRef,
GraphNodeId,
NpcBinding,
Project,
ProjectNpc,
ProjectNpcGroup,
@@ -241,6 +239,8 @@ export function buildPartialExportProject(
newProjectId: ProjectId;
exportTitle: string;
labels: StorylineLabels;
/** Явный список НПС; пустой — без НПС в архиве. */
npcIds?: readonly string[];
},
): Project {
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
@@ -278,7 +278,7 @@ export function buildPartialExportProject(
const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges);
draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) };
const exportedNpcs = filterNpcsForStorylineExport(source, selections);
const exportedNpcs = selectNpcsByIds(source, opts.npcIds ?? []);
const exportedNpcIds = new Set(exportedNpcs.map((n) => n.id));
const exportedRelations = (source.npcRelations ?? []).filter(
(r) => exportedNpcIds.has(r.sourceNpcId) && exportedNpcIds.has(r.targetNpcId),
@@ -312,33 +312,16 @@ export function buildPartialExportProject(
};
}
/** НПС для экспорта выбранных линий (main + unbound; side только свои). */
export function filterNpcsForStorylineExport(
project: Project,
selections: StorylineSelection[],
): ProjectNpc[] {
const npcs = project.npcs ?? [];
if (selections.length === 0) return [];
/** НПС по явному списку id (порядок — как в проекте). */
export function selectNpcsByIds(project: Project, npcIds: readonly string[]): ProjectNpc[] {
if (npcIds.length === 0) return [];
const wanted = new Set(npcIds);
return (project.npcs ?? []).filter((n) => wanted.has(n.id));
}
const includeUnbound = selections.some((s) => s.kind === 'main');
const hasMain = selections.some((s) => s.kind === 'main');
const sideRefs = new Set(
selections
.filter((s): s is { kind: 'side'; startGraphNodeId: GraphNodeId } => s.kind === 'side')
.map((s) => s.startGraphNodeId),
);
const sceneIdsInExport = new Set(collectSceneIdsForSelections(project, selections));
return npcs.filter((npc) => {
const b: NpcBinding = npc.binding ?? noneBinding();
if (b.kind === 'none') return includeUnbound;
if (b.kind === 'storyline') {
if (b.storyline.kind === 'main') return hasMain;
return sideRefs.has(b.storyline.startGraphNodeId);
}
if (b.kind === 'scene') return sceneIdsInExport.has(b.sceneId);
return false;
});
/** НПС из экспортного пакета — импортируем всех, кто в нём лежит. */
export function listExportedNpcsFromBundle(source: Project): ProjectNpc[] {
return [...(source.npcs ?? [])];
}
function collectNpcGroupIdsForNpcs(groups: ProjectNpcGroup[], npcs: ProjectNpc[]): Set<NpcGroupId> {
@@ -493,7 +476,7 @@ export function mergeStorylinesIntoProject(
for (const au of source.campaignAudios) neededAssetIds.add(au.assetId);
for (const m of source.materials ?? []) neededAssetIds.add(m.assetId);
const exportedNpcs = filterNpcsForStorylineExport(source, selections);
const exportedNpcs = listExportedNpcsFromBundle(source);
for (const n of exportedNpcs) {
const res = npcResolutionBySource.get(n.id);
if (res?.mode === 'use') continue;
@@ -695,7 +678,6 @@ export function mergeStorylinesIntoProject(
const mappedGroupId = n.groupId
? (asNpcGroupId(groupIdMap.get(n.groupId) ?? n.groupId) as typeof n.groupId)
: null;
const binding = remapNpcBinding(n.binding ?? noneBinding(), sceneIdMap, graphNodeIdMap);
npcs.push({
id: newId,
name,
@@ -704,7 +686,6 @@ export function mergeStorylinesIntoProject(
x: n.x,
y: n.y,
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
binding,
});
npcNameKeys.add(name.toLowerCase());
npcsCreated += 1;
@@ -791,25 +772,6 @@ function topologicalNpcGroups(groups: ProjectNpcGroup[]): ProjectNpcGroup[] {
return out;
}
function remapNpcBinding(
binding: NpcBinding,
sceneIdMap: Map<SceneId, SceneId>,
graphNodeIdMap: Map<GraphNodeId, GraphNodeId>,
): NpcBinding {
if (binding.kind === 'none') return noneBinding();
if (binding.kind === 'scene') {
const mapped = sceneIdMap.get(binding.sceneId);
return mapped ? { kind: 'scene', sceneId: mapped } : noneBinding();
}
if (binding.storyline.kind === 'main') {
return { kind: 'storyline', storyline: { kind: 'main' } };
}
const mappedGn = graphNodeIdMap.get(binding.storyline.startGraphNodeId);
return mappedGn
? { kind: 'storyline', storyline: { kind: 'side', startGraphNodeId: mappedGn } }
: noneBinding();
}
export function newExportBundleProjectId(): ProjectId {
return asProjectId(`p_${generateId()}`);
}