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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user