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) {
|
||||
|
||||
@@ -20,6 +20,8 @@ function channelRequiresLicense(channel: string): boolean {
|
||||
if (channel === ipcChannels.windows.closeMultiWindow) return false;
|
||||
if (channel === ipcChannels.windows.closeSceneDescription) return false;
|
||||
if (channel === ipcChannels.windows.closeMaterials) return false;
|
||||
if (channel === ipcChannels.windows.closeNpcs) return false;
|
||||
if (channel === ipcChannels.windows.closeNpcsEditor) return false;
|
||||
if (channel === ipcChannels.windows.togglePresentationFullscreen) return false;
|
||||
// Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать».
|
||||
if (channel === ipcChannels.project.list) return false;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
clampNpcsLayout,
|
||||
DEFAULT_NPCS_OVERLAY_LAYOUT,
|
||||
type NpcId,
|
||||
type NpcsOverlayEvent,
|
||||
type NpcsOverlayLayout,
|
||||
type NpcsOverlayState,
|
||||
type NpcsZoomTool,
|
||||
zoomNpcsLayoutAt,
|
||||
} from '../../shared/types';
|
||||
|
||||
function emptyState(): NpcsOverlayState {
|
||||
return {
|
||||
revision: 1,
|
||||
activeNpcId: null,
|
||||
layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
zoomTool: null,
|
||||
};
|
||||
}
|
||||
|
||||
export class NpcsOverlayStore {
|
||||
private state: NpcsOverlayState = emptyState();
|
||||
|
||||
getState(): NpcsOverlayState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
clear(): NpcsOverlayState {
|
||||
if (this.state.activeNpcId === null && this.state.zoomTool === null) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcId: null,
|
||||
layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
zoomTool: null,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
|
||||
switch (event.kind) {
|
||||
case 'hide':
|
||||
return this.clear();
|
||||
case 'show':
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcId: event.npcId,
|
||||
layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
case 'toggle': {
|
||||
if (this.state.activeNpcId === event.npcId) {
|
||||
return this.clear();
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcId: event.npcId,
|
||||
layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'layout.set': {
|
||||
if (this.state.activeNpcId === null) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
layout: clampNpcsLayout(event.layout),
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomTool.set': {
|
||||
const tool: NpcsZoomTool = event.tool;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
zoomTool: tool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomAt': {
|
||||
if (this.state.activeNpcId === null || !this.state.zoomTool) return this.state;
|
||||
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
|
||||
const layout: NpcsOverlayLayout = zoomNpcsLayoutAt(
|
||||
this.state.layout,
|
||||
event.nx,
|
||||
event.ny,
|
||||
factor,
|
||||
);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
layout,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default:
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
|
||||
const active = this.state.activeNpcId;
|
||||
if (active === null || npcIds.has(active)) return this.state;
|
||||
return this.clear();
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,10 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
|
||||
},
|
||||
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
|
||||
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
|
||||
npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }],
|
||||
} as unknown as Project;
|
||||
const s = collectReferencedAssetIds(p);
|
||||
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'mat1', 'pr', 'th', 'v1'].sort());
|
||||
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'mat1', 'npc1', 'pr', 'th', 'v1'].sort());
|
||||
});
|
||||
|
||||
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ export function collectReferencedAssetIds(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -52,6 +52,16 @@ void test('createWindows: окно материалов закрывается
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно НПС закрывается с multi-window', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openNpcsWindow'));
|
||||
assert.ok(src.includes('closeNpcsWindow'));
|
||||
assert.ok(src.includes('openNpcsEditorWindow'));
|
||||
assert.ok(src.includes("createWindow('npcs'"));
|
||||
assert.ok(src.includes("createWindow('npcsEditor'"));
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('loadFile'));
|
||||
|
||||
@@ -8,7 +8,14 @@ import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { getBootSplashWindow } from './bootWindow';
|
||||
import { loadBrandingWindowIcon } from './brandingIcon';
|
||||
|
||||
type WindowKind = 'editor' | 'presentation' | 'control' | 'sceneDescription' | 'materials';
|
||||
type WindowKind =
|
||||
| 'editor'
|
||||
| 'presentation'
|
||||
| 'control'
|
||||
| 'sceneDescription'
|
||||
| 'materials'
|
||||
| 'npcsEditor'
|
||||
| 'npcs';
|
||||
|
||||
const windows = new Map<WindowKind, BrowserWindow>();
|
||||
|
||||
@@ -19,6 +26,14 @@ let pendingSceneDescriptionHtml = '';
|
||||
const MATERIALS_WINDOW_WIDTH = 300;
|
||||
const MATERIALS_WINDOW_HEIGHT = 720;
|
||||
|
||||
/** Редактор НПС — как основной редактор, шире инспектор. */
|
||||
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
||||
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
||||
|
||||
/** Пульт НПС: деталь слева + список справа. */
|
||||
const NPCS_WINDOW_WIDTH = 720;
|
||||
const NPCS_WINDOW_HEIGHT = 720;
|
||||
|
||||
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
|
||||
function broadcastMultiWindowStateChanged(open: boolean): void {
|
||||
for (const w of BrowserWindow.getAllWindows()) {
|
||||
@@ -77,6 +92,10 @@ function pageNameForKind(kind: WindowKind): string {
|
||||
return 'sceneDescription.html';
|
||||
case 'materials':
|
||||
return 'materials.html';
|
||||
case 'npcsEditor':
|
||||
return 'npcsEditor.html';
|
||||
case 'npcs':
|
||||
return 'npcs.html';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +163,8 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
||||
if (kind === 'control') return { width: 1200, height: 800 };
|
||||
if (kind === 'sceneDescription') return { width: 720, height: 640 };
|
||||
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
|
||||
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
||||
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
||||
return { width: 1280, height: 800 };
|
||||
}
|
||||
|
||||
@@ -171,6 +192,25 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcsEditor'
|
||||
? {
|
||||
width: NPCS_EDITOR_WINDOW_WIDTH,
|
||||
height: NPCS_EDITOR_WINDOW_HEIGHT,
|
||||
minWidth: 1100,
|
||||
minHeight: 640,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcs'
|
||||
? {
|
||||
width: NPCS_WINDOW_WIDTH,
|
||||
height: NPCS_WINDOW_HEIGHT,
|
||||
minWidth: 560,
|
||||
maxWidth: 900,
|
||||
minHeight: 480,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
show: false,
|
||||
backgroundColor: '#09090B',
|
||||
...(icon ? { icon } : {}),
|
||||
@@ -195,7 +235,12 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
}
|
||||
|
||||
win.setTitle(windowChromeTitle(kind, app.getLocale()));
|
||||
if (kind === 'sceneDescription' || kind === 'materials') {
|
||||
if (
|
||||
kind === 'sceneDescription' ||
|
||||
kind === 'materials' ||
|
||||
kind === 'npcsEditor' ||
|
||||
kind === 'npcs'
|
||||
) {
|
||||
win.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
@@ -227,6 +272,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
if (!open) {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closeNpcsWindow();
|
||||
}
|
||||
broadcastMultiWindowStateChanged(open);
|
||||
});
|
||||
@@ -312,6 +358,7 @@ export function openMultiWindow() {
|
||||
export function closeMultiWindow(): void {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closeNpcsWindow();
|
||||
const pres = windows.get('presentation');
|
||||
const ctrl = windows.get('control');
|
||||
if (pres) pres.close();
|
||||
@@ -336,6 +383,20 @@ export function closeMaterialsWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function closeNpcsEditorWindow(): void {
|
||||
const win = windows.get('npcsEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeNpcsWindow(): void {
|
||||
const win = windows.get('npcs');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function getSceneDescriptionContent(): string {
|
||||
return pendingSceneDescriptionHtml;
|
||||
}
|
||||
@@ -413,6 +474,75 @@ export function openMaterialsWindow(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Редактор НПС: отдельное окно с графом и инспектором. */
|
||||
export function openNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('npcsEditor', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Пульт НПС: список + описание выбранного; оверлей аватара на сцене. */
|
||||
export function openNpcsWindow(): void {
|
||||
const existing = windows.get('npcs');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
const b = existing.getBounds();
|
||||
existing.setBounds({
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
width: Math.min(NPCS_WINDOW_WIDTH, Math.max(560, b.width)),
|
||||
height: Math.max(NPCS_WINDOW_HEIGHT, b.height),
|
||||
});
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('control') ?? windows.get('presentation');
|
||||
const win = createWindow('npcs', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, dw - width - 24)),
|
||||
y: Math.round(y + (dh - height) / 2),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function togglePresentationFullscreen(): boolean {
|
||||
const pres = windows.get('presentation');
|
||||
if (!pres) return false;
|
||||
|
||||
Reference in New Issue
Block a user