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
+288 -2
View File
@@ -37,14 +37,23 @@ import type {
MediaAssetType,
Project,
ProjectId,
ProjectNpc,
ProjectNpcRelation,
Scene,
SceneGraphEdge,
SceneGraphNode,
SceneId,
} from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import type { AssetId, GraphNodeId, MaterialId } from '../../shared/types/ids';
import { asAssetId, asGraphNodeId, asMaterialId, asProjectId } from '../../shared/types/ids';
import type { AssetId, GraphNodeId, MaterialId, NpcId, NpcRelationId } from '../../shared/types/ids';
import {
asAssetId,
asGraphNodeId,
asMaterialId,
asNpcId,
asNpcRelationId,
asProjectId,
} from '../../shared/types/ids';
import { getAppSemanticVersion } from '../versionInfo';
import { reconcileAssetFiles } from './assetPrune';
@@ -226,6 +235,8 @@ export class ZipProjectStore {
assets: {},
campaignAudios: [],
materials: [],
npcs: [],
npcRelations: [],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
@@ -1134,6 +1145,225 @@ export class ZipProjectStore {
return latest;
}
/**
* Создаёт или обновляет НПС.
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
*/
async upsertNpc(input: {
npcId?: NpcId;
name: string;
description?: string;
filePath?: string;
}): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const name = input.name.trim();
if (name.length < 1) throw new Error('NPC name is required');
const nameKey = name.toLowerCase();
const existing = open.project.npcs ?? [];
const editingId = input.npcId ?? null;
if (existing.some((n) => n.id !== editingId && n.name.trim().toLowerCase() === nameKey)) {
throw new Error('NPC name already exists');
}
let nextAssetId: AssetId | null = null;
let stagedAsset: MediaAsset | null = null;
if (input.filePath) {
const kind = classifyMediaPath(input.filePath);
if (kind?.type !== 'image') throw new Error('NPC avatar must be an image (png/jpg/webp)');
const ext = path.extname(input.filePath).toLowerCase();
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
throw new Error('NPC avatar must be an image (png/jpg/webp)');
}
let buf = await fs.readFile(input.filePath);
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
// keep original buffer
}
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId());
const orig = path.basename(input.filePath);
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, buf);
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
nextAssetId = id;
}
await this.updateProject((p) => {
const npcs = [...(p.npcs ?? [])];
const assets = { ...p.assets };
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
if (editingId) {
const idx = npcs.findIndex((n) => n.id === editingId);
if (idx < 0) throw new Error('NPC not found');
const prev = npcs[idx]!;
npcs[idx] = {
...prev,
name,
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
description:
typeof input.description === 'string' ? input.description : prev.description,
};
} else {
if (!nextAssetId) throw new Error('NPC avatar is required');
const count = npcs.length;
npcs.push({
id: asNpcId(`npc_${this.randomId()}`),
name,
avatarAssetId: nextAssetId,
description: typeof input.description === 'string' ? input.description : '',
x: 80 + (count % 4) * 220,
y: 80 + Math.floor(count / 4) * 200,
});
}
return { ...p, assets, npcs };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async updateNpcFields(
npcId: NpcId,
patch: { name?: string; description?: string },
): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const name =
typeof patch.name === 'string' ? patch.name.trim() : undefined;
if (name !== undefined) {
if (name.length < 1) throw new Error('NPC name is required');
const nameKey = name.toLowerCase();
if (
(open.project.npcs ?? []).some(
(n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey,
)
) {
throw new Error('NPC name already exists');
}
}
await this.updateProject((p) => {
const npcs = (p.npcs ?? []).map((n) => {
if (n.id !== npcId) return n;
return {
...n,
...(name !== undefined ? { name } : {}),
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
};
});
return { ...p, npcs };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async updateNpcPosition(npcId: NpcId, x: number, y: number): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
npcs: (p.npcs ?? []).map((n) => (n.id === npcId ? { ...n, x, y } : n)),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async deleteNpc(npcId: NpcId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
npcRelations: (p.npcRelations ?? []).filter(
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setNpcsOrder(npcIds: NpcId[]): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const byId = new Map((p.npcs ?? []).map((n) => [n.id, n]));
const next: ProjectNpc[] = [];
for (const id of npcIds) {
const n = byId.get(id);
if (n) {
next.push(n);
byId.delete(id);
}
}
for (const n of byId.values()) next.push(n);
return { ...p, npcs: next };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async upsertNpcRelation(input: {
relationId?: NpcRelationId;
sourceNpcId: NpcId;
targetNpcId: NpcId;
label: string;
}): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const label = input.label.trim();
if (label.length < 1) throw new Error('Relation label is required');
if (input.sourceNpcId === input.targetNpcId) throw new Error('Cannot relate NPC to itself');
const npcs = open.project.npcs ?? [];
if (
!npcs.some((n) => n.id === input.sourceNpcId) ||
!npcs.some((n) => n.id === input.targetNpcId)
) {
throw new Error('NPC not found');
}
await this.updateProject((p) => {
const relations = [...(p.npcRelations ?? [])];
if (input.relationId) {
const idx = relations.findIndex((r) => r.id === input.relationId);
if (idx < 0) throw new Error('Relation not found');
relations[idx] = { ...relations[idx]!, label };
} else {
relations.push({
id: asNpcRelationId(`nrel_${this.randomId()}`),
sourceNpcId: input.sourceNpcId,
targetNpcId: input.targetNpcId,
label,
});
}
return { ...p, npcRelations: relations };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async deleteNpcRelation(relationId: NpcRelationId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
npcRelations: (p.npcRelations ?? []).filter((r) => r.id !== relationId),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async saveNow(): Promise<void> {
const open = this.openProject;
if (!open) return;
@@ -1865,6 +2095,60 @@ function normalizeProject(p: Project): Project {
(x): x is { id: MaterialId; name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270 } =>
Boolean(x),
);
const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs;
const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : [])
.map((n, index) => {
if (!n || typeof n !== 'object') return null;
const obj = n as {
id?: string;
name?: string;
avatarAssetId?: AssetId;
description?: string;
x?: number;
y?: number;
};
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
const name = obj.name.trim();
if (!name) return null;
const x = typeof obj.x === 'number' && Number.isFinite(obj.x) ? obj.x : 80 + (index % 4) * 220;
const y =
typeof obj.y === 'number' && Number.isFinite(obj.y) ? obj.y : 80 + Math.floor(index / 4) * 200;
return {
id: asNpcId(String(obj.id)),
name,
avatarAssetId: obj.avatarAssetId,
description: typeof obj.description === 'string' ? obj.description : '',
x,
y,
};
})
.filter((x): x is ProjectNpc => Boolean(x));
const npcIdSet = new Set(npcs.map((n) => n.id));
const rawNpcRelations = (p as unknown as { npcRelations?: unknown[] }).npcRelations;
const npcRelations: ProjectNpcRelation[] = (Array.isArray(rawNpcRelations) ? rawNpcRelations : [])
.map((r) => {
if (!r || typeof r !== 'object') return null;
const obj = r as {
id?: string;
sourceNpcId?: string;
targetNpcId?: string;
/** legacy undirected fields — трактуем как source→target */
npcAId?: string;
npcBId?: string;
label?: string;
};
const rawSource = obj.sourceNpcId ?? obj.npcAId;
const rawTarget = obj.targetNpcId ?? obj.npcBId;
if (!obj.id || !rawSource || !rawTarget || typeof obj.label !== 'string') return null;
const label = obj.label.trim();
if (!label) return null;
const sourceNpcId = asNpcId(String(rawSource));
const targetNpcId = asNpcId(String(rawTarget));
if (sourceNpcId === targetNpcId) return null;
if (!npcIdSet.has(sourceNpcId) || !npcIdSet.has(targetNpcId)) return null;
return { id: asNpcRelationId(String(obj.id)), sourceNpcId, targetNpcId, label };
})
.filter((x): x is ProjectNpcRelation => Boolean(x));
const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string };
const createdWithAppVersion = (() => {
const c = metaRaw.createdWithAppVersion?.trim();
@@ -1886,6 +2170,8 @@ function normalizeProject(p: Project): Project {
scenes,
campaignAudios,
materials,
npcs,
npcRelations,
sceneGraphNodes,
sceneGraphEdges,
currentGraphNodeId,