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:
@@ -18,7 +18,6 @@ import type {
|
|||||||
FoundryPlaylistDoc,
|
FoundryPlaylistDoc,
|
||||||
FoundrySceneDoc,
|
FoundrySceneDoc,
|
||||||
} from '../../shared/foundry/foundryTypes';
|
} from '../../shared/foundry/foundryTypes';
|
||||||
import { noneBinding } from '../../shared/npcs/npcBinding';
|
|
||||||
import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor } from '../../shared/npcs/npcGroups';
|
import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor } from '../../shared/npcs/npcGroups';
|
||||||
import type {
|
import type {
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
@@ -447,7 +446,6 @@ export async function buildProjectFromFoundryDocuments(
|
|||||||
x: 80 + (npcIndex % 4) * 220,
|
x: 80 + (npcIndex % 4) * 220,
|
||||||
y: 80 + Math.floor(npcIndex / 4) * 200,
|
y: 80 + Math.floor(npcIndex / 4) * 200,
|
||||||
groupId,
|
groupId,
|
||||||
binding: noneBinding(),
|
|
||||||
});
|
});
|
||||||
npcIndex += 1;
|
npcIndex += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-7
@@ -757,7 +757,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
registerHandler(
|
registerHandler(
|
||||||
ipcChannels.project.upsertNpc,
|
ipcChannels.project.upsertNpc,
|
||||||
async ({ npcId, name, description, filePath: pathFromDrop, groupId, binding }) => {
|
async ({ npcId, name, description, filePath: pathFromDrop, groupId }) => {
|
||||||
let filePath = pathFromDrop;
|
let filePath = pathFromDrop;
|
||||||
if (!filePath && !npcId) {
|
if (!filePath && !npcId) {
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||||
@@ -781,7 +781,6 @@ async function main() {
|
|||||||
...(typeof description === 'string' ? { description } : {}),
|
...(typeof description === 'string' ? { description } : {}),
|
||||||
...(filePath ? { filePath } : {}),
|
...(filePath ? { filePath } : {}),
|
||||||
...(groupId !== undefined ? { groupId } : {}),
|
...(groupId !== undefined ? { groupId } : {}),
|
||||||
...(binding !== undefined ? { binding } : {}),
|
|
||||||
},
|
},
|
||||||
(p) => emitNpcUpsertProgress(p),
|
(p) => emitNpcUpsertProgress(p),
|
||||||
);
|
);
|
||||||
@@ -793,12 +792,11 @@ async function main() {
|
|||||||
);
|
);
|
||||||
registerHandler(
|
registerHandler(
|
||||||
ipcChannels.project.updateNpcFields,
|
ipcChannels.project.updateNpcFields,
|
||||||
async ({ npcId, name, description, groupId, binding }) => {
|
async ({ npcId, name, description, groupId }) => {
|
||||||
const project = await projectStore.updateNpcFields(npcId, {
|
const project = await projectStore.updateNpcFields(npcId, {
|
||||||
...(typeof name === 'string' ? { name } : {}),
|
...(typeof name === 'string' ? { name } : {}),
|
||||||
...(typeof description === 'string' ? { description } : {}),
|
...(typeof description === 'string' ? { description } : {}),
|
||||||
...(groupId !== undefined ? { groupId } : {}),
|
...(groupId !== undefined ? { groupId } : {}),
|
||||||
...(binding !== undefined ? { binding } : {}),
|
|
||||||
});
|
});
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { project };
|
return { project };
|
||||||
@@ -1017,8 +1015,7 @@ async function main() {
|
|||||||
return { canceled: false as const, project };
|
return { canceled: false as const, project };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.project.getProjectStorylines, async ({ projectId, labels }) => {
|
registerHandler(ipcChannels.project.getProjectStorylines, async ({ projectId, labels }) => {
|
||||||
const storylines = await projectStore.getProjectStorylines(projectId, labels);
|
return projectStore.getProjectStorylines(projectId, labels);
|
||||||
return { storylines };
|
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.project.peekImportZip, async ({ labels, targetHasMainStart }) => {
|
registerHandler(ipcChannels.project.peekImportZip, async ({ labels, targetHasMainStart }) => {
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||||
@@ -1159,7 +1156,7 @@ async function main() {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, labels }) => {
|
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, npcIds, labels }) => {
|
||||||
const list = await projectStore.listProjects();
|
const list = await projectStore.listProjects();
|
||||||
const entry = list.find((p) => p.id === projectId);
|
const entry = list.find((p) => p.id === projectId);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
@@ -1183,6 +1180,7 @@ async function main() {
|
|||||||
await projectStore.exportStorylinesZipToPath(
|
await projectStore.exportStorylinesZipToPath(
|
||||||
projectId,
|
projectId,
|
||||||
storylineSelections,
|
storylineSelections,
|
||||||
|
npcIds ?? [],
|
||||||
dest,
|
dest,
|
||||||
labels,
|
labels,
|
||||||
(p) => {
|
(p) => {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import type {
|
|||||||
MaterialLegend,
|
MaterialLegend,
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
MediaAssetType,
|
MediaAssetType,
|
||||||
NpcBinding,
|
|
||||||
Project,
|
Project,
|
||||||
ProjectId,
|
ProjectId,
|
||||||
ProjectNpc,
|
ProjectNpc,
|
||||||
@@ -66,12 +65,6 @@ import {
|
|||||||
asNpcRelationId,
|
asNpcRelationId,
|
||||||
asProjectId,
|
asProjectId,
|
||||||
} from '../../shared/types/ids';
|
} from '../../shared/types/ids';
|
||||||
import {
|
|
||||||
clearNpcBindingsForDeletedScene,
|
|
||||||
clearNpcBindingsForRemovedStoryline,
|
|
||||||
noneBinding,
|
|
||||||
normalizeNpcBinding,
|
|
||||||
} from '../../shared/npcs/npcBinding';
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_NPC_GROUP_COLOR,
|
DEFAULT_NPC_GROUP_COLOR,
|
||||||
normalizeHexColor,
|
normalizeHexColor,
|
||||||
@@ -712,25 +705,9 @@ export class ZipProjectStore {
|
|||||||
currentSceneId = ids[0] ?? null;
|
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 {
|
return {
|
||||||
...withGraph,
|
...withGraph,
|
||||||
scenes: nextScenes,
|
scenes: nextScenes,
|
||||||
npcs,
|
|
||||||
sceneListOrder: removeFromSceneListOrder(
|
sceneListOrder: removeFromSceneListOrder(
|
||||||
reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder),
|
reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder),
|
||||||
sceneId,
|
sceneId,
|
||||||
@@ -792,25 +769,9 @@ export class ZipProjectStore {
|
|||||||
if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
|
if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
|
||||||
throw new Error('Graph node not found');
|
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) => {
|
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 {
|
return {
|
||||||
...p,
|
...p,
|
||||||
npcs,
|
|
||||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||||
const isMain = graphNodeId !== null && n.id === graphNodeId;
|
const isMain = graphNodeId !== null && n.id === graphNodeId;
|
||||||
if (isMain) {
|
if (isMain) {
|
||||||
@@ -841,19 +802,8 @@ export class ZipProjectStore {
|
|||||||
return open.project;
|
return open.project;
|
||||||
}
|
}
|
||||||
await this.updateProject((p) => {
|
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 {
|
return {
|
||||||
...p,
|
...p,
|
||||||
npcs,
|
|
||||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||||
if (n.id !== graphNodeId) return n;
|
if (n.id !== graphNodeId) return n;
|
||||||
if (enabling) {
|
if (enabling) {
|
||||||
@@ -916,23 +866,7 @@ export class ZipProjectStore {
|
|||||||
await this.updateProject((p) => {
|
await this.updateProject((p) => {
|
||||||
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
|
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
|
||||||
const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
|
const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
|
||||||
const npcs = (p.npcs ?? []).map((n) => {
|
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
|
||||||
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) };
|
|
||||||
});
|
});
|
||||||
const latest = this.getOpenProject();
|
const latest = this.getOpenProject();
|
||||||
if (!latest) throw new Error('No open project');
|
if (!latest) throw new Error('No open project');
|
||||||
@@ -1309,7 +1243,6 @@ export class ZipProjectStore {
|
|||||||
description?: string;
|
description?: string;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
groupId?: NpcGroupId | null;
|
groupId?: NpcGroupId | null;
|
||||||
binding?: NpcBinding;
|
|
||||||
},
|
},
|
||||||
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
|
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
|
||||||
): Promise<Project> {
|
): Promise<Project> {
|
||||||
@@ -1384,7 +1317,6 @@ export class ZipProjectStore {
|
|||||||
description:
|
description:
|
||||||
typeof input.description === 'string' ? input.description : prev.description,
|
typeof input.description === 'string' ? input.description : prev.description,
|
||||||
groupId: resolveGroup(input.groupId, prev.groupId),
|
groupId: resolveGroup(input.groupId, prev.groupId),
|
||||||
binding: input.binding !== undefined ? input.binding : prev.binding,
|
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
if (!nextAssetId) throw new Error('NPC avatar is required');
|
if (!nextAssetId) throw new Error('NPC avatar is required');
|
||||||
@@ -1397,7 +1329,6 @@ export class ZipProjectStore {
|
|||||||
x: 80 + (count % 4) * 220,
|
x: 80 + (count % 4) * 220,
|
||||||
y: 80 + Math.floor(count / 4) * 200,
|
y: 80 + Math.floor(count / 4) * 200,
|
||||||
groupId: resolveGroup(input.groupId, null),
|
groupId: resolveGroup(input.groupId, null),
|
||||||
binding: input.binding ?? noneBinding(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { ...p, assets, npcs };
|
return { ...p, assets, npcs };
|
||||||
@@ -1415,7 +1346,6 @@ export class ZipProjectStore {
|
|||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
groupId?: NpcGroupId | null;
|
groupId?: NpcGroupId | null;
|
||||||
binding?: NpcBinding;
|
|
||||||
},
|
},
|
||||||
): Promise<Project> {
|
): Promise<Project> {
|
||||||
const open = this.openProject;
|
const open = this.openProject;
|
||||||
@@ -1447,7 +1377,6 @@ export class ZipProjectStore {
|
|||||||
...(name !== undefined ? { name } : {}),
|
...(name !== undefined ? { name } : {}),
|
||||||
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
|
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
|
||||||
groupId,
|
groupId,
|
||||||
...(patch.binding !== undefined ? { binding: patch.binding } : {}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { ...p, npcs };
|
return { ...p, npcs };
|
||||||
@@ -2000,10 +1929,13 @@ export class ZipProjectStore {
|
|||||||
async getProjectStorylines(
|
async getProjectStorylines(
|
||||||
projectId: ProjectId,
|
projectId: ProjectId,
|
||||||
labels: StorylineLabels,
|
labels: StorylineLabels,
|
||||||
): Promise<StorylineListItem[]> {
|
): Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }> {
|
||||||
const snap = await this.loadProjectSnapshot(projectId);
|
const snap = await this.loadProjectSnapshot(projectId);
|
||||||
try {
|
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 {
|
} finally {
|
||||||
if (snap.ownsCache) {
|
if (snap.ownsCache) {
|
||||||
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
|
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
@@ -2014,6 +1946,7 @@ export class ZipProjectStore {
|
|||||||
async exportStorylinesZipToPath(
|
async exportStorylinesZipToPath(
|
||||||
projectId: ProjectId,
|
projectId: ProjectId,
|
||||||
selections: StorylineSelection[],
|
selections: StorylineSelection[],
|
||||||
|
npcIds: string[],
|
||||||
destinationPath: string,
|
destinationPath: string,
|
||||||
labels: StorylineLabels,
|
labels: StorylineLabels,
|
||||||
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
|
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
|
||||||
@@ -2030,6 +1963,7 @@ export class ZipProjectStore {
|
|||||||
newProjectId: newExportBundleProjectId(),
|
newProjectId: newExportBundleProjectId(),
|
||||||
exportTitle: entry?.name ?? snap.project.meta.name,
|
exportTitle: entry?.name ?? snap.project.meta.name,
|
||||||
labels,
|
labels,
|
||||||
|
npcIds,
|
||||||
});
|
});
|
||||||
await fs.mkdir(path.join(exportCache, 'assets'), { recursive: true });
|
await fs.mkdir(path.join(exportCache, 'assets'), { recursive: true });
|
||||||
const assetIds = Object.keys(partial.assets) as AssetId[];
|
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 npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups);
|
||||||
const groupIdSet = new Set(npcGroups.map((g) => g.id));
|
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 rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs;
|
||||||
const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : [])
|
const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : [])
|
||||||
.map((n, index) => {
|
.map((n, index) => {
|
||||||
@@ -2500,7 +2429,6 @@ function normalizeProject(p: Project): Project {
|
|||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
binding?: unknown;
|
|
||||||
};
|
};
|
||||||
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
|
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
|
||||||
const name = obj.name.trim();
|
const name = obj.name.trim();
|
||||||
@@ -2516,11 +2444,6 @@ function normalizeProject(p: Project): Project {
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
|
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
|
||||||
binding: normalizeNpcBinding(obj.binding, {
|
|
||||||
sceneIds: sceneIdSet,
|
|
||||||
sideStartIds,
|
|
||||||
hasMainStart,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((x): x is ProjectNpc => Boolean(x));
|
.filter((x): x is ProjectNpc => Boolean(x));
|
||||||
|
|||||||
@@ -562,11 +562,7 @@ export function EditorApp() {
|
|||||||
const continueImportAfterScenes = useCallback(
|
const continueImportAfterScenes = useCallback(
|
||||||
(selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => {
|
(selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => {
|
||||||
if (!importPeek || !state.project) return;
|
if (!importPeek || !state.project) return;
|
||||||
const npcConflicts = computeNpcImportConflicts(
|
const npcConflicts = computeNpcImportConflicts(state.project, importPeek.sourceProject);
|
||||||
state.project,
|
|
||||||
importPeek.sourceProject,
|
|
||||||
selections,
|
|
||||||
);
|
|
||||||
setPendingImportSelections(selections);
|
setPendingImportSelections(selections);
|
||||||
setPendingSceneResolutions(sceneResolutions);
|
setPendingSceneResolutions(sceneResolutions);
|
||||||
setImportConflictsOpen(false);
|
setImportConflictsOpen(false);
|
||||||
@@ -578,7 +574,6 @@ export function EditorApp() {
|
|||||||
const npcResolutions = buildNpcResolutionsForImport(
|
const npcResolutions = buildNpcResolutionsForImport(
|
||||||
state.project,
|
state.project,
|
||||||
importPeek.sourceProject,
|
importPeek.sourceProject,
|
||||||
selections,
|
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -1459,8 +1454,8 @@ export function EditorApp() {
|
|||||||
storylineLabels={storylineLabels}
|
storylineLabels={storylineLabels}
|
||||||
loadStorylines={loadProjectStorylines}
|
loadStorylines={loadProjectStorylines}
|
||||||
onClose={() => setExportModalOpen(false)}
|
onClose={() => setExportModalOpen(false)}
|
||||||
onExport={async (projectId, selections) => {
|
onExport={async (projectId, selections, npcIds) => {
|
||||||
await actions.exportProject(projectId, selections, storylineLabels);
|
await actions.exportProject(projectId, selections, npcIds, storylineLabels);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ImportSourceModal
|
<ImportSourceModal
|
||||||
@@ -1535,7 +1530,6 @@ export function EditorApp() {
|
|||||||
const npcResolutions = buildNpcResolutionsForImport(
|
const npcResolutions = buildNpcResolutionsForImport(
|
||||||
state.project,
|
state.project,
|
||||||
importPeek.sourceProject,
|
importPeek.sourceProject,
|
||||||
pendingImportSelections,
|
|
||||||
importNpcConflicts,
|
importNpcConflicts,
|
||||||
userNpcResolutions,
|
userNpcResolutions,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { createPortal } from 'react-dom';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
collectSceneIdsForSelections,
|
collectSceneIdsForSelections,
|
||||||
filterNpcsForStorylineExport,
|
|
||||||
findNpcNameConflicts,
|
findNpcNameConflicts,
|
||||||
findSceneTitleConflicts,
|
findSceneTitleConflicts,
|
||||||
|
listExportedNpcsFromBundle,
|
||||||
storylineSelectionKey,
|
storylineSelectionKey,
|
||||||
type NpcImportResolution,
|
type NpcImportResolution,
|
||||||
type NpcNameConflict,
|
type NpcNameConflict,
|
||||||
@@ -22,29 +22,40 @@ import { Button, Select } from '../shared/ui/controls';
|
|||||||
import styles from './EditorApp.module.css';
|
import styles from './EditorApp.module.css';
|
||||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||||
|
|
||||||
|
export type ExportNpcOption = { id: string; name: string };
|
||||||
|
|
||||||
type ExportProjectModalProps = {
|
type ExportProjectModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
projects: { id: ProjectId; name: string; fileName: string }[];
|
projects: { id: ProjectId; name: string; fileName: string }[];
|
||||||
initialProjectId: ProjectId | null;
|
initialProjectId: ProjectId | null;
|
||||||
storylineLabels: StorylineLabels;
|
storylineLabels: StorylineLabels;
|
||||||
loadStorylines: (projectId: ProjectId) => Promise<StorylineListItem[]>;
|
loadStorylines: (
|
||||||
|
projectId: ProjectId,
|
||||||
|
) => Promise<{ storylines: StorylineListItem[]; npcs: ExportNpcOption[] }>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onExport: (projectId: ProjectId, selections: StorylineSelection[]) => Promise<void>;
|
onExport: (
|
||||||
|
projectId: ProjectId,
|
||||||
|
selections: StorylineSelection[],
|
||||||
|
npcIds: string[],
|
||||||
|
) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ExportProjectModal({
|
export function ExportProjectModal({
|
||||||
open,
|
open,
|
||||||
projects,
|
projects,
|
||||||
initialProjectId,
|
initialProjectId,
|
||||||
storylineLabels,
|
storylineLabels: _storylineLabels,
|
||||||
loadStorylines,
|
loadStorylines,
|
||||||
onClose,
|
onClose,
|
||||||
onExport,
|
onExport,
|
||||||
}: ExportProjectModalProps) {
|
}: ExportProjectModalProps) {
|
||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
|
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
|
||||||
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
|
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
|
||||||
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
|
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
|
||||||
|
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
|
||||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
|
||||||
const [loadingStorylines, setLoadingStorylines] = useState(false);
|
const [loadingStorylines, setLoadingStorylines] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -55,23 +66,38 @@ export function ExportProjectModal({
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSelectedKeys(new Set());
|
setSelectedKeys(new Set());
|
||||||
|
setSelectedNpcIds(new Set());
|
||||||
|
setStep('storylines');
|
||||||
|
setNpcs([]);
|
||||||
}, [initialProjectId, open]);
|
}, [initialProjectId, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !projectId) {
|
if (!open || !projectId) {
|
||||||
setStorylines([]);
|
setStorylines([]);
|
||||||
|
setNpcs([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoadingStorylines(true);
|
setLoadingStorylines(true);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const list = await loadStorylines(projectId);
|
const res = await loadStorylines(projectId);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
const list = Array.isArray(res?.storylines) ? res.storylines : [];
|
||||||
|
const npcList = Array.isArray(res?.npcs) ? res.npcs : [];
|
||||||
setStorylines(list);
|
setStorylines(list);
|
||||||
|
setNpcs(npcList);
|
||||||
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
|
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
|
||||||
|
setSelectedNpcIds(new Set(npcList.map((n) => n.id)));
|
||||||
|
setStep('storylines');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
if (!cancelled) {
|
||||||
|
setStorylines([]);
|
||||||
|
setNpcs([]);
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
setSelectedNpcIds(new Set());
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoadingStorylines(false);
|
if (!cancelled) setLoadingStorylines(false);
|
||||||
}
|
}
|
||||||
@@ -84,15 +110,18 @@ export function ExportProjectModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') {
|
||||||
|
if (step === 'npcs') setStep('storylines');
|
||||||
|
else onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [onClose, open]);
|
}, [onClose, open, step]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const canExport =
|
const canContinueStorylines =
|
||||||
projectId !== null &&
|
projectId !== null &&
|
||||||
projects.some((p) => p.id === projectId) &&
|
projects.some((p) => p.id === projectId) &&
|
||||||
selectedKeys.size > 0 &&
|
selectedKeys.size > 0 &&
|
||||||
@@ -108,10 +137,44 @@ export function ExportProjectModal({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleNpc = (id: string) => {
|
||||||
|
setSelectedNpcIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const selectedSelections = storylines
|
const selectedSelections = storylines
|
||||||
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
|
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
|
||||||
.map((item) => item.selection);
|
.map((item) => item.selection);
|
||||||
|
|
||||||
|
const runExport = (npcIds: string[]) => {
|
||||||
|
if (!projectId || !canContinueStorylines) return;
|
||||||
|
void (async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onExport(projectId, selectedSelections, npcIds);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
const goNextFromStorylines = () => {
|
||||||
|
if (!canContinueStorylines) return;
|
||||||
|
if (npcs.length === 0) {
|
||||||
|
runExport([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep('npcs');
|
||||||
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -122,7 +185,9 @@ export function ExportProjectModal({
|
|||||||
/>
|
/>
|
||||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<div className={styles.modalTitle}>{t('export.title')}</div>
|
<div className={styles.modalTitle}>
|
||||||
|
{step === 'storylines' ? t('export.title') : t('export.npcsTitle')}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t('common.close')}
|
aria-label={t('common.close')}
|
||||||
@@ -133,12 +198,13 @@ export function ExportProjectModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{step === 'storylines' ? (
|
||||||
<div className={styles.fieldGrid}>
|
<div className={styles.fieldGrid}>
|
||||||
<div className={styles.fieldLabel}>{t('export.project')}</div>
|
<div className={styles.fieldLabel}>{t('export.project')}</div>
|
||||||
<Select
|
<Select
|
||||||
value={projectId ?? ''}
|
value={projectId ?? ''}
|
||||||
onChange={(next) => setProjectId((next as ProjectId) || null)}
|
onChange={(next) => setProjectId((next as ProjectId) || null)}
|
||||||
disabled={projects.length === 0}
|
disabled={projects.length === 0 || saving}
|
||||||
ariaLabel={t('export.project')}
|
ariaLabel={t('export.project')}
|
||||||
options={projects.map((p) => ({
|
options={projects.map((p) => ({
|
||||||
value: p.id,
|
value: p.id,
|
||||||
@@ -165,7 +231,7 @@ export function ExportProjectModal({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled || saving}
|
||||||
onChange={() => toggleKey(key, disabled)}
|
onChange={() => toggleKey(key, disabled)}
|
||||||
/>
|
/>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
@@ -180,34 +246,60 @@ export function ExportProjectModal({
|
|||||||
|
|
||||||
<div className={styles.muted}>{t('export.hint')}</div>
|
<div className={styles.muted}>{t('export.hint')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.fieldGrid}>
|
||||||
|
<div className={styles.muted}>{t('export.npcsHint')}</div>
|
||||||
|
<div className={styles.storylineChecklist}>
|
||||||
|
{npcs.map((n) => (
|
||||||
|
<label key={n.id} className={styles.storylineCheck}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedNpcIds.has(n.id)}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={() => toggleNpc(n.id)}
|
||||||
|
/>
|
||||||
|
<span>{n.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={saving || npcs.length === 0}
|
||||||
|
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
|
||||||
|
>
|
||||||
|
{t('export.selectAllNpcs')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||||
|
|
||||||
<div className={styles.modalFooter}>
|
<div className={styles.modalFooter}>
|
||||||
|
{step === 'npcs' ? (
|
||||||
|
<Button onClick={() => setStep('storylines')} disabled={saving}>
|
||||||
|
{t('export.back')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
|
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 'storylines' ? (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={!canExport || saving}
|
disabled={!canContinueStorylines || saving}
|
||||||
onClick={() => {
|
onClick={goNextFromStorylines}
|
||||||
if (!projectId || !canExport) return;
|
>
|
||||||
void (async () => {
|
{npcs.length > 0 ? t('export.next') : t('export.saveAs')}
|
||||||
setSaving(true);
|
</Button>
|
||||||
setError(null);
|
) : (
|
||||||
try {
|
<Button
|
||||||
await onExport(projectId, selectedSelections);
|
variant="primary"
|
||||||
onClose();
|
disabled={saving}
|
||||||
} catch (e) {
|
onClick={() => runExport([...selectedNpcIds])}
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('export.saveAs')}
|
{t('export.saveAs')}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>,
|
</>,
|
||||||
@@ -831,11 +923,10 @@ export function computeImportConflicts(
|
|||||||
export function buildNpcResolutionsForImport(
|
export function buildNpcResolutionsForImport(
|
||||||
_targetProject: Project,
|
_targetProject: Project,
|
||||||
sourceProject: Project,
|
sourceProject: Project,
|
||||||
selections: StorylineSelection[],
|
|
||||||
conflicts: NpcNameConflict[],
|
conflicts: NpcNameConflict[],
|
||||||
userResolutions: NpcImportResolution[],
|
userResolutions: NpcImportResolution[],
|
||||||
): NpcImportResolution[] {
|
): NpcImportResolution[] {
|
||||||
const exported = filterNpcsForStorylineExport(sourceProject, selections);
|
const exported = listExportedNpcsFromBundle(sourceProject);
|
||||||
const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId));
|
const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId));
|
||||||
const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r]));
|
const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r]));
|
||||||
const out: NpcImportResolution[] = [];
|
const out: NpcImportResolution[] = [];
|
||||||
@@ -853,9 +944,8 @@ export function buildNpcResolutionsForImport(
|
|||||||
export function computeNpcImportConflicts(
|
export function computeNpcImportConflicts(
|
||||||
targetProject: Project,
|
targetProject: Project,
|
||||||
sourceProject: Project,
|
sourceProject: Project,
|
||||||
selections: StorylineSelection[],
|
|
||||||
): NpcNameConflict[] {
|
): NpcNameConflict[] {
|
||||||
const exported = filterNpcsForStorylineExport(sourceProject, selections);
|
const exported = listExportedNpcsFromBundle(sourceProject);
|
||||||
return findNpcNameConflicts(
|
return findNpcNameConflicts(
|
||||||
targetProject,
|
targetProject,
|
||||||
sourceProject,
|
sourceProject,
|
||||||
|
|||||||
@@ -298,7 +298,13 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'export.title': 'Экспорт проекта',
|
'export.title': 'Экспорт проекта',
|
||||||
'export.project': 'ПРОЕКТ',
|
'export.project': 'ПРОЕКТ',
|
||||||
'export.hint':
|
'export.hint':
|
||||||
'Выберите сюжетные линии для экспорта. В архив попадут только выбранные линии, их сцены и материалы. Далее откроется окно сохранения .ttrpg.zip.',
|
'Выберите сюжетные линии для экспорта. В архив попадут выбранные линии, их сцены и материалы. Если в проекте есть НПС, на следующем шаге можно отметить, кого включить.',
|
||||||
|
'export.npcsTitle': 'Экспорт НПС',
|
||||||
|
'export.npcsHint':
|
||||||
|
'Отметьте НПС для экспорта. Вместе с ними попадут связи между отмеченными и группы этих персонажей.',
|
||||||
|
'export.selectAllNpcs': 'Отметить всех',
|
||||||
|
'export.next': 'Далее',
|
||||||
|
'export.back': 'Назад',
|
||||||
'export.exporting': 'Экспорт…',
|
'export.exporting': 'Экспорт…',
|
||||||
'export.saveAs': 'Сохранить как…',
|
'export.saveAs': 'Сохранить как…',
|
||||||
|
|
||||||
@@ -475,12 +481,6 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'Удалить группу «{name}»? НПС станут без группы, вложенные группы будут подняты на уровень выше.',
|
'Удалить группу «{name}»? НПС станут без группы, вложенные группы будут подняты на уровень выше.',
|
||||||
'npcs.addSubgroup': 'Добавить подгруппу',
|
'npcs.addSubgroup': 'Добавить подгруппу',
|
||||||
'npcs.group': 'ГРУППА',
|
'npcs.group': 'ГРУППА',
|
||||||
'npcs.bindingEnable': 'Привязать…',
|
|
||||||
'npcs.bindingKind': 'ТИП ПРИВЯЗКИ',
|
|
||||||
'npcs.bindingStoryline': 'Сюжетная линия',
|
|
||||||
'npcs.bindingScene': 'Сцена',
|
|
||||||
'npcs.bindingMain': 'Основная линия',
|
|
||||||
'npcs.bindingSelect': 'ОБЪЕКТ',
|
|
||||||
'npcs.graphFilterAll': 'Все',
|
'npcs.graphFilterAll': 'Все',
|
||||||
'npcs.graphFilterUngrouped': 'Без группы',
|
'npcs.graphFilterUngrouped': 'Без группы',
|
||||||
'npcs.graphFilter': 'Фильтр графа',
|
'npcs.graphFilter': 'Фильтр графа',
|
||||||
@@ -866,7 +866,13 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'export.title': 'Export project',
|
'export.title': 'Export project',
|
||||||
'export.project': 'PROJECT',
|
'export.project': 'PROJECT',
|
||||||
'export.hint':
|
'export.hint':
|
||||||
'Select storylines to export. The archive will include only the chosen lines, their scenes, and assets. Then choose where to save the .ttrpg.zip file.',
|
'Select storylines to export. The archive will include the chosen lines, their scenes, and assets. If the project has NPCs, the next step lets you choose which ones to include.',
|
||||||
|
'export.npcsTitle': 'Export NPCs',
|
||||||
|
'export.npcsHint':
|
||||||
|
'Select NPCs to export. Relations between selected NPCs and their groups are included.',
|
||||||
|
'export.selectAllNpcs': 'Select all',
|
||||||
|
'export.next': 'Next',
|
||||||
|
'export.back': 'Back',
|
||||||
'export.exporting': 'Exporting…',
|
'export.exporting': 'Exporting…',
|
||||||
'export.saveAs': 'Save as…',
|
'export.saveAs': 'Save as…',
|
||||||
|
|
||||||
@@ -1044,12 +1050,6 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'Delete group “{name}”? NPCs will become ungrouped; child groups will move up one level.',
|
'Delete group “{name}”? NPCs will become ungrouped; child groups will move up one level.',
|
||||||
'npcs.addSubgroup': 'Add subgroup',
|
'npcs.addSubgroup': 'Add subgroup',
|
||||||
'npcs.group': 'GROUP',
|
'npcs.group': 'GROUP',
|
||||||
'npcs.bindingEnable': 'Bind…',
|
|
||||||
'npcs.bindingKind': 'BINDING TYPE',
|
|
||||||
'npcs.bindingStoryline': 'Storyline',
|
|
||||||
'npcs.bindingScene': 'Scene',
|
|
||||||
'npcs.bindingMain': 'Main storyline',
|
|
||||||
'npcs.bindingSelect': 'TARGET',
|
|
||||||
'npcs.graphFilterAll': 'All',
|
'npcs.graphFilterAll': 'All',
|
||||||
'npcs.graphFilterUngrouped': 'Ungrouped',
|
'npcs.graphFilterUngrouped': 'Ungrouped',
|
||||||
'npcs.graphFilter': 'Graph filter',
|
'npcs.graphFilter': 'Graph filter',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
import { EditorApp } from './EditorApp';
|
import { EditorApp } from './EditorApp';
|
||||||
import { EditorI18nProvider } from './i18n/EditorI18nContext';
|
import { EditorI18nProvider } from './i18n/EditorI18nContext';
|
||||||
|
|
||||||
@@ -12,8 +13,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="Редактор">
|
||||||
<EditorI18nProvider>
|
<EditorI18nProvider>
|
||||||
<EditorApp />
|
<EditorApp />
|
||||||
</EditorI18nProvider>
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -148,10 +148,14 @@ type Actions = {
|
|||||||
npcResolutions?: NpcImportResolution[],
|
npcResolutions?: NpcImportResolution[],
|
||||||
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
|
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
|
||||||
importProjectFromPath: (filePath: string) => Promise<void>;
|
importProjectFromPath: (filePath: string) => Promise<void>;
|
||||||
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>;
|
getProjectStorylines: (
|
||||||
|
projectId: ProjectId,
|
||||||
|
labels: StorylineLabels,
|
||||||
|
) => Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }>;
|
||||||
exportProject: (
|
exportProject: (
|
||||||
projectId: ProjectId,
|
projectId: ProjectId,
|
||||||
storylineSelections: StorylineSelection[],
|
storylineSelections: StorylineSelection[],
|
||||||
|
npcIds: string[],
|
||||||
labels: StorylineLabels,
|
labels: StorylineLabels,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
deleteProject: (projectId: ProjectId) => Promise<void>;
|
deleteProject: (projectId: ProjectId) => Promise<void>;
|
||||||
@@ -869,18 +873,23 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
|
|
||||||
const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => {
|
const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => {
|
||||||
const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels });
|
const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels });
|
||||||
return res.storylines;
|
return {
|
||||||
|
storylines: Array.isArray(res?.storylines) ? res.storylines : [],
|
||||||
|
npcs: Array.isArray(res?.npcs) ? res.npcs : [],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportProject = async (
|
const exportProject = async (
|
||||||
projectId: ProjectId,
|
projectId: ProjectId,
|
||||||
storylineSelections: StorylineSelection[],
|
storylineSelections: StorylineSelection[],
|
||||||
|
npcIds: string[],
|
||||||
labels: StorylineLabels,
|
labels: StorylineLabels,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const res = await api.invoke(ipcChannels.project.exportZip, {
|
const res = await api.invoke(ipcChannels.project.exportZip, {
|
||||||
projectId,
|
projectId,
|
||||||
storylineSelections,
|
storylineSelections,
|
||||||
|
npcIds,
|
||||||
labels,
|
labels,
|
||||||
});
|
});
|
||||||
if (res.canceled) return;
|
if (res.canceled) return;
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import React, { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { isNpcBindingNone, listStorylineOptionsForBinding, noneBinding } from '../../shared/npcs/npcBinding';
|
|
||||||
import type { GraphNodeId, NpcBinding, Project, SceneId } from '../../shared/types';
|
|
||||||
import editorStyles from '../editor/EditorApp.module.css';
|
|
||||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
|
||||||
import { Select } from '../shared/ui/controls';
|
|
||||||
|
|
||||||
type NpcBindingFieldsProps = {
|
|
||||||
project: Project;
|
|
||||||
binding: NpcBinding;
|
|
||||||
onChange: (binding: NpcBinding) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function defaultBinding(project: Project): NpcBinding {
|
|
||||||
const opts = listStorylineOptionsForBinding(project);
|
|
||||||
if (opts.main) return { kind: 'storyline', storyline: { kind: 'main' } };
|
|
||||||
if (opts.sides[0]) {
|
|
||||||
return {
|
|
||||||
kind: 'storyline',
|
|
||||||
storyline: { kind: 'side', startGraphNodeId: opts.sides[0].startGraphNodeId },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const firstScene = Object.keys(project.scenes)[0] as SceneId | undefined;
|
|
||||||
if (firstScene) return { kind: 'scene', sceneId: firstScene };
|
|
||||||
return noneBinding();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NpcBindingFields({ project, binding, onChange }: NpcBindingFieldsProps) {
|
|
||||||
const { t } = useEditorI18n();
|
|
||||||
const enabled = !isNpcBindingNone(binding);
|
|
||||||
const storylineOpts = useMemo(() => listStorylineOptionsForBinding(project), [project]);
|
|
||||||
const sceneOptions = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.entries(project.scenes)
|
|
||||||
.map(([id, scene]) => ({ id: id as SceneId, title: scene.title.trim() || id }))
|
|
||||||
.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })),
|
|
||||||
[project.scenes],
|
|
||||||
);
|
|
||||||
|
|
||||||
const kind = binding.kind === 'none' ? 'storyline' : binding.kind;
|
|
||||||
|
|
||||||
const bindingTargetValue = useMemo(() => {
|
|
||||||
if (binding.kind === 'scene') return binding.sceneId;
|
|
||||||
if (binding.kind === 'storyline') {
|
|
||||||
if (binding.storyline.kind === 'main') return 'main';
|
|
||||||
return `side:${binding.storyline.startGraphNodeId}`;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}, [binding]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={editorStyles.fieldGrid}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enabled}
|
|
||||||
onChange={(e) => {
|
|
||||||
onChange(e.target.checked ? defaultBinding(project) : noneBinding());
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{t('npcs.bindingEnable')}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{enabled ? (
|
|
||||||
<>
|
|
||||||
<div className={editorStyles.fieldLabel}>{t('npcs.bindingKind')}</div>
|
|
||||||
<Select
|
|
||||||
value={kind}
|
|
||||||
ariaLabel={t('npcs.bindingKind')}
|
|
||||||
options={[
|
|
||||||
{ value: 'storyline', label: t('npcs.bindingStoryline') },
|
|
||||||
{ value: 'scene', label: t('npcs.bindingScene') },
|
|
||||||
]}
|
|
||||||
onChange={(nextKind) => {
|
|
||||||
if (nextKind === 'scene') {
|
|
||||||
const first = sceneOptions[0];
|
|
||||||
onChange(first ? { kind: 'scene', sceneId: first.id } : noneBinding());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (storylineOpts.main) {
|
|
||||||
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
|
|
||||||
} else if (storylineOpts.sides[0]) {
|
|
||||||
onChange({
|
|
||||||
kind: 'storyline',
|
|
||||||
storyline: { kind: 'side', startGraphNodeId: storylineOpts.sides[0].startGraphNodeId },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
onChange(noneBinding());
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={editorStyles.fieldLabel}>{t('npcs.bindingSelect')}</div>
|
|
||||||
<Select
|
|
||||||
value={bindingTargetValue}
|
|
||||||
ariaLabel={t('npcs.bindingSelect')}
|
|
||||||
options={
|
|
||||||
kind === 'storyline'
|
|
||||||
? [
|
|
||||||
...(storylineOpts.main
|
|
||||||
? [{ value: 'main', label: t('npcs.bindingMain') }]
|
|
||||||
: []),
|
|
||||||
...storylineOpts.sides.map((s) => ({
|
|
||||||
value: `side:${s.startGraphNodeId}`,
|
|
||||||
label: s.label,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
: sceneOptions.map((s) => ({ value: s.id, label: s.title }))
|
|
||||||
}
|
|
||||||
onChange={(v) => {
|
|
||||||
if (kind === 'scene') {
|
|
||||||
onChange({ kind: 'scene', sceneId: v as SceneId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (v === 'main') {
|
|
||||||
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (v.startsWith('side:')) {
|
|
||||||
onChange({
|
|
||||||
kind: 'storyline',
|
|
||||||
storyline: {
|
|
||||||
kind: 'side',
|
|
||||||
startGraphNodeId: v.slice(5) as GraphNodeId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,8 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import { createPortal, flushSync } from 'react-dom';
|
import { createPortal, flushSync } from 'react-dom';
|
||||||
|
|
||||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
import { noneBinding } from '../../shared/npcs/npcBinding';
|
|
||||||
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
|
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
|
||||||
import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
|
import type { NpcGroupId, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
|
||||||
import editorStyles from '../editor/EditorApp.module.css';
|
import editorStyles from '../editor/EditorApp.module.css';
|
||||||
import {
|
import {
|
||||||
filterMaterialImagePaths,
|
filterMaterialImagePaths,
|
||||||
@@ -18,8 +17,6 @@ import { getDndApi } from '../shared/dndApi';
|
|||||||
import { Button, Input, Select } from '../shared/ui/controls';
|
import { Button, Input, Select } from '../shared/ui/controls';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
|
|
||||||
import { NpcBindingFields } from './NpcBindingFields';
|
|
||||||
|
|
||||||
function normalizeName(input: string): string {
|
function normalizeName(input: string): string {
|
||||||
return input.trim().toLowerCase();
|
return input.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -41,7 +38,6 @@ type NpcEditModalProps = {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
initial: ProjectNpc | null;
|
initial: ProjectNpc | null;
|
||||||
existingNames: string[];
|
existingNames: string[];
|
||||||
project: Project | null;
|
|
||||||
npcGroups: ProjectNpcGroup[];
|
npcGroups: ProjectNpcGroup[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||||||
@@ -49,7 +45,6 @@ type NpcEditModalProps = {
|
|||||||
name: string;
|
name: string;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
groupId?: NpcGroupId | null;
|
groupId?: NpcGroupId | null;
|
||||||
binding?: NpcBinding;
|
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,7 +52,6 @@ export function NpcEditModal({
|
|||||||
open,
|
open,
|
||||||
initial,
|
initial,
|
||||||
existingNames,
|
existingNames,
|
||||||
project,
|
|
||||||
npcGroups,
|
npcGroups,
|
||||||
onClose,
|
onClose,
|
||||||
onPickImage,
|
onPickImage,
|
||||||
@@ -69,7 +63,6 @@ export function NpcEditModal({
|
|||||||
const [filePath, setFilePath] = useState<string | null>(null);
|
const [filePath, setFilePath] = useState<string | null>(null);
|
||||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||||
const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
|
const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
|
||||||
const [binding, setBinding] = useState<NpcBinding>(noneBinding());
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null);
|
const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -86,7 +79,6 @@ export function NpcEditModal({
|
|||||||
setFilePath(null);
|
setFilePath(null);
|
||||||
setLocalPreviewUrl(null);
|
setLocalPreviewUrl(null);
|
||||||
setGroupId(initial?.groupId ?? '');
|
setGroupId(initial?.groupId ?? '');
|
||||||
setBinding(initial?.binding ?? noneBinding());
|
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
setSaveProgress(null);
|
setSaveProgress(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -261,10 +253,6 @@ export function NpcEditModal({
|
|||||||
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
|
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{project && !initial ? (
|
|
||||||
<NpcBindingFields project={project} binding={binding} onChange={setBinding} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
||||||
|
|
||||||
<div className={editorStyles.modalFooter}>
|
<div className={editorStyles.modalFooter}>
|
||||||
@@ -286,7 +274,7 @@ export function NpcEditModal({
|
|||||||
await onSave({
|
await onSave({
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
...(filePath ? { filePath } : {}),
|
...(filePath ? { filePath } : {}),
|
||||||
...(!initial ? { groupId: groupId || null, binding } : {}),
|
...(!initial ? { groupId: groupId || null } : {}),
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createPortal } from 'react-dom';
|
|||||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||||
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
|
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
|
||||||
import type {
|
import type {
|
||||||
NpcBinding,
|
|
||||||
NpcGroupId,
|
NpcGroupId,
|
||||||
NpcId,
|
NpcId,
|
||||||
NpcRelationId,
|
NpcRelationId,
|
||||||
@@ -18,7 +17,6 @@ import { getDndApi } from '../shared/dndApi';
|
|||||||
import { Button, Input, Select } from '../shared/ui/controls';
|
import { Button, Input, Select } from '../shared/ui/controls';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
|
|
||||||
import { NpcBindingFields } from './NpcBindingFields';
|
|
||||||
import { NpcDescriptionField } from './NpcDescriptionField';
|
import { NpcDescriptionField } from './NpcDescriptionField';
|
||||||
import { NpcEditModal } from './NpcEditModal';
|
import { NpcEditModal } from './NpcEditModal';
|
||||||
import type { GraphGroupFilter } from './NpcGraph';
|
import type { GraphGroupFilter } from './NpcGraph';
|
||||||
@@ -822,20 +820,6 @@ export function NpcsEditorApp() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className={styles.fieldLabel}>{t('npcs.bindingEnable')}</div>
|
|
||||||
<NpcBindingFields
|
|
||||||
project={project}
|
|
||||||
binding={selected.binding}
|
|
||||||
onChange={(binding: NpcBinding) => {
|
|
||||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
|
||||||
npcId: selected.id,
|
|
||||||
binding,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{relationsForSelected.length > 0 ? (
|
{relationsForSelected.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
|
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
|
||||||
@@ -860,7 +844,6 @@ export function NpcsEditorApp() {
|
|||||||
open={editOpen}
|
open={editOpen}
|
||||||
initial={editInitial}
|
initial={editInitial}
|
||||||
existingNames={npcs.map((n) => n.name)}
|
existingNames={npcs.map((n) => n.name)}
|
||||||
project={project}
|
|
||||||
npcGroups={npcGroups}
|
npcGroups={npcGroups}
|
||||||
onClose={() => setEditOpen(false)}
|
onClose={() => setEditOpen(false)}
|
||||||
onPickImage={pickAvatar}
|
onPickImage={pickAvatar}
|
||||||
@@ -870,7 +853,6 @@ export function NpcsEditorApp() {
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
...(input.filePath ? { filePath: input.filePath } : {}),
|
...(input.filePath ? { filePath: input.filePath } : {}),
|
||||||
...(input.groupId !== undefined ? { groupId: input.groupId } : {}),
|
...(input.groupId !== undefined ? { groupId: input.groupId } : {}),
|
||||||
...(input.binding !== undefined ? { binding: input.binding } : {}),
|
|
||||||
});
|
});
|
||||||
const created = res.project.npcs.find((n) => n.name === input.name.trim());
|
const created = res.project.npcs.find((n) => n.name === input.name.trim());
|
||||||
if (created) setSelectedId(created.id);
|
if (created) setSelectedId(created.id);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const rendererRoot = path.resolve(here, '../..');
|
const rendererRoot = path.resolve(here, '../..');
|
||||||
|
|
||||||
const SECONDARY_WINDOW_MAINS = [
|
const SECONDARY_WINDOW_MAINS = [
|
||||||
|
'editor/main.tsx',
|
||||||
'npcs/npcsEditorMain.tsx',
|
'npcs/npcsEditorMain.tsx',
|
||||||
'npcs/npcsMain.tsx',
|
'npcs/npcsMain.tsx',
|
||||||
'materials/main.tsx',
|
'materials/main.tsx',
|
||||||
@@ -45,6 +46,24 @@ void test('NpcsEditorApp: no undefined controlStyles (inspector crash)', () => {
|
|||||||
assert.doesNotMatch(src, /controlStyles/);
|
assert.doesNotMatch(src, /controlStyles/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('NPC binding removed from editor UI', () => {
|
||||||
|
const editor = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
|
||||||
|
const modal = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcEditModal.tsx'), 'utf8');
|
||||||
|
assert.doesNotMatch(editor, /NpcBindingFields|binding/);
|
||||||
|
assert.doesNotMatch(modal, /NpcBindingFields|binding|noneBinding/);
|
||||||
|
assert.ok(!fs.existsSync(path.join(rendererRoot, 'npcs/NpcBindingFields.tsx')));
|
||||||
|
assert.ok(!fs.existsSync(path.join(rendererRoot, '../shared/npcs/npcBinding.ts')));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('storyline export modal: NPC selection step', () => {
|
||||||
|
const src = fs.readFileSync(path.join(rendererRoot, 'editor/StorylineTransferModals.tsx'), 'utf8');
|
||||||
|
assert.match(src, /step === 'npcs'/);
|
||||||
|
assert.match(src, /export\.selectAllNpcs/);
|
||||||
|
assert.match(src, /npcIds/);
|
||||||
|
assert.match(src, /Array\.isArray\(res\?\.npcs\)/);
|
||||||
|
assert.doesNotMatch(src, /filterNpcsForStorylineExport/);
|
||||||
|
});
|
||||||
|
|
||||||
void test('WindowErrorBoundary component exists and catches errors', () => {
|
void test('WindowErrorBoundary component exists and catches errors', () => {
|
||||||
const src = fs.readFileSync(path.join(here, 'WindowErrorBoundary.tsx'), 'utf8');
|
const src = fs.readFileSync(path.join(here, 'WindowErrorBoundary.tsx'), 'utf8');
|
||||||
assert.ok(src.includes('getDerivedStateFromError'));
|
assert.ok(src.includes('getDerivedStateFromError'));
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import type { Project, Scene, SceneGraphEdge, SceneGraphNode } from '../types';
|
import type { Project, ProjectNpc, ProjectNpcGroup, ProjectNpcRelation, Scene, SceneGraphEdge, SceneGraphNode } from '../types';
|
||||||
import { asAssetId, asGraphNodeId, asProjectId, asSceneId } from '../types/ids';
|
import {
|
||||||
|
asAssetId,
|
||||||
|
asGraphNodeId,
|
||||||
|
asNpcGroupId,
|
||||||
|
asNpcId,
|
||||||
|
asNpcRelationId,
|
||||||
|
asProjectId,
|
||||||
|
asSceneId,
|
||||||
|
} from '../types/ids';
|
||||||
|
import { PROJECT_SCHEMA_VERSION } from '../types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildPartialExportProject,
|
buildPartialExportProject,
|
||||||
collectStorylineGraphNodeIds,
|
collectStorylineGraphNodeIds,
|
||||||
computeGraphImportOffsetX,
|
computeGraphImportOffsetX,
|
||||||
findSceneTitleConflicts,
|
findSceneTitleConflicts,
|
||||||
|
listExportedNpcsFromBundle,
|
||||||
listImportableStorylines,
|
listImportableStorylines,
|
||||||
mergeStorylinesIntoProject,
|
mergeStorylinesIntoProject,
|
||||||
newExportBundleProjectId,
|
newExportBundleProjectId,
|
||||||
|
selectNpcsByIds,
|
||||||
} from './storylineExportImport';
|
} from './storylineExportImport';
|
||||||
|
|
||||||
const LABELS = { main: 'Основная линия', untitled: 'Без названия' };
|
const LABELS = { main: 'Основная линия', untitled: 'Без названия' };
|
||||||
@@ -64,7 +75,7 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
|
|||||||
updatedAt: '2020-01-01T00:00:00.000Z',
|
updatedAt: '2020-01-01T00:00:00.000Z',
|
||||||
createdWithAppVersion: '1',
|
createdWithAppVersion: '1',
|
||||||
appVersion: '1',
|
appVersion: '1',
|
||||||
schemaVersion: 9,
|
schemaVersion: PROJECT_SCHEMA_VERSION,
|
||||||
},
|
},
|
||||||
scenes: {},
|
scenes: {},
|
||||||
sceneListOrder: [],
|
sceneListOrder: [],
|
||||||
@@ -210,6 +221,147 @@ void test('mergeStorylinesIntoProject: offset X, create scene, rename side title
|
|||||||
assert.equal(report.edgesAdded, 1);
|
assert.equal(report.edgesAdded, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations, ancestor groups', () => {
|
||||||
|
const gRoot = asNpcGroupId('g_root');
|
||||||
|
const gChild = asNpcGroupId('g_child');
|
||||||
|
const n1 = asNpcId('npc1');
|
||||||
|
const n2 = asNpcId('npc2');
|
||||||
|
const n3 = asNpcId('npc3');
|
||||||
|
const avatar = asAssetId('a1');
|
||||||
|
const groups: ProjectNpcGroup[] = [
|
||||||
|
{ id: gRoot, name: 'Root', color: '#ffffff', parentId: null },
|
||||||
|
{ id: gChild, name: 'Child', color: '#ff0000', parentId: gRoot },
|
||||||
|
{ id: asNpcGroupId('g_other'), name: 'Other', color: '#00ff00', parentId: null },
|
||||||
|
];
|
||||||
|
const npcs: ProjectNpc[] = [
|
||||||
|
{
|
||||||
|
id: n1,
|
||||||
|
name: 'A',
|
||||||
|
avatarAssetId: avatar,
|
||||||
|
description: '',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
groupId: gChild,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: n2,
|
||||||
|
name: 'B',
|
||||||
|
avatarAssetId: avatar,
|
||||||
|
description: '',
|
||||||
|
x: 10,
|
||||||
|
y: 0,
|
||||||
|
groupId: gChild,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: n3,
|
||||||
|
name: 'C',
|
||||||
|
avatarAssetId: avatar,
|
||||||
|
description: '',
|
||||||
|
x: 20,
|
||||||
|
y: 0,
|
||||||
|
groupId: asNpcGroupId('g_other'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const relations: ProjectNpcRelation[] = [
|
||||||
|
{ id: asNpcRelationId('r1'), sourceNpcId: n1, targetNpcId: n2, label: 'friends' },
|
||||||
|
{ id: asNpcRelationId('r2'), sourceNpcId: n1, targetNpcId: n3, label: 'rivals' },
|
||||||
|
];
|
||||||
|
const source = minimalProject({
|
||||||
|
scenes: { [asSceneId('s1')]: scene('s1', 'Start') },
|
||||||
|
sceneGraphNodes: [node('main', 's1', { isStartScene: true })],
|
||||||
|
assets: {
|
||||||
|
[avatar]: {
|
||||||
|
id: avatar,
|
||||||
|
type: 'image',
|
||||||
|
mime: 'image/png',
|
||||||
|
originalName: 'a.png',
|
||||||
|
relPath: 'assets/a.png',
|
||||||
|
sha256: 'abc',
|
||||||
|
sizeBytes: 1,
|
||||||
|
createdAt: '2020-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
npcs,
|
||||||
|
npcGroups: groups,
|
||||||
|
npcRelations: relations,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
selectNpcsByIds(source, [n1, n2]).map((n) => n.id),
|
||||||
|
[n1, n2],
|
||||||
|
);
|
||||||
|
|
||||||
|
const partial = buildPartialExportProject(source, [{ kind: 'main' }], {
|
||||||
|
newProjectId: newExportBundleProjectId(),
|
||||||
|
exportTitle: 'Export',
|
||||||
|
labels: LABELS,
|
||||||
|
npcIds: [n1, n2],
|
||||||
|
});
|
||||||
|
assert.equal(partial.npcs.length, 2);
|
||||||
|
assert.ok(partial.npcs.every((n) => n.id === n1 || n.id === n2));
|
||||||
|
assert.equal(partial.npcRelations.length, 1);
|
||||||
|
assert.equal(partial.npcRelations[0]!.label, 'friends');
|
||||||
|
assert.deepEqual(
|
||||||
|
[...partial.npcGroups.map((g) => g.id)].sort(),
|
||||||
|
[gRoot, gChild].sort(),
|
||||||
|
);
|
||||||
|
assert.ok(!partial.npcGroups.some((g) => g.id === asNpcGroupId('g_other')));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', () => {
|
||||||
|
const avatar = asAssetId('a1');
|
||||||
|
const sourceNpcId = asNpcId('src_npc');
|
||||||
|
const source = minimalProject({
|
||||||
|
scenes: { [asSceneId('s1')]: scene('s1', 'Side') },
|
||||||
|
sceneGraphNodes: [node('side', 's1', { isSideStoryStart: true, sideStoryLineTitle: 'Side', x: 0 })],
|
||||||
|
assets: {
|
||||||
|
[avatar]: {
|
||||||
|
id: avatar,
|
||||||
|
type: 'image',
|
||||||
|
mime: 'image/png',
|
||||||
|
originalName: 'a.png',
|
||||||
|
relPath: 'assets/a.png',
|
||||||
|
sha256: 'npcsha',
|
||||||
|
sizeBytes: 1,
|
||||||
|
createdAt: '2020-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
npcs: [
|
||||||
|
{
|
||||||
|
id: sourceNpcId,
|
||||||
|
name: 'Hero',
|
||||||
|
avatarAssetId: avatar,
|
||||||
|
description: '',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
groupId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const target = minimalProject({
|
||||||
|
scenes: { [asSceneId('t1')]: scene('t1', 'Existing') },
|
||||||
|
sceneGraphNodes: [node('tgn', 't1', { x: 0 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(listExportedNpcsFromBundle(source).length, 1);
|
||||||
|
|
||||||
|
const { project: merged, report } = mergeStorylinesIntoProject(
|
||||||
|
target,
|
||||||
|
source,
|
||||||
|
[{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }],
|
||||||
|
[{ sourceSceneId: asSceneId('s1'), mode: 'create' }],
|
||||||
|
{
|
||||||
|
graphOffsetX: 200,
|
||||||
|
npcResolutions: [{ sourceNpcId, mode: 'create' }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(report.npcsCreated, 1);
|
||||||
|
assert.equal(merged.npcs.length, 1);
|
||||||
|
assert.equal(merged.npcs[0]!.name, 'Hero');
|
||||||
|
assert.ok(!('binding' in (merged.npcs[0] as object)));
|
||||||
|
});
|
||||||
|
|
||||||
void test('mergeStorylinesIntoProject: reuse existing scene by resolution', () => {
|
void test('mergeStorylinesIntoProject: reuse existing scene by resolution', () => {
|
||||||
const targetSceneId = asSceneId('t1');
|
const targetSceneId = asSceneId('t1');
|
||||||
const target = minimalProject({
|
const target = minimalProject({
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { noneBinding } from '../npcs/npcBinding';
|
|
||||||
import type {
|
import type {
|
||||||
ExportedStorylineRef,
|
ExportedStorylineRef,
|
||||||
GraphNodeId,
|
GraphNodeId,
|
||||||
NpcBinding,
|
|
||||||
Project,
|
Project,
|
||||||
ProjectNpc,
|
ProjectNpc,
|
||||||
ProjectNpcGroup,
|
ProjectNpcGroup,
|
||||||
@@ -241,6 +239,8 @@ export function buildPartialExportProject(
|
|||||||
newProjectId: ProjectId;
|
newProjectId: ProjectId;
|
||||||
exportTitle: string;
|
exportTitle: string;
|
||||||
labels: StorylineLabels;
|
labels: StorylineLabels;
|
||||||
|
/** Явный список НПС; пустой — без НПС в архиве. */
|
||||||
|
npcIds?: readonly string[];
|
||||||
},
|
},
|
||||||
): Project {
|
): Project {
|
||||||
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
|
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
|
||||||
@@ -278,7 +278,7 @@ export function buildPartialExportProject(
|
|||||||
const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges);
|
const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges);
|
||||||
draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) };
|
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 exportedNpcIds = new Set(exportedNpcs.map((n) => n.id));
|
||||||
const exportedRelations = (source.npcRelations ?? []).filter(
|
const exportedRelations = (source.npcRelations ?? []).filter(
|
||||||
(r) => exportedNpcIds.has(r.sourceNpcId) && exportedNpcIds.has(r.targetNpcId),
|
(r) => exportedNpcIds.has(r.sourceNpcId) && exportedNpcIds.has(r.targetNpcId),
|
||||||
@@ -312,33 +312,16 @@ export function buildPartialExportProject(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** НПС для экспорта выбранных линий (main + unbound; side только свои). */
|
/** НПС по явному списку id (порядок — как в проекте). */
|
||||||
export function filterNpcsForStorylineExport(
|
export function selectNpcsByIds(project: Project, npcIds: readonly string[]): ProjectNpc[] {
|
||||||
project: Project,
|
if (npcIds.length === 0) return [];
|
||||||
selections: StorylineSelection[],
|
const wanted = new Set(npcIds);
|
||||||
): ProjectNpc[] {
|
return (project.npcs ?? []).filter((n) => wanted.has(n.id));
|
||||||
const npcs = project.npcs ?? [];
|
|
||||||
if (selections.length === 0) return [];
|
|
||||||
|
|
||||||
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> {
|
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 au of source.campaignAudios) neededAssetIds.add(au.assetId);
|
||||||
for (const m of source.materials ?? []) neededAssetIds.add(m.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) {
|
for (const n of exportedNpcs) {
|
||||||
const res = npcResolutionBySource.get(n.id);
|
const res = npcResolutionBySource.get(n.id);
|
||||||
if (res?.mode === 'use') continue;
|
if (res?.mode === 'use') continue;
|
||||||
@@ -695,7 +678,6 @@ export function mergeStorylinesIntoProject(
|
|||||||
const mappedGroupId = n.groupId
|
const mappedGroupId = n.groupId
|
||||||
? (asNpcGroupId(groupIdMap.get(n.groupId) ?? n.groupId) as typeof n.groupId)
|
? (asNpcGroupId(groupIdMap.get(n.groupId) ?? n.groupId) as typeof n.groupId)
|
||||||
: null;
|
: null;
|
||||||
const binding = remapNpcBinding(n.binding ?? noneBinding(), sceneIdMap, graphNodeIdMap);
|
|
||||||
npcs.push({
|
npcs.push({
|
||||||
id: newId,
|
id: newId,
|
||||||
name,
|
name,
|
||||||
@@ -704,7 +686,6 @@ export function mergeStorylinesIntoProject(
|
|||||||
x: n.x,
|
x: n.x,
|
||||||
y: n.y,
|
y: n.y,
|
||||||
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
|
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
|
||||||
binding,
|
|
||||||
});
|
});
|
||||||
npcNameKeys.add(name.toLowerCase());
|
npcNameKeys.add(name.toLowerCase());
|
||||||
npcsCreated += 1;
|
npcsCreated += 1;
|
||||||
@@ -791,25 +772,6 @@ function topologicalNpcGroups(groups: ProjectNpcGroup[]): ProjectNpcGroup[] {
|
|||||||
return out;
|
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 {
|
export function newExportBundleProjectId(): ProjectId {
|
||||||
return asProjectId(`p_${generateId()}`);
|
return asProjectId(`p_${generateId()}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type {
|
|||||||
NpcId,
|
NpcId,
|
||||||
NpcRelationId,
|
NpcRelationId,
|
||||||
NpcGroupId,
|
NpcGroupId,
|
||||||
NpcBinding,
|
|
||||||
NpcsOverlayEvent,
|
NpcsOverlayEvent,
|
||||||
NpcsOverlayState,
|
NpcsOverlayState,
|
||||||
Project,
|
Project,
|
||||||
@@ -369,7 +368,6 @@ export type IpcInvokeMap = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
groupId?: NpcGroupId | null;
|
groupId?: NpcGroupId | null;
|
||||||
binding?: NpcBinding;
|
|
||||||
};
|
};
|
||||||
res: { project: Project };
|
res: { project: Project };
|
||||||
};
|
};
|
||||||
@@ -379,7 +377,6 @@ export type IpcInvokeMap = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
groupId?: NpcGroupId | null;
|
groupId?: NpcGroupId | null;
|
||||||
binding?: NpcBinding;
|
|
||||||
};
|
};
|
||||||
res: { project: Project };
|
res: { project: Project };
|
||||||
};
|
};
|
||||||
@@ -493,7 +490,10 @@ export type IpcInvokeMap = {
|
|||||||
};
|
};
|
||||||
[ipcChannels.project.getProjectStorylines]: {
|
[ipcChannels.project.getProjectStorylines]: {
|
||||||
req: { projectId: ProjectId; labels: StorylineLabels };
|
req: { projectId: ProjectId; labels: StorylineLabels };
|
||||||
res: { storylines: StorylineListItem[] };
|
res: {
|
||||||
|
storylines: StorylineListItem[];
|
||||||
|
npcs: { id: string; name: string }[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
[ipcChannels.project.peekImportZip]: {
|
[ipcChannels.project.peekImportZip]: {
|
||||||
req: { labels: StorylineLabels; targetHasMainStart: boolean };
|
req: { labels: StorylineLabels; targetHasMainStart: boolean };
|
||||||
@@ -560,7 +560,12 @@ export type IpcInvokeMap = {
|
|||||||
res: { project: Project };
|
res: { project: Project };
|
||||||
};
|
};
|
||||||
[ipcChannels.project.exportZip]: {
|
[ipcChannels.project.exportZip]: {
|
||||||
req: { projectId: ProjectId; storylineSelections: StorylineSelection[]; labels: StorylineLabels };
|
req: {
|
||||||
|
projectId: ProjectId;
|
||||||
|
storylineSelections: StorylineSelection[];
|
||||||
|
npcIds: string[];
|
||||||
|
labels: StorylineLabels;
|
||||||
|
};
|
||||||
res: { canceled: true } | { canceled: false };
|
res: { canceled: true } | { canceled: false };
|
||||||
};
|
};
|
||||||
[ipcChannels.project.deleteProject]: {
|
[ipcChannels.project.deleteProject]: {
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import { listSideStoryStarts } from '../graph/sceneGraphLineage';
|
|
||||||
import type { GraphNodeId, NpcBinding, NpcStorylineRef, Project, ProjectNpc, SceneId } from '../types';
|
|
||||||
|
|
||||||
export function noneBinding(): NpcBinding {
|
|
||||||
return { kind: 'none' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isNpcBindingNone(b: NpcBinding | undefined | null): boolean {
|
|
||||||
return !b || b.kind === 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function storylineRefKey(ref: NpcStorylineRef): string {
|
|
||||||
return ref.kind === 'main' ? 'main' : `side:${ref.startGraphNodeId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeNpcBinding(
|
|
||||||
raw: unknown,
|
|
||||||
ctx: { sceneIds: Set<SceneId>; sideStartIds: Set<GraphNodeId>; hasMainStart: boolean },
|
|
||||||
): NpcBinding {
|
|
||||||
if (!raw || typeof raw !== 'object') return noneBinding();
|
|
||||||
const obj = raw as {
|
|
||||||
kind?: string;
|
|
||||||
sceneId?: string;
|
|
||||||
storyline?: { kind?: string; startGraphNodeId?: string };
|
|
||||||
};
|
|
||||||
if (obj.kind === 'scene' && typeof obj.sceneId === 'string') {
|
|
||||||
const sceneId = obj.sceneId as SceneId;
|
|
||||||
if (ctx.sceneIds.has(sceneId)) return { kind: 'scene', sceneId };
|
|
||||||
return noneBinding();
|
|
||||||
}
|
|
||||||
if (obj.kind === 'storyline' && obj.storyline && typeof obj.storyline === 'object') {
|
|
||||||
if (obj.storyline.kind === 'main') {
|
|
||||||
return ctx.hasMainStart ? { kind: 'storyline', storyline: { kind: 'main' } } : noneBinding();
|
|
||||||
}
|
|
||||||
if (obj.storyline.kind === 'side' && typeof obj.storyline.startGraphNodeId === 'string') {
|
|
||||||
const id = obj.storyline.startGraphNodeId as GraphNodeId;
|
|
||||||
if (ctx.sideStartIds.has(id)) {
|
|
||||||
return { kind: 'storyline', storyline: { kind: 'side', startGraphNodeId: id } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return noneBinding();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearNpcBindingsForDeletedScene(npcs: ProjectNpc[], sceneId: SceneId): ProjectNpc[] {
|
|
||||||
return npcs.map((n) => {
|
|
||||||
if (n.binding?.kind === 'scene' && n.binding.sceneId === sceneId) {
|
|
||||||
return { ...n, binding: noneBinding() };
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearNpcBindingsForRemovedStoryline(
|
|
||||||
npcs: ProjectNpc[],
|
|
||||||
removed: NpcStorylineRef,
|
|
||||||
): ProjectNpc[] {
|
|
||||||
return npcs.map((n) => {
|
|
||||||
if (n.binding?.kind !== 'storyline') return n;
|
|
||||||
const ref = n.binding.storyline;
|
|
||||||
if (removed.kind === 'main' && ref.kind === 'main') return { ...n, binding: noneBinding() };
|
|
||||||
if (removed.kind === 'side' && ref.kind === 'side' && ref.startGraphNodeId === removed.startGraphNodeId) {
|
|
||||||
return { ...n, binding: noneBinding() };
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listStorylineOptionsForBinding(project: Project): {
|
|
||||||
main: boolean;
|
|
||||||
sides: { startGraphNodeId: GraphNodeId; label: string }[];
|
|
||||||
} {
|
|
||||||
const hasMain = project.sceneGraphNodes.some((n) => n.isStartScene);
|
|
||||||
const sides = listSideStoryStarts(project.sceneGraphNodes).map((n) => {
|
|
||||||
const scene = project.scenes[n.sceneId];
|
|
||||||
const label = n.sideStoryLineTitle.trim() || scene?.title.trim() || String(n.id);
|
|
||||||
return { startGraphNodeId: n.id, label };
|
|
||||||
});
|
|
||||||
return { main: hasMain, sides };
|
|
||||||
}
|
|
||||||
@@ -34,17 +34,6 @@ export type ProjectNpcGroup = {
|
|||||||
parentId: NpcGroupId | null;
|
parentId: NpcGroupId | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Привязка НПС к сюжетной линии. */
|
|
||||||
export type NpcStorylineRef =
|
|
||||||
| { kind: 'main' }
|
|
||||||
| { kind: 'side'; startGraphNodeId: GraphNodeId };
|
|
||||||
|
|
||||||
/** Привязка НПС к линии или сцене; `none` — без привязки. */
|
|
||||||
export type NpcBinding =
|
|
||||||
| { kind: 'none' }
|
|
||||||
| { kind: 'storyline'; storyline: NpcStorylineRef }
|
|
||||||
| { kind: 'scene'; sceneId: SceneId };
|
|
||||||
|
|
||||||
/** НПС кампании: персонаж с аватаром, описанием и связями на графе. */
|
/** НПС кампании: персонаж с аватаром, описанием и связями на графе. */
|
||||||
export type ProjectNpc = {
|
export type ProjectNpc = {
|
||||||
id: NpcId;
|
id: NpcId;
|
||||||
@@ -57,7 +46,6 @@ export type ProjectNpc = {
|
|||||||
y: number;
|
y: number;
|
||||||
/** `null` — системная секция «Без группы». */
|
/** `null` — системная секция «Без группы». */
|
||||||
groupId: NpcGroupId | null;
|
groupId: NpcGroupId | null;
|
||||||
binding: NpcBinding;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */
|
/** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */
|
||||||
|
|||||||
Reference in New Issue
Block a user