feat(npcs): add campaign NPCs with relation graph and session overlay

Add a dedicated NPC editor window, directed relations, control/presentation avatar overlay, and ru/en help for the new section.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-17 13:13:58 +08:00
parent 61875be857
commit 37ba855faf
40 changed files with 3655 additions and 17 deletions
+67 -1
View File
@@ -14,7 +14,15 @@ import type {
SceneId,
} from '../types';
import type { AssetId, ProjectId } from '../types/ids';
import { asAssetId, asGraphNodeId, asMaterialId, asProjectId, asSceneId } from '../types/ids';
import {
asAssetId,
asGraphNodeId,
asMaterialId,
asNpcId,
asNpcRelationId,
asProjectId,
asSceneId,
} from '../types/ids';
export type StorylineKind = 'main' | 'side';
@@ -274,6 +282,8 @@ export function buildPartialExportProject(
assets,
campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
materials: (source.materials ?? []).map((m) => ({ ...m })),
npcs: (source.npcs ?? []).map((n) => ({ ...n })),
npcRelations: (source.npcRelations ?? []).map((r) => ({ ...r })),
currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null,
currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null,
};
@@ -289,6 +299,7 @@ function collectReferencedAssetIdsForProject(p: Project): Set<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;
}
@@ -376,6 +387,7 @@ export function mergeStorylinesIntoProject(
}
for (const au of source.campaignAudios) neededAssetIds.add(au.assetId);
for (const m of source.materials ?? []) neededAssetIds.add(m.assetId);
for (const n of source.npcs ?? []) neededAssetIds.add(n.avatarAssetId);
const assetMap = new Map<AssetId, AssetId>();
const targetSha = new Map<string, AssetId>();
@@ -509,12 +521,66 @@ export function mergeStorylinesIntoProject(
materialAssetIds.add(mapped);
}
const npcs = [...(target.npcs ?? [])];
const npcNameKeys = new Set(npcs.map((n) => n.name.trim().toLowerCase()));
const npcAssetIds = new Set(npcs.map((n) => n.avatarAssetId));
const npcIdMap = new Map<string, string>();
for (const n of source.npcs ?? []) {
const mappedAvatar = assetMap.get(n.avatarAssetId) ?? n.avatarAssetId;
const existingByAsset = npcs.find((x) => x.avatarAssetId === mappedAvatar);
if (existingByAsset || npcAssetIds.has(mappedAvatar)) {
if (existingByAsset) npcIdMap.set(n.id, existingByAsset.id);
continue;
}
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);
npcs.push({
id: newId,
name,
avatarAssetId: mappedAvatar,
description: n.description ?? '',
x: n.x,
y: n.y,
});
npcNameKeys.add(name.toLowerCase());
npcAssetIds.add(mappedAvatar);
}
const npcRelations = [...(target.npcRelations ?? [])];
for (const r of source.npcRelations ?? []) {
const sourceId = npcIdMap.get(r.sourceNpcId) ?? r.sourceNpcId;
const targetId = npcIdMap.get(r.targetNpcId) ?? r.targetNpcId;
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,
npcRelations,
sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes],
sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges],
};