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
+145
View File
@@ -20,6 +20,7 @@ import { SceneDarknessStore } from './effects/sceneDarknessStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
import { LicenseService } from './license/licenseService';
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { ZipProjectStore } from './project/zipStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { installAutoUpdater } from './update/installAutoUpdater';
@@ -43,8 +44,12 @@ import {
markAppQuitting,
openMaterialsWindow,
openMultiWindow,
openNpcsEditorWindow,
openNpcsWindow,
openSceneDescriptionWindow,
closeMaterialsWindow,
closeNpcsEditorWindow,
closeNpcsWindow,
togglePresentationFullscreen,
waitForEditorWindowReady,
} from './windows/createWindows';
@@ -138,6 +143,7 @@ const effectsStore = new EffectsStore();
const sceneDarknessStore = new SceneDarknessStore();
const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore();
function emitEffectsState(): void {
const state = effectsStore.getState();
@@ -162,6 +168,22 @@ function syncMaterialsOverlayWithProject(project: Project | null): void {
materialsOverlayStore.ensureMaterialStillExists(ids);
}
function emitNpcsOverlayState(): void {
const state = npcsOverlayStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.npcsOverlay.stateChanged, { state });
}
}
function syncNpcsOverlayWithProject(project: Project | null): void {
if (!project) {
npcsOverlayStore.clear();
return;
}
const ids = new Set((project.npcs ?? []).map((n) => n.id));
npcsOverlayStore.ensureNpcStillExists(ids);
}
function emitSceneDarknessState(): void {
const state = sceneDarknessStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
@@ -221,6 +243,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
emitSessionState();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitVideoState();
return;
}
@@ -235,6 +258,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
emitSessionState();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitVideoState();
return;
}
@@ -273,6 +297,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
emitSessionState();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitVideoState();
}
@@ -357,6 +382,22 @@ async function main() {
closeMaterialsWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
openNpcsEditorWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeNpcsEditor, () => {
closeNpcsEditorWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.openNpcs, () => {
openNpcsWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeNpcs, () => {
closeNpcsWindow();
return { ok: true };
});
registerHandler(ipcChannels.materialsOverlay.getState, () => {
return { state: materialsOverlayStore.getState() };
@@ -366,6 +407,14 @@ async function main() {
emitMaterialsOverlayState();
return { ok: true };
});
registerHandler(ipcChannels.npcsOverlay.getState, () => {
return { state: npcsOverlayStore.getState() };
});
registerHandler(ipcChannels.npcsOverlay.dispatch, ({ event }) => {
npcsOverlayStore.dispatch(event);
emitNpcsOverlayState();
return { ok: true };
});
registerHandler(ipcChannels.project.list, async () => {
const projects = await projectStore.listProjects();
@@ -392,9 +441,11 @@ async function main() {
await projectStore.closeOpenProject();
effectsStore.clear();
materialsOverlayStore.clear();
npcsOverlayStore.clear();
sceneDarknessStore.resetSession();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneDarknessState();
emitSessionState();
return { ok: true };
@@ -410,10 +461,12 @@ async function main() {
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
effectsStore.clear();
materialsOverlayStore.clear();
npcsOverlayStore.clear();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneDarknessState();
emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
@@ -429,10 +482,12 @@ async function main() {
}));
effectsStore.clear();
materialsOverlayStore.clear();
npcsOverlayStore.clear();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneDarknessState();
emitSessionState();
const p = projectStore.getOpenProject();
@@ -570,6 +625,96 @@ async function main() {
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(ipcChannels.project.upsertNpc, async ({ npcId, name, description, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath && !npcId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) {
throw new Error('NPC avatar is required');
}
filePath = filePaths[0];
}
const project = await projectStore.upsertNpc({
...(npcId ? { npcId } : {}),
name,
...(typeof description === 'string' ? { description } : {}),
...(filePath ? { filePath } : {}),
});
syncNpcsOverlayWithProject(project);
emitNpcsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.updateNpcFields, async ({ npcId, name, description }) => {
const project = await projectStore.updateNpcFields(npcId, {
...(typeof name === 'string' ? { name } : {}),
...(typeof description === 'string' ? { description } : {}),
});
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.updateNpcPosition, async ({ npcId, x, y }) => {
const project = await projectStore.updateNpcPosition(npcId, x, y);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteNpc, async ({ npcId }) => {
const project = await projectStore.deleteNpc(npcId);
syncNpcsOverlayWithProject(project);
emitNpcsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setNpcsOrder, async ({ npcIds }) => {
const project = await projectStore.setNpcsOrder(npcIds);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.pickNpcAvatar, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) return { canceled: true as const };
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(
ipcChannels.project.upsertNpcRelation,
async ({ relationId, sourceNpcId, targetNpcId, label }) => {
const project = await projectStore.upsertNpcRelation({
...(relationId ? { relationId } : {}),
sourceNpcId,
targetNpcId,
label,
});
emitSessionState();
return { project };
},
);
registerHandler(ipcChannels.project.deleteNpcRelation, async ({ relationId }) => {
const project = await projectStore.deleteNpcRelation(relationId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath) {