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:
@@ -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