e08f5ef550
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>
810 lines
26 KiB
TypeScript
810 lines
26 KiB
TypeScript
import type {
|
|
ExportedStorylineRef,
|
|
GraphNodeId,
|
|
Project,
|
|
ProjectNpc,
|
|
ProjectNpcGroup,
|
|
Scene,
|
|
SceneGraphEdge,
|
|
SceneGraphNode,
|
|
SceneId,
|
|
} from '../types';
|
|
import type { AssetId, NpcGroupId, ProjectId, TokenId } from '../types/ids';
|
|
import {
|
|
asAssetId,
|
|
asGraphNodeId,
|
|
asMaterialId,
|
|
asNpcGroupId,
|
|
asNpcId,
|
|
asNpcRelationId,
|
|
asProjectId,
|
|
asSceneId,
|
|
asTokenId,
|
|
} from '../types/ids';
|
|
|
|
import {
|
|
getConnectedComponent,
|
|
getSideStoryComponentNodeIds,
|
|
listSideStoryStarts,
|
|
} from './sceneGraphLineage';
|
|
import { reconcileSceneListOrder } from './sceneListOrder';
|
|
|
|
export type StorylineKind = 'main' | 'side';
|
|
|
|
/** Выбор сюжетной линии для экспорта/импорта. */
|
|
export type StorylineSelection = { kind: 'main' } | { kind: 'side'; startGraphNodeId: GraphNodeId };
|
|
|
|
export type StorylineListItem = {
|
|
selection: StorylineSelection;
|
|
label: string;
|
|
disabled?: boolean;
|
|
disabledReason?: string;
|
|
};
|
|
|
|
export type SceneImportResolution =
|
|
| { sourceSceneId: SceneId; mode: 'create' }
|
|
| { sourceSceneId: SceneId; mode: 'use'; targetSceneId: SceneId };
|
|
|
|
export type SceneTitleConflict = {
|
|
sourceSceneId: SceneId;
|
|
sourceTitle: string;
|
|
matches: { sceneId: SceneId; title: string }[];
|
|
};
|
|
|
|
export type NpcImportResolution =
|
|
| { sourceNpcId: string; mode: 'create' }
|
|
| { sourceNpcId: string; mode: 'use'; targetNpcId: string };
|
|
|
|
export type NpcNameConflict = {
|
|
sourceNpcId: string;
|
|
sourceName: string;
|
|
matches: { npcId: string; name: string }[];
|
|
};
|
|
|
|
export type StorylineImportMergeReport = {
|
|
storylinesImported: number;
|
|
scenesCreated: number;
|
|
scenesReused: number;
|
|
graphNodesAdded: number;
|
|
edgesAdded: number;
|
|
assetsCopied: number;
|
|
assetsReused: number;
|
|
npcsCreated: number;
|
|
npcsReused: number;
|
|
renamedSideTitles: string[];
|
|
};
|
|
|
|
export type StorylineLabels = {
|
|
main: string;
|
|
untitled: string;
|
|
};
|
|
|
|
function generateId(): string {
|
|
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
export function storylineSelectionKey(sel: StorylineSelection): string {
|
|
return sel.kind === 'main' ? 'main' : `side:${sel.startGraphNodeId}`;
|
|
}
|
|
|
|
export function storylineSelectionsEqual(a: StorylineSelection, b: StorylineSelection): boolean {
|
|
return storylineSelectionKey(a) === storylineSelectionKey(b);
|
|
}
|
|
|
|
export function sideStoryDisplayLabel(
|
|
node: SceneGraphNode,
|
|
scenes: Record<SceneId, Scene>,
|
|
untitledLabel: string,
|
|
): string {
|
|
const custom = node.sideStoryLineTitle.trim();
|
|
if (custom) return custom;
|
|
const scene = scenes[node.sceneId];
|
|
const sceneTitle = scene?.title.trim();
|
|
if (sceneTitle) return sceneTitle;
|
|
return untitledLabel;
|
|
}
|
|
|
|
function findMainStartNode(nodes: SceneGraphNode[]): SceneGraphNode | null {
|
|
return nodes.find((n) => n.isStartScene) ?? null;
|
|
}
|
|
|
|
export function collectStorylineGraphNodeIds(
|
|
project: Project,
|
|
selection: StorylineSelection,
|
|
): Set<GraphNodeId> {
|
|
const { sceneGraphNodes, sceneGraphEdges } = project;
|
|
if (selection.kind === 'main') {
|
|
const mainStart = findMainStartNode(sceneGraphNodes);
|
|
if (!mainStart) return new Set();
|
|
return getConnectedComponent(sceneGraphNodes, sceneGraphEdges, mainStart.id);
|
|
}
|
|
return getSideStoryComponentNodeIds(sceneGraphNodes, sceneGraphEdges, selection.startGraphNodeId);
|
|
}
|
|
|
|
export function collectSelectionsGraphNodeIds(
|
|
project: Project,
|
|
selections: StorylineSelection[],
|
|
): Set<GraphNodeId> {
|
|
const out = new Set<GraphNodeId>();
|
|
for (const sel of selections) {
|
|
for (const id of collectStorylineGraphNodeIds(project, sel)) {
|
|
out.add(id);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function listExportableStorylines(project: Project, labels: StorylineLabels): StorylineListItem[] {
|
|
const items: StorylineListItem[] = [];
|
|
if (findMainStartNode(project.sceneGraphNodes)) {
|
|
items.push({ selection: { kind: 'main' }, label: labels.main });
|
|
}
|
|
for (const gn of listSideStoryStarts(project.sceneGraphNodes)) {
|
|
items.push({
|
|
selection: { kind: 'side', startGraphNodeId: gn.id },
|
|
label: sideStoryDisplayLabel(gn, project.scenes, labels.untitled),
|
|
});
|
|
}
|
|
return items;
|
|
}
|
|
|
|
export function listImportableStorylines(
|
|
source: Project,
|
|
labels: StorylineLabels,
|
|
targetHasMainStart: boolean,
|
|
): StorylineListItem[] {
|
|
const fromManifest = source.exportedStorylines;
|
|
if (fromManifest && fromManifest.length > 0) {
|
|
return fromManifest.map((entry) => {
|
|
const selection: StorylineSelection =
|
|
entry.kind === 'main'
|
|
? { kind: 'main' }
|
|
: { kind: 'side', startGraphNodeId: entry.startGraphNodeId! };
|
|
const item: StorylineListItem = { selection, label: entry.label };
|
|
if (entry.kind === 'main' && targetHasMainStart) {
|
|
item.disabled = true;
|
|
item.disabledReason = 'main_exists';
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
return listExportableStorylines(source, labels).map((item) => {
|
|
if (item.selection.kind === 'main' && targetHasMainStart) {
|
|
return { ...item, disabled: true, disabledReason: 'main_exists' };
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
|
|
function recomputeOutgoing(nodes: SceneGraphNode[], edges: SceneGraphEdge[]): Map<SceneId, Set<SceneId>> {
|
|
const gnMap = new Map(nodes.map((n) => [n.id, n]));
|
|
const outgoing = new Map<SceneId, Set<SceneId>>();
|
|
for (const e of edges) {
|
|
const a = gnMap.get(e.sourceGraphNodeId);
|
|
const b = gnMap.get(e.targetGraphNodeId);
|
|
if (!a || !b || a.sceneId === b.sceneId) continue;
|
|
let set = outgoing.get(a.sceneId);
|
|
if (!set) {
|
|
set = new Set();
|
|
outgoing.set(a.sceneId, set);
|
|
}
|
|
set.add(b.sceneId);
|
|
}
|
|
return outgoing;
|
|
}
|
|
|
|
function applyConnectionSets(
|
|
scenes: Record<SceneId, Scene>,
|
|
outgoing: Map<SceneId, Set<SceneId>>,
|
|
): Record<SceneId, Scene> {
|
|
const next: Record<SceneId, Scene> = { ...scenes };
|
|
for (const sid of Object.keys(next) as SceneId[]) {
|
|
const prev = next[sid];
|
|
if (!prev) continue;
|
|
const c = outgoing.get(sid);
|
|
next[sid] = { ...prev, connections: c ? [...c] : [] };
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function selectionToExportedRef(
|
|
project: Project,
|
|
selection: StorylineSelection,
|
|
labels: StorylineLabels,
|
|
): ExportedStorylineRef {
|
|
if (selection.kind === 'main') {
|
|
return { kind: 'main', startGraphNodeId: null, label: labels.main };
|
|
}
|
|
const start = project.sceneGraphNodes.find((n) => n.id === selection.startGraphNodeId);
|
|
return {
|
|
kind: 'side',
|
|
startGraphNodeId: selection.startGraphNodeId,
|
|
label: start ? sideStoryDisplayLabel(start, project.scenes, labels.untitled) : labels.untitled,
|
|
};
|
|
}
|
|
|
|
export function collectSceneIdsForSelections(project: Project, selections: StorylineSelection[]): SceneId[] {
|
|
const nodeIds = collectSelectionsGraphNodeIds(project, selections);
|
|
const sceneIds = new Set<SceneId>();
|
|
for (const gn of project.sceneGraphNodes) {
|
|
if (nodeIds.has(gn.id)) sceneIds.add(gn.sceneId);
|
|
}
|
|
return [...sceneIds];
|
|
}
|
|
|
|
export function buildPartialExportProject(
|
|
source: Project,
|
|
selections: StorylineSelection[],
|
|
opts: {
|
|
newProjectId: ProjectId;
|
|
exportTitle: string;
|
|
labels: StorylineLabels;
|
|
/** Явный список НПС; пустой — без НПС в архиве. */
|
|
npcIds?: readonly string[];
|
|
},
|
|
): Project {
|
|
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
|
|
const sceneIds = new Set(collectSceneIdsForSelections(source, selections));
|
|
|
|
const sceneGraphNodes = source.sceneGraphNodes.filter((n) => nodeIds.has(n.id)).map((n) => ({ ...n }));
|
|
|
|
const sceneGraphEdges = source.sceneGraphEdges.filter(
|
|
(e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId),
|
|
);
|
|
|
|
const scenes: Record<SceneId, Scene> = {};
|
|
for (const sid of sceneIds) {
|
|
const sc = source.scenes[sid];
|
|
if (sc) scenes[sid] = { ...sc };
|
|
}
|
|
|
|
let draft: Project = {
|
|
...source,
|
|
id: opts.newProjectId,
|
|
meta: {
|
|
...source.meta,
|
|
name: opts.exportTitle,
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
scenes,
|
|
sceneListOrder: reconcileSceneListOrder(scenes, source.sceneListOrder),
|
|
sceneGraphNodes,
|
|
sceneGraphEdges,
|
|
currentSceneId: null,
|
|
currentGraphNodeId: null,
|
|
exportedStorylines: selections.map((s) => selectionToExportedRef(source, s, opts.labels)),
|
|
};
|
|
|
|
const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges);
|
|
draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) };
|
|
|
|
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),
|
|
);
|
|
const exportedGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], exportedNpcs);
|
|
const exportedGroups = (source.npcGroups ?? []).filter((g) => exportedGroupIds.has(g.id));
|
|
|
|
draft = {
|
|
...draft,
|
|
npcs: exportedNpcs.map((n) => ({ ...n })),
|
|
npcGroups: exportedGroups.map((g) => ({ ...g })),
|
|
npcRelations: exportedRelations.map((r) => ({ ...r })),
|
|
};
|
|
|
|
const assetIds = collectReferencedAssetIdsForProject(draft);
|
|
const assets: Project['assets'] = {} as Project['assets'];
|
|
for (const id of assetIds) {
|
|
const a = source.assets[id];
|
|
if (a) assets[id] = { ...a };
|
|
}
|
|
|
|
const mainStart = findMainStartNode(draft.sceneGraphNodes);
|
|
const firstSide = listSideStoryStarts(draft.sceneGraphNodes)[0];
|
|
return {
|
|
...draft,
|
|
assets,
|
|
campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
|
|
materials: (source.materials ?? []).map((m) => ({ ...m })),
|
|
currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null,
|
|
currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null,
|
|
};
|
|
}
|
|
|
|
/** НПС по явному списку 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));
|
|
}
|
|
|
|
/** НПС из экспортного пакета — импортируем всех, кто в нём лежит. */
|
|
export function listExportedNpcsFromBundle(source: Project): ProjectNpc[] {
|
|
return [...(source.npcs ?? [])];
|
|
}
|
|
|
|
function collectNpcGroupIdsForNpcs(groups: ProjectNpcGroup[], npcs: ProjectNpc[]): Set<NpcGroupId> {
|
|
const needed = new Set<NpcGroupId>();
|
|
for (const n of npcs) {
|
|
if (n.groupId) needed.add(n.groupId);
|
|
}
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const g of groups) {
|
|
if (needed.has(g.id) && g.parentId && !needed.has(g.parentId)) {
|
|
needed.add(g.parentId);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
return needed;
|
|
}
|
|
|
|
export function findNpcNameConflicts(
|
|
target: Project,
|
|
source: Project,
|
|
sourceNpcIds: string[],
|
|
): NpcNameConflict[] {
|
|
const targetByName = new Map<string, { npcId: string; name: string }[]>();
|
|
for (const n of target.npcs ?? []) {
|
|
const key = n.name.trim().toLowerCase();
|
|
if (!key) continue;
|
|
const list = targetByName.get(key) ?? [];
|
|
list.push({ npcId: n.id, name: n.name });
|
|
targetByName.set(key, list);
|
|
}
|
|
const conflicts: NpcNameConflict[] = [];
|
|
const seen = new Set<string>();
|
|
for (const id of sourceNpcIds) {
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
const src = (source.npcs ?? []).find((n) => n.id === id);
|
|
if (!src) continue;
|
|
const key = src.name.trim().toLowerCase();
|
|
if (!key) continue;
|
|
const matches = targetByName.get(key);
|
|
if (matches && matches.length > 0) {
|
|
conflicts.push({ sourceNpcId: src.id, sourceName: src.name, matches });
|
|
}
|
|
}
|
|
return conflicts;
|
|
}
|
|
|
|
function collectReferencedAssetIdsForProject(p: Project): Set<AssetId> {
|
|
const refs = new Set<AssetId>();
|
|
for (const sc of Object.values(p.scenes)) {
|
|
if (sc.previewAssetId) refs.add(sc.previewAssetId);
|
|
if (sc.previewThumbAssetId) refs.add(sc.previewThumbAssetId);
|
|
for (const vid of sc.media.videos) refs.add(vid);
|
|
for (const au of sc.media.audios) refs.add(au.assetId);
|
|
}
|
|
for (const au of p.campaignAudios) refs.add(au.assetId);
|
|
for (const m of p.materials ?? []) refs.add(m.assetId);
|
|
for (const n of p.npcs ?? []) refs.add(n.avatarAssetId);
|
|
return refs;
|
|
}
|
|
|
|
export function findSceneTitleConflicts(
|
|
target: Project,
|
|
source: Project,
|
|
sourceSceneIds: SceneId[],
|
|
): SceneTitleConflict[] {
|
|
const targetByTitle = new Map<string, { sceneId: SceneId; title: string }[]>();
|
|
for (const sc of Object.values(target.scenes)) {
|
|
const key = sc.title.trim().toLowerCase();
|
|
if (!key) continue;
|
|
const list = targetByTitle.get(key) ?? [];
|
|
list.push({ sceneId: sc.id, title: sc.title });
|
|
targetByTitle.set(key, list);
|
|
}
|
|
|
|
const conflicts: SceneTitleConflict[] = [];
|
|
const seen = new Set<SceneId>();
|
|
for (const sid of sourceSceneIds) {
|
|
if (seen.has(sid)) continue;
|
|
seen.add(sid);
|
|
const src = source.scenes[sid];
|
|
if (!src) continue;
|
|
const key = src.title.trim().toLowerCase();
|
|
if (!key) continue;
|
|
const matches = targetByTitle.get(key);
|
|
if (matches && matches.length > 0) {
|
|
conflicts.push({ sourceSceneId: sid, sourceTitle: src.title, matches });
|
|
}
|
|
}
|
|
return conflicts;
|
|
}
|
|
|
|
function remapSceneAssetRefs(scene: Scene, assetMap: Map<AssetId, AssetId>): Scene {
|
|
const mapId = (id: AssetId | null): AssetId | null => (id ? (assetMap.get(id) ?? id) : null);
|
|
return {
|
|
...scene,
|
|
previewAssetId: mapId(scene.previewAssetId),
|
|
previewThumbAssetId: mapId(scene.previewThumbAssetId),
|
|
media: {
|
|
videos: scene.media.videos.map((id) => assetMap.get(id) ?? id),
|
|
audios: scene.media.audios.map((a) => ({
|
|
...a,
|
|
assetId: assetMap.get(a.assetId) ?? a.assetId,
|
|
})),
|
|
},
|
|
};
|
|
}
|
|
|
|
function uniqueSideTitle(existing: Set<string>, desired: string): string {
|
|
const base = desired.trim() || 'Побочная';
|
|
if (!existing.has(base.toLowerCase())) {
|
|
existing.add(base.toLowerCase());
|
|
return base;
|
|
}
|
|
let n = 2;
|
|
while (existing.has(`${base} (${String(n)})`.toLowerCase())) n += 1;
|
|
const next = `${base} (${String(n)})`;
|
|
existing.add(next.toLowerCase());
|
|
return next;
|
|
}
|
|
|
|
export function mergeStorylinesIntoProject(
|
|
target: Project,
|
|
source: Project,
|
|
selections: StorylineSelection[],
|
|
sceneResolutions: SceneImportResolution[],
|
|
opts: { graphOffsetX: number; npcResolutions?: NpcImportResolution[] },
|
|
): {
|
|
project: Project;
|
|
report: StorylineImportMergeReport;
|
|
assetCopies: { fromId: AssetId; toId: AssetId }[];
|
|
} {
|
|
const resolutionBySource = new Map(sceneResolutions.map((r) => [r.sourceSceneId, r]));
|
|
const npcResolutionBySource = new Map((opts.npcResolutions ?? []).map((r) => [r.sourceNpcId, r]));
|
|
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
|
|
const sourceSceneIds = collectSceneIdsForSelections(source, selections);
|
|
|
|
const neededAssetIds = new Set<AssetId>();
|
|
for (const sid of sourceSceneIds) {
|
|
const sc = source.scenes[sid];
|
|
if (!sc) continue;
|
|
const res = resolutionBySource.get(sid);
|
|
if (res?.mode === 'use') continue;
|
|
if (sc.previewAssetId) neededAssetIds.add(sc.previewAssetId);
|
|
if (sc.previewThumbAssetId) neededAssetIds.add(sc.previewThumbAssetId);
|
|
for (const vid of sc.media.videos) neededAssetIds.add(vid);
|
|
for (const au of sc.media.audios) neededAssetIds.add(au.assetId);
|
|
}
|
|
for (const au of source.campaignAudios) neededAssetIds.add(au.assetId);
|
|
for (const m of source.materials ?? []) neededAssetIds.add(m.assetId);
|
|
|
|
const exportedNpcs = listExportedNpcsFromBundle(source);
|
|
for (const n of exportedNpcs) {
|
|
const res = npcResolutionBySource.get(n.id);
|
|
if (res?.mode === 'use') continue;
|
|
neededAssetIds.add(n.avatarAssetId);
|
|
}
|
|
|
|
const assetMap = new Map<AssetId, AssetId>();
|
|
const targetSha = new Map<string, AssetId>();
|
|
for (const a of Object.values(target.assets)) {
|
|
targetSha.set(a.sha256, a.id);
|
|
}
|
|
let assetsCopied = 0;
|
|
let assetsReused = 0;
|
|
const assetCopies: { fromId: AssetId; toId: AssetId }[] = [];
|
|
for (const id of neededAssetIds) {
|
|
const srcAsset = source.assets[id];
|
|
if (!srcAsset) continue;
|
|
const existing = targetSha.get(srcAsset.sha256);
|
|
if (existing) {
|
|
assetMap.set(id, existing);
|
|
assetsReused += 1;
|
|
} else {
|
|
const newId = asAssetId(`a_${generateId()}`);
|
|
assetMap.set(id, newId);
|
|
targetSha.set(srcAsset.sha256, newId);
|
|
assetCopies.push({ fromId: id, toId: newId });
|
|
assetsCopied += 1;
|
|
}
|
|
}
|
|
|
|
const sceneIdMap = new Map<SceneId, SceneId>();
|
|
let scenesCreated = 0;
|
|
let scenesReused = 0;
|
|
const scenes: Record<SceneId, Scene> = { ...target.scenes };
|
|
for (const sid of sourceSceneIds) {
|
|
const res = resolutionBySource.get(sid);
|
|
const srcScene = source.scenes[sid];
|
|
if (!srcScene) continue;
|
|
if (res?.mode === 'use') {
|
|
sceneIdMap.set(sid, res.targetSceneId);
|
|
scenesReused += 1;
|
|
} else {
|
|
const newId = asSceneId(`s_${generateId()}`);
|
|
sceneIdMap.set(sid, newId);
|
|
scenes[newId] = remapSceneAssetRefs({ ...srcScene, id: newId }, assetMap);
|
|
scenesCreated += 1;
|
|
}
|
|
}
|
|
|
|
const importedNodes = source.sceneGraphNodes.filter((n) => nodeIds.has(n.id));
|
|
const minX = importedNodes.length > 0 ? Math.min(...importedNodes.map((n) => n.x)) : 0;
|
|
const xShift = opts.graphOffsetX - minX;
|
|
|
|
const graphNodeIdMap = new Map<GraphNodeId, GraphNodeId>();
|
|
for (const gn of importedNodes) {
|
|
graphNodeIdMap.set(gn.id, asGraphNodeId(`gn_${generateId()}`));
|
|
}
|
|
|
|
const existingSideTitles = new Set(
|
|
target.sceneGraphNodes
|
|
.filter((n) => n.isSideStoryStart)
|
|
.map((n) => (n.sideStoryLineTitle.trim() || 'Побочная').toLowerCase()),
|
|
);
|
|
const renamedSideTitles: string[] = [];
|
|
|
|
const importMain = selections.some((s) => s.kind === 'main');
|
|
const targetHasMain = !!findMainStartNode(target.sceneGraphNodes);
|
|
|
|
const newGraphNodes: SceneGraphNode[] = importedNodes.map((gn) => {
|
|
const newId = graphNodeIdMap.get(gn.id)!;
|
|
let sideStoryLineTitle = gn.sideStoryLineTitle;
|
|
if (gn.isSideStoryStart) {
|
|
const desired = sideStoryDisplayLabel(gn, source.scenes, 'Без названия');
|
|
const resolved = uniqueSideTitle(existingSideTitles, desired);
|
|
if (resolved !== desired) renamedSideTitles.push(resolved);
|
|
sideStoryLineTitle = resolved;
|
|
}
|
|
return {
|
|
...gn,
|
|
id: newId,
|
|
sceneId: sceneIdMap.get(gn.sceneId) ?? gn.sceneId,
|
|
x: gn.x + xShift,
|
|
y: gn.y,
|
|
isStartScene: importMain && !targetHasMain ? gn.isStartScene : false,
|
|
sideStoryLineTitle,
|
|
};
|
|
});
|
|
|
|
const newEdges: SceneGraphEdge[] = source.sceneGraphEdges
|
|
.filter((e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId))
|
|
.map((e) => ({
|
|
id: `e_${generateId()}`,
|
|
sourceGraphNodeId: graphNodeIdMap.get(e.sourceGraphNodeId)!,
|
|
targetGraphNodeId: graphNodeIdMap.get(e.targetGraphNodeId)!,
|
|
}));
|
|
|
|
const assets: Project['assets'] = { ...target.assets };
|
|
for (const [srcId, tgtId] of assetMap) {
|
|
if (assets[tgtId]) continue;
|
|
const srcAsset = source.assets[srcId];
|
|
if (srcAsset) {
|
|
assets[tgtId] = { ...srcAsset, id: tgtId };
|
|
}
|
|
}
|
|
|
|
const campaignAudios = [...target.campaignAudios];
|
|
const campaignAssetIds = new Set(campaignAudios.map((a) => a.assetId));
|
|
for (const au of source.campaignAudios) {
|
|
const mapped = assetMap.get(au.assetId) ?? au.assetId;
|
|
if (!campaignAssetIds.has(mapped)) {
|
|
campaignAudios.push({ ...au, assetId: mapped });
|
|
campaignAssetIds.add(mapped);
|
|
}
|
|
}
|
|
|
|
const materials = [...(target.materials ?? [])];
|
|
const materialNameKeys = new Set(materials.map((m) => m.name.trim().toLowerCase()));
|
|
const materialAssetIds = new Set(materials.map((m) => m.assetId));
|
|
for (const m of source.materials ?? []) {
|
|
const mapped = assetMap.get(m.assetId) ?? m.assetId;
|
|
if (materialAssetIds.has(mapped)) continue;
|
|
let name = m.name.trim();
|
|
const baseKey = name.toLowerCase();
|
|
if (materialNameKeys.has(baseKey)) {
|
|
let n = 2;
|
|
while (materialNameKeys.has(`${baseKey} (${String(n)})`)) n += 1;
|
|
name = `${name} (${String(n)})`;
|
|
}
|
|
materials.push({
|
|
id: asMaterialId(`mat_${generateId()}`),
|
|
name,
|
|
assetId: mapped,
|
|
rotationDeg: m.rotationDeg ?? 0,
|
|
});
|
|
materialNameKeys.add(name.toLowerCase());
|
|
materialAssetIds.add(mapped);
|
|
}
|
|
|
|
// Группы только для создаваемых НПС выбранных линий.
|
|
const npcsToCreate = exportedNpcs.filter((n) => npcResolutionBySource.get(n.id)?.mode !== 'use');
|
|
const neededGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], npcsToCreate);
|
|
const npcGroups = [...(target.npcGroups ?? [])];
|
|
const groupIdMap = new Map<string, string>();
|
|
const orderedSourceGroups = topologicalNpcGroups(source.npcGroups ?? []).filter((g) =>
|
|
neededGroupIds.has(g.id),
|
|
);
|
|
for (const g of orderedSourceGroups) {
|
|
const mappedParent = g.parentId
|
|
? (asNpcGroupId(groupIdMap.get(g.parentId) ?? g.parentId) as NpcGroupId | null)
|
|
: null;
|
|
const parentKey = mappedParent;
|
|
const siblings = npcGroups.filter((x) => x.parentId === parentKey);
|
|
const nameKey = g.name.trim().toLowerCase();
|
|
const existing = siblings.find((x) => x.name.trim().toLowerCase() === nameKey);
|
|
if (existing) {
|
|
groupIdMap.set(g.id, existing.id);
|
|
continue;
|
|
}
|
|
let name = g.name.trim();
|
|
const siblingKeys = new Set(siblings.map((x) => x.name.trim().toLowerCase()));
|
|
if (siblingKeys.has(name.toLowerCase())) {
|
|
let i = 2;
|
|
while (siblingKeys.has(`${name.toLowerCase()} (${String(i)})`)) i += 1;
|
|
name = `${name} (${String(i)})`;
|
|
}
|
|
const newId = asNpcGroupId(`ng_${generateId()}`);
|
|
groupIdMap.set(g.id, newId);
|
|
npcGroups.push({
|
|
id: newId,
|
|
name,
|
|
color: g.color,
|
|
parentId: mappedParent,
|
|
});
|
|
}
|
|
|
|
const npcs = [...(target.npcs ?? [])];
|
|
const npcNameKeys = new Set(npcs.map((n) => n.name.trim().toLowerCase()));
|
|
const npcIdMap = new Map<string, string>();
|
|
let npcsCreated = 0;
|
|
let npcsReused = 0;
|
|
for (const n of exportedNpcs) {
|
|
const mappedAvatar = assetMap.get(n.avatarAssetId) ?? n.avatarAssetId;
|
|
const res = npcResolutionBySource.get(n.id);
|
|
if (res?.mode === 'use') {
|
|
npcIdMap.set(n.id, res.targetNpcId);
|
|
npcsReused += 1;
|
|
continue;
|
|
}
|
|
// default create (also when no resolution entry)
|
|
let name = n.name.trim();
|
|
const baseKey = name.toLowerCase();
|
|
if (npcNameKeys.has(baseKey)) {
|
|
let i = 2;
|
|
while (npcNameKeys.has(`${baseKey} (${String(i)})`)) i += 1;
|
|
name = `${name} (${String(i)})`;
|
|
}
|
|
const newId = asNpcId(`npc_${generateId()}`);
|
|
npcIdMap.set(n.id, newId);
|
|
const mappedGroupId = n.groupId
|
|
? (asNpcGroupId(groupIdMap.get(n.groupId) ?? n.groupId) as typeof n.groupId)
|
|
: null;
|
|
npcs.push({
|
|
id: newId,
|
|
name,
|
|
avatarAssetId: mappedAvatar,
|
|
description: n.description ?? '',
|
|
x: n.x,
|
|
y: n.y,
|
|
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
|
|
});
|
|
npcNameKeys.add(name.toLowerCase());
|
|
npcsCreated += 1;
|
|
}
|
|
|
|
const npcRelations = [...(target.npcRelations ?? [])];
|
|
const exportedNpcIdSet = new Set(exportedNpcs.map((n) => n.id));
|
|
for (const r of source.npcRelations ?? []) {
|
|
if (!exportedNpcIdSet.has(r.sourceNpcId) || !exportedNpcIdSet.has(r.targetNpcId)) continue;
|
|
const sourceId = npcIdMap.get(r.sourceNpcId);
|
|
const targetId = npcIdMap.get(r.targetNpcId);
|
|
if (!sourceId || !targetId) continue;
|
|
if (!npcs.some((n) => n.id === sourceId) || !npcs.some((n) => n.id === targetId)) continue;
|
|
const label = r.label.trim();
|
|
if (!label) continue;
|
|
const dup = npcRelations.some(
|
|
(x) => x.label === label && x.sourceNpcId === sourceId && x.targetNpcId === targetId,
|
|
);
|
|
if (dup) continue;
|
|
npcRelations.push({
|
|
id: asNpcRelationId(`nrel_${generateId()}`),
|
|
sourceNpcId: asNpcId(sourceId),
|
|
targetNpcId: asNpcId(targetId),
|
|
label,
|
|
});
|
|
}
|
|
|
|
let merged: Project = {
|
|
...target,
|
|
scenes,
|
|
assets,
|
|
campaignAudios,
|
|
materials,
|
|
npcs,
|
|
npcGroups,
|
|
npcRelations,
|
|
sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes],
|
|
sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges],
|
|
};
|
|
|
|
const outgoing = recomputeOutgoing(merged.sceneGraphNodes, merged.sceneGraphEdges);
|
|
merged = {
|
|
...merged,
|
|
scenes: applyConnectionSets(merged.scenes, outgoing),
|
|
sceneListOrder: reconcileSceneListOrder(scenes, target.sceneListOrder),
|
|
};
|
|
|
|
return {
|
|
project: merged,
|
|
report: {
|
|
storylinesImported: selections.length,
|
|
scenesCreated,
|
|
scenesReused,
|
|
graphNodesAdded: newGraphNodes.length,
|
|
edgesAdded: newEdges.length,
|
|
assetsCopied,
|
|
assetsReused,
|
|
npcsCreated,
|
|
npcsReused,
|
|
renamedSideTitles,
|
|
},
|
|
assetCopies,
|
|
};
|
|
}
|
|
|
|
export function computeGraphImportOffsetX(target: Project, padding = 120): number {
|
|
const maxX = target.sceneGraphNodes.reduce((m, n) => Math.max(m, n.x), 0);
|
|
return maxX + padding;
|
|
}
|
|
|
|
function topologicalNpcGroups(groups: ProjectNpcGroup[]): ProjectNpcGroup[] {
|
|
const byId = new Map(groups.map((g) => [g.id, g]));
|
|
const out: ProjectNpcGroup[] = [];
|
|
const seen = new Set<string>();
|
|
const visit = (id: string): void => {
|
|
if (seen.has(id)) return;
|
|
seen.add(id);
|
|
const g = byId.get(asNpcGroupId(id));
|
|
if (!g) return;
|
|
if (g.parentId && byId.has(g.parentId)) visit(g.parentId);
|
|
out.push(g);
|
|
};
|
|
for (const g of groups) visit(g.id);
|
|
return out;
|
|
}
|
|
|
|
export function newExportBundleProjectId(): ProjectId {
|
|
return asProjectId(`p_${generateId()}`);
|
|
}
|
|
|
|
/** Id app-токенов, реально стоящих на сценах проекта (для экспорта с линией). */
|
|
export function collectTokenIdsFromProject(project: Project): TokenId[] {
|
|
const ids = new Set<string>();
|
|
for (const scene of Object.values(project.scenes)) {
|
|
for (const t of scene.tokens ?? []) {
|
|
if (t.tokenId) ids.add(t.tokenId);
|
|
}
|
|
}
|
|
return [...ids].map((id) => asTokenId(id));
|
|
}
|
|
|
|
/** Переписать tokenId на сценах после импорта в app-пул. */
|
|
export function remapProjectSceneTokenIds(
|
|
project: Project,
|
|
remap: ReadonlyMap<string, string>,
|
|
): Project {
|
|
if (remap.size === 0) return project;
|
|
const scenes: Record<SceneId, Scene> = { ...project.scenes };
|
|
for (const sid of Object.keys(scenes) as SceneId[]) {
|
|
const scene = scenes[sid];
|
|
if (!scene?.tokens?.length) continue;
|
|
scenes[sid] = {
|
|
...scene,
|
|
tokens: scene.tokens.map((t) => ({
|
|
...t,
|
|
tokenId: asTokenId(remap.get(t.tokenId) ?? t.tokenId),
|
|
})),
|
|
};
|
|
}
|
|
return { ...project, scenes };
|
|
}
|