Files
DndGamePlayer/app/main/windows/createWindows.ts
T
Ivan Fontosh 2979d06f1c feat: multi-material overlays, presentation guides, and release prebuilds
Align control/presentation with presentation screen rect and darkness z-order; sync window titles and session window cleanup. Pack Win/Mac/Linux with npmRebuild disabled and release-native-prep for classic-level and sharp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:28:18 +08:00

758 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import path from 'node:path';
import { app, BrowserWindow, screen } from 'electron';
import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding';
import { ipcChannels } from '../../shared/ipc/contracts';
import { safeConsoleError } from '../safeConsole';
import { getBootSplashWindow } from './bootWindow';
import { loadBrandingWindowIcon } from './brandingIcon';
export type WindowKind =
| 'editor'
| 'presentation'
| 'control'
| 'sceneDescription'
| 'materials'
| 'npcsEditor'
| 'sceneEditor'
| 'npcs';
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
'presentation',
'control',
'materials',
'npcs',
'npcsEditor',
'sceneEditor',
] as const;
const windows = new Map<WindowKind, BrowserWindow>();
/** Язык заголовков окон (из редактора); иначе `app.getLocale()`. */
let chromeLocaleTagOverride: string | null = null;
function resolveChromeLocaleTag(): string {
return chromeLocaleTagOverride ?? app.getLocale();
}
let appQuitting = false;
/** Защита от каскада close(control) ↔ close(presentation). */
let closingPlaySession = false;
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()) {
if (w.isDestroyed()) continue;
if (w.webContents.isDestroyed()) continue;
try {
w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open });
} catch {
/* окно могло закрыться между проверкой и send */
}
}
}
export function getPresentationContentSize(): { width: number; height: number } | null {
const pres = windows.get('presentation');
if (!pres || pres.isDestroyed()) return null;
const [width, height] = pres.getContentSize();
if (width <= 0 || height <= 0) return null;
return { width, height };
}
function broadcastPresentationContentSize(): void {
const size = getPresentationContentSize();
if (!size) return;
for (const w of BrowserWindow.getAllWindows()) {
if (w.isDestroyed() || w.webContents.isDestroyed()) continue;
try {
w.webContents.send(ipcChannels.windows.presentationContentSizeChanged, size);
} catch {
/* ignore */
}
}
}
function bindPresentationContentSizeTracking(win: BrowserWindow): void {
const emit = () => broadcastPresentationContentSize();
win.on('resize', emit);
win.on('enter-full-screen', emit);
win.on('leave-full-screen', emit);
win.webContents.once('did-finish-load', emit);
}
function sendSceneDescriptionContent(win: BrowserWindow, html: string): void {
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
try {
win.webContents.send(ipcChannels.windows.sceneDescriptionContent, { html });
} catch {
/* ignore */
}
}
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
export function markAppQuitting(): void {
appQuitting = true;
}
/** Точечная рассылка в известные окна приложения (без splash / чужих BrowserWindow). */
export function sendToAppWindows(
channel: string,
payload: unknown,
kinds: readonly WindowKind[] = SESSION_STATE_WINDOW_KINDS,
): void {
for (const kind of kinds) {
const win = windows.get(kind);
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) continue;
try {
win.webContents.send(channel, payload);
} catch {
/* окно могло закрыться между проверкой и send */
}
}
}
function applyWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
if (win.isDestroyed()) return;
win.setTitle(windowChromeTitle(kind as AppWindowKind, resolveChromeLocaleTag()));
}
export function syncAllWindowChromeTitles(localeTag: string): void {
chromeLocaleTagOverride = localeTag.trim() || null;
for (const [kind, win] of windows.entries()) {
applyWindowChromeTitle(win, kind);
}
}
function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
const apply = () => applyWindowChromeTitle(win, kind);
apply();
win.webContents.on('page-title-updated', (event) => {
event.preventDefault();
apply();
});
win.webContents.on('did-finish-load', () => {
apply();
});
}
/** Закрыть окна сессии, кроме редактора и его дочерних окон. */
function closePlaySessionAuxiliaryWindows(): void {
closeSceneDescriptionWindow();
closeMaterialsWindow();
closeNpcsWindow();
}
function closePresentationWindow(): void {
const pres = windows.get('presentation');
if (pres && !pres.isDestroyed()) {
pres.close();
}
}
function closeControlWindow(): void {
const ctrl = windows.get('control');
if (ctrl && !ctrl.isDestroyed()) {
ctrl.close();
}
}
function quitAppFromEditorClose(): void {
markAppQuitting();
app.quit();
}
function isDev() {
return process.env.NODE_ENV === 'development' || process.env.VITE_DEV_SERVER_URL !== undefined;
}
/** Вне dev-сервера на Windows с `loadFile` + preload иногда ломается sandbox; оставляем изоляцию через preload/contextBridge. */
function shouldUseRendererSandbox(): boolean {
if (process.env.VITE_DEV_SERVER_URL) return true;
return process.platform !== 'win32';
}
function getRendererHtmlPath(kind: WindowKind): string {
return path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`);
}
function pageNameForKind(kind: WindowKind): string {
switch (kind) {
case 'editor':
return 'editor.html';
case 'presentation':
return 'presentation.html';
case 'control':
return 'control.html';
case 'sceneDescription':
return 'sceneDescription.html';
case 'materials':
return 'materials.html';
case 'npcsEditor':
return 'npcsEditor.html';
case 'sceneEditor':
return 'sceneEditor.html';
case 'npcs':
return 'npcs.html';
}
}
/**
* В production `loadURL(file://…)` на Windows с asar иногда даёт чёрный экран;
* `loadFile` корректно открывает HTML из asar и на Windows, и на macOS.
*/
function loadWindowPage(win: BrowserWindow, kind: WindowKind): void {
const dev = process.env.VITE_DEV_SERVER_URL;
if (dev) {
void win.loadURL(new URL(pageNameForKind(kind), dev).toString());
return;
}
void win.loadFile(getRendererHtmlPath(kind));
}
function getPreloadPath(): string {
return path.join(app.getAppPath(), 'dist', 'preload', 'index.cjs');
}
/** macOS: в Dock — тот же растр, что и у окон (ICO/PNG из brandingIcon). */
export function applyDockIconIfNeeded(): void {
if (process.platform !== 'darwin' || !app.dock) return;
const icon = loadBrandingWindowIcon();
if (!icon || icon.isEmpty()) return;
try {
app.dock.setIcon(icon);
} catch {
/* ignore */
}
}
type CreateWindowOpts = {
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
parent?: BrowserWindow;
/** Не показывать окно до явного `show()` (экран загрузки / прогрев НПС). */
deferVisibility?: boolean;
};
/**
* Только `ready-to-show` на части систем (первый холодный старт Windows) не приходит вовремя —
* окно остаётся с `show: false` и кажется «зависшим». Дублируем показ по `did-finish-load` и таймауту.
*/
function ensureWindowBecomesVisible(win: BrowserWindow): void {
let shown = false;
const showOnce = (): void => {
if (shown) return;
if (win.isDestroyed()) return;
shown = true;
win.show();
};
win.once('ready-to-show', showOnce);
win.webContents.once('did-finish-load', () => {
showOnce();
});
const safetyTimer = setTimeout(showOnce, 8000);
win.once('closed', () => {
clearTimeout(safetyTimer);
});
}
function windowSizeForKind(kind: WindowKind): { width: number; height: number } {
if (kind === 'editor') return { width: 1280, height: 800 };
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 === 'sceneEditor') return { width: 1280, height: 800 };
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
return { width: 1280, height: 800 };
}
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
const deferShow = opts?.deferVisibility === true;
const icon = loadBrandingWindowIcon();
const size = windowSizeForKind(kind);
const win = new BrowserWindow({
width: size.width,
height: size.height,
autoHideMenuBar: true,
...(kind === 'sceneDescription'
? {
minWidth: 520,
minHeight: 420,
}
: {}),
...(kind === 'materials'
? {
width: MATERIALS_WINDOW_WIDTH,
height: MATERIALS_WINDOW_HEIGHT,
minWidth: 260,
maxWidth: 360,
minHeight: 480,
}
: {}),
...(kind === 'npcsEditor'
? {
width: NPCS_EDITOR_WINDOW_WIDTH,
height: NPCS_EDITOR_WINDOW_HEIGHT,
minWidth: 1100,
minHeight: 640,
}
: {}),
...(kind === 'sceneEditor'
? {
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
}
: {}),
...(kind === 'npcs'
? {
width: NPCS_WINDOW_WIDTH,
height: NPCS_WINDOW_HEIGHT,
minWidth: 560,
maxWidth: 900,
minHeight: 480,
}
: {}),
show: false,
backgroundColor: '#09090B',
...(icon ? { icon } : {}),
...(opts?.parent ? { parent: opts.parent } : {}),
webPreferences: {
contextIsolation: true,
sandbox: shouldUseRendererSandbox(),
nodeIntegration: false,
devTools: isDev() || process.env.DND_OPEN_DEVTOOLS === '1',
preload: getPreloadPath(),
autoplayPolicy: 'no-user-gesture-required',
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
},
});
if (icon) {
try {
win.setIcon(icon);
} catch {
/* ignore */
}
}
bindWindowChromeTitle(win, kind);
if (
kind === 'sceneDescription' ||
kind === 'materials' ||
kind === 'npcsEditor' ||
kind === 'sceneEditor' ||
kind === 'npcs'
) {
win.setMenuBarVisibility(false);
}
win.webContents.on('preload-error', (_event, preloadPath, error) => {
safeConsoleError(`[preload-error] ${preloadPath}:`, error);
});
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
if (errorCode === -3) return;
if (!isMainFrame) return;
safeConsoleError(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
});
win.webContents.on('render-process-gone', (_event, details) => {
safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
});
if (!deferShow) {
ensureWindowBecomesVisible(win);
}
loadWindowPage(win, kind);
if (kind === 'editor') {
win.on('close', (e) => {
if (appQuitting) return;
e.preventDefault();
quitAppFromEditorClose();
});
}
if (kind === 'control') {
win.on('close', () => {
if (appQuitting || closingPlaySession) return;
closingPlaySession = true;
closePlaySessionAuxiliaryWindows();
closePresentationWindow();
});
}
if (kind === 'presentation') {
bindPresentationContentSizeTracking(win);
win.on('close', () => {
if (appQuitting || closingPlaySession) return;
closingPlaySession = true;
closePlaySessionAuxiliaryWindows();
closeControlWindow();
});
}
win.on('closed', () => windows.delete(kind));
win.on('closed', () => {
if (kind !== 'presentation' && kind !== 'control') return;
const open = windows.has('presentation') || windows.has('control');
if (!open) {
closingPlaySession = false;
closePlaySessionAuxiliaryWindows();
}
broadcastMultiWindowStateChanged(open);
});
windows.set(kind, win);
return win;
}
export function createWindows() {
if (!windows.has('editor')) {
createWindow('editor');
}
}
/** Редактор создаётся скрытым до окончания экрана загрузки. */
export function createEditorWindowDeferred(): BrowserWindow {
const existing = windows.get('editor');
if (existing) {
return existing;
}
return createWindow('editor', { deferVisibility: true });
}
/** Дождаться первой отрисовки редактора (готовность к показу без чёрного экрана). */
export function waitForEditorWindowReady(win: BrowserWindow): Promise<void> {
return new Promise<void>((resolve) => {
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
resolve(undefined);
}
}, 35000);
const finish = (): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(undefined);
};
win.once('ready-to-show', finish);
win.webContents.once('did-finish-load', finish);
}).then(
() =>
new Promise<void>((r) => {
setTimeout(r, 120);
}),
);
}
export function focusEditorWindow(): void {
const splash = getBootSplashWindow();
if (splash && !splash.isDestroyed()) {
splash.focus();
return;
}
const win = windows.get('editor');
if (win) {
if (win.isMinimized()) win.restore();
win.show();
win.focus();
} else {
createWindows();
}
}
export function openMultiWindow() {
let presentation = windows.get('presentation');
if (!presentation) {
const display = screen.getPrimaryDisplay();
const { x, y, width, height } = display.bounds;
presentation = createWindow('presentation');
presentation.setBounds({ x, y, width, height });
presentation.setMenuBarVisibility(false);
presentation.maximize();
}
if (!windows.has('control')) {
// macOS: parent-child window binding moves child with the parent (unlike Windows behavior we want).
// Keep control window independent on darwin.
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
}
broadcastMultiWindowStateChanged(true);
broadcastPresentationContentSize();
}
export function closeMultiWindow(): void {
closingPlaySession = true;
closePlaySessionAuxiliaryWindows();
const pres = windows.get('presentation');
const ctrl = windows.get('control');
if (pres && !pres.isDestroyed()) pres.close();
if (ctrl && !ctrl.isDestroyed()) ctrl.close();
if (!windows.has('presentation') && !windows.has('control')) {
closingPlaySession = false;
}
}
export function isMultiWindowOpen(): boolean {
return windows.has('presentation') || windows.has('control');
}
export function closeSceneDescriptionWindow(): void {
const win = windows.get('sceneDescription');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeMaterialsWindow(): void {
const win = windows.get('materials');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeNpcsEditorWindow(): void {
const win = windows.get('npcsEditor');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeSceneEditorWindow(): void {
const win = windows.get('sceneEditor');
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;
}
/** Одно окно описания: переиспользовать существующее или создать новое. */
export function openSceneDescriptionWindow(html: string): void {
pendingSceneDescriptionHtml = html;
const existing = windows.get('sceneDescription');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
sendSceneDescriptionContent(existing, html);
return;
}
// Держим поверх пульта/презентации, иначе окно уходит под полноэкранный экран просмотра.
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('sceneDescription', 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 + (dw - width) / 2),
y: Math.round(y + (dh - height) / 2),
width,
height,
});
win.webContents.once('did-finish-load', () => {
sendSceneDescriptionContent(win, pendingSceneDescriptionHtml);
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
/** Полоса материалов: фиксированная ширина плитки, переиспользование окна. */
export function openMaterialsWindow(): void {
const existing = windows.get('materials');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
const b = existing.getBounds();
existing.setBounds({
x: b.x,
y: b.y,
width: MATERIALS_WINDOW_WIDTH,
height: MATERIALS_WINDOW_HEIGHT,
});
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('materials', 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();
}
});
}
function positionNpcsEditorWindow(win: BrowserWindow): void {
const parent = windows.get('editor');
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,
});
}
/** Прогрев окна НПС в фоне после открытия проекта — клик «НПС» не ждёт холодной загрузки. */
export function warmNpcsEditorWindow(): void {
const existing = windows.get('npcsEditor');
if (existing && !existing.isDestroyed()) return;
const parent = windows.get('editor');
createWindow('npcsEditor', {
...(parent ? { parent } : {}),
deferVisibility: true,
});
}
/** Редактор НПС: отдельное окно с графом и инспектором. */
export function openNpcsEditorWindow(): void {
const existing = windows.get('npcsEditor');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
positionNpcsEditorWindow(existing);
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('editor');
const win = createWindow('npcsEditor', {
...(parent ? { parent } : {}),
deferVisibility: true,
});
positionNpcsEditorWindow(win);
// Показываем сразу (тёмный фон), не дожидаясь полной загрузки React/ReactFlow.
win.show();
win.focus();
win.moveTop();
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.focus();
win.moveTop();
}
});
}
/** Редактор сцены: расстановка ловушек и легенды материалов. */
export function openSceneEditorWindow(): void {
const existing = windows.get('sceneEditor');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('editor');
const win = createWindow('sceneEditor', 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;
const next = !pres.isFullScreen();
pres.setFullScreen(next);
return pres.isFullScreen();
}